Iteration Teil D: Lieferantenmanagement (VDA-ISA 2027 Kap. 6 + NIS2)
Datenmodell (Prisma, RLS): Supplier + SupplierAsset, SupplierAssessment, Contract, Nda, SupplierEvidence, ServiceResponsibility, Subcontractor, ManagementDecision, SupplierControl (globaler Katalog) + SupplierControlMaturity. - VDA-ISA-2027-Kap.-6-Mini-Katalog (Controls 6.1.1–6.1.3) mit Zielbild, Muss/Soll, Anforderungen für hoch/sehr hoch, Simplified Group Assessment, Ziel-Reifegrad 3 und Cross-Referenzen (ISO/NIST/BSI); im Seed befüllt - Lieferantenverzeichnis mit KPIs (gesamt, NIS2-relevant, ablaufend, Reviews fällig), Kritikalität, NIS2-Flag, Review-Fristen - Detail-Popup: Stammdaten (Sektor/Leistung/Datenkategorien/CIA), betroffene Assets, VDA-ISA-Reifegrade je Control (Ziel 3, farbige Balken), Nachweise (Angemessenheit + Ablauf), Assessments, Verträge (AV/DPA, Flow-down, Fristen), NDAs (Fristen), Shared-Responsibility- Matrix, Subunternehmer, Managemententscheidungen - Bearbeiten-Popup: Stammdaten (ein Speichern), Reifegrad je Control, Add/Delete für alle Kind-Entitäten, Löschen im ⋯-Menü - Regel 6.1.1: fehlt geprüfter Audit-/TISAX-Nachweis → Warnung, dass eine dokumentierte risikobasierte Managemententscheidung nötig ist - Server-Actions mit Zod/RBAC/Audit-Log; Seed mit Demo-Lieferant (TISAX-Label, AV/DPA, NDA, Assessment, RACI, Reifegrade) - Menüpunkt „Lieferanten" aktiv; Lieferant ↔ Asset verknüpft (Graph) Verifiziert: Register/Detail/Bearbeiten dunkel & vollständig, Reifegrad- Save (6.1.2→4 mit Audit), Managemententscheidungs-Logik. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
18bd82e54a
commit
32ab91efbf
@@ -0,0 +1,651 @@
|
||||
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 {
|
||||
addAssessment,
|
||||
addContract,
|
||||
addDecision,
|
||||
addEvidence,
|
||||
addNda,
|
||||
addResponsibility,
|
||||
addSubcontractor,
|
||||
createSupplier,
|
||||
deleteChild,
|
||||
deleteSupplier,
|
||||
saveMaturity,
|
||||
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 { CiaBadge, CriticalityPill, Pill } from "@/components/mockup-ui";
|
||||
import { supplierRef, SUPPLIER_STATUS_TONE, isExpired, isExpiring, needsManagementDecision } from "@/lib/supplier";
|
||||
|
||||
export type SupplierWithDetail = Prisma.SupplierGetPayload<{
|
||||
include: {
|
||||
assessments: true;
|
||||
contracts: true;
|
||||
ndas: true;
|
||||
evidence: true;
|
||||
responsibilities: true;
|
||||
subcontractors: true;
|
||||
decisions: true;
|
||||
controlMaturity: true;
|
||||
assetLinks: { include: { asset: { select: { id: true; name: 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) ─────────────────────────── */
|
||||
|
||||
export async function SupplierDetailModal({
|
||||
supplier,
|
||||
controls,
|
||||
canWrite,
|
||||
}: {
|
||||
supplier: SupplierWithDetail;
|
||||
controls: SupplierControl[];
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const t = await getTranslations("suppliers");
|
||||
const tStatus = await getTranslations("supplierStatus");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tAType = await getTranslations("assessmentType");
|
||||
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]));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${supplierRef(supplier.refNo)} · ${supplier.name}`}
|
||||
sub={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>
|
||||
</span>
|
||||
}
|
||||
closeHref="/suppliers"
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?edit=${supplier.id}`} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</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 ─────────────────────────── */
|
||||
|
||||
async function SupplierFields({ supplier, formId }: { supplier?: SupplierWithDetail; formId?: string }) {
|
||||
const t = await getTranslations("suppliers");
|
||||
const tStatus = await getTranslations("supplierStatus");
|
||||
const f = formId ? { form: formId } : {};
|
||||
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={supplier?.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} />
|
||||
</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} />
|
||||
</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>
|
||||
))}
|
||||
</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} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("protection")} (C/I/A)</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{(["confidentiality", "integrity", "availability"] as const).map((n) => (
|
||||
<SegmentedRating key={n} name={n} defaultValue={(supplier?.[n] as number) ?? 1} form={formId} />
|
||||
))}
|
||||
</div>
|
||||
</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} />
|
||||
</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")}
|
||||
</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} />
|
||||
</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,
|
||||
controls,
|
||||
}: {
|
||||
supplier: SupplierWithDetail;
|
||||
controls: SupplierControl[];
|
||||
}) {
|
||||
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}`}
|
||||
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)}>
|
||||
<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={`/suppliers?detail=${supplier.id}`}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?detail=${supplier.id}`} />}>{tc("cancel")}</Button>
|
||||
<Button type="submit" form={FORM}>{tc("save")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={FORM} action={updateSupplier.bind(null, supplier.id)} className="hidden" />
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{/* 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}
|
||||
>
|
||||
<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="flowdown" /> {t("flowdown")}</label>
|
||||
<label className="flex items-center gap-1.5"><input type="checkbox" name="customerTransparency" /> {t("customerTransparency")}</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}
|
||||
>
|
||||
<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")} />
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
</ChildSection>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
async function ChildSection({
|
||||
title,
|
||||
items,
|
||||
kind,
|
||||
addLabel,
|
||||
discClass,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
items: { id: string; label: string }[];
|
||||
kind: "assessment" | "contract" | "nda" | "evidence" | "responsibility" | "subcontractor" | "decision";
|
||||
addLabel: string;
|
||||
discClass: 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={discClass}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user