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 Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Plus, Search } from "lucide-react";
|
import { Plus, Search } from "lucide-react";
|
||||||
import type { AssetType } from "@prisma/client";
|
import type { AssetType } from "@prisma/client";
|
||||||
@@ -22,6 +21,17 @@ import {
|
|||||||
AssetDetailModal,
|
AssetDetailModal,
|
||||||
AssetEditModal,
|
AssetEditModal,
|
||||||
} from "@/components/asset-modals";
|
} 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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -35,22 +45,6 @@ const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLI
|
|||||||
|
|
||||||
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
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({
|
export default async function AssetsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -103,43 +97,75 @@ export default async function AssetsPage({
|
|||||||
|
|
||||||
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
|
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
|
||||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
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({
|
? await db.asset.findUnique({
|
||||||
where: { id: modalId },
|
where: { id: modalId },
|
||||||
include: {
|
select: {
|
||||||
owner: { select: { name: true } },
|
type: true,
|
||||||
supplierProfile: { select: { id: true } },
|
supplierProfile: { select: { id: true } },
|
||||||
serviceProfile: { 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;
|
: 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
|
const supplierModal =
|
||||||
// (nur wenn ein Fachprofil existiert; profillose Alt-Assets bleiben im Asset-Detail)
|
modalKind === "supplier"
|
||||||
if (modalAsset && ((modalAsset.type === "SUPPLIER" && modalAsset.supplierProfile) || (modalAsset.type === "IT_SERVICE" && modalAsset.serviceProfile))) {
|
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
|
||||||
const base = modalAsset.type === "IT_SERVICE" ? "/suppliers?tab=services" : "/suppliers";
|
: null;
|
||||||
redirect(`${base}${base.includes("?") ? "&" : "?"}${params.edit && canWrite ? "edit" : "detail"}=${modalAsset.id}`);
|
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 =
|
const users =
|
||||||
canWrite && (params.edit || params.new)
|
canWrite && (params.edit || params.new)
|
||||||
@@ -247,7 +273,7 @@ export default async function AssetsPage({
|
|||||||
{assets.map((asset) => (
|
{assets.map((asset) => (
|
||||||
<TableRow key={asset.id}>
|
<TableRow key={asset.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={assetDetailHref(asset)} className="font-bold hover:underline">
|
<Link href={`/assets?detail=${asset.id}`} className="font-bold hover:underline">
|
||||||
{asset.name}
|
{asset.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -274,7 +300,17 @@ export default async function AssetsPage({
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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} />
|
<AssetEditModal asset={modalAsset} users={users} otherAssets={otherAssets} />
|
||||||
) : modalAsset ? (
|
) : modalAsset ? (
|
||||||
<AssetDetailModal asset={modalAsset} canWrite={canWrite} />
|
<AssetDetailModal asset={modalAsset} canWrite={canWrite} />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
CONFORMITY_LABEL,
|
CONFORMITY_LABEL,
|
||||||
TARGET_MATURITY,
|
TARGET_MATURITY,
|
||||||
} from "@/lib/supplier";
|
} from "@/lib/supplier";
|
||||||
|
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE } from "@/lib/supplier-include";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -40,29 +41,6 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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({
|
export default async function SuppliersPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Modal } from "@/components/modal";
|
import { Modal } from "@/components/modal";
|
||||||
import { SegmentedRating } from "@/components/segmented-rating";
|
import { SegmentedRating } from "@/components/segmented-rating";
|
||||||
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
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 { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||||
import { ISA_CONTROLS } from "@/lib/isa-controls";
|
import { ISA_CONTROLS } from "@/lib/isa-controls";
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ const RACI_TONE = { PROVIDER: "info", US: "violet", SHARED: "warn" } as const;
|
|||||||
|
|
||||||
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
|
/* ─────────────────────── 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 t = await getTranslations("services");
|
||||||
const ta = await getTranslations("assets");
|
const ta = await getTranslations("assets");
|
||||||
const tParty = await getTranslations("raciParty");
|
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))} />
|
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
closeHref="/suppliers?tab=services"
|
closeHref={backHref}
|
||||||
closeLabel={tp("close")}
|
closeLabel={tp("close")}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
{canWrite && (
|
{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")}
|
<Pencil className="size-4" /> {tc("edit")}
|
||||||
</Button>
|
</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 t = await getTranslations("services");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
const FORM = "service-edit";
|
const FORM = "service-edit";
|
||||||
|
const detailHref = withQuery(backHref, "detail", service.id);
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("editTitle")}
|
title={t("editTitle")}
|
||||||
@@ -346,22 +347,24 @@ export async function ServiceEditModal({ service, providers }: { service: Servic
|
|||||||
<details className="relative">
|
<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>
|
<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">
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
closeHref={`/suppliers?tab=services&detail=${service.id}`}
|
closeHref={detailHref}
|
||||||
closeLabel={t("close")}
|
closeLabel={t("close")}
|
||||||
footer={
|
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>
|
<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">
|
<div className="p-5">
|
||||||
<ServiceFields service={service} providers={providers} formId={FORM} />
|
<ServiceFields service={service} providers={providers} formId={FORM} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Pill } from "@/components/mockup-ui";
|
|||||||
import { SupplierRequirements } from "@/components/supplier-cockpit";
|
import { SupplierRequirements } from "@/components/supplier-cockpit";
|
||||||
import {
|
import {
|
||||||
supplierRef,
|
supplierRef,
|
||||||
|
withQuery,
|
||||||
SUPPLIER_LIFECYCLE_TONE,
|
SUPPLIER_LIFECYCLE_TONE,
|
||||||
protectionLevel,
|
protectionLevel,
|
||||||
PROTECTION_LABEL,
|
PROTECTION_LABEL,
|
||||||
@@ -80,7 +81,7 @@ function contextOf(s: SupplierAssetDetail) {
|
|||||||
|
|
||||||
/* ─────────────────────── Cockpit (Detail) ─────────────────────── */
|
/* ─────────────────────── 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 t = await getTranslations("suppliers");
|
||||||
const tLife = await getTranslations("supplierStatus");
|
const tLife = await getTranslations("supplierStatus");
|
||||||
const tEKind = await getTranslations("evidenceKind");
|
const tEKind = await getTranslations("evidenceKind");
|
||||||
@@ -94,8 +95,9 @@ export async function SupplierDetailModal({ supplier, canWrite }: { supplier: Su
|
|||||||
const computed = computedMaturity(level, ctx);
|
const computed = computedMaturity(level, ctx);
|
||||||
const reqs = REQUIREMENTS.map((r) => ({ key: r.key, label: r.label, control: r.control, tier: r.tier, theme: r.theme, met: r.met(ctx) }));
|
const 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 editHref = withQuery(backHref, "edit", supplier.id);
|
||||||
const gateDecisionHref = `/suppliers?edit=${supplier.id}`;
|
const gateEvidenceHref = editHref;
|
||||||
|
const gateDecisionHref = editHref;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -108,16 +110,16 @@ export async function SupplierDetailModal({ supplier, canWrite }: { supplier: Su
|
|||||||
<Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill>
|
<Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
closeHref="/suppliers"
|
closeHref={backHref}
|
||||||
closeLabel={t("close")}
|
closeLabel={t("close")}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
{canWrite && (
|
{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")}
|
<Pencil className="size-4" /> {tc("edit")}
|
||||||
</Button>
|
</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 ─────────────────────── */
|
/* ─────────────────────── 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 t = await getTranslations("suppliers");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
const tEKind = await getTranslations("evidenceKind");
|
const tEKind = await getTranslations("evidenceKind");
|
||||||
const tAType = await getTranslations("assessmentType");
|
const tAType = await getTranslations("assessmentType");
|
||||||
const FORM = "supplier-edit";
|
const FORM = "supplier-edit";
|
||||||
|
const detailHref = withQuery(backHref, "detail", supplier.id);
|
||||||
const disc =
|
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";
|
"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">
|
<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>
|
<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">
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
closeHref={`/suppliers?detail=${supplier.id}`}
|
closeHref={detailHref}
|
||||||
closeLabel={t("close")}
|
closeLabel={t("close")}
|
||||||
footer={
|
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>
|
<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">
|
<div className="space-y-6 p-5">
|
||||||
<SupplierFields supplier={supplier} formId={FORM} />
|
<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")}`;
|
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 = {
|
export const SUPPLIER_LIFECYCLE_TONE = {
|
||||||
ACTIVE: "ok",
|
ACTIVE: "ok",
|
||||||
ONBOARDING: "info",
|
ONBOARDING: "info",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { requireSession } from "@/server/auth";
|
|||||||
import { dbForTenant, prisma } from "@/server/db";
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
import { requirePermission } from "@/server/rbac";
|
import { requirePermission } from "@/server/rbac";
|
||||||
import { writeAuditLog } from "@/server/audit";
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { withQuery } from "@/lib/supplier";
|
||||||
|
|
||||||
const level = z.coerce.number().int().min(1).max(4);
|
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 } });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "it_service", entityId: assetId, before, after: { ...asset, ...profile } });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
revalidatePath("/assets");
|
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();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = dbForTenant(session.user.tenantId);
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "it_service", entityId: assetId, before });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
revalidatePath("/assets");
|
revalidatePath("/assets");
|
||||||
redirect("/suppliers?tab=services");
|
redirect(returnTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addRaci(assetId: string, formData: FormData) {
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "service_raci", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verantwortung einer RACI-Zeile per Klick ändern (PROVIDER/US/SHARED durchschalten). */
|
/** 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 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] } });
|
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("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRaci(assetId: string, raciId: string) {
|
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 db.serviceControlResponsibility.delete({ where: { id: raciId } });
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "service_raci", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { requireSession } from "@/server/auth";
|
|||||||
import { dbForTenant, prisma } from "@/server/db";
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
import { requirePermission } from "@/server/rbac";
|
import { requirePermission } from "@/server/rbac";
|
||||||
import { writeAuditLog } from "@/server/audit";
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { withQuery } from "@/lib/supplier";
|
||||||
|
|
||||||
const level = z.coerce.number().int().min(1).max(4);
|
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 } });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "supplier", entityId: assetId, before, after: { ...asset, ...profile } });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
revalidatePath("/assets");
|
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();
|
const session = await requireSession();
|
||||||
requirePermission(session, "supplier:write");
|
requirePermission(session, "supplier:write");
|
||||||
const db = dbForTenant(session.user.tenantId);
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "supplier", entityId: assetId, before });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
revalidatePath("/assets");
|
revalidatePath("/assets");
|
||||||
redirect("/suppliers");
|
redirect(returnTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensure(session: Awaited<ReturnType<typeof requireSession>>, assetId: string) {
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "contract", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addNda(assetId: string, formData: FormData) {
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "nda", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addEvidence(assetId: string, formData: FormData) {
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_evidence", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addAssessment(assetId: string, formData: FormData) {
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "supplier_assessment", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addDecision(assetId: string, formData: FormData) {
|
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 });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "management_decision", entityId: assetId });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gate-Kompensation: echtes Risiko im Risikomodul anlegen und mit dem Lieferanten verknüpfen. */
|
/** 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 } });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "maturity_assessment", entityId: assetId, after: { computed, isbValue, justification } });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteChild(
|
export async function deleteChild(
|
||||||
@@ -303,4 +311,5 @@ export async function deleteChild(
|
|||||||
await map[kind].delete({ where: { id } });
|
await map[kind].delete({ where: { id } });
|
||||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
|
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: `supplier_${kind}`, entityId: id });
|
||||||
revalidatePath("/suppliers");
|
revalidatePath("/suppliers");
|
||||||
|
revalidatePath("/assets");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user