Assets & Prozesse: Detail/Bearbeiten/Anlegen als Popups
- Wiederverwendbares, serverseitig gerendertes Modal (searchParams- gesteuert: ?detail= / ?edit= / ?new=1) — Browser-Zurück schließt, kein Client-State nötig - Assets: Read-only-Detail-Popup (Stammdaten, C/I/A, Abhängigkeiten, Prozesse, Risiken-Platzhalter), Bearbeiten-Popup (Formular + Abhängigkeiten verwalten + Löschen), Anlegen-Popup - Prozesse: "Bearbeiten" wechselt jetzt innerhalb des Popups in den Editiermodus (Stammdaten, Asset-Zuordnung, BIA, Löschen) statt auf die alte Detailseite zu springen; Anlegen ebenfalls als Popup - Server-Actions leiten auf die Popup-URLs zurück (Speichern im Bearbeiten-Popup → Detail-Popup; BIA-/Zuordnungs-Änderungen lassen das Popup offen); BIA-Formular remountet nach dem Speichern (key) - Alte Routen (/assets/[id], /assets/new, /processes/[id], …) leiten auf die Popup-URLs um Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,11 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { requireSession } from "@/server/auth";
|
|
||||||
import { dbForTenant } from "@/server/db";
|
|
||||||
import { requirePermission } from "@/server/rbac";
|
|
||||||
import { updateAsset } from "@/server/actions/assets";
|
|
||||||
import { AssetForm } from "@/components/asset-form";
|
|
||||||
|
|
||||||
export default async function EditAssetPage({
|
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||||
|
export default async function Page({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireSession();
|
|
||||||
requirePermission(session, "asset:write");
|
|
||||||
const t = await getTranslations("assets");
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
redirect(`/assets?edit=${id}`);
|
||||||
const asset = await db.asset.findUnique({ where: { id } });
|
|
||||||
if (!asset) notFound();
|
|
||||||
|
|
||||||
const users = await db.user.findMany({
|
|
||||||
where: { status: "ACTIVE" },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const action = updateAsset.bind(null, asset.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex-1 p-6">
|
|
||||||
<h1 className="text-xl font-semibold">{t("editTitle")}</h1>
|
|
||||||
<div className="mt-6">
|
|
||||||
<AssetForm action={action} asset={asset} users={users} cancelHref={`/assets/${asset.id}`} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,204 +1,11 @@
|
|||||||
import Link from "next/link";
|
import { redirect } from "next/navigation";
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { ArrowLeft, Pencil, Trash2, X } from "lucide-react";
|
|
||||||
import { requireSession } from "@/server/auth";
|
|
||||||
import { dbForTenant } from "@/server/db";
|
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
|
||||||
import {
|
|
||||||
addAssetRelation,
|
|
||||||
deleteAsset,
|
|
||||||
removeAssetRelation,
|
|
||||||
} from "@/server/actions/assets";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { CiaBadge, Pill, Tag } from "@/components/mockup-ui";
|
|
||||||
|
|
||||||
export default async function AssetDetailPage({
|
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||||
|
export default async function Page({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireSession();
|
|
||||||
requirePermission(session, "asset:read");
|
|
||||||
const t = await getTranslations("assets");
|
|
||||||
const tType = await getTranslations("assetType");
|
|
||||||
const tStatus = await getTranslations("assetStatus");
|
|
||||||
const tRole = await getTranslations("processRole");
|
|
||||||
const tc = await getTranslations("common");
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
redirect(`/assets?detail=${id}`);
|
||||||
|
|
||||||
const asset = await db.asset.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
owner: { select: { name: true } },
|
|
||||||
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } },
|
|
||||||
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
|
|
||||||
processAssets: { include: { process: { select: { id: true, name: true } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!asset) notFound();
|
|
||||||
|
|
||||||
const canWrite = hasPermission(session, "asset:write");
|
|
||||||
const otherAssets = canWrite
|
|
||||||
? await db.asset.findMany({
|
|
||||||
where: { id: { not: asset.id } },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const removeAction = deleteAsset.bind(null, asset.id);
|
|
||||||
const addRelationAction = addAssetRelation.bind(null, asset.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex-1 p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button variant="ghost" size="icon" nativeButton={false} render={<Link href="/assets" />}>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<h1 className="text-xl font-semibold">{asset.name}</h1>
|
|
||||||
<Tag>{tType(asset.type)}</Tag>
|
|
||||||
<Pill tone={asset.status === "ACTIVE" ? "ok" : asset.status === "PLANNED" ? "info" : "mut"}>
|
|
||||||
{tStatus(asset.status)}
|
|
||||||
</Pill>
|
|
||||||
</div>
|
|
||||||
{canWrite && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" nativeButton={false} render={<Link href={`/assets/${asset.id}/edit`} />}>
|
|
||||||
<Pencil className="size-4" /> {tc("edit")}
|
|
||||||
</Button>
|
|
||||||
<form action={removeAction}>
|
|
||||||
<Button type="submit" variant="destructive">
|
|
||||||
<Trash2 className="size-4" /> {tc("delete")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("detailTitle")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
|
||||||
<dt className="text-muted-foreground">{t("description")}</dt>
|
|
||||||
<dd className="whitespace-pre-wrap">{asset.description ?? tc("none")}</dd>
|
|
||||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
|
||||||
<dd>{asset.owner?.name ?? tc("none")}</dd>
|
|
||||||
<dt className="text-muted-foreground">{t("location")}</dt>
|
|
||||||
<dd>{asset.location ?? tc("none")}</dd>
|
|
||||||
<dt className="text-muted-foreground">{t("protection")}</dt>
|
|
||||||
<dd>
|
|
||||||
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
|
|
||||||
</dd>
|
|
||||||
<dt className="text-muted-foreground">{t("tags")}</dt>
|
|
||||||
<dd className="flex flex-wrap gap-1">
|
|
||||||
{asset.tags.length === 0
|
|
||||||
? tc("none")
|
|
||||||
: asset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("linkedRisks")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">{t("linkedRisksPlaceholder")}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("relations")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 text-muted-foreground">{t("relationHint")}</p>
|
|
||||||
{asset.relationsFrom.length === 0 && <p>{tc("none")}</p>}
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{asset.relationsFrom.map((rel) => (
|
|
||||||
<li key={rel.id} className="flex items-center gap-2">
|
|
||||||
<Link href={`/assets/${rel.relatedAsset.id}`} className="hover:underline">
|
|
||||||
{rel.relatedAsset.name}
|
|
||||||
</Link>
|
|
||||||
{canWrite && (
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 text-muted-foreground">{t("relationReverseHint")}</p>
|
|
||||||
{asset.relationsTo.length === 0 && <p>{tc("none")}</p>}
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{asset.relationsTo.map((rel) => (
|
|
||||||
<li key={rel.id}>
|
|
||||||
<Link href={`/assets/${rel.asset.id}`} className="hover:underline">
|
|
||||||
{rel.asset.name}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{canWrite && otherAssets.length > 0 && (
|
|
||||||
<form action={addRelationAction} className="flex gap-2 border-t pt-3">
|
|
||||||
<select
|
|
||||||
name="relatedAssetId"
|
|
||||||
required
|
|
||||||
className="h-9 flex-1 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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("processes")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm">
|
|
||||||
{asset.processAssets.length === 0 && <p>{tc("none")}</p>}
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{asset.processAssets.map((pa) => (
|
|
||||||
<li key={pa.id} className="flex items-center gap-2">
|
|
||||||
<Link href={`/processes/${pa.process.id}`} className="hover:underline">
|
|
||||||
{pa.process.name}
|
|
||||||
</Link>
|
|
||||||
<Pill tone={pa.role === "PRIMARY" ? "violet" : "info"}>{tRole(pa.role)}</Pill>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="mt-3 text-xs text-muted-foreground">{t("inheritedNote")}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { redirect } from "next/navigation";
|
||||||
import { requireSession } from "@/server/auth";
|
|
||||||
import { dbForTenant } from "@/server/db";
|
|
||||||
import { requirePermission } from "@/server/rbac";
|
|
||||||
import { createAsset } from "@/server/actions/assets";
|
|
||||||
import { AssetForm } from "@/components/asset-form";
|
|
||||||
|
|
||||||
export default async function NewAssetPage() {
|
// Alte Route — Anlegen läuft jetzt als Popup über die Listen-Seite.
|
||||||
const session = await requireSession();
|
export default function Page() {
|
||||||
requirePermission(session, "asset:write");
|
redirect("/assets?new=1");
|
||||||
const t = await getTranslations("assets");
|
|
||||||
|
|
||||||
const db = dbForTenant(session.user.tenantId);
|
|
||||||
const users = await db.user.findMany({
|
|
||||||
where: { status: "ACTIVE" },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex-1 p-6">
|
|
||||||
<h1 className="text-xl font-semibold">{t("createTitle")}</h1>
|
|
||||||
<div className="mt-6">
|
|
||||||
<AssetForm action={createAsset} users={users} cancelHref="/assets" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
Pill,
|
Pill,
|
||||||
Tag,
|
Tag,
|
||||||
} from "@/components/mockup-ui";
|
} from "@/components/mockup-ui";
|
||||||
|
import {
|
||||||
|
AssetCreateModal,
|
||||||
|
AssetDetailModal,
|
||||||
|
AssetEditModal,
|
||||||
|
} from "@/components/asset-modals";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -32,7 +37,7 @@ const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
|||||||
export default async function AssetsPage({
|
export default async function AssetsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; type?: string }>;
|
searchParams: Promise<{ q?: string; type?: string; detail?: string; edit?: string; new?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "asset:read");
|
requirePermission(session, "asset:read");
|
||||||
@@ -41,7 +46,8 @@ export default async function AssetsPage({
|
|||||||
const tStatus = await getTranslations("assetStatus");
|
const tStatus = await getTranslations("assetStatus");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
|
|
||||||
const { q, type } = await searchParams;
|
const params = await searchParams;
|
||||||
|
const { q, type } = params;
|
||||||
const activeType = ASSET_TYPES.includes(type as AssetType) ? (type as AssetType) : null;
|
const activeType = ASSET_TYPES.includes(type as AssetType) ? (type as AssetType) : null;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
|
||||||
@@ -76,6 +82,36 @@ export default async function AssetsPage({
|
|||||||
|
|
||||||
const canWrite = hasPermission(session, "asset:write");
|
const canWrite = hasPermission(session, "asset:write");
|
||||||
|
|
||||||
|
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
|
||||||
|
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||||
|
const modalAsset = modalId
|
||||||
|
? await db.asset.findUnique({
|
||||||
|
where: { id: modalId },
|
||||||
|
include: {
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } },
|
||||||
|
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
|
||||||
|
processAssets: { include: { process: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const users =
|
||||||
|
canWrite && (params.edit || params.new)
|
||||||
|
? await db.user.findMany({
|
||||||
|
where: { status: "ACTIVE" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const otherAssets =
|
||||||
|
canWrite && params.edit && modalAsset
|
||||||
|
? await db.asset.findMany({
|
||||||
|
where: { id: { not: modalAsset.id } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const typeHref = (v: string | null) =>
|
const typeHref = (v: string | null) =>
|
||||||
`/assets${v ? `?type=${v}` : ""}${q ? `${v ? "&" : "?"}q=${encodeURIComponent(q)}` : ""}`;
|
`/assets${v ? `?type=${v}` : ""}${q ? `${v ? "&" : "?"}q=${encodeURIComponent(q)}` : ""}`;
|
||||||
|
|
||||||
@@ -94,7 +130,7 @@ export default async function AssetsPage({
|
|||||||
{t("export")}
|
{t("export")}
|
||||||
</Button>
|
</Button>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<Button nativeButton={false} render={<Link href="/assets/new" />}>
|
<Button nativeButton={false} render={<Link href="/assets?new=1" />}>
|
||||||
<Plus className="size-4" /> {t("newAsset")}
|
<Plus className="size-4" /> {t("newAsset")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -167,7 +203,7 @@ export default async function AssetsPage({
|
|||||||
{assets.map((asset) => (
|
{assets.map((asset) => (
|
||||||
<TableRow key={asset.id}>
|
<TableRow key={asset.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/assets/${asset.id}`} className="font-bold hover:underline">
|
<Link href={`/assets?detail=${asset.id}`} className="font-bold hover:underline">
|
||||||
{asset.name}
|
{asset.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -193,6 +229,14 @@ export default async function AssetsPage({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{modalAsset && params.edit && canWrite ? (
|
||||||
|
<AssetEditModal asset={modalAsset} users={users} otherAssets={otherAssets} />
|
||||||
|
) : modalAsset ? (
|
||||||
|
<AssetDetailModal asset={modalAsset} canWrite={canWrite} />
|
||||||
|
) : params.new && canWrite ? (
|
||||||
|
<AssetCreateModal users={users} />
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,11 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { requireSession } from "@/server/auth";
|
|
||||||
import { dbForTenant } from "@/server/db";
|
|
||||||
import { requirePermission } from "@/server/rbac";
|
|
||||||
import { updateProcess } from "@/server/actions/processes";
|
|
||||||
import { ProcessForm } from "@/components/process-form";
|
|
||||||
|
|
||||||
export default async function EditProcessPage({
|
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||||
|
export default async function Page({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireSession();
|
|
||||||
requirePermission(session, "bia:write");
|
|
||||||
const t = await getTranslations("processes");
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
redirect(`/processes?edit=${id}`);
|
||||||
const process = await db.process.findUnique({ where: { id } });
|
|
||||||
if (!process) notFound();
|
|
||||||
|
|
||||||
const users = await db.user.findMany({
|
|
||||||
where: { status: "ACTIVE" },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const action = updateProcess.bind(null, process.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex-1 p-6">
|
|
||||||
<h1 className="text-xl font-semibold">{t("editTitle")}</h1>
|
|
||||||
<div className="mt-6">
|
|
||||||
<ProcessForm
|
|
||||||
action={action}
|
|
||||||
process={process}
|
|
||||||
users={users}
|
|
||||||
cancelHref={`/processes/${process.id}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,285 +1,11 @@
|
|||||||
import Link from "next/link";
|
import { redirect } from "next/navigation";
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import { ArrowLeft, Pencil, Trash2, X } from "lucide-react";
|
|
||||||
import { requireSession } from "@/server/auth";
|
|
||||||
import { dbForTenant } from "@/server/db";
|
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
|
||||||
import {
|
|
||||||
assignAsset,
|
|
||||||
deleteProcess,
|
|
||||||
saveBia,
|
|
||||||
unassignAsset,
|
|
||||||
} from "@/server/actions/processes";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { CiaBadge, CriticalityPill } from "@/components/mockup-ui";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
const LEVELS = [1, 2, 3, 4] as const;
|
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||||
|
export default async function Page({
|
||||||
export default async function ProcessDetailPage({
|
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireSession();
|
|
||||||
requirePermission(session, "bia:read");
|
|
||||||
const t = await getTranslations("processes");
|
|
||||||
const tAssets = await getTranslations("assets");
|
|
||||||
const tRole = await getTranslations("processRole");
|
|
||||||
const tCrit = await getTranslations("criticality");
|
|
||||||
const tc = await getTranslations("common");
|
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
redirect(`/processes?detail=${id}`);
|
||||||
|
|
||||||
const process = await db.process.findUnique({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
owner: { select: { name: true } },
|
|
||||||
bia: true,
|
|
||||||
processAssets: {
|
|
||||||
include: {
|
|
||||||
asset: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
confidentiality: true,
|
|
||||||
integrity: true,
|
|
||||||
availability: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { role: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!process) notFound();
|
|
||||||
|
|
||||||
const canWrite = hasPermission(session, "bia:write");
|
|
||||||
const assignedIds = process.processAssets.map((pa) => pa.assetId);
|
|
||||||
const availableAssets = canWrite
|
|
||||||
? await db.asset.findMany({
|
|
||||||
where: { id: { notIn: assignedIds } },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
|
||||||
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
|
||||||
const bia = process.bia;
|
|
||||||
|
|
||||||
const assetList = (items: typeof primary) => {
|
|
||||||
return (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{items.map((pa) => (
|
|
||||||
<li key={pa.id} className="flex items-center gap-2">
|
|
||||||
<Link href={`/assets/${pa.asset.id}`} className="hover:underline">
|
|
||||||
{pa.asset.name}
|
|
||||||
</Link>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex-1 p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button variant="ghost" size="icon" nativeButton={false} render={<Link href="/processes" />}>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<h1 className="text-xl font-semibold">{process.name}</h1>
|
|
||||||
{bia && <CriticalityPill level={bia.criticality} label={tCrit(String(bia.criticality))} />}
|
|
||||||
</div>
|
|
||||||
{canWrite && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" nativeButton={false} render={<Link href={`/processes/${process.id}/edit`} />}>
|
|
||||||
<Pencil className="size-4" /> {tc("edit")}
|
|
||||||
</Button>
|
|
||||||
<form action={deleteProcess.bind(null, process.id)}>
|
|
||||||
<Button type="submit" variant="destructive">
|
|
||||||
<Trash2 className="size-4" /> {tc("delete")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("detailTitle")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
|
||||||
<dt className="text-muted-foreground">{t("description")}</dt>
|
|
||||||
<dd className="whitespace-pre-wrap">{process.description ?? tc("none")}</dd>
|
|
||||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
|
||||||
<dd>{process.owner?.name ?? tc("none")}</dd>
|
|
||||||
</dl>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("linkedRisks")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">{t("linkedRisksPlaceholder")}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("assets")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{t("primaryAssets")}</p>
|
|
||||||
<p className="mb-1 text-xs text-muted-foreground">{t("primaryHint")}</p>
|
|
||||||
{primary.length === 0 ? <p>{tc("none")}</p> : assetList(primary)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{t("secondaryAssets")}</p>
|
|
||||||
<p className="mb-1 text-xs text-muted-foreground">{t("secondaryHint")}</p>
|
|
||||||
{secondary.length === 0 ? <p>{tc("none")}</p> : assetList(secondary)}
|
|
||||||
</div>
|
|
||||||
{canWrite && availableAssets.length > 0 && (
|
|
||||||
<form
|
|
||||||
action={assignAsset.bind(null, process.id)}
|
|
||||||
className="flex flex-wrap gap-2 border-t pt-3"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("bia")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!canWrite && !bia && (
|
|
||||||
<p className="text-sm text-muted-foreground">{t("noBia")}</p>
|
|
||||||
)}
|
|
||||||
{!canWrite && bia && (
|
|
||||||
<dl className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
|
|
||||||
<dt className="text-muted-foreground">RTO</dt>
|
|
||||||
<dd>{bia.rtoHours != null ? `${bia.rtoHours} h` : tc("none")}</dd>
|
|
||||||
<dt className="text-muted-foreground">RPO</dt>
|
|
||||||
<dd>{bia.rpoHours != null ? `${bia.rpoHours} h` : tc("none")}</dd>
|
|
||||||
<dt className="text-muted-foreground">MTD</dt>
|
|
||||||
<dd>{bia.mtdHours != null ? `${bia.mtdHours} h` : tc("none")}</dd>
|
|
||||||
<dt className="text-muted-foreground">{t("impact")}</dt>
|
|
||||||
<dd>
|
|
||||||
<CiaBadge c={bia.impactC} i={bia.impactI} a={bia.impactA} />
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
{canWrite && (
|
|
||||||
<form action={saveBia.bind(null, process.id)} className="space-y-4 text-sm">
|
|
||||||
<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}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
defaultValue={value ?? ""}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<fieldset>
|
|
||||||
<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 htmlFor={name}>{label}</Label>
|
|
||||||
<select
|
|
||||||
id={name}
|
|
||||||
name={name}
|
|
||||||
defaultValue={value ?? 1}
|
|
||||||
className={`${selectClass} mt-1 w-full`}
|
|
||||||
>
|
|
||||||
{LEVELS.map((l) => (
|
|
||||||
<option key={l} value={l}>
|
|
||||||
{l} — {tCrit(String(l))}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="notes">{t("notes")}</Label>
|
|
||||||
<Textarea
|
|
||||||
id="notes"
|
|
||||||
name="notes"
|
|
||||||
rows={3}
|
|
||||||
defaultValue={bia?.notes ?? ""}
|
|
||||||
className="mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit">{tc("save")}</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,6 @@
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { redirect } from "next/navigation";
|
||||||
import { requireSession } from "@/server/auth";
|
|
||||||
import { dbForTenant } from "@/server/db";
|
|
||||||
import { requirePermission } from "@/server/rbac";
|
|
||||||
import { createProcess } from "@/server/actions/processes";
|
|
||||||
import { ProcessForm } from "@/components/process-form";
|
|
||||||
|
|
||||||
export default async function NewProcessPage() {
|
// Alte Route — Anlegen läuft jetzt als Popup über die Listen-Seite.
|
||||||
const session = await requireSession();
|
export default function Page() {
|
||||||
requirePermission(session, "bia:write");
|
redirect("/processes?new=1");
|
||||||
const t = await getTranslations("processes");
|
|
||||||
|
|
||||||
const db = dbForTenant(session.user.tenantId);
|
|
||||||
const users = await db.user.findMany({
|
|
||||||
where: { status: "ACTIVE" },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="flex-1 p-6">
|
|
||||||
<h1 className="text-xl font-semibold">{t("createTitle")}</h1>
|
|
||||||
<div className="mt-6">
|
|
||||||
<ProcessForm action={createProcess} users={users} cancelHref="/processes" />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Plus, X, Pencil } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ModuleTabs } from "@/components/module-tabs";
|
import { ModuleTabs } from "@/components/module-tabs";
|
||||||
|
import { CriticalityPill, PageHead, SectTitle } from "@/components/mockup-ui";
|
||||||
import {
|
import {
|
||||||
CiaBadge,
|
ProcessCreateModal,
|
||||||
CriticalityPill,
|
ProcessDetailModal,
|
||||||
PageHead,
|
ProcessEditModal,
|
||||||
Pill,
|
} from "@/components/process-modals";
|
||||||
SectTitle,
|
|
||||||
Tag,
|
|
||||||
} from "@/components/mockup-ui";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -23,21 +21,41 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
const PROCESS_INCLUDE = {
|
||||||
|
owner: { 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 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export default async function ProcessesPage({
|
export default async function ProcessesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ detail?: string }>;
|
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "bia:read");
|
requirePermission(session, "bia:read");
|
||||||
const t = await getTranslations("processes");
|
const t = await getTranslations("processes");
|
||||||
const tAssets = await getTranslations("assets");
|
const tAssets = await getTranslations("assets");
|
||||||
const tType = await getTranslations("assetType");
|
|
||||||
const tCrit = await getTranslations("criticality");
|
const tCrit = await getTranslations("criticality");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
|
|
||||||
const { detail } = await searchParams;
|
const params = await searchParams;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
const canWrite = hasPermission(session, "bia:write");
|
||||||
|
|
||||||
const processes = await db.process.findMany({
|
const processes = await db.process.findMany({
|
||||||
include: {
|
include: {
|
||||||
@@ -48,37 +66,30 @@ export default async function ProcessesPage({
|
|||||||
take: 200,
|
take: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read-only-Detail fürs Popup (?detail=<id>)
|
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
|
||||||
const detailProcess = detail
|
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||||
? await db.process.findUnique({
|
const modalProcess = modalId
|
||||||
where: { id: detail },
|
? await db.process.findUnique({ where: { id: modalId }, include: PROCESS_INCLUDE })
|
||||||
include: {
|
|
||||||
owner: { 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 } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const canWrite = hasPermission(session, "bia:write");
|
const users =
|
||||||
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
|
canWrite && (params.edit || params.new)
|
||||||
|
? await db.user.findMany({
|
||||||
|
where: { status: "ACTIVE" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const availableAssets =
|
||||||
|
canWrite && params.edit && modalProcess
|
||||||
|
? await db.asset.findMany({
|
||||||
|
where: { id: { notIn: modalProcess.processAssets.map((pa) => pa.assetId) } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
const primary = detailProcess?.processAssets.filter((pa) => pa.role === "PRIMARY") ?? [];
|
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
|
||||||
const secondary = detailProcess?.processAssets.filter((pa) => pa.role === "SECONDARY") ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
@@ -92,7 +103,7 @@ export default async function ProcessesPage({
|
|||||||
{t("biaReport")}
|
{t("biaReport")}
|
||||||
</Button>
|
</Button>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<Button nativeButton={false} render={<Link href="/processes/new" />}>
|
<Button nativeButton={false} render={<Link href="/processes?new=1" />}>
|
||||||
<Plus className="size-4" /> {t("newProcess")}
|
<Plus className="size-4" /> {t("newProcess")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -160,144 +171,13 @@ export default async function ProcessesPage({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailProcess && (
|
{modalProcess && params.edit && canWrite ? (
|
||||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]">
|
<ProcessEditModal process={modalProcess} users={users} availableAssets={availableAssets} />
|
||||||
<div className="shadow-card w-full max-w-3xl rounded-2xl border bg-card">
|
) : modalProcess ? (
|
||||||
<div className="flex items-start justify-between gap-3 border-b p-5">
|
<ProcessDetailModal process={modalProcess} canWrite={canWrite} />
|
||||||
<SectTitle
|
) : params.new && canWrite ? (
|
||||||
title={t("detailHeading", { name: detailProcess.name })}
|
<ProcessCreateModal users={users} />
|
||||||
sub={t("detailSub")}
|
) : null}
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{detailProcess.bia && (
|
|
||||||
<CriticalityPill
|
|
||||||
level={detailProcess.bia.criticality}
|
|
||||||
label={t("critLabel", {
|
|
||||||
label: tCrit(String(detailProcess.bia.criticality)),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Link
|
|
||||||
href="/processes"
|
|
||||||
title={t("close")}
|
|
||||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="size-4.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-[#5d52a3] bg-[#faf9fd] 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}
|
|
||||||
/>
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
|
|
||||||
<div className="grid gap-2 sm:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<b>RTO</b>
|
|
||||||
<div className="mt-0.5">{hours(detailProcess.bia?.rtoHours)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>RPO</b>
|
|
||||||
<div className="mt-0.5">{hours(detailProcess.bia?.rpoHours)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>MTD</b>
|
|
||||||
<div className="mt-0.5">{hours(detailProcess.bia?.mtdHours)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<b>{t("impactShort")}</b>
|
|
||||||
<div className="mt-1">
|
|
||||||
{detailProcess.bia ? (
|
|
||||||
<CiaBadge
|
|
||||||
c={detailProcess.bia.impactC}
|
|
||||||
i={detailProcess.bia.impactI}
|
|
||||||
a={detailProcess.bia.impactA}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t("noBia")
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{detailProcess.bia?.notes && (
|
|
||||||
<p className="mt-3 border-t border-[#e6e2f3] pt-2.5">{detailProcess.bia.notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 p-5">
|
|
||||||
{canWrite && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
nativeButton={false}
|
|
||||||
render={<Link href={`/processes/${detailProcess.id}`} />}
|
|
||||||
>
|
|
||||||
<Pencil className="size-4" /> {tc("edit")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button nativeButton={false} render={<Link href="/processes" />}>
|
|
||||||
{t("close")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Pencil, 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, Pill, Tag } from "@/components/mockup-ui";
|
||||||
|
|
||||||
|
export type AssetWithRelations = Prisma.AssetGetPayload<{
|
||||||
|
include: {
|
||||||
|
owner: { select: { name: true } };
|
||||||
|
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true } } } };
|
||||||
|
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
|
||||||
|
processAssets: { include: { process: { select: { id: true; name: true } } } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
||||||
|
|
||||||
|
/** Read-only-Detailansicht als Popup — keine veränderbaren Werte. */
|
||||||
|
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 tc = await getTranslations("common");
|
||||||
|
const tp = await getTranslations("processes");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={asset.name}
|
||||||
|
sub={t("detailTitle")}
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<dl className="grid grid-cols-[8.5rem_1fr] gap-2 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{t("description")}</dt>
|
||||||
|
<dd className="whitespace-pre-wrap">{asset.description ?? tc("none")}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||||
|
<dd>{asset.owner?.name ?? tc("none")}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("location")}</dt>
|
||||||
|
<dd>{asset.location ?? tc("none")}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("protection")}</dt>
|
||||||
|
<dd>
|
||||||
|
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">Tags</dt>
|
||||||
|
<dd className="flex flex-wrap gap-1">
|
||||||
|
{asset.tags.length === 0 ? tc("none") : asset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{t("relations")}</p>
|
||||||
|
<p className="mt-2 text-[12.5px] text-muted-foreground">{t("relationHint")}</p>
|
||||||
|
{asset.relationsFrom.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{tc("none")}</p>
|
||||||
|
)}
|
||||||
|
<ul className="mt-1 space-y-1 text-sm">
|
||||||
|
{asset.relationsFrom.map((rel) => (
|
||||||
|
<li key={rel.id}>
|
||||||
|
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">
|
||||||
|
{rel.relatedAsset.name}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
|
||||||
|
<b>{t("linkedRisks")}</b>
|
||||||
|
<p className="mt-1">{t("linkedRisksPlaceholder")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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,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-3xl 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,382 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Pencil, Trash2, X } from "lucide-react";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
assignAsset,
|
||||||
|
createProcess,
|
||||||
|
deleteProcess,
|
||||||
|
saveBia,
|
||||||
|
unassignAsset,
|
||||||
|
updateProcess,
|
||||||
|
} 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 { CiaBadge, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
||||||
|
|
||||||
|
export type ProcessWithDetail = Prisma.ProcessGetPayload<{
|
||||||
|
include: {
|
||||||
|
owner: { 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 } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const LEVELS = [1, 2, 3, 4] as const;
|
||||||
|
|
||||||
|
/** Anlegen als Popup (Stammdaten; Assets/BIA folgen im Bearbeiten-Popup). */
|
||||||
|
export async function ProcessCreateModal({
|
||||||
|
users,
|
||||||
|
}: {
|
||||||
|
users: { id: string; name: string }[];
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations("processes");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("createTitle")} closeHref="/processes" closeLabel={t("close")}>
|
||||||
|
<div className="p-5">
|
||||||
|
<ProcessForm action={createProcess} users={users} cancelHref="/processes" />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only-Prozess-Detail als Popup (Mockup: "Prozess-Detail · …"). */
|
||||||
|
export async function ProcessDetailModal({
|
||||||
|
process,
|
||||||
|
canWrite,
|
||||||
|
}: {
|
||||||
|
process: ProcessWithDetail;
|
||||||
|
canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations("processes");
|
||||||
|
const tAssets = await getTranslations("assets");
|
||||||
|
const tType = await getTranslations("assetType");
|
||||||
|
const tCrit = await getTranslations("criticality");
|
||||||
|
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={
|
||||||
|
process.bia && (
|
||||||
|
<CriticalityPill
|
||||||
|
level={process.bia.criticality}
|
||||||
|
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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-[#5d52a3] bg-[#faf9fd] 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}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-5 mb-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
|
||||||
|
<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-[#e6e2f3] pt-2.5">{process.bia.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bearbeiten im selben Popup: Stammdaten, Asset-Zuordnung, BIA, Löschen. */
|
||||||
|
export async function ProcessEditModal({
|
||||||
|
process,
|
||||||
|
users,
|
||||||
|
availableAssets,
|
||||||
|
}: {
|
||||||
|
process: ProcessWithDetail;
|
||||||
|
users: { id: string; name: string }[];
|
||||||
|
availableAssets: { id: string; name: string }[];
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations("processes");
|
||||||
|
const tAssets = await getTranslations("assets");
|
||||||
|
const tRole = await getTranslations("processRole");
|
||||||
|
const tCrit = await getTranslations("criticality");
|
||||||
|
const tc = await getTranslations("common");
|
||||||
|
|
||||||
|
const bia = process.bia;
|
||||||
|
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||||
|
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
||||||
|
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||||
|
|
||||||
|
const assetRow = (pa: ProcessWithDetail["processAssets"][number]) => (
|
||||||
|
<li key={pa.id} className="flex items-center gap-2">
|
||||||
|
{pa.asset.name}
|
||||||
|
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t("editTitle")}
|
||||||
|
sub={process.name}
|
||||||
|
closeHref={`/processes?detail=${process.id}`}
|
||||||
|
closeLabel={t("close")}
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 p-5 md:grid-cols-2">
|
||||||
|
{/* Stammdaten */}
|
||||||
|
<form action={updateProcess.bind(null, process.id)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">{t("name")}</Label>
|
||||||
|
<Input id="name" name="name" required defaultValue={process.name} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">{t("description")}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
defaultValue={process.description ?? ""}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||||
|
<select
|
||||||
|
id="ownerId"
|
||||||
|
name="ownerId"
|
||||||
|
defaultValue={process.ownerId ?? ""}
|
||||||
|
className={`${selectClass} mt-1 w-full`}
|
||||||
|
>
|
||||||
|
<option value="">{tc("none")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">{tc("save")}</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Asset-Zuordnung */}
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("primaryAssets")}</p>
|
||||||
|
<p className="mb-1 text-xs text-muted-foreground">{t("primaryHint")}</p>
|
||||||
|
{primary.length === 0 ? <p>{tc("none")}</p> : <ul className="space-y-1">{primary.map(assetRow)}</ul>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("secondaryAssets")}</p>
|
||||||
|
<p className="mb-1 text-xs text-muted-foreground">{t("secondaryHint")}</p>
|
||||||
|
{secondary.length === 0 ? <p>{tc("none")}</p> : <ul className="space-y-1">{secondary.map(assetRow)}</ul>}
|
||||||
|
</div>
|
||||||
|
{availableAssets.length > 0 && (
|
||||||
|
<form
|
||||||
|
action={assignAsset.bind(null, process.id)}
|
||||||
|
className="flex flex-wrap gap-2 border-t pt-3"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BIA */}
|
||||||
|
<div className="border-t p-5">
|
||||||
|
<p className="font-heading text-[15px] font-semibold">{t("bia")}</p>
|
||||||
|
{/* key erzwingt Remount nach dem Speichern — sonst behalten uncontrolled Inputs alte Werte */}
|
||||||
|
<form
|
||||||
|
key={bia?.updatedAt.toISOString() ?? "new"}
|
||||||
|
action={saveBia.bind(null, process.id)}
|
||||||
|
className="mt-3 space-y-4 text-sm"
|
||||||
|
>
|
||||||
|
<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} type="number" min={0} defaultValue={value ?? ""} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<fieldset>
|
||||||
|
<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 htmlFor={name}>{label}</Label>
|
||||||
|
<select id={name} name={name} defaultValue={value ?? 1} className={`${selectClass} mt-1 w-full`}>
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{l} — {tCrit(String(l))}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="notes">{t("notes")}</Label>
|
||||||
|
<Textarea id="notes" name="notes" rows={3} defaultValue={bia?.notes ?? ""} className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button type="submit">{tc("save")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="mt-4 border-t pt-4">
|
||||||
|
<form action={deleteProcess.bind(null, process.id)}>
|
||||||
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
|
<Trash2 className="size-4" /> {tc("delete")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ export async function createAsset(formData: FormData) {
|
|||||||
after: data,
|
after: data,
|
||||||
});
|
});
|
||||||
revalidatePath("/assets");
|
revalidatePath("/assets");
|
||||||
redirect(`/assets/${asset.id}`);
|
redirect(`/assets?detail=${asset.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAsset(assetId: string, formData: FormData) {
|
export async function updateAsset(assetId: string, formData: FormData) {
|
||||||
@@ -91,8 +91,7 @@ export async function updateAsset(assetId: string, formData: FormData) {
|
|||||||
after: data,
|
after: data,
|
||||||
});
|
});
|
||||||
revalidatePath("/assets");
|
revalidatePath("/assets");
|
||||||
revalidatePath(`/assets/${assetId}`);
|
redirect(`/assets?detail=${assetId}`);
|
||||||
redirect(`/assets/${assetId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAsset(assetId: string) {
|
export async function deleteAsset(assetId: string) {
|
||||||
@@ -150,7 +149,7 @@ export async function addAssetRelation(assetId: string, formData: FormData) {
|
|||||||
entityId: assetId,
|
entityId: assetId,
|
||||||
after: { assetId, relatedAssetId, type: "depends_on" },
|
after: { assetId, relatedAssetId, type: "depends_on" },
|
||||||
});
|
});
|
||||||
revalidatePath(`/assets/${assetId}`);
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeAssetRelation(assetId: string, relationId: string) {
|
export async function removeAssetRelation(assetId: string, relationId: string) {
|
||||||
@@ -170,5 +169,5 @@ export async function removeAssetRelation(assetId: string, relationId: string) {
|
|||||||
entityId: relationId,
|
entityId: relationId,
|
||||||
before,
|
before,
|
||||||
});
|
});
|
||||||
revalidatePath(`/assets/${assetId}`);
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function createProcess(formData: FormData) {
|
|||||||
after: data,
|
after: data,
|
||||||
});
|
});
|
||||||
revalidatePath("/processes");
|
revalidatePath("/processes");
|
||||||
redirect(`/processes/${process.id}`);
|
redirect(`/processes?edit=${process.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProcess(processId: string, formData: FormData) {
|
export async function updateProcess(processId: string, formData: FormData) {
|
||||||
@@ -71,8 +71,7 @@ export async function updateProcess(processId: string, formData: FormData) {
|
|||||||
after: data,
|
after: data,
|
||||||
});
|
});
|
||||||
revalidatePath("/processes");
|
revalidatePath("/processes");
|
||||||
revalidatePath(`/processes/${processId}`);
|
redirect(`/processes?detail=${processId}`);
|
||||||
redirect(`/processes/${processId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProcess(processId: string) {
|
export async function deleteProcess(processId: string) {
|
||||||
@@ -123,7 +122,7 @@ export async function assignAsset(processId: string, formData: FormData) {
|
|||||||
entityId: processId,
|
entityId: processId,
|
||||||
after: { processId, assetId, role },
|
after: { processId, assetId, role },
|
||||||
});
|
});
|
||||||
revalidatePath(`/processes/${processId}`);
|
revalidatePath("/processes");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unassignAsset(processId: string, processAssetId: string) {
|
export async function unassignAsset(processId: string, processAssetId: string) {
|
||||||
@@ -143,7 +142,7 @@ export async function unassignAsset(processId: string, processAssetId: string) {
|
|||||||
entityId: processAssetId,
|
entityId: processAssetId,
|
||||||
before,
|
before,
|
||||||
});
|
});
|
||||||
revalidatePath(`/processes/${processId}`);
|
revalidatePath("/processes");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = z
|
const hours = z
|
||||||
@@ -188,5 +187,4 @@ export async function saveBia(processId: string, formData: FormData) {
|
|||||||
after: { ...data, criticality },
|
after: { ...data, criticality },
|
||||||
});
|
});
|
||||||
revalidatePath("/processes");
|
revalidatePath("/processes");
|
||||||
revalidatePath(`/processes/${processId}`);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user