Lieferantenmodul neu: Asset-basiert + Anforderungs-Engine (Phase 1)

Referenz: docs/ISMS-Lieferantenmanagement-GEFIM.html

Architektur: Lieferanten (und IT-Services) sind jetzt Assets.
- AssetType += IT_SERVICE; SupplierProfile/ITServiceProfile als 1:1-
  Erweiterung eines Asset; alle Kind-Entitäten (Contract, Nda,
  SupplierEvidence, SupplierAssessment, ManagementDecision, Subcontractor,
  ServiceControlResponsibility, MaturityAssessment) hängen am Asset
  (keine parallele Datenhaltung). Migration + RLS; Demo neu geseedet.
- Lieferanten erscheinen im Asset-Inventar (Filter „Lieferant"/„IT-Service"),
  tragen C/I/A, nutzbar in Graph/BIA/Risiko.

Anforderungs-Engine (Herzstück, src/lib/supplier.ts):
- Schutzbedarf aus max C/I/A der verknüpften Assets → Stufe normal/hoch/
  sehr hoch; schaltet Anforderungen gestuft scharf (must/should/high/veryhigh)
- Flache Anforderungsliste (nach Themen gruppiert) mit Status-Ableitung aus
  den hinterlegten Objekten, Referenz + Herkunfts-Control (6.1.1–6.1.3)
- Gate-Logik: sehr hoch ohne Audit/Label → Kompensation (Management-
  entscheidung UND verknüpftes Risiko); „Risiko anlegen" erzeugt echten Risk
- Konformität (Konform/Teilweise/Lücken/Handlung nötig) + berechneter
  Reifegrad; ISB-Freigabe (Abweichung nur mit Begründung → Audit-Log)
- Cockpit-Detail mit Schutzbedarf-Banner + Live-Simulation der Stufe

Verifiziert: Register mit abgeleiteter Stufe/Reifegrad/Konformität,
Cockpit-Simulation (Tiers werden inaktiv), Gate bei sehr hoch ohne Audit,
„Risiko anlegen" erzeugt R-005 im Risikomodul.

Offen (Phase 2): Fragebogen-Builder, Self-Service-Portal, IT-Service-
Detail mit RACI-Matrix, Klick im Asset-Inventar öffnet Cockpit,
Scheduler-Automatisierung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-03 11:57:17 +02:00
co-authored by Claude Opus 4.8
parent 32ab91efbf
commit dc884062b2
17 changed files with 1605 additions and 972 deletions
+192 -414
View File
@@ -1,19 +1,18 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { Pencil, Plus, Trash2, X, AlertTriangle } from "lucide-react";
import type { Prisma, SupplierControl } from "@prisma/client";
import { Pencil, Plus, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
addAssessment,
addContract,
addDecision,
addEvidence,
addNda,
addResponsibility,
addSubcontractor,
approveMaturity,
createGateRisk,
createSupplier,
deleteChild,
deleteSupplier,
saveMaturity,
updateSupplier,
} from "@/server/actions/suppliers";
import { Button } from "@/components/ui/button";
@@ -22,59 +21,91 @@ 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, CriticalityPill, Pill } from "@/components/mockup-ui";
import { supplierRef, SUPPLIER_STATUS_TONE, isExpired, isExpiring, needsManagementDecision } from "@/lib/supplier";
import { Pill } from "@/components/mockup-ui";
import { SupplierRequirements } from "@/components/supplier-cockpit";
import {
supplierRef,
SUPPLIER_LIFECYCLE_TONE,
protectionLevel,
PROTECTION_LABEL,
buildReqContext,
REQUIREMENTS,
conformity,
computedMaturity,
CONFORMITY_TONE,
CONFORMITY_LABEL,
TARGET_MATURITY,
isExpired,
isExpiring,
} from "@/lib/supplier";
export type SupplierWithDetail = Prisma.SupplierGetPayload<{
export type SupplierAssetDetail = Prisma.AssetGetPayload<{
include: {
assessments: true;
supplierProfile: true;
contracts: true;
ndas: true;
evidence: true;
responsibilities: true;
assessments: true;
subcontractors: true;
decisions: true;
controlMaturity: true;
assetLinks: { include: { asset: { select: { id: true; name: 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 STATUSES = ["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"] as const;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
/* ─────────────────────────── Detail (read-only) ─────────────────────────── */
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 };
}
export async function SupplierDetailModal({
supplier,
controls,
canWrite,
}: {
supplier: SupplierWithDetail;
controls: SupplierControl[];
canWrite: boolean;
}) {
/* ─────────────────────── Cockpit (Detail) ─────────────────────── */
export async function SupplierDetailModal({ supplier, canWrite }: { supplier: SupplierAssetDetail; canWrite: boolean }) {
const t = await getTranslations("suppliers");
const tStatus = await getTranslations("supplierStatus");
const tCrit = await getTranslations("criticality");
const tAType = await getTranslations("assessmentType");
const tLife = await getTranslations("supplierStatus");
const tEKind = await getTranslations("evidenceKind");
const tParty = await getTranslations("responsibleParty");
const tc = await getTranslations("common");
const fmt = await getFormatter();
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : tc("none"));
const decisionNeeded = needsManagementDecision(supplier.evidence);
const matById = new Map(supplier.controlMaturity.map((m) => [m.controlId, m.maturity]));
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 gateEvidenceHref = `/suppliers?edit=${supplier.id}`;
const gateDecisionHref = `/suppliers?edit=${supplier.id}`;
return (
<Modal
title={`${supplierRef(supplier.refNo)} · ${supplier.name}`}
sub={t("detailSub")}
title={`${supplierRef(p.refNo)} · ${supplier.name}`}
sub={p.serviceDesc ?? t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
{supplier.nis2Relevant && <Pill tone="info">NIS2</Pill>}
<CriticalityPill level={supplier.criticality} label={tCrit(String(supplier.criticality))} />
<Pill tone={SUPPLIER_STATUS_TONE[supplier.status]}>{tStatus(supplier.status)}</Pill>
{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="/suppliers"
@@ -86,234 +117,112 @@ export async function SupplierDetailModal({
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/suppliers" />}>
{t("close")}
</Button>
<Button nativeButton={false} render={<Link href="/suppliers" />}>{t("close")}</Button>
</>
}
>
<div className="space-y-5 p-5">
{/* Stammdaten + Assets */}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4 text-[12.5px]">
<dl className="grid grid-cols-[9rem_1fr] gap-1.5">
<dt className="text-muted-foreground">{t("sector")}</dt>
<dd>{supplier.sector ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("services")}</dt>
<dd>{supplier.services ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("contact")}</dt>
<dd>{supplier.contact ?? tc("none")}</dd>
<dt className="text-muted-foreground">{t("dataCategories")}</dt>
<dd>{supplier.dataCategories.join(", ") || tc("none")}</dd>
<dt className="text-muted-foreground">{t("protection")}</dt>
<dd><CiaBadge c={supplier.confidentiality} i={supplier.integrity} a={supplier.availability} /></dd>
<dt className="text-muted-foreground">{t("nextReview")}</dt>
<dd>{date(supplier.nextReview)}</dd>
</dl>
</div>
<div>
<p className="text-sm font-semibold">{t("assets")}</p>
{supplier.assetLinks.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
<ul className="mt-1.5 space-y-1 text-sm">
{supplier.assetLinks.map((l) => (
<li key={l.id}>
<Link href={`/assets?detail=${l.asset.id}`} className="hover:underline">{l.asset.name}</Link>
</li>
))}
</ul>
{supplier.notes && <p className="mt-3 text-[12.5px] text-muted-foreground">{supplier.notes}</p>}
</div>
</div>
<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)}
/>
{/* Managemententscheidung nötig? */}
{decisionNeeded && (
<div className="flex items-start gap-2.5 rounded-xl border border-[rgba(240,173,78,0.4)] bg-[rgba(240,173,78,0.12)] p-3.5 text-[12.5px] text-[var(--warn)]">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<span>{t("decisionNeeded")}</span>
</div>
)}
{/* VDA-ISA Kap. 6 Reifegrade */}
{/* Risiken */}
<section>
<p className="font-heading text-[15px] font-semibold">{t("catalog")}</p>
<p className="text-[12.5px] text-muted-foreground">{t("catalogNote")}</p>
<div className="mt-2 space-y-2">
{controls.map((c) => {
const m = matById.get(c.id) ?? 0;
return (
<div key={c.id} className="rounded-xl border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[13px] font-semibold">
{c.ref} · {c.objective}
</span>
<span className="shrink-0 text-[12px] text-muted-foreground">
{t("maturity")} {m}/5 · {t("target")} {c.targetMaturity}
</span>
</div>
<div className="mt-2 flex gap-1">
{[1, 2, 3, 4, 5].map((s) => (
<span
key={s}
className="h-2 flex-1 rounded-full"
style={{
background:
s <= m
? m >= c.targetMaturity
? "var(--ok)"
: "var(--warn)"
: "rgba(120,135,180,0.2)",
}}
/>
))}
</div>
{c.references && (
<p className="mt-2 text-[11px] text-muted-foreground">{t("references")}: {c.references}</p>
)}
</div>
);
})}
</div>
<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>
{/* Nachweise */}
<ListSection title={t("evidence")}>
{supplier.evidence.length === 0 ? (
<Empty t={tc("none")} />
) : (
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.protectsCia && <span className="text-xs text-muted-foreground">({e.protectsCia})</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>
))
)}
</ListSection>
{/* 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>
{/* Bewertungen */}
<ListSection title={t("assessments")}>
{supplier.assessments.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.assessments.map((a) => (
<li key={a.id} className="flex flex-wrap items-center gap-2">
<Pill tone="info">{tAType(a.type)}</Pill>
{a.score != null && <span className="font-semibold">{a.score}/100</span>}
{a.result && <span>{a.result}</span>}
<span className="text-xs text-muted-foreground">{date(a.date)}</span>
</li>
))
)}
</ListSection>
{/* Verträge */}
<ListSection title={t("contracts")}>
{supplier.contracts.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.contracts.map((k) => (
<li key={k.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{k.reference ?? k.type}</span>
{k.avDpa && <Pill tone="ok">{t("avDpa")}</Pill>}
{k.flowdown && <Pill tone="info">{t("flowdown")}</Pill>}
{k.validTo && (
<span className={isExpiring(k.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>
{t("validTo")}: {date(k.validTo)}
</span>
)}
</li>
))
)}
</ListSection>
{/* NDAs */}
<ListSection title={t("ndas")}>
{supplier.ndas.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.ndas.map((n) => (
<li key={n.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{n.subject ?? n.parties ?? "NDA"}</span>
{n.validTo && (
<span className={isExpiring(n.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>
{t("validTo")}: {date(n.validTo)}
</span>
)}
{n.extensionStatus && <span className="text-xs text-muted-foreground">· {n.extensionStatus}</span>}
</li>
))
)}
</ListSection>
{/* RACI */}
<ListSection title={t("raci")}>
{supplier.responsibilities.length === 0 ? (
<Empty t={tc("none")} />
) : (
supplier.responsibilities.map((r) => (
<li key={r.id} className="flex flex-wrap items-center gap-2">
<span className="font-medium">{r.itService}</span>
<span className="text-muted-foreground">· {r.requirement}</span>
<Pill tone={r.responsibleParty === "SUPPLIER" ? "warn" : r.responsibleParty === "CLIENT" ? "info" : "violet"}>
{tParty(r.responsibleParty)}
</Pill>
</li>
))
)}
</ListSection>
{/* Subunternehmer */}
{supplier.subcontractors.length > 0 && (
<ListSection title={t("subcontractors")}>
{supplier.subcontractors.map((s) => (
<li key={s.id} className="flex items-center gap-2">
{s.name} {s.flowdownObligation && <Pill tone="ok">{t("flowdownObl")}</Pill>}
</li>
))}
</ListSection>
{/* 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>
)}
{/* Entscheidungen */}
{supplier.decisions.length > 0 && (
<ListSection title={t("decision")}>
{supplier.decisions.map((d) => (
<li key={d.id}>
<span className="font-medium">{d.decision}</span>
<span className="text-muted-foreground"> {d.reasonNoAudit}</span>
<span className="text-xs text-muted-foreground"> · {d.decidedBy} · {date(d.date)}</span>
</li>
))}
</ListSection>
)}
{/* 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>
);
}
function ListSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<p className="text-sm font-semibold">{title}</p>
<ul className="mt-1.5 space-y-1.5 text-sm">{children}</ul>
</section>
);
}
function Empty({ t }: { t: string }) {
return <li className="text-muted-foreground">{t}</li>;
}
/* ─────────────────────── Stammdaten-Formular ─────────────────────── */
/* ─────────────────────────── Stammdaten-Formular ─────────────────────────── */
async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDetail; formId?: string }) {
async function SupplierFields({ supplier, formId }: { supplier?: SupplierAssetDetail; formId?: string }) {
const t = await getTranslations("suppliers");
const tStatus = await getTranslations("supplierStatus");
const tLife = await getTranslations("supplierStatus");
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">
@@ -322,33 +231,27 @@ async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDet
</div>
<div>
<Label htmlFor="sector">{t("sector")}</Label>
<Input id="sector" name="sector" defaultValue={supplier?.sector ?? ""} className="mt-1" {...f} />
<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={supplier?.contact ?? ""} className="mt-1" {...f} />
<Input id="contact" name="contact" defaultValue={p?.contact ?? ""} className="mt-1" {...f} />
</div>
<div className="md:col-span-2">
<Label htmlFor="services">{t("services")}</Label>
<Textarea id="services" name="services" rows={2} defaultValue={supplier?.services ?? ""} className="mt-1" {...f} />
<Label htmlFor="serviceDesc">{t("services")}</Label>
<Textarea id="serviceDesc" name="serviceDesc" rows={2} defaultValue={p?.serviceDesc ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select id="status" name="status" defaultValue={supplier?.status ?? "ACTIVE"} className={`${inputCls} mt-1`} {...f}>
{STATUSES.map((v) => (
<option key={v} value={v}>{tStatus(v)}</option>
<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 htmlFor="criticality">{t("criticality")}</Label>
<div className="mt-1">
<SegmentedRating name="criticality" defaultValue={supplier?.criticality ?? 1} form={formId} />
</div>
</div>
<div className="md:col-span-2">
<Label htmlFor="dataCategories">{t("dataCategories")}</Label>
<Input id="dataCategories" name="dataCategories" defaultValue={supplier?.dataCategories.join(", ") ?? ""} className="mt-1" {...f} />
<Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div>
<div>
<Label>{t("protection")} (C/I/A)</Label>
@@ -360,14 +263,18 @@ async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDet
</div>
<div>
<Label htmlFor="nextReview">{t("nextReview")}</Label>
<Input id="nextReview" name="nextReview" type="date" defaultValue={supplier?.nextReview ? supplier.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
<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={supplier?.nis2Relevant} {...f} /> {t("nis2")}
<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={supplier?.notes ?? ""} className="mt-1" {...f} />
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
</div>
</div>
);
@@ -389,40 +296,27 @@ export async function SupplierCreateModal() {
);
}
/* ─────────────────────────── Bearbeiten ─────────────────────────── */
/* ─────────────────────── Bearbeiten ─────────────────────── */
export async function SupplierEditModal({
supplier,
controls,
}: {
supplier: SupplierWithDetail;
controls: SupplierControl[];
}) {
export async function SupplierEditModal({ supplier }: { supplier: SupplierAssetDetail }) {
const t = await getTranslations("suppliers");
const tc = await getTranslations("common");
const tEKind = await getTranslations("evidenceKind");
const tAType = await getTranslations("assessmentType");
const tParty = await getTranslations("responsibleParty");
const FORM = "supplier-edit";
const matById = new Map(supplier.controlMaturity.map((m) => [m.controlId, m.maturity]));
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={`${supplierRef(supplier.refNo)} · ${supplier.name}`}
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>
<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)}>
<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>
<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>
@@ -440,41 +334,9 @@ export async function SupplierEditModal({
<div className="space-y-6 p-5">
<SupplierFields supplier={supplier} formId={FORM} />
{/* Reifegrade je Control */}
<section className="border-t pt-4">
<p className="font-heading text-[15px] font-semibold">{t("catalog")}</p>
<div className="mt-2 space-y-2">
{controls.map((c) => (
<form key={c.id} action={saveMaturity.bind(null, supplier.id, c.id)} className="flex items-center gap-3 text-sm">
<span className="flex-1"><b>{c.ref}</b> · {t("target")} {c.targetMaturity}</span>
<select name="maturity" defaultValue={matById.get(c.id) ?? 0} className={`${inputCls} w-28`}>
{[0, 1, 2, 3, 4, 5].map((v) => (
<option key={v} value={v}>{t("maturity")} {v}</option>
))}
</select>
<Button type="submit" variant="secondary" size="sm">{tc("save")}</Button>
</form>
))}
</div>
</section>
{/* Nachweise */}
<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")}
discClass={disc}
>
<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}>
{(["CERTIFICATE", "TISAX_LABEL", "ATTESTATION", "AUDIT_REPORT", "SELF_ASSESSMENT"] as const).map((k) => (
<option key={k} value={k}>{tEKind(k)}</option>
))}
</select>
<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" />
@@ -483,119 +345,44 @@ export async function SupplierEditModal({
</form>
</ChildSection>
{/* Bewertungen */}
<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")}
discClass={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}>
{(["QUESTIONNAIRE", "SELF_ASSESSMENT", "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="date" type="date" />
<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>
{/* Verträge */}
<ChildSection
title={t("contracts")}
items={supplier.contracts.map((k) => ({ id: k.id, label: `${k.reference ?? k.type}${k.avDpa ? " · AV" : ""}` }))}
kind="contract"
addLabel={t("addContract")}
discClass={disc}
>
<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")} />
<Input name="type" placeholder={t("type")} defaultValue="av_dpa" />
<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" /> {t("securityClauses")}</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="customerTransparency" /> {t("customerTransparency")}</label>
<label className="flex items-center gap-1.5"><input type="checkbox" name="customerRequirementsPassed" /> {t("customerReq")}</label>
</div>
<Input name="validFrom" type="date" />
<Input name="validTo" type="date" />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* NDAs */}
<ChildSection
title={t("ndas")}
items={supplier.ndas.map((n) => ({ id: n.id, label: n.subject ?? n.parties ?? "NDA" }))}
kind="nda"
addLabel={t("addNda")}
discClass={disc}
>
<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="infoScope" placeholder={t("infoScope")} />
<Input name="validFrom" type="date" />
<Input name="validTo" type="date" />
<Input name="extensionStatus" placeholder={t("extensionStatus")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* RACI */}
<ChildSection
title={t("raci")}
items={supplier.responsibilities.map((r) => ({ id: r.id, label: `${r.itService} · ${tParty(r.responsibleParty)}` }))}
kind="responsibility"
addLabel={t("addRaci")}
discClass={disc}
>
<form action={addResponsibility.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="itService" required placeholder={t("itService")} />
<Input name="requirement" required placeholder={t("requirement")} />
<select name="responsibleParty" className={inputCls}>
{(["CLIENT", "SUPPLIER", "SHARED"] as const).map((k) => (
<option key={k} value={k}>{tParty(k)}</option>
))}
</select>
<Input name="isaApplicability" placeholder={t("isaApplicability")} />
<Input name="integratedLocalControls" placeholder={t("localControls")} />
<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>
{/* Subunternehmer */}
<ChildSection
title={t("subcontractors")}
items={supplier.subcontractors.map((s) => ({ id: s.id, label: s.name }))}
kind="subcontractor"
addLabel={t("addSub")}
discClass={disc}
>
<form action={addSubcontractor.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
<Input name="name" required placeholder={t("name")} />
<label className="flex items-center gap-2 text-[12.5px]"><input type="checkbox" name="flowdownObligation" defaultChecked /> {t("flowdownObl")}</label>
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
</ChildSection>
{/* Managemententscheidung */}
<ChildSection
title={t("decision")}
items={supplier.decisions.map((d) => ({ id: d.id, label: d.decision }))}
kind="decision"
addLabel={t("addDecision")}
discClass={disc}
>
<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="decidedBy" placeholder={t("decidedBy")} />
<Input name="recordRef" placeholder={t("recordRef")} />
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
</form>
@@ -606,18 +393,13 @@ export async function SupplierEditModal({
}
async function ChildSection({
title,
items,
kind,
addLabel,
discClass,
children,
title, items, kind, addLabel, disc, children,
}: {
title: string;
items: { id: string; label: string }[];
kind: "assessment" | "contract" | "nda" | "evidence" | "responsibility" | "subcontractor" | "decision";
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor";
addLabel: string;
discClass: string;
disc: string;
children: React.ReactNode;
}) {
const tc = await getTranslations("common");
@@ -626,9 +408,7 @@ async function ChildSection({
<div className="flex items-center justify-between gap-2">
<p className="font-heading text-[15px] font-semibold">{title}</p>
<details className="relative">
<summary className={discClass}>
<span className="inline-flex items-center gap-1.5"><Plus className="size-3.5" /> {addLabel}</span>
</summary>
<summary className={disc}><span className="inline-flex items-center gap-1.5"><Plus className="size-3.5" /> {addLabel}</span></summary>
{children}
</details>
</div>
@@ -638,9 +418,7 @@ async function ChildSection({
<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>
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
</form>
</li>
))}