Files
certvia/src/components/supplier-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

448 lines
22 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 { 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")} (03)</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")} (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={(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>
);
}