GAP WP3.0: Generisches, editierbares Register-Datenmodell + Editor
Verwaltete Register (GAP-Report §5.2) sind nicht mehr nur benannte Dokumente, sondern editierbare Register mit Pflichtspalten und Cross-Links. - Schema: ManagedRegister (code, title, columns JSON, reviewCycle, responsible, supplierLink/assetLink) + RegisterRow (values JSON, supplierRef, assetRef). Migration + RLS; in TENANT_MODELS. - import-managed.ts: 7 generische Register (REG-PROJECTS, -SENS-ROLES, -AUDIT-PLAN, -EXT-SERVICES, -SW-WHITELIST, -CRIT-SERVICES, -NET) mit definierten Pflichtspalten + Cross-Link-Flags; legt Register-Dokument (Bibliothek/Link) UND ManagedRegister- Definition idempotent an (RegisterRow bleibt bei Re-Import erhalten). - actions/register.ts: addRegisterRow/updateRegisterRow/deleteRegisterRow (Modul policies, policy:write; Werte gegen die Pflichtspalten, optional Lieferant/Asset). - components/generic-register.tsx: editierbare Zeilen-Tabelle + Cross-Link-Selects (Lieferant = Asset SUPPLIER/IT_SERVICE, Asset = alle); in /policies/[code] eingebunden. Browser-verifiziert: REG-EXT-SERVICES rendert 7 Spalten + Lieferant/Asset-Selects; Eintrag anlegen persistiert alle Werte. tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
POLICY_TYPE_LABEL,
|
||||
} from "@/components/policy-modals";
|
||||
import { PolicyRegisterView } from "@/components/policy-registers";
|
||||
import { GenericRegisterView, type RegisterColumn } from "@/components/generic-register";
|
||||
|
||||
const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]);
|
||||
// Aus Vorlagen-Markdown gerenderte Dokumente (bearbeitbar über den Editor)
|
||||
@@ -35,9 +36,31 @@ export default async function PolicyDetailPage({
|
||||
|
||||
const canWrite = hasPermission(session, "policy:write");
|
||||
const isRegister = REGISTER_CODES.has(doc.code);
|
||||
// Generisches verwaltetes Register (WP3.0): editierbare Zeilen nach Pflichtspalten.
|
||||
const genericReg = doc.type === "REGISTER" && !isRegister
|
||||
? await db.managedRegister.findFirst({ where: { code: doc.code } })
|
||||
: null;
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (isRegister) {
|
||||
if (genericReg) {
|
||||
const [rows, allAssets] = await Promise.all([
|
||||
db.registerRow.findMany({ where: { registerId: genericReg.id }, orderBy: { orderIdx: "asc" } }),
|
||||
db.asset.findMany({ select: { id: true, name: true, type: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
content = (
|
||||
<GenericRegisterView
|
||||
register={{
|
||||
code: genericReg.code, title: genericReg.title, description: genericReg.description,
|
||||
columns: (genericReg.columns as unknown as RegisterColumn[]) ?? [],
|
||||
supplierLink: genericReg.supplierLink, assetLink: genericReg.assetLink,
|
||||
}}
|
||||
rows={rows.map((r) => ({ id: r.id, values: (r.values as Record<string, string>) ?? {}, supplierRef: r.supplierRef, assetRef: r.assetRef }))}
|
||||
canWrite={canWrite}
|
||||
suppliers={allAssets.filter((a) => a.type === "SUPPLIER" || a.type === "IT_SERVICE").map((a) => ({ id: a.id, name: a.name }))}
|
||||
assets={allAssets.map((a) => ({ id: a.id, name: a.name }))}
|
||||
/>
|
||||
);
|
||||
} else if (isRegister) {
|
||||
const data =
|
||||
doc.code === "CRYPTO"
|
||||
? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) }
|
||||
@@ -81,7 +104,7 @@ export default async function PolicyDetailPage({
|
||||
</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">
|
||||
{POLICY_TYPE_LABEL[doc.type]} · v{doc.version}
|
||||
{isRegister && canWrite ? ` · ${t("editableInline")}` : ""}
|
||||
{(isRegister || genericReg) && canWrite ? ` · ${t("editableInline")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{showEdit && (
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { addRegisterRow, updateRegisterRow, deleteRegisterRow } from "@/server/actions/register";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
/**
|
||||
* Generischer Editor für verwaltete Register (WP3.0): rendert die Pflichtspalten
|
||||
* als editierbare Zeilen inkl. optionaler Cross-Links (Lieferant/Asset).
|
||||
*/
|
||||
export interface RegisterColumn { key: string; label: string }
|
||||
export interface RegisterInfo {
|
||||
code: string; title: string; description: string | null;
|
||||
columns: RegisterColumn[]; supplierLink: boolean; assetLink: boolean;
|
||||
}
|
||||
export interface RegisterRowData { id: string; values: Record<string, string>; supplierRef: string | null; assetRef: string | null }
|
||||
export interface AssetOption { id: string; name: string }
|
||||
|
||||
const cell = "h-8 text-[12.5px]";
|
||||
|
||||
function RefSelect({ name, value, options, label }: { name: string; value: string | null; options: AssetOption[]; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[11px] text-muted-foreground">{label}</label>
|
||||
<select name={name} defaultValue={value ?? ""} className="mt-0.5 h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
|
||||
<option value="">—</option>
|
||||
{options.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fields({ register, row, suppliers, assets, disabled }: {
|
||||
register: RegisterInfo; row?: RegisterRowData; suppliers: AssetOption[]; assets: AssetOption[]; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{register.columns.map((c) => (
|
||||
<div key={c.key}>
|
||||
<label className="block text-[11px] text-muted-foreground">{c.label}</label>
|
||||
<Input name={`col_${c.key}`} defaultValue={row?.values?.[c.key] ?? ""} disabled={disabled} className={`mt-0.5 ${cell}`} />
|
||||
</div>
|
||||
))}
|
||||
{register.supplierLink && <RefSelect name="supplierRef" value={row?.supplierRef ?? null} options={suppliers} label="Lieferant" />}
|
||||
{register.assetLink && <RefSelect name="assetRef" value={row?.assetRef ?? null} options={assets} label="Asset" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenericRegisterView({ register, rows, canWrite, suppliers, assets }: {
|
||||
register: RegisterInfo; rows: RegisterRowData[]; canWrite: boolean; suppliers: AssetOption[]; assets: AssetOption[];
|
||||
}) {
|
||||
const gridCols = register.columns.length + (register.supplierLink ? 1 : 0) + (register.assetLink ? 1 : 0);
|
||||
const gridStyle = { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` };
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{register.description && <p className="text-[13px] text-muted-foreground">{register.description}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="rounded-xl border bg-card p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<form action={updateRegisterRow.bind(null, r.id)} className="grid flex-1 gap-2" style={gridStyle}>
|
||||
<Fields register={register} row={r} suppliers={suppliers} assets={assets} disabled={!canWrite} />
|
||||
{canWrite && <div className="col-span-full"><Button type="submit" variant="outline" size="sm">Speichern</Button></div>}
|
||||
</form>
|
||||
{canWrite && (
|
||||
<form action={deleteRegisterRow.bind(null, r.id)}>
|
||||
<Button type="submit" variant="ghost" size="sm" title="Löschen"><Trash2 className="size-4" /></Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Einträge in diesem Register.</p>}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<form action={addRegisterRow.bind(null, register.code)} className="rounded-xl border border-dashed bg-card p-3">
|
||||
<p className="mb-2 font-heading text-[12.5px] font-semibold">Eintrag hinzufügen</p>
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
<Fields register={register} suppliers={suppliers} assets={assets} />
|
||||
</div>
|
||||
<Button type="submit" size="sm" className="mt-3"><Plus className="size-4" /> Hinzufügen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
/**
|
||||
* Editierbare verwaltete Register (WP3.0). Zeilen werden gegen die Pflichtspalten
|
||||
* der ManagedRegister-Definition gepflegt; optionale Cross-Links an Lieferant/Asset.
|
||||
* Modul „policies", Recht policy:write.
|
||||
*/
|
||||
const guard = moduleGuard("policies");
|
||||
|
||||
interface Column { key: string; label: string }
|
||||
|
||||
function readValues(columns: Column[], formData: FormData): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const c of columns) values[c.key] = String(formData.get(`col_${c.key}`) ?? "").trim();
|
||||
return values;
|
||||
}
|
||||
const optRef = (v: FormDataEntryValue | null) => {
|
||||
const s = v ? String(v).trim() : "";
|
||||
return s || null;
|
||||
};
|
||||
|
||||
export async function addRegisterRow(code: string, formData: FormData) {
|
||||
const { session, db } = await guard("policy:write");
|
||||
const reg = await db.managedRegister.findFirst({ where: { code } });
|
||||
if (!reg) throw new Error("Register nicht gefunden");
|
||||
const columns = reg.columns as unknown as Column[];
|
||||
const last = await db.registerRow.aggregate({ where: { registerId: reg.id }, _max: { orderIdx: true } });
|
||||
await db.registerRow.create({
|
||||
data: {
|
||||
tenantId: session.user.tenantId, registerId: reg.id,
|
||||
values: readValues(columns, formData),
|
||||
supplierRef: reg.supplierLink ? optRef(formData.get("supplierRef")) : null,
|
||||
assetRef: reg.assetLink ? optRef(formData.get("assetRef")) : null,
|
||||
orderIdx: (last._max.orderIdx ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "create", entity: "register_row", entityId: reg.code });
|
||||
revalidatePath(`/policies/${code}`);
|
||||
}
|
||||
|
||||
export async function updateRegisterRow(rowId: string, formData: FormData) {
|
||||
const { session, db } = await guard("policy:write");
|
||||
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: true } });
|
||||
if (!row) throw new Error("Zeile nicht gefunden");
|
||||
const columns = row.register.columns as unknown as Column[];
|
||||
await db.registerRow.update({
|
||||
where: { id: rowId },
|
||||
data: {
|
||||
values: readValues(columns, formData),
|
||||
supplierRef: row.register.supplierLink ? optRef(formData.get("supplierRef")) : row.supplierRef,
|
||||
assetRef: row.register.assetLink ? optRef(formData.get("assetRef")) : row.assetRef,
|
||||
},
|
||||
});
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "update", entity: "register_row", entityId: rowId });
|
||||
revalidatePath(`/policies/${row.register.code}`);
|
||||
}
|
||||
|
||||
export async function deleteRegisterRow(rowId: string) {
|
||||
const { session, db } = await guard("policy:write");
|
||||
const row = await db.registerRow.findUnique({ where: { id: rowId }, include: { register: { select: { code: true } } } });
|
||||
if (!row) return;
|
||||
await db.registerRow.delete({ where: { id: rowId } });
|
||||
await writeAuditLog({ tenantId: session.user.tenantId, actorId: session.user.id, action: "delete", entity: "register_row", entityId: rowId });
|
||||
revalidatePath(`/policies/${row.register.code}`);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ const TENANT_MODELS = new Set<string>([
|
||||
"AuditLog",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"ManagedRegister",
|
||||
"RegisterRow",
|
||||
"TenantSettings",
|
||||
"TenantModule",
|
||||
"Asset",
|
||||
|
||||
Reference in New Issue
Block a user