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>
448 lines
22 KiB
TypeScript
448 lines
22 KiB
TypeScript
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")} (0–3)</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")} (1–4)</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>
|
||
);
|
||
}
|