Lieferanten-/IT-Service-Cockpit direkt auf der Asset-Seite öffnen
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>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import type { AssetType } from "@prisma/client";
|
||||
@@ -22,6 +21,17 @@ import {
|
||||
AssetDetailModal,
|
||||
AssetEditModal,
|
||||
} from "@/components/asset-modals";
|
||||
import {
|
||||
SupplierDetailModal,
|
||||
SupplierEditModal,
|
||||
type SupplierAssetDetail,
|
||||
} from "@/components/supplier-modals";
|
||||
import {
|
||||
ServiceDetailModal,
|
||||
ServiceEditModal,
|
||||
type ServiceAssetDetail,
|
||||
} from "@/components/service-modals";
|
||||
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE } from "@/lib/supplier-include";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -35,22 +45,6 @@ const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLI
|
||||
|
||||
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
||||
|
||||
/**
|
||||
* Lieferanten- und IT-Service-Assets werden in ihrem Fach-Cockpit geöffnet
|
||||
* (sofern ein Fachprofil existiert), alle übrigen Assets in der normalen
|
||||
* Asset-Detailansicht.
|
||||
*/
|
||||
function assetDetailHref(asset: {
|
||||
id: string;
|
||||
type: AssetType;
|
||||
supplierProfile: { id: string } | null;
|
||||
serviceProfile: { id: string } | null;
|
||||
}) {
|
||||
if (asset.type === "SUPPLIER" && asset.supplierProfile) return `/suppliers?detail=${asset.id}`;
|
||||
if (asset.type === "IT_SERVICE" && asset.serviceProfile) return `/suppliers?tab=services&detail=${asset.id}`;
|
||||
return `/assets?detail=${asset.id}`;
|
||||
}
|
||||
|
||||
export default async function AssetsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -103,43 +97,75 @@ export default async function AssetsPage({
|
||||
|
||||
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalAsset = modalId
|
||||
const isEdit = Boolean(params.edit && canWrite);
|
||||
|
||||
// Art des Popups vorab bestimmen: Lieferanten & IT-Services mit Fachprofil
|
||||
// werden direkt hier als Fach-Cockpit gerendert (backHref="/assets"), sodass
|
||||
// man auf der Asset-Seite bleibt; profillose Alt-Assets bleiben normale Assets.
|
||||
const modalMeta = modalId
|
||||
? await db.asset.findUnique({
|
||||
where: { id: modalId },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
select: {
|
||||
type: true,
|
||||
supplierProfile: { select: { id: true } },
|
||||
serviceProfile: { select: { id: 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 } } } },
|
||||
riskAssets: {
|
||||
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
|
||||
orderBy: { risk: { score: "desc" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const modalKind: "supplier" | "service" | "asset" | null = !modalMeta
|
||||
? null
|
||||
: modalMeta.type === "SUPPLIER" && modalMeta.supplierProfile
|
||||
? "supplier"
|
||||
: modalMeta.type === "IT_SERVICE" && modalMeta.serviceProfile
|
||||
? "service"
|
||||
: "asset";
|
||||
|
||||
// Lieferanten/IT-Services im Fach-Cockpit öffnen, auch bei direktem Aufruf
|
||||
// (nur wenn ein Fachprofil existiert; profillose Alt-Assets bleiben im Asset-Detail)
|
||||
if (modalAsset && ((modalAsset.type === "SUPPLIER" && modalAsset.supplierProfile) || (modalAsset.type === "IT_SERVICE" && modalAsset.serviceProfile))) {
|
||||
const base = modalAsset.type === "IT_SERVICE" ? "/suppliers?tab=services" : "/suppliers";
|
||||
redirect(`${base}${base.includes("?") ? "&" : "?"}${params.edit && canWrite ? "edit" : "detail"}=${modalAsset.id}`);
|
||||
}
|
||||
const supplierModal =
|
||||
modalKind === "supplier"
|
||||
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
|
||||
: null;
|
||||
const serviceModal =
|
||||
modalKind === "service"
|
||||
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SERVICE_INCLUDE })) as ServiceAssetDetail | null)
|
||||
: null;
|
||||
const providers =
|
||||
modalKind === "service" && isEdit
|
||||
? await db.asset.findMany({
|
||||
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const modalAsset =
|
||||
modalKind === "asset"
|
||||
? await db.asset.findUnique({
|
||||
where: { id: modalId! },
|
||||
include: {
|
||||
owner: { select: { name: 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 } } } },
|
||||
riskAssets: {
|
||||
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
|
||||
orderBy: { risk: { score: "desc" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const users =
|
||||
canWrite && (params.edit || params.new)
|
||||
@@ -247,7 +273,7 @@ export default async function AssetsPage({
|
||||
{assets.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell>
|
||||
<Link href={assetDetailHref(asset)} className="font-bold hover:underline">
|
||||
<Link href={`/assets?detail=${asset.id}`} className="font-bold hover:underline">
|
||||
{asset.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
@@ -274,7 +300,17 @@ export default async function AssetsPage({
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{modalAsset && params.edit && canWrite ? (
|
||||
{/* Lieferanten- & IT-Service-Cockpits werden hier direkt gerendert
|
||||
(backHref="/assets"), damit man auf der Asset-Seite bleibt. */}
|
||||
{supplierModal && isEdit ? (
|
||||
<SupplierEditModal supplier={supplierModal} backHref="/assets" />
|
||||
) : supplierModal ? (
|
||||
<SupplierDetailModal supplier={supplierModal} canWrite={canWrite} backHref="/assets" />
|
||||
) : serviceModal && isEdit ? (
|
||||
<ServiceEditModal service={serviceModal} providers={providers} backHref="/assets" />
|
||||
) : serviceModal ? (
|
||||
<ServiceDetailModal service={serviceModal} canWrite={canWrite} backHref="/assets" />
|
||||
) : modalAsset && isEdit ? (
|
||||
<AssetEditModal asset={modalAsset} users={users} otherAssets={otherAssets} />
|
||||
) : modalAsset ? (
|
||||
<AssetDetailModal asset={modalAsset} canWrite={canWrite} />
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
CONFORMITY_LABEL,
|
||||
TARGET_MATURITY,
|
||||
} from "@/lib/supplier";
|
||||
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE } from "@/lib/supplier-include";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -40,29 +41,6 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const SUPPLIER_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 } } } },
|
||||
} as const;
|
||||
|
||||
const SERVICE_INCLUDE = {
|
||||
serviceProfile: { include: { provider: { select: { id: true, name: true } } } },
|
||||
raci: { orderBy: { controlRef: "asc" } },
|
||||
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 } } } },
|
||||
} as const;
|
||||
|
||||
export default async function SuppliersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 { serviceRef } from "@/lib/supplier";
|
||||
import { serviceRef, withQuery } from "@/lib/supplier";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
import { ISA_CONTROLS } from "@/lib/isa-controls";
|
||||
|
||||
@@ -37,7 +37,7 @@ const RACI_TONE = { PROVIDER: "info", US: "violet", SHARED: "warn" } as const;
|
||||
|
||||
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
|
||||
|
||||
export async function ServiceDetailModal({ service, canWrite }: { service: ServiceAssetDetail; canWrite: boolean }) {
|
||||
export async function ServiceDetailModal({ service, canWrite, backHref = "/suppliers?tab=services" }: { service: ServiceAssetDetail; canWrite: boolean; backHref?: string }) {
|
||||
const t = await getTranslations("services");
|
||||
const ta = await getTranslations("assets");
|
||||
const tParty = await getTranslations("raciParty");
|
||||
@@ -60,16 +60,16 @@ export async function ServiceDetailModal({ service, canWrite }: { service: Servi
|
||||
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
|
||||
</span>
|
||||
}
|
||||
closeHref="/suppliers?tab=services"
|
||||
closeHref={backHref}
|
||||
closeLabel={tp("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?tab=services&edit=${service.id}`} />}>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", service.id)} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/suppliers?tab=services" />}>{tp("close")}</Button>
|
||||
<Button nativeButton={false} render={<Link href={backHref} />}>{tp("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -334,10 +334,11 @@ export async function ServiceCreateModal({ providers }: { providers: { id: strin
|
||||
);
|
||||
}
|
||||
|
||||
export async function ServiceEditModal({ service, providers }: { service: ServiceAssetDetail; providers: { id: string; name: string }[] }) {
|
||||
export async function ServiceEditModal({ service, providers, backHref = "/suppliers?tab=services" }: { service: ServiceAssetDetail; providers: { id: string; name: string }[]; backHref?: string }) {
|
||||
const t = await getTranslations("services");
|
||||
const tc = await getTranslations("common");
|
||||
const FORM = "service-edit";
|
||||
const detailHref = withQuery(backHref, "detail", service.id);
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
@@ -346,22 +347,24 @@ export async function ServiceEditModal({ service, providers }: { service: Servic
|
||||
<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={deleteService.bind(null, service.id)}>
|
||||
<form action={deleteService.bind(null, service.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={`/suppliers?tab=services&detail=${service.id}`}
|
||||
closeHref={detailHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?tab=services&detail=${service.id}`} />}>{tc("cancel")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
|
||||
<Button type="submit" form={FORM}>{tc("save")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={FORM} action={updateService.bind(null, service.id)} className="hidden" />
|
||||
<form id={FORM} action={updateService.bind(null, service.id)} className="hidden">
|
||||
<input type="hidden" name="returnTo" value={backHref} />
|
||||
</form>
|
||||
<div className="p-5">
|
||||
<ServiceFields service={service} providers={providers} formId={FORM} />
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Pill } from "@/components/mockup-ui";
|
||||
import { SupplierRequirements } from "@/components/supplier-cockpit";
|
||||
import {
|
||||
supplierRef,
|
||||
withQuery,
|
||||
SUPPLIER_LIFECYCLE_TONE,
|
||||
protectionLevel,
|
||||
PROTECTION_LABEL,
|
||||
@@ -80,7 +81,7 @@ function contextOf(s: SupplierAssetDetail) {
|
||||
|
||||
/* ─────────────────────── Cockpit (Detail) ─────────────────────── */
|
||||
|
||||
export async function SupplierDetailModal({ supplier, canWrite }: { supplier: SupplierAssetDetail; canWrite: boolean }) {
|
||||
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");
|
||||
@@ -94,8 +95,9 @@ export async function SupplierDetailModal({ supplier, canWrite }: { supplier: Su
|
||||
const computed = computedMaturity(level, ctx);
|
||||
const reqs = REQUIREMENTS.map((r) => ({ key: r.key, label: r.label, control: r.control, tier: r.tier, theme: r.theme, met: r.met(ctx) }));
|
||||
|
||||
const gateEvidenceHref = `/suppliers?edit=${supplier.id}`;
|
||||
const gateDecisionHref = `/suppliers?edit=${supplier.id}`;
|
||||
const editHref = withQuery(backHref, "edit", supplier.id);
|
||||
const gateEvidenceHref = editHref;
|
||||
const gateDecisionHref = editHref;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -108,16 +110,16 @@ export async function SupplierDetailModal({ supplier, canWrite }: { supplier: Su
|
||||
<Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref="/suppliers"
|
||||
closeHref={backHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?edit=${supplier.id}`} />}>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={editHref} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/suppliers" />}>{t("close")}</Button>
|
||||
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -311,12 +313,13 @@ export async function SupplierCreateModal() {
|
||||
|
||||
/* ─────────────────────── Bearbeiten ─────────────────────── */
|
||||
|
||||
export async function SupplierEditModal({ supplier }: { supplier: SupplierAssetDetail }) {
|
||||
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";
|
||||
|
||||
@@ -328,22 +331,24 @@ export async function SupplierEditModal({ supplier }: { supplier: SupplierAssetD
|
||||
<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)}>
|
||||
<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={`/suppliers?detail=${supplier.id}`}
|
||||
closeHref={detailHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/suppliers?detail=${supplier.id}`} />}>{tc("cancel")}</Button>
|
||||
<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" />
|
||||
<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} />
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Gemeinsame Prisma-Includes für das Lieferanten- und IT-Service-Cockpit.
|
||||
* Werden sowohl auf /suppliers als auch auf /assets verwendet, damit die
|
||||
* Fach-Popups an beiden Stellen mit identischer Datenform gerendert werden.
|
||||
*/
|
||||
export const SUPPLIER_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 } } } },
|
||||
} satisfies Prisma.AssetInclude;
|
||||
|
||||
export const SERVICE_INCLUDE = {
|
||||
serviceProfile: { include: { provider: { select: { id: true, name: true } } } },
|
||||
raci: { orderBy: { controlRef: "asc" } },
|
||||
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;
|
||||
@@ -11,6 +11,15 @@ export function serviceRef(refNo: number): string {
|
||||
return `S-${String(refNo).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hängt einen Query-Parameter an einen Basis-Pfad an und wählt automatisch
|
||||
* das richtige Trennzeichen (`?` oder `&`). Erlaubt, Detail-/Bearbeiten-Popups
|
||||
* sowohl auf /suppliers als auch auf /assets zu öffnen (backHref-Muster).
|
||||
*/
|
||||
export function withQuery(base: string, key: string, val: string): string {
|
||||
return `${base}${base.includes("?") ? "&" : "?"}${key}=${val}`;
|
||||
}
|
||||
|
||||
export const SUPPLIER_LIFECYCLE_TONE = {
|
||||
ACTIVE: "ok",
|
||||
ONBOARDING: "info",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { withQuery } from "@/lib/supplier";
|
||||
|
||||
const level = z.coerce.number().int().min(1).max(4);
|
||||
|
||||
@@ -73,10 +74,11 @@ export async function updateService(assetId: string, formData: FormData) {
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "it_service", entityId: assetId, before, after: { ...asset, ...profile } });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
redirect(`/suppliers?tab=services&detail=${assetId}`);
|
||||
const returnTo = (formData.get("returnTo") as string) || "/suppliers?tab=services";
|
||||
redirect(withQuery(returnTo, "detail", assetId));
|
||||
}
|
||||
|
||||
export async function deleteService(assetId: string) {
|
||||
export async function deleteService(assetId: string, returnTo: string = "/suppliers?tab=services") {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
@@ -86,7 +88,7 @@ export async function deleteService(assetId: string) {
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "it_service", entityId: assetId, before });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
redirect("/suppliers?tab=services");
|
||||
redirect(returnTo);
|
||||
}
|
||||
|
||||
export async function addRaci(assetId: string, formData: FormData) {
|
||||
@@ -107,6 +109,7 @@ export async function addRaci(assetId: string, formData: FormData) {
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_raci", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
|
||||
@@ -120,6 +123,7 @@ export async function cycleRaci(assetId: string, raciId: string) {
|
||||
await db.serviceControlResponsibility.update({ where: { id: raciId }, data: { responsibility: next[row.responsibility] } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "service_raci", entityId: assetId, after: { responsibility: next[row.responsibility] } });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
export async function deleteRaci(assetId: string, raciId: string) {
|
||||
@@ -129,4 +133,5 @@ export async function deleteRaci(assetId: string, raciId: string) {
|
||||
await db.serviceControlResponsibility.delete({ where: { id: raciId } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { withQuery } from "@/lib/supplier";
|
||||
|
||||
const level = z.coerce.number().int().min(1).max(4);
|
||||
|
||||
@@ -110,10 +111,11 @@ export async function updateSupplier(assetId: string, formData: FormData) {
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier", entityId: assetId, before, after: { ...asset, ...profile } });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
redirect(`/suppliers?detail=${assetId}`);
|
||||
const returnTo = (formData.get("returnTo") as string) || "/suppliers";
|
||||
redirect(withQuery(returnTo, "detail", assetId));
|
||||
}
|
||||
|
||||
export async function deleteSupplier(assetId: string) {
|
||||
export async function deleteSupplier(assetId: string, returnTo: string = "/suppliers") {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
@@ -123,7 +125,7 @@ export async function deleteSupplier(assetId: string) {
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "supplier", entityId: assetId, before });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
redirect("/suppliers");
|
||||
redirect(returnTo);
|
||||
}
|
||||
|
||||
async function ensure(session: Awaited<ReturnType<typeof requireSession>>, assetId: string) {
|
||||
@@ -154,6 +156,7 @@ export async function addContract(assetId: string, formData: FormData) {
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
export async function addNda(assetId: string, formData: FormData) {
|
||||
@@ -174,6 +177,7 @@ export async function addNda(assetId: string, formData: FormData) {
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
export async function addEvidence(assetId: string, formData: FormData) {
|
||||
@@ -193,6 +197,7 @@ export async function addEvidence(assetId: string, formData: FormData) {
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
export async function addAssessment(assetId: string, formData: FormData) {
|
||||
@@ -212,6 +217,7 @@ export async function addAssessment(assetId: string, formData: FormData) {
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
export async function addDecision(assetId: string, formData: FormData) {
|
||||
@@ -230,6 +236,7 @@ export async function addDecision(assetId: string, formData: FormData) {
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: assetId });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
|
||||
@@ -282,6 +289,7 @@ export async function approveMaturity(assetId: string, computed: number, formDat
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "maturity_assessment", entityId: assetId, after: { computed, isbValue, justification } });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
export async function deleteChild(
|
||||
@@ -303,4 +311,5 @@ export async function deleteChild(
|
||||
await map[kind].delete({ where: { id } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
|
||||
revalidatePath("/suppliers");
|
||||
revalidatePath("/assets");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user