Software und Projekte als Assets + Auto-Sicht kritische Dienste

Software (Lieferantenmodul):
- Neuer Tab „Software" mit Popups (Detail/Anlegen/Bearbeiten) analog IT-Services,
  Anbieter-Verknüpfung, Freigabestatus, Version, Review; Server-Actions
  createSoftware/updateSoftware/deleteSoftware.

Projekte (Assetinventar):
- AssetType PROJECT als Filter, Popup mit Kritikalität (C/I/A), Projektleitung,
  IS-Klassifizierung, Status, ISB-Einbindung; Risiko-Verknüpfung über die
  bestehende RiskAsset-Logik; Server-Actions createProject/updateProject/
  deleteProject. Kontextabhängiger „Neu"-Button je Filter.

Kritische IT-Dienste:
- Schreibgeschützte Auto-Sicht (/assets?view=critical), abgeleitet aus
  Verfügbarkeit und BIA; RTO/RPO aus den verknüpften Prozessen (strengster
  Wert), Abhängigkeiten aus Asset-Relationen.

Software-/Projekt-Cockpits werden auch auf der Asset-Seite gerendert
(backHref=/assets). i18n (de/en) und Modul-Guard-Registrierung ergänzt;
alle Editierfunktionen als Popup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:13:19 +02:00
co-authored by Claude Opus 4.8
parent 150a1d9dae
commit 9b479abb0f
11 changed files with 1148 additions and 23 deletions
+74 -4
View File
@@ -14,7 +14,9 @@
"remove": "Entfernen",
"none": "—",
"comingSoon": "Bald verfügbar",
"confirmDelete": "Wirklich löschen?"
"confirmDelete": "Wirklich löschen?",
"yes": "Ja",
"no": "Nein"
},
"nav": {
"dashboard": "Dashboard",
@@ -116,7 +118,15 @@
"masterNote": "Klassifizierung & Verantwortung",
"depPill": "◆ Abhängigkeiten",
"depNote": "Verknüpfte Assets",
"riskCount": "{count} Risiken"
"riskCount": "{count} Risiken",
"criticalTitle": "Kritische IT-Dienste",
"criticalSub": "Automatisch aus Assetinventar und BIA abgeleitet schreibgeschützt.",
"criticalService": "Kritischer IT-Dienst",
"biaCriticality": "BIA-Kritikalität",
"supportedProcesses": "Gestützte Prozesse",
"criticalEmpty": "Keine kritischen IT-Dienste ermittelt (hohe Verfügbarkeit oder BIA-kritischer Prozess).",
"criticalNote": "Diese Sicht wird laufend aus dem Assetinventar und der Business-Impact-Analyse abgeleitet. RTO/RPO stammen aus den verknüpften Prozessen (strengster Wert). Pflege erfolgt am jeweiligen Asset bzw. Prozess.",
"backToInventory": "Zum Assetinventar"
},
"assetType": {
"INFORMATION": "Information",
@@ -126,7 +136,9 @@
"SUPPLIER": "Lieferant",
"PERSON": "Person/Rolle",
"DATA": "Datenkategorie",
"IT_SERVICE": "IT-Service"
"IT_SERVICE": "IT-Service",
"SOFTWARE": "Software",
"PROJECT": "Projekt"
},
"assetStatus": {
"ACTIVE": "Aktiv",
@@ -505,7 +517,8 @@
"evidence": "Nachweis",
"raciEmpty": "Noch keine Controls zugeordnet.",
"close": "Schließen",
"none": "—"
"none": "—",
"tabSoftware": "Software"
},
"raciParty": {
"PROVIDER": "Provider",
@@ -583,5 +596,62 @@
"levelGlobal": "Global übernehmen",
"levelAl2": "MUSS·SOLL·HOCH",
"levelAl3": "+ SEHR HOCH"
},
"software": {
"title": "Software-Freigaben",
"tabTitle": "Software",
"newSoftware": "Software erfassen",
"ref": "Kürzel",
"name": "Software",
"provider": "Anbieter/Lieferant",
"noProvider": "kein Anbieter",
"version": "Version/Patch-Stand",
"approvalStatus": "Freigabestatus",
"approvedBy": "Freigeber",
"criticality": "Kritikalität",
"protection": "Schutzbedarf",
"nextReview": "Nächstes Review",
"review": "Review",
"notes": "Notizen",
"createTitle": "Software erfassen",
"editTitle": "Software bearbeiten",
"detailSub": "Freigegebene Software (Whitelist) mit Anbieter und Review",
"masterPill": "Stammdaten",
"linkedRisks": "Zugeordnete Risiken",
"empty": "Noch keine Software erfasst.",
"close": "Schließen",
"none": "—"
},
"softwareStatus": {
"BEANTRAGT": "Beantragt",
"FREIGEGEBEN": "Freigegeben",
"GESPERRT": "Gesperrt"
},
"projects": {
"title": "Projekte",
"newProject": "Projekt anlegen",
"ref": "Kürzel",
"name": "Projektname",
"owner": "Projektleitung",
"classification": "IS-Klassifizierung",
"status": "Status",
"isbInvolved": "ISB eingebunden",
"criticality": "Kritikalität",
"protection": "Schutzbedarf",
"notes": "Notizen",
"createTitle": "Projekt anlegen",
"editTitle": "Projekt bearbeiten",
"detailSub": "Informationssicherheit in Projekten (R01 / VA-19)",
"masterPill": "Stammdaten",
"linkedRisks": "Zugeordnete Risiken",
"empty": "Noch keine Projekte erfasst.",
"close": "Schließen",
"none": "—"
},
"projectStatus": {
"GEPLANT": "Geplant",
"LAUFEND": "Laufend",
"ABGESCHLOSSEN": "Abgeschlossen",
"ABGEBROCHEN": "Abgebrochen"
}
}
+74 -4
View File
@@ -14,7 +14,9 @@
"remove": "Remove",
"none": "—",
"comingSoon": "Coming soon",
"confirmDelete": "Really delete?"
"confirmDelete": "Really delete?",
"yes": "Yes",
"no": "No"
},
"nav": {
"dashboard": "Dashboard",
@@ -116,7 +118,15 @@
"masterNote": "Classification & responsibility",
"depPill": "◆ Dependencies",
"depNote": "Linked assets",
"riskCount": "{count} risks"
"riskCount": "{count} risks",
"criticalTitle": "Critical IT services",
"criticalSub": "Automatically derived from asset inventory and BIA read-only.",
"criticalService": "Critical IT service",
"biaCriticality": "BIA criticality",
"supportedProcesses": "Supported processes",
"criticalEmpty": "No critical IT services identified (high availability or BIA-critical process).",
"criticalNote": "This view is derived continuously from the asset inventory and the business impact analysis. RTO/RPO come from the linked processes (strictest value). Maintain data on the respective asset or process.",
"backToInventory": "Back to inventory"
},
"assetType": {
"INFORMATION": "Information",
@@ -126,7 +136,9 @@
"SUPPLIER": "Supplier",
"PERSON": "Person/role",
"DATA": "Data category",
"IT_SERVICE": "IT service"
"IT_SERVICE": "IT service",
"SOFTWARE": "Software",
"PROJECT": "Project"
},
"assetStatus": {
"ACTIVE": "Active",
@@ -505,7 +517,8 @@
"evidence": "Evidence",
"raciEmpty": "No controls assigned yet.",
"close": "Close",
"none": "—"
"none": "—",
"tabSoftware": "Software"
},
"raciParty": {
"PROVIDER": "Provider",
@@ -583,5 +596,62 @@
"levelGlobal": "Inherit global",
"levelAl2": "MUST·SHOULD·HIGH",
"levelAl3": "+ VERY HIGH"
},
"software": {
"title": "Software approvals",
"tabTitle": "Software",
"newSoftware": "Add software",
"ref": "Ref",
"name": "Software",
"provider": "Provider/Supplier",
"noProvider": "no provider",
"version": "Version/patch level",
"approvalStatus": "Approval status",
"approvedBy": "Approved by",
"criticality": "Criticality",
"protection": "Protection need",
"nextReview": "Next review",
"review": "Review",
"notes": "Notes",
"createTitle": "Add software",
"editTitle": "Edit software",
"detailSub": "Approved software (whitelist) with provider and review",
"masterPill": "Master data",
"linkedRisks": "Linked risks",
"empty": "No software recorded yet.",
"close": "Close",
"none": "—"
},
"softwareStatus": {
"BEANTRAGT": "Requested",
"FREIGEGEBEN": "Approved",
"GESPERRT": "Blocked"
},
"projects": {
"title": "Projects",
"newProject": "Add project",
"ref": "Ref",
"name": "Project name",
"owner": "Project lead",
"classification": "IS classification",
"status": "Status",
"isbInvolved": "ISB involved",
"criticality": "Criticality",
"protection": "Protection need",
"notes": "Notes",
"createTitle": "Add project",
"editTitle": "Edit project",
"detailSub": "Information security in projects (R01 / VA-19)",
"masterPill": "Master data",
"linkedRisks": "Linked risks",
"empty": "No projects recorded yet.",
"close": "Close",
"none": "—"
},
"projectStatus": {
"GEPLANT": "Planned",
"LAUFEND": "Running",
"ABGESCHLOSSEN": "Completed",
"ABGEBROCHEN": "Cancelled"
}
}
+2
View File
@@ -29,6 +29,8 @@ const ACTION_MODULE: Record<string, string> = {
"tasks.ts": "tasks",
"suppliers.ts": "suppliers",
"services.ts": "suppliers",
"software.ts": "suppliers",
"projects.ts": "assets",
"policies.ts": "policies",
"register.ts": "policies",
// Plattform-Betrieb (eigene Auth) und Kunden-Einstellungen (tenant:manage) sind
+137 -5
View File
@@ -10,6 +10,7 @@ import { FilterTabs } from "@/components/filter-tabs";
import {
CiaBadge,
CiaLegend,
CriticalityPill,
KpiCard,
OwnerChip,
PageHead,
@@ -31,7 +32,18 @@ import {
ServiceEditModal,
type ServiceAssetDetail,
} from "@/components/service-modals";
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE } from "@/lib/supplier-include";
import {
SoftwareDetailModal,
SoftwareEditModal,
type SoftwareAssetDetail,
} from "@/components/software-modals";
import {
ProjectCreateModal,
ProjectDetailModal,
ProjectEditModal,
type ProjectAssetDetail,
} from "@/components/project-modals";
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE, SOFTWARE_INCLUDE, PROJECT_INCLUDE } from "@/lib/supplier-include";
import {
Table,
TableBody,
@@ -41,20 +53,22 @@ import {
TableRow,
} from "@/components/ui/table";
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"] as const;
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "SOFTWARE", "PROJECT", "PERSON", "DATA"] as const;
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
export default async function AssetsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; type?: string; detail?: string; edit?: string; new?: string }>;
searchParams: Promise<{ q?: string; type?: string; view?: string; detail?: string; edit?: string; new?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "asset:read");
const t = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("assetStatus");
const tProj = await getTranslations("projects");
const tSw = await getTranslations("software");
const tc = await getTranslations("common");
const params = await searchParams;
@@ -62,6 +76,90 @@ export default async function AssetsPage({
const activeType = ASSET_TYPES.includes(type as AssetType) ? (type as AssetType) : null;
const db = dbForTenant(session.user.tenantId);
/* Auto-Sicht Kritische IT-Dienste" (schreibgeschützt, aus Assetinventar/BIA)
Ziel des früheren Registers REG-CRIT-SERVICES: keine gepflegte Liste, sondern eine
abgeleitete Sicht. Kritisch = hohe Verfügbarkeit (3) ODER stützt einen BIA-kritischen
Prozess. RTO/RPO stammen aus den verknüpften Prozessen (BiaEntry, strengster Wert). */
if (params.view === "critical") {
const tCrit = await getTranslations("criticality");
const critical = await db.asset.findMany({
where: {
type: { in: ["IT_SERVICE", "SYSTEM", "APPLICATION", "SOFTWARE"] },
OR: [
{ availability: { gte: 3 } },
{ processAssets: { some: { process: { bia: { criticality: { gte: 3 } } } } } },
],
},
include: {
processAssets: { include: { process: { select: { id: true, name: true, bia: { select: { criticality: true, rtoHours: true, rpoHours: true } } } } } },
relationsFrom: { include: { relatedAsset: { select: { name: true } } } },
},
orderBy: [{ availability: "desc" }, { name: "asc" }],
take: 300,
});
const min = (xs: (number | null | undefined)[]) => {
const v = xs.filter((x): x is number => typeof x === "number");
return v.length ? Math.min(...v) : null;
};
const fmtH = (h: number | null) => (h == null ? tc("none") : h >= 24 && h % 24 === 0 ? `${h / 24} d` : `${h} h`);
const rows = critical.map((a) => {
const bias = a.processAssets.map((pa) => pa.process.bia).filter(Boolean);
const biaCrit = Math.max(a.availability, ...bias.map((b) => b!.criticality));
return {
a,
biaCrit,
rto: min(bias.map((b) => b!.rtoHours)),
rpo: min(bias.map((b) => b!.rpoHours)),
processes: a.processAssets.map((pa) => pa.process.name),
deps: a.relationsFrom.map((r) => r.relatedAsset.name),
};
});
return (
<main className="flex-1 p-6">
<PageHead crumb={t("crumb")} title={t("criticalTitle")} sub={t("criticalSub")} actions={
<Button variant="outline" nativeButton={false} render={<Link href="/assets" />}>{t("backToInventory")}</Button>
} />
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("criticalService")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("biaCriticality")}</TableHead>
<TableHead>RTO</TableHead>
<TableHead>RPO</TableHead>
<TableHead>{t("supportedProcesses")}</TableHead>
<TableHead>{t("dependencies")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">{t("criticalEmpty")}</TableCell>
</TableRow>
)}
{rows.map(({ a, biaCrit, rto, rpo, processes, deps }) => (
<TableRow key={a.id}>
<TableCell>
<Link href={`/assets?detail=${a.id}`} className="font-bold hover:underline">{a.name}</Link>
</TableCell>
<TableCell><Tag>{tType(a.type)}</Tag></TableCell>
<TableCell><CriticalityPill level={biaCrit} label={tCrit(String(biaCrit))} /></TableCell>
<TableCell className="text-muted-foreground">{fmtH(rto)}</TableCell>
<TableCell className="text-muted-foreground">{fmtH(rpo)}</TableCell>
<TableCell className="max-w-52 truncate text-muted-foreground">{processes.join(", ") || tc("none")}</TableCell>
<TableCell className="max-w-52 truncate text-muted-foreground">{deps.join(", ") || tc("none")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("criticalNote")}</p>
</main>
);
}
const [assets, allAssets] = await Promise.all([
db.asset.findMany({
where: {
@@ -109,15 +207,21 @@ export default async function AssetsPage({
type: true,
supplierProfile: { select: { id: true } },
serviceProfile: { select: { id: true } },
softwareProfile: { select: { id: true } },
projectProfile: { select: { id: true } },
},
})
: null;
const modalKind: "supplier" | "service" | "asset" | null = !modalMeta
const modalKind: "supplier" | "service" | "software" | "project" | "asset" | null = !modalMeta
? null
: modalMeta.type === "SUPPLIER" && modalMeta.supplierProfile
? "supplier"
: modalMeta.type === "IT_SERVICE" && modalMeta.serviceProfile
? "service"
: modalMeta.type === "SOFTWARE" && modalMeta.softwareProfile
? "software"
: modalMeta.type === "PROJECT" && modalMeta.projectProfile
? "project"
: "asset";
const supplierModal =
@@ -128,8 +232,16 @@ export default async function AssetsPage({
modalKind === "service"
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SERVICE_INCLUDE })) as ServiceAssetDetail | null)
: null;
const softwareModal =
modalKind === "software"
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SOFTWARE_INCLUDE })) as SoftwareAssetDetail | null)
: null;
const projectModal =
modalKind === "project"
? ((await db.asset.findUnique({ where: { id: modalId! }, include: PROJECT_INCLUDE })) as ProjectAssetDetail | null)
: null;
const providers =
modalKind === "service" && isEdit
(modalKind === "service" || modalKind === "software") && isEdit
? await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
select: { id: true, name: true },
@@ -202,9 +314,19 @@ export default async function AssetsPage({
{t("export")}
</Button>
{canWrite && (
activeType === "PROJECT" ? (
<Button nativeButton={false} render={<Link href="/assets?type=PROJECT&new=1" />}>
<Plus className="size-4" /> {tProj("newProject")}
</Button>
) : activeType === "SOFTWARE" ? (
<Button nativeButton={false} render={<Link href="/suppliers?tab=software&new=1" />}>
<Plus className="size-4" /> {tSw("newSoftware")}
</Button>
) : (
<Button nativeButton={false} render={<Link href="/assets?new=1" />}>
<Plus className="size-4" /> {t("newAsset")}
</Button>
)
)}
</>
}
@@ -310,6 +432,16 @@ export default async function AssetsPage({
<ServiceEditModal service={serviceModal} providers={providers} backHref="/assets" />
) : serviceModal ? (
<ServiceDetailModal service={serviceModal} canWrite={canWrite} backHref="/assets" />
) : softwareModal && isEdit ? (
<SoftwareEditModal software={softwareModal} providers={providers} backHref="/assets" />
) : softwareModal ? (
<SoftwareDetailModal software={softwareModal} canWrite={canWrite} backHref="/assets" />
) : projectModal && isEdit ? (
<ProjectEditModal project={projectModal} users={users} backHref="/assets" />
) : projectModal ? (
<ProjectDetailModal project={projectModal} canWrite={canWrite} backHref="/assets" />
) : params.new && canWrite && activeType === "PROJECT" ? (
<ProjectCreateModal users={users} />
) : modalAsset && isEdit ? (
<AssetEditModal asset={modalAsset} users={users} otherAssets={otherAssets} />
) : modalAsset ? (
+88 -4
View File
@@ -19,9 +19,16 @@ import {
ServiceEditModal,
type ServiceAssetDetail,
} from "@/components/service-modals";
import {
SoftwareCreateModal,
SoftwareDetailModal,
SoftwareEditModal,
type SoftwareAssetDetail,
} from "@/components/software-modals";
import {
supplierRef,
serviceRef,
softwareRef,
protectionLevel,
PROTECTION_LABEL,
buildReqContext,
@@ -31,7 +38,7 @@ import {
CONFORMITY_LABEL,
TARGET_MATURITY,
} from "@/lib/supplier";
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE } from "@/lib/supplier-include";
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE, SOFTWARE_INCLUDE } from "@/lib/supplier-include";
import {
Table,
TableBody,
@@ -50,19 +57,25 @@ export default async function SuppliersPage({
requirePermission(session, "supplier:read");
const t = await getTranslations("suppliers");
const ts = await getTranslations("services");
const tsw = await getTranslations("software");
const tSwStatus = await getTranslations("softwareStatus");
const tCrit = await getTranslations("criticality");
const tc = await getTranslations("common");
const params = await searchParams;
const isServices = params.tab === "services";
const isSoftware = params.tab === "software";
const db = dbForTenant(session.user.tenantId);
const canWrite = hasPermission(session, "supplier:write");
const tabs = [
{ href: "/suppliers", label: ts("tabSuppliers"), active: !isServices },
{ href: "/suppliers", label: ts("tabSuppliers"), active: !isServices && !isSoftware },
{ href: "/suppliers?tab=services", label: ts("tabServices"), active: isServices },
{ href: "/suppliers?tab=software", label: ts("tabSoftware"), active: isSoftware },
];
const newHref = isServices ? "/suppliers?tab=services&new=1" : isSoftware ? "/suppliers?tab=software&new=1" : "/suppliers?new=1";
const newLabel = isServices ? ts("newService") : isSoftware ? tsw("newSoftware") : t("newSupplier");
const head = (
<PageHead
crumb={t("crumb")}
@@ -70,8 +83,8 @@ export default async function SuppliersPage({
sub={t("sub")}
actions={
canWrite && (
<Button nativeButton={false} render={<Link href={isServices ? "/suppliers?tab=services&new=1" : "/suppliers?new=1"} />}>
<Plus className="size-4" /> {isServices ? ts("newService") : t("newSupplier")}
<Button nativeButton={false} render={<Link href={newHref} />}>
<Plus className="size-4" /> {newLabel}
</Button>
)
}
@@ -147,6 +160,77 @@ export default async function SuppliersPage({
);
}
/* ── Software-Whitelist ── */
if (isSoftware) {
const software = (await db.asset.findMany({
where: { type: "SOFTWARE", softwareProfile: { isNot: null } },
include: SOFTWARE_INCLUDE,
orderBy: { softwareProfile: { refNo: "asc" } },
take: 300,
})) as SoftwareAssetDetail[];
const providers = await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalSoftware = modalId
? ((await db.asset.findUnique({ where: { id: modalId }, include: SOFTWARE_INCLUDE })) as SoftwareAssetDetail | null)
: null;
const statusTone = { BEANTRAGT: "warn", FREIGEGEBEN: "ok", GESPERRT: "risk" } as const;
return (
<main className="flex-1 p-6">
{head}
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{tsw("ref")}</TableHead>
<TableHead>{tsw("name")}</TableHead>
<TableHead>{tsw("provider")}</TableHead>
<TableHead>{tsw("version")}</TableHead>
<TableHead>{tsw("approvalStatus")}</TableHead>
<TableHead>{tsw("criticality")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{software.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{tsw("empty")}</TableCell>
</TableRow>
)}
{software.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{softwareRef(s.softwareProfile!.refNo)}</TableCell>
<TableCell>
<Link href={`/suppliers?tab=software&detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
</TableCell>
<TableCell className="text-muted-foreground">{s.softwareProfile?.provider?.name ?? tc("none")}</TableCell>
<TableCell className="text-muted-foreground">{s.softwareProfile?.version ?? tc("none")}</TableCell>
<TableCell><Pill tone={statusTone[s.softwareProfile!.approvalStatus]}>{tSwStatus(s.softwareProfile!.approvalStatus)}</Pill></TableCell>
<TableCell><CriticalityPill level={s.softwareProfile!.criticality} label={tCrit(String(s.softwareProfile!.criticality))} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{modalSoftware && params.edit && canWrite ? (
<SoftwareEditModal software={modalSoftware} providers={providers} />
) : modalSoftware ? (
<SoftwareDetailModal software={modalSoftware} canWrite={canWrite} />
) : params.new && canWrite ? (
<SoftwareCreateModal providers={providers} />
) : null}
</main>
);
}
/* ── Lieferanten ── */
const suppliers = await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
+277
View File
@@ -0,0 +1,277 @@
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 { createProject, deleteProject, updateProject } from "@/server/actions/projects";
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, Pill, Tag } from "@/components/mockup-ui";
import { projectRef, withQuery } from "@/lib/supplier";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
export type ProjectAssetDetail = Prisma.AssetGetPayload<{
include: {
projectProfile: true;
owner: { 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 } } } };
processAssets: { include: { process: { select: { id: true; name: true } } } };
};
}>;
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
const STATUS_TONE = { GEPLANT: "info", LAUFEND: "warn", ABGESCHLOSSEN: "ok", ABGEBROCHEN: "risk" } as const;
const STATUS_VALUES = ["GEPLANT", "LAUFEND", "ABGESCHLOSSEN", "ABGEBROCHEN"] as const;
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
export async function ProjectDetailModal({ project, canWrite, backHref = "/assets?type=PROJECT" }: { project: ProjectAssetDetail; canWrite: boolean; backHref?: string }) {
const t = await getTranslations("projects");
const ta = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("projectStatus");
const tLevel = await getTranslations("riskLevel");
const tRisks = await getTranslations("risks");
const tDep = await getTranslations("dependencies");
const tc = await getTranslations("common");
const p = project.projectProfile!;
return (
<Modal
title={project.name}
sub={t("detailSub")}
headerExtra={
<span className="flex items-center gap-2">
<Tag>{tType(project.type)}</Tag>
<Pill tone={STATUS_TONE[p.status]}>{tStatus(p.status)}</Pill>
</span>
}
closeHref={backHref}
closeLabel={t("close")}
footer={
<>
{canWrite && (
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", project.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>{projectRef(p.refNo)} · {project.name}</b>
<CiaBadge c={project.confidentiality} i={project.integrity} a={project.availability} labels />
</div>
<div className="mt-1.5 space-y-1 text-[12.5px] leading-relaxed text-muted-foreground">
<div>{t("owner")}: {project.owner?.name ?? tc("none")}</div>
{p.classification && <div>{t("classification")}: {p.classification}</div>}
<div>{t("status")}: {tStatus(p.status)}</div>
<div>{t("isbInvolved")}: {p.isbInvolved ? tc("yes") : tc("no")}</div>
</div>
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
</div>
<div className="mt-4">
<p className="text-sm font-semibold">{ta("processes")}</p>
{project.processAssets.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">
{project.processAssets.map((pa) => (
<li key={pa.id}>
<Link href={`/processes?detail=${pa.process.id}`} className="hover:underline">{pa.process.name}</Link>
</li>
))}
</ul>
</div>
</div>
{/* Verknüpfte Assets */}
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{ta("depPill")}</Pill>
</div>
{project.relationsFrom.length === 0 && project.relationsTo.length === 0 && (
<p className="text-sm text-muted-foreground">{tc("none")}</p>
)}
{project.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>
{project.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=${project.id}`} />}>
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
</Button>
)}
</div>
{project.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
<ul className="mt-2 space-y-1.5">
{project.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 ProjectFields({ project, users, formId }: { project?: ProjectAssetDetail; users: { id: string; name: string }[]; formId?: string }) {
const t = await getTranslations("projects");
const tA = await getTranslations("assets");
const tLevel = await getTranslations("protectionLevel");
const tStatus = await getTranslations("projectStatus");
const f = formId ? { form: formId } : {};
const p = project?.projectProfile;
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={project?.name} className="mt-1" {...f} />
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select id="ownerId" name="ownerId" defaultValue={project?.ownerId ?? ""} className={`${inputCls} mt-1`} {...f}>
<option value="">{t("none")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="status">{t("status")}</Label>
<select id="status" name="status" defaultValue={p?.status ?? "GEPLANT"} className={`${inputCls} mt-1`} {...f}>
{STATUS_VALUES.map((s) => (
<option key={s} value={s}>{tStatus(s)}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="classification">{t("classification")}</Label>
<Input id="classification" name="classification" defaultValue={p?.classification ?? ""} className="mt-1" {...f} />
</div>
<fieldset className="md:col-span-2">
<legend className="text-sm font-medium">{t("protection")} (14)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", tA("confidentiality")],
["integrity", tA("integrity")],
["availability", tA("availability")],
] as const
).map(([n, label]) => (
<div key={n}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={n} defaultValue={(project?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
</div>
</div>
))}
</div>
</fieldset>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input type="checkbox" name="isbInvolved" defaultChecked={p?.isbInvolved} {...f} /> {t("isbInvolved")}
</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 ProjectCreateModal({ users }: { users: { id: string; name: string }[] }) {
const t = await getTranslations("projects");
const tc = await getTranslations("common");
return (
<Modal title={t("createTitle")} closeHref="/assets?type=PROJECT" closeLabel={t("close")}>
<form action={createProject} className="space-y-4 p-5">
<ProjectFields users={users} />
<div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/assets?type=PROJECT" />}>{tc("cancel")}</Button>
</div>
</form>
</Modal>
);
}
export async function ProjectEditModal({ project, users, backHref = "/assets?type=PROJECT" }: { project: ProjectAssetDetail; users: { id: string; name: string }[]; backHref?: string }) {
const t = await getTranslations("projects");
const tc = await getTranslations("common");
const FORM = "project-edit";
const detailHref = withQuery(backHref, "detail", project.id);
return (
<Modal
title={t("editTitle")}
sub={project.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={deleteProject.bind(null, project.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={updateProject.bind(null, project.id)} className="hidden">
<input type="hidden" name="returnTo" value={backHref} />
</form>
<div className="p-5">
<ProjectFields project={project} users={users} formId={FORM} />
</div>
</Modal>
);
}
+280
View File
@@ -0,0 +1,280 @@
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")} (14)</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
[
["confidentiality", tA("confidentiality")],
["integrity", tA("integrity")],
["availability", tA("availability")],
] as const
).map(([n, label]) => (
<div key={n}>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={n} defaultValue={(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>
);
}
+16
View File
@@ -27,3 +27,19 @@ export const SERVICE_INCLUDE = {
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
processAssets: { include: { process: { select: { id: true, name: true } } } },
} satisfies Prisma.AssetInclude;
export const SOFTWARE_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 } } } },
} satisfies Prisma.AssetInclude;
export const PROJECT_INCLUDE = {
projectProfile: true,
owner: { 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 } } } },
processAssets: { include: { process: { select: { id: true, name: true } } } },
} satisfies Prisma.AssetInclude;
+6
View File
@@ -10,6 +10,12 @@ export function supplierRef(refNo: number): string {
export function serviceRef(refNo: number): string {
return `S-${String(refNo).padStart(3, "0")}`;
}
export function softwareRef(refNo: number): string {
return `SW-${String(refNo).padStart(3, "0")}`;
}
export function projectRef(refNo: number): string {
return `P-${String(refNo).padStart(3, "0")}`;
}
/**
* Hängt einen Query-Parameter an einen Basis-Pfad an und wählt automatisch
+92
View File
@@ -0,0 +1,92 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("assets");
const level = z.coerce.number().int().min(1).max(4);
const projectSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
ownerId: z.string().optional(),
classification: z.string().trim().max(200).optional(),
status: z.enum(["GEPLANT", "LAUFEND", "ABGESCHLOSSEN", "ABGEBROCHEN"]),
isbInvolved: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = projectSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
ownerId: formData.get("ownerId") || undefined,
classification: formData.get("classification") || undefined,
status: formData.get("status") || "GEPLANT",
isbInvolved: formData.get("isbInvolved") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: {
name: p.name,
confidentiality: p.confidentiality,
integrity: p.integrity,
availability: p.availability,
ownerId: p.ownerId || null,
},
profile: {
classification: p.classification || null,
status: p.status,
isbInvolved: p.isbInvolved === "on",
notes: p.notes || null,
},
};
}
export async function createProject(formData: FormData) {
const { session, db } = await guard("asset:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "PROJECT", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.projectProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.projectProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "project", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/assets");
redirect(`/assets?type=PROJECT&edit=${created.id}`);
}
export async function updateProject(assetId: string, formData: FormData) {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { projectProfile: true } });
if (!before) throw new Error("Projekt nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.projectProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "project", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/assets?type=PROJECT";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteProject(assetId: string, returnTo: string = "/assets?type=PROJECT") {
const { session, db } = await guard("asset:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Projekt nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "project", entityId: assetId, before });
revalidatePath("/assets");
redirect(returnTo);
}
+96
View File
@@ -0,0 +1,96 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { prisma } from "@/server/db";
import { moduleGuard } from "@/server/action-guard";
import { writeAuditLog } from "@/server/audit";
import { withQuery } from "@/lib/supplier";
const guard = moduleGuard("suppliers");
const level = z.coerce.number().int().min(1).max(4);
const softwareSchema = z.object({
name: z.string().trim().min(1).max(200),
confidentiality: level,
integrity: level,
availability: level,
criticality: level,
providerAssetId: z.string().optional(),
version: z.string().trim().max(100).optional(),
approvalStatus: z.enum(["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"]),
approvedBy: z.string().trim().max(200).optional(),
nextReview: z.string().optional(),
notes: z.string().trim().max(5000).optional(),
});
function parse(formData: FormData) {
const p = softwareSchema.parse({
name: formData.get("name"),
confidentiality: formData.get("confidentiality"),
integrity: formData.get("integrity"),
availability: formData.get("availability"),
criticality: formData.get("criticality"),
providerAssetId: formData.get("providerAssetId") || undefined,
version: formData.get("version") || undefined,
approvalStatus: formData.get("approvalStatus") || "BEANTRAGT",
approvedBy: formData.get("approvedBy") || undefined,
nextReview: formData.get("nextReview") || undefined,
notes: formData.get("notes") || undefined,
});
return {
asset: { name: p.name, confidentiality: p.confidentiality, integrity: p.integrity, availability: p.availability },
profile: {
criticality: p.criticality,
providerAssetId: p.providerAssetId || null,
version: p.version || null,
approvalStatus: p.approvalStatus,
approvedBy: p.approvedBy || null,
nextReview: p.nextReview ? new Date(p.nextReview) : null,
notes: p.notes || null,
},
};
}
export async function createSoftware(formData: FormData) {
const { session, db } = await guard("supplier:write");
const { asset, profile } = parse(formData);
const created = await db.asset.create({
data: { ...asset, type: "SOFTWARE", tenantId: session.user.tenantId, createdBy: session.user.id },
});
const last = await prisma.softwareProfile.aggregate({ where: { tenantId: session.user.tenantId }, _max: { refNo: true } });
await db.softwareProfile.create({
data: { ...profile, assetId: created.id, refNo: (last._max.refNo ?? 0) + 1, tenantId: session.user.tenantId, createdBy: session.user.id },
});
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "software", entityId: created.id, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(`/suppliers?tab=software&edit=${created.id}`);
}
export async function updateSoftware(assetId: string, formData: FormData) {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId }, include: { softwareProfile: true } });
if (!before) throw new Error("Software nicht gefunden");
const { asset, profile } = parse(formData);
await db.asset.update({ where: { id: assetId }, data: asset });
await db.softwareProfile.update({ where: { assetId }, data: profile });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "software", entityId: assetId, before, after: { ...asset, ...profile } });
revalidatePath("/suppliers");
revalidatePath("/assets");
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=software";
redirect(withQuery(returnTo, "detail", assetId));
}
export async function deleteSoftware(assetId: string, returnTo: string = "/suppliers?tab=software") {
const { session, db } = await guard("supplier:write");
const before = await db.asset.findUnique({ where: { id: assetId } });
if (!before) throw new Error("Software nicht gefunden");
await db.asset.delete({ where: { id: assetId } });
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "software", entityId: assetId, before });
revalidatePath("/suppliers");
revalidatePath("/assets");
redirect(returnTo);
}