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:
2026-07-23 11:07:08 +02:00
co-authored by Claude Opus 4.8
parent b9784e55f4
commit bd56a478fc
8 changed files with 326 additions and 8 deletions
+69
View File
@@ -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}`);
}
+2
View File
@@ -30,6 +30,8 @@ const TENANT_MODELS = new Set<string>([
"AuditLog",
"Task",
"TaskComment",
"ManagedRegister",
"RegisterRow",
"TenantSettings",
"TenantModule",
"Asset",