Files
craftvia/src/components/software-modals.tsx
T
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

281 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
import type { Prisma } from "@prisma/client";
import { createSoftware, deleteSoftware, updateSoftware } from "@/server/actions/software";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal";
import { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { softwareRef, withQuery } from "@/lib/supplier";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type SoftwareAssetDetail = Prisma.AssetGetPayload<{
include: {
softwareProfile: { include: { provider: { select: { id: true; name: true } } } };
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true; type: true; confidentiality: true; integrity: true; availability: true } } } };
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
};
}>;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
const APPROVAL_TONE = { BEANTRAGT: "warn", FREIGEGEBEN: "ok", GESPERRT: "risk" } as const;
const APPROVAL_VALUES = ["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"] as const;
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
export async function SoftwareDetailModal({ software, canWrite, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; canWrite: boolean; backHref?: string }) {
const t = await getTranslations("software");
const ta = await getTranslations("assets");
const tCrit = await getTranslations("criticality");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("softwareStatus");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const p = software.softwareProfile!;
return (
<Modal
title={software.name}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(software.type)}</Tag>
<Pill tone={APPROVAL_TONE[p.approvalStatus]}>{tStatus(p.approvalStatus)}</Pill>
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
</span>
}
closeHref={backHref}
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", software.id)} />}>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
</>
}
>
<div className="grid gap-5 p-5 md:grid-cols-2">
{/* Stammdaten */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("masterPill")}</Pill>
</div>
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
<div className="flex items-center justify-between gap-2">
<b>{softwareRef(p.refNo)} · {software.name}</b>
<CiaBadge c={software.confidentiality} i={software.integrity} a={software.availability} labels />
</div>
<div className="mt-1.5 space-y-1 text-[12.5px] leading-relaxed text-muted-foreground">
<div>
{t("provider")}:{" "}
{p.provider ? (
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
) : t("noProvider")}
</div>
{p.version && <div>{t("version")}: {p.version}</div>}
<div>{t("approvalStatus")}: {tStatus(p.approvalStatus)}{p.approvedBy ? ` · ${t("approvedBy")}: ${p.approvedBy}` : ""}</div>
<div>{t("criticality")}: {tCrit(String(p.criticality))}</div>
{p.nextReview && <div>{t("nextReview")}: {p.nextReview.toISOString().slice(0, 10)}</div>}
</div>
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
</div>
</div>
{/* Verknüpfte Assets */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{ta("depPill")}</Pill>
</div>
{software.relationsFrom.length === 0 && software.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
{software.relationsFrom.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr><th /><th /><th className="pb-1 text-right font-normal"><CiaLegend /></th></tr>
</thead>
<tbody>
{software.relationsFrom.map((rel) => (
<tr key={rel.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">{rel.relatedAsset.name}</Link>
</td>
<td className="py-2.5 pr-2"><Tag>{tType(rel.relatedAsset.type)}</Tag></td>
<td className="py-2.5 text-right">
<CiaBadge c={rel.relatedAsset.confidentiality} i={rel.relatedAsset.integrity} a={rel.relatedAsset.availability} />
</td>
</tr>
))}
</tbody>
</table>
)}
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
<Network className="size-3.5" /> {tDep("openGraph")} →
</Link>
</div>
</div>
{/* Zugeordnete Risiken */}
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
<div className="flex items-center justify-between gap-2">
<b>{t("linkedRisks")}</b>
{canWrite && (
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/risks?new=1&asset=${software.id}`} />}>
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
</Button>
)}
</div>
{software.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
<ul className="mt-2 space-y-1.5">
{software.riskAssets.map((ra) => (
<li key={ra.id} className="flex items-center gap-2">
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">{riskRef(ra.risk.refNo)}</Link>
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}</Pill>
</li>
))}
</ul>
</div>
</Modal>
);
}
/* ─────────────────────── Formular ─────────────────────── */
async function SoftwareFields({ software, providers, formId }: { software?: SoftwareAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
const t = await getTranslations("software");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const tStatus = await getTranslations("softwareStatus");
const f = formId ? { form: formId } : {};
const p = software?.softwareProfile;
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={software?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="providerAssetId">{t("provider")}</Label>
<select id="providerAssetId" name="providerAssetId" defaultValue={p?.providerAssetId ?? ""} className={`${inputCls} mt-1`} {...f}>
<option value="">{t("noProvider")}</option>
{providers.map((pr) => (
<option key={pr.id} value={pr.id}>{pr.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="version">{t("version")}</Label>
<Input id="version" name="version" defaultValue={p?.version ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="approvalStatus">{t("approvalStatus")}</Label>
<select id="approvalStatus" name="approvalStatus" defaultValue={p?.approvalStatus ?? "BEANTRAGT"} className={`${inputCls} mt-1`} {...f}>
{APPROVAL_VALUES.map((s) => (
<option key={s} value={s}>{tStatus(s)}</option>
))}
</select>
</div>
<div>
<Label htmlFor="approvedBy">{t("approvedBy")}</Label>
<Input id="approvedBy" name="approvedBy" defaultValue={p?.approvedBy ?? ""} className="mt-1" {...f} />
</div>
<div>
<Label>{t("criticality")}</Label>
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
</div>
<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>
<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={(software?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
</div>
</div>
))}
</div>
</fieldset>
<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 SoftwareCreateModal({ providers }: { providers: { id: string; name: string }[] }) {
const t = await getTranslations("software");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/suppliers?tab=software" closeLabel={t("close")}>
<form action={createSoftware} className="space-y-4 p-5">
<SoftwareFields providers={providers} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers?tab=software" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
export async function SoftwareEditModal({ software, providers, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; providers: { id: string; name: string }[]; backHref?: string }) {
const t = await getTranslations("software");
const tc = await getTranslations("common");
const FORM = "software-edit";
const detailHref = withQuery(backHref, "detail", software.id);
return (
<Modal
title={t("editTitle")}
sub={software.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={deleteSoftware.bind(null, software.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={updateSoftware.bind(null, software.id)} className="hidden">
<input type="hidden" name="returnTo" value={backHref} />
</form>
<div className="p-5">
<SoftwareFields software={software} providers={providers} formId={FORM} />
</div>
</Modal>
);
}