Files
certvia/src/components/service-modals.tsx
T
msolarczekandClaude Opus 4.8 2706424c08 Lieferanten-/IT-Service-Cockpit direkt auf der Asset-Seite öffnen
Statt beim Klick auf ein Lieferanten-/IT-Service-Asset zur Lieferanten-Seite zu
wechseln, wird das Fach-Cockpit jetzt als Popup auf /assets gerendert; Schließen,
Bearbeiten, Speichern und Löschen bleiben auf der Asset-Seite.

- backHref-Prop an SupplierDetail/Edit- und ServiceDetail/Edit-Modal: alle
  Schließen-/Bearbeiten-/Abbrechen-Links leiten sich davon ab (Default /suppliers)
- withQuery-Helper (lib/supplier) für korrekte ?/&-Verkettung
- updateSupplier/updateService lesen returnTo (verstecktes Feld) und leiten dorthin
  zurück; deleteSupplier/deleteService bekommen returnTo-Parameter
- Kind-Actions revalidieren zusätzlich /assets, damit das Popup dort aktuell bleibt
- SUPPLIER_INCLUDE/SERVICE_INCLUDE in lib/supplier-include ausgelagert und von
  beiden Seiten genutzt
- assets/page.tsx bestimmt die Popup-Art vorab (supplier/service/asset) und rendert
  das passende Cockpit mit backHref="/assets"; profillose Alt-Assets bleiben Assets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:55:46 +02:00

374 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")} (14)</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>
);
}