Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.
`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.
Go side:
- models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
The MinRole pointer is now the only seniority-threshold surface.
- LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
drop the required_role SELECT projection.
- UpsertProjectPolicySplit / UpsertUnitPolicySplit /
DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
drop the required_role write. The audit-log row still uses the
legacy string format ('partner|...|none'); composed via
legacyFromSplit() from the new columns so the audit table layout
keeps working without a parallel migration.
- submit() reads policy.MinRole directly (LookupPolicy guarantees
non-nil when a non-nil policy is returned).
- nullToPtr helper retired (no remaining callers).
Frontend side:
- admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
legacy required_role optional. The 2-control UI was already on the
split-grammar path.
- deadlines-new.ts + appointments-new.ts form-time hint readers prefer
requires_approval+min_role. They keep a soft-fall back to the
legacy required_role for one cycle in case any cached pre-M2 server
is still serving the old shape — that path is dead-code post-deploy
and can be dropped later.
Test:
- TestApprovalService_PolicyCRUD asserts MinRole instead of
RequiredRole after re-upsert.
Build: bun build OK, go build ./... OK, go test ./... OK.
Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
662 lines
25 KiB
TypeScript
662 lines
25 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
// t-paliad-154 — admin approval-policy authoring page orchestration.
|
|
//
|
|
// Two sections: Partner-Unit-Standards (accordion list) and Projekt-spezifisch
|
|
// (project picker → 8-cell matrix). Edits hit the per-scope CRUD endpoints
|
|
// from the same page; re-renders refresh from server state to surface
|
|
// inheritance changes.
|
|
|
|
interface PartnerUnit {
|
|
id: string;
|
|
name: string;
|
|
office: string;
|
|
}
|
|
|
|
interface UnitPolicy {
|
|
id: string;
|
|
partner_unit_id: string | null;
|
|
project_id: string | null;
|
|
entity_type: string;
|
|
lifecycle_event: string;
|
|
// t-paliad-160 split-grammar.
|
|
requires_approval: boolean;
|
|
min_role?: string | null;
|
|
}
|
|
|
|
interface EffectivePolicy {
|
|
entity_type: string;
|
|
lifecycle_event: string;
|
|
requires_approval: boolean;
|
|
min_role?: string | null;
|
|
source?: string | null;
|
|
source_id?: string | null;
|
|
source_name?: string | null;
|
|
}
|
|
|
|
interface ProjectNode {
|
|
id: string;
|
|
title: string;
|
|
reference?: string | null;
|
|
type?: string;
|
|
parent_id?: string | null;
|
|
children?: ProjectNode[];
|
|
}
|
|
|
|
const ENTITY_TYPES = ["deadline", "appointment"] as const;
|
|
const LIFECYCLES = ["create", "update", "complete", "delete"] as const;
|
|
// Strict-ladder roles only. The legacy "none" sentinel is gone — its job
|
|
// (suppress the gate) is now done by the requires_approval=false checkbox
|
|
// (t-paliad-160 §A).
|
|
const ROLE_OPTIONS = [
|
|
"partner",
|
|
"of_counsel",
|
|
"associate",
|
|
"senior_pa",
|
|
"pa",
|
|
];
|
|
|
|
let partnerUnits: PartnerUnit[] = [];
|
|
let unitPolicies: Record<string, UnitPolicy[]> = {};
|
|
let allProjects: ProjectNode[] = [];
|
|
let selectedProjectID: string | null = null;
|
|
let selectedProjectTitle: string = "";
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function escAttr(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
}
|
|
|
|
function showFeedback(msg: string, isError: boolean): void {
|
|
const el = document.getElementById("ap-feedback");
|
|
if (!el) return;
|
|
el.textContent = msg;
|
|
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
|
el.style.display = "block";
|
|
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Loaders.
|
|
// ============================================================================
|
|
|
|
async function loadPartnerUnits(): Promise<void> {
|
|
const resp = await fetch("/api/partner-units");
|
|
if (!resp.ok) {
|
|
partnerUnits = [];
|
|
return;
|
|
}
|
|
partnerUnits = (await resp.json()) as PartnerUnit[];
|
|
}
|
|
|
|
async function loadUnitPolicies(unitID: string): Promise<UnitPolicy[]> {
|
|
const resp = await fetch(`/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies`);
|
|
if (!resp.ok) return [];
|
|
return (await resp.json()) as UnitPolicy[];
|
|
}
|
|
|
|
async function loadAllUnitPolicies(): Promise<void> {
|
|
const out: Record<string, UnitPolicy[]> = {};
|
|
for (const u of partnerUnits) {
|
|
out[u.id] = await loadUnitPolicies(u.id);
|
|
}
|
|
unitPolicies = out;
|
|
}
|
|
|
|
async function loadProjects(): Promise<void> {
|
|
const resp = await fetch("/api/projects/tree");
|
|
if (!resp.ok) {
|
|
allProjects = [];
|
|
return;
|
|
}
|
|
const tree = (await resp.json()) as ProjectNode[];
|
|
allProjects = flattenTree(tree);
|
|
}
|
|
|
|
function flattenTree(nodes: ProjectNode[]): ProjectNode[] {
|
|
const out: ProjectNode[] = [];
|
|
const walk = (n: ProjectNode): void => {
|
|
out.push(n);
|
|
if (n.children) n.children.forEach(walk);
|
|
};
|
|
nodes.forEach(walk);
|
|
return out;
|
|
}
|
|
|
|
async function loadMatrix(projectID: string): Promise<EffectivePolicy[]> {
|
|
const resp = await fetch(`/api/admin/approval-policies/matrix?project_id=${encodeURIComponent(projectID)}`);
|
|
if (!resp.ok) return [];
|
|
return (await resp.json()) as EffectivePolicy[];
|
|
}
|
|
|
|
// ============================================================================
|
|
// Rendering — partner unit accordion.
|
|
// ============================================================================
|
|
|
|
function lifecycleLabel(l: string): string {
|
|
return tDyn("admin.approval_policies.lifecycle." + l) || l;
|
|
}
|
|
|
|
function entityLabel(e: string): string {
|
|
return tDyn("admin.approval_policies.entity." + e) || e;
|
|
}
|
|
|
|
function roleLabel(r: string): string {
|
|
return tDyn("admin.approval_policies.role." + r) || r;
|
|
}
|
|
|
|
function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): UnitPolicy | undefined {
|
|
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
|
|
}
|
|
|
|
// Cell control state, t-paliad-160 §A.
|
|
// none → no project-specific rule authored (the cell inherits).
|
|
// off → requires_approval=false explicitly authored.
|
|
// on(role) → requires_approval=true with the given min_role.
|
|
//
|
|
// rendered as: [✓] requires approval [role select]
|
|
// - checkbox unchecked → role select disabled (greyed).
|
|
// - checkbox checked → role select enabled, min_role required.
|
|
// - "no rule" — surfaced as a third button next to the controls so the
|
|
// admin can explicitly clear an authored cell back to inheritance.
|
|
type CellAuthored =
|
|
| { kind: "none" }
|
|
| { kind: "off" }
|
|
| { kind: "on"; role: string };
|
|
|
|
function authoredFromUnitPolicy(p: UnitPolicy | undefined): CellAuthored {
|
|
if (!p) return { kind: "none" };
|
|
if (!p.requires_approval) return { kind: "off" };
|
|
return { kind: "on", role: p.min_role || "associate" };
|
|
}
|
|
|
|
function authoredFromEffective(r: EffectivePolicy): CellAuthored {
|
|
if (r.source !== "project") return { kind: "none" };
|
|
if (!r.requires_approval) return { kind: "off" };
|
|
return { kind: "on", role: r.min_role || "associate" };
|
|
}
|
|
|
|
function renderCellControls(authored: CellAuthored, dataAttrs: string): string {
|
|
const checked = authored.kind === "on";
|
|
const disabled = authored.kind !== "on";
|
|
const role = authored.kind === "on" ? authored.role : "associate";
|
|
const opts = ROLE_OPTIONS.map((r) =>
|
|
`<option value="${esc(r)}"${role === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`
|
|
).join("");
|
|
const reqLabel = esc(t("admin.approval_policies.cell.requires") || "Genehmigung");
|
|
const clearLabel = esc(t("admin.approval_policies.cell.clear") || "—");
|
|
const clearTitle = esc(t("admin.approval_policies.cell.clear.title") || "Regel zurücksetzen (erben)");
|
|
const cleared = authored.kind === "none";
|
|
return `
|
|
<label class="ap-cell-toggle">
|
|
<input type="checkbox" class="ap-cell-requires" ${dataAttrs}${checked ? " checked" : ""} aria-label="${reqLabel}" />
|
|
<span class="ap-cell-toggle-label">${reqLabel}</span>
|
|
</label>
|
|
<select class="ap-cell-role" ${dataAttrs}${disabled ? " disabled" : ""}>${opts}</select>
|
|
<button type="button" class="ap-cell-clear" ${dataAttrs} title="${clearTitle}"${cleared ? " disabled" : ""}>${clearLabel}</button>
|
|
`;
|
|
}
|
|
|
|
function renderUnitMatrix(unit: PartnerUnit): string {
|
|
const rows = unitPolicies[unit.id] || [];
|
|
const buildCell = (e: string, l: string): string => {
|
|
const p = policyForCell(rows, e, l);
|
|
const authored = authoredFromUnitPolicy(p);
|
|
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
|
|
return `<div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>`;
|
|
};
|
|
|
|
let cells = "";
|
|
for (const e of ENTITY_TYPES) {
|
|
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
|
|
for (const l of LIFECYCLES) {
|
|
cells += `<td class="ap-matrix-cell">${buildCell(e, l)}</td>`;
|
|
}
|
|
cells += `</tr>`;
|
|
}
|
|
|
|
// Stacked sections for mobile (CSS toggles the table vs the list).
|
|
let stacked = "";
|
|
for (const e of ENTITY_TYPES) {
|
|
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
|
|
for (const l of LIFECYCLES) {
|
|
stacked += `<div class="ap-matrix-row">
|
|
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
|
|
${buildCell(e, l)}
|
|
</div>`;
|
|
}
|
|
stacked += `</div>`;
|
|
}
|
|
|
|
return `
|
|
<table class="ap-matrix">
|
|
<thead><tr><th></th>
|
|
<th>${esc(lifecycleLabel("create"))}</th>
|
|
<th>${esc(lifecycleLabel("update"))}</th>
|
|
<th>${esc(lifecycleLabel("complete"))}</th>
|
|
<th>${esc(lifecycleLabel("delete"))}</th>
|
|
</tr></thead>
|
|
<tbody>${cells}</tbody>
|
|
</table>
|
|
<div class="ap-matrix-stacked">${stacked}</div>
|
|
`;
|
|
}
|
|
|
|
function renderUnits(): void {
|
|
const host = document.getElementById("ap-units-list");
|
|
if (!host) return;
|
|
// Preserve which unit blocks were expanded across re-renders. Without this,
|
|
// changing any cell's required_role saves and re-renders, collapsing the
|
|
// accordion the admin was working in (m, 2026-05-08).
|
|
const openUnitIDs = new Set<string>();
|
|
host.querySelectorAll<HTMLDetailsElement>("details.ap-unit-block").forEach((d) => {
|
|
if (d.open && d.dataset.unitId) openUnitIDs.add(d.dataset.unitId);
|
|
});
|
|
if (partnerUnits.length === 0) {
|
|
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.units.empty") || "Keine Partner Units vorhanden.")}</p>`;
|
|
return;
|
|
}
|
|
host.innerHTML = partnerUnits.map((u) => `
|
|
<details class="ap-unit-block" data-unit-id="${esc(u.id)}"${openUnitIDs.has(u.id) ? " open" : ""}>
|
|
<summary class="ap-unit-summary">
|
|
<span class="ap-unit-name">${esc(u.name)}</span>
|
|
<span class="office-chip office-${esc(u.office)}">${esc(u.office)}</span>
|
|
</summary>
|
|
<div class="ap-unit-body">
|
|
${renderUnitMatrix(u)}
|
|
</div>
|
|
</details>
|
|
`).join("");
|
|
bindCellChangeHandlers(host);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Rendering — project matrix with attribution chips.
|
|
// ============================================================================
|
|
|
|
function renderProjectMatrix(rows: EffectivePolicy[]): string {
|
|
const cell = (r: EffectivePolicy): string => {
|
|
const own = r.source === "project";
|
|
const attrs = `data-scope="project" data-project-id="${escAttr(selectedProjectID || "")}" data-entity="${esc(r.entity_type)}" data-lifecycle="${esc(r.lifecycle_event)}"`;
|
|
// The controls show the AUTHORED state — the project row's own values
|
|
// when there is one, else the inherited state is rendered via the
|
|
// attribution chip and the controls sit unset (kind="none"). Most-
|
|
// strict-wins inheritance from ancestors / unit defaults is purely
|
|
// informational on this row; flipping the controls writes a new
|
|
// project-specific row.
|
|
const authored = authoredFromEffective(r);
|
|
let chip = "";
|
|
if (r.source && !own && r.requires_approval) {
|
|
// Inherited from ancestor or unit default. Surface attribution +
|
|
// the inherited min_role so the admin sees what the cell is
|
|
// resolving to before they author an override.
|
|
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
|
r.source === "unit_default" ? "admin.approval_policies.source.unit_default" :
|
|
"admin.approval_policies.source.project";
|
|
const label = tDyn(sourceKey) || r.source;
|
|
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
|
const role = r.min_role ? ` · ${esc(roleLabel(r.min_role))}+` : "";
|
|
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}${role}</span>`;
|
|
} else if (r.source && !own && !r.requires_approval) {
|
|
// Inherited "no approval needed" — distinct from "no rule at all".
|
|
const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" :
|
|
"admin.approval_policies.source.unit_default";
|
|
const label = tDyn(sourceKey) || r.source;
|
|
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
|
|
const offLabel = esc(t("admin.approval_policies.source.no_approval") || "keine Genehmigung");
|
|
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name} · ${offLabel}</span>`;
|
|
} else if (own) {
|
|
chip = `<span class="ap-source-chip ap-source-project">${esc(t("admin.approval_policies.source.project") || "Projekt")}</span>`;
|
|
}
|
|
return `<div class="ap-cell-wrap"><div class="ap-cell-controls">${renderCellControls(authored, attrs)}</div>${chip}</div>`;
|
|
};
|
|
|
|
const byCell = new Map<string, EffectivePolicy>();
|
|
for (const r of rows) byCell.set(`${r.entity_type}:${r.lifecycle_event}`, r);
|
|
const cellFor = (e: string, l: string): EffectivePolicy =>
|
|
byCell.get(`${e}:${l}`) || { entity_type: e, lifecycle_event: l };
|
|
|
|
let table = "";
|
|
for (const e of ENTITY_TYPES) {
|
|
table += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
|
|
for (const l of LIFECYCLES) {
|
|
table += `<td class="ap-matrix-cell">${cell(cellFor(e, l))}</td>`;
|
|
}
|
|
table += `</tr>`;
|
|
}
|
|
|
|
let stacked = "";
|
|
for (const e of ENTITY_TYPES) {
|
|
stacked += `<div class="ap-matrix-section"><h4>${esc(entityLabel(e))}</h4>`;
|
|
for (const l of LIFECYCLES) {
|
|
stacked += `<div class="ap-matrix-row">
|
|
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
|
|
${cell(cellFor(e, l))}
|
|
</div>`;
|
|
}
|
|
stacked += `</div>`;
|
|
}
|
|
|
|
return `
|
|
<table class="ap-matrix">
|
|
<thead><tr><th></th>
|
|
<th>${esc(lifecycleLabel("create"))}</th>
|
|
<th>${esc(lifecycleLabel("update"))}</th>
|
|
<th>${esc(lifecycleLabel("complete"))}</th>
|
|
<th>${esc(lifecycleLabel("delete"))}</th>
|
|
</tr></thead>
|
|
<tbody>${table}</tbody>
|
|
</table>
|
|
<div class="ap-matrix-stacked">${stacked}</div>
|
|
`;
|
|
}
|
|
|
|
async function selectProject(p: ProjectNode): Promise<void> {
|
|
selectedProjectID = p.id;
|
|
selectedProjectTitle = p.title;
|
|
const matrix = await loadMatrix(p.id);
|
|
const wrap = document.getElementById("ap-project-matrix");
|
|
const host = document.getElementById("ap-matrix-host");
|
|
const titleEl = document.getElementById("ap-project-title");
|
|
if (!wrap || !host || !titleEl) return;
|
|
wrap.style.display = "block";
|
|
titleEl.textContent = p.title + (p.reference ? ` · ${p.reference}` : "");
|
|
host.innerHTML = renderProjectMatrix(matrix);
|
|
bindCellChangeHandlers(host);
|
|
}
|
|
|
|
function renderProjectResults(filter: string): void {
|
|
const host = document.getElementById("ap-project-results");
|
|
if (!host) return;
|
|
const q = filter.trim().toLowerCase();
|
|
let matches = allProjects;
|
|
if (q.length > 0) {
|
|
matches = allProjects.filter((p) => {
|
|
const t = (p.title || "").toLowerCase();
|
|
const r = (p.reference || "").toLowerCase();
|
|
return t.includes(q) || r.includes(q);
|
|
});
|
|
}
|
|
matches = matches.slice(0, 30);
|
|
if (matches.length === 0) {
|
|
host.innerHTML = `<p class="form-hint">${esc(t("admin.approval_policies.picker.no_results") || "Keine Treffer.")}</p>`;
|
|
return;
|
|
}
|
|
host.innerHTML = matches.map((p) => `
|
|
<button type="button" class="ap-project-result" data-id="${escAttr(p.id)}">
|
|
<span class="ap-project-result-title">${esc(p.title)}</span>
|
|
${p.reference ? `<span class="ap-project-result-ref">${esc(p.reference)}</span>` : ""}
|
|
</button>
|
|
`).join("");
|
|
host.querySelectorAll<HTMLButtonElement>(".ap-project-result").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const id = btn.dataset.id || "";
|
|
const p = allProjects.find((x) => x.id === id);
|
|
if (p) void selectProject(p);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Cell change → server.
|
|
// ============================================================================
|
|
|
|
function bindCellChangeHandlers(scope: HTMLElement): void {
|
|
// Each cell now has THREE controls — the requires-approval checkbox,
|
|
// the role select, and the explicit "clear / inherit" button. They all
|
|
// share data-* attrs so onCellChange can derive the URL + intended
|
|
// post-state from any of them.
|
|
scope.querySelectorAll<HTMLInputElement>(".ap-cell-requires").forEach((cb) => {
|
|
cb.addEventListener("change", () => {
|
|
// Toggle the sibling role select disabled state immediately for
|
|
// visual feedback — the server PUT might lag.
|
|
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
|
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
|
if (sel) sel.disabled = !cb.checked;
|
|
void onCellChangeFromCheckbox(cb);
|
|
});
|
|
});
|
|
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-role").forEach((sel) => {
|
|
sel.addEventListener("change", () => void onCellChangeFromRole(sel));
|
|
});
|
|
scope.querySelectorAll<HTMLButtonElement>(".ap-cell-clear").forEach((btn) => {
|
|
btn.addEventListener("click", () => void onCellClear(btn));
|
|
});
|
|
}
|
|
|
|
function cellEndpointURL(el: HTMLElement): string | null {
|
|
const scope = el.dataset.scope;
|
|
const entity = el.dataset.entity || "";
|
|
const lifecycle = el.dataset.lifecycle || "";
|
|
if (scope === "unit") {
|
|
const unitID = el.dataset.unitId || "";
|
|
return `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
|
}
|
|
if (scope === "project") {
|
|
const projectID = el.dataset.projectId || "";
|
|
return `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function onCellChangeFromCheckbox(cb: HTMLInputElement): Promise<void> {
|
|
// Checkbox flipped — write the cell as (requires_approval, min_role).
|
|
const wrap = cb.closest(".ap-cell-controls") as HTMLElement | null;
|
|
const sel = wrap?.querySelector<HTMLSelectElement>(".ap-cell-role");
|
|
const requires = cb.checked;
|
|
const minRole = requires ? (sel?.value || "associate") : null;
|
|
await putCellSplit(cb, requires, minRole);
|
|
}
|
|
|
|
async function onCellChangeFromRole(sel: HTMLSelectElement): Promise<void> {
|
|
// Role select changed — only meaningful when the checkbox is on (the
|
|
// disabled state would block this on a real interaction, but pin it
|
|
// for safety).
|
|
const wrap = sel.closest(".ap-cell-controls") as HTMLElement | null;
|
|
const cb = wrap?.querySelector<HTMLInputElement>(".ap-cell-requires");
|
|
if (!cb || !cb.checked) return;
|
|
await putCellSplit(sel, true, sel.value);
|
|
}
|
|
|
|
async function onCellClear(btn: HTMLButtonElement): Promise<void> {
|
|
// Explicit "back to inheritance" — DELETE the project / unit row.
|
|
const url = cellEndpointURL(btn);
|
|
if (!url) return;
|
|
try {
|
|
const resp = await fetch(url, { method: "DELETE" });
|
|
if (!resp.ok) {
|
|
const errBody = await resp.text();
|
|
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
|
return;
|
|
}
|
|
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
|
await refreshAfterCellMutation(btn);
|
|
} catch (err) {
|
|
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
|
}
|
|
}
|
|
|
|
async function putCellSplit(el: HTMLElement, requires: boolean, minRole: string | null): Promise<void> {
|
|
const url = cellEndpointURL(el);
|
|
if (!url) return;
|
|
try {
|
|
const resp = await fetch(url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ requires_approval: requires, min_role: minRole }),
|
|
});
|
|
if (!resp.ok) {
|
|
const errBody = await resp.text();
|
|
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${errBody}`, true);
|
|
return;
|
|
}
|
|
showFeedback(t("admin.approval_policies.cell.saved_msg") || "Gespeichert.", false);
|
|
await refreshAfterCellMutation(el);
|
|
} catch (err) {
|
|
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
|
|
}
|
|
}
|
|
|
|
async function refreshAfterCellMutation(el: HTMLElement): Promise<void> {
|
|
const scope = el.dataset.scope;
|
|
if (scope === "unit") {
|
|
const unitID = el.dataset.unitId || "";
|
|
unitPolicies[unitID] = await loadUnitPolicies(unitID);
|
|
renderUnits();
|
|
} else if (selectedProjectID) {
|
|
const matrix = await loadMatrix(selectedProjectID);
|
|
const host = document.getElementById("ap-matrix-host");
|
|
if (host) {
|
|
host.innerHTML = renderProjectMatrix(matrix);
|
|
bindCellChangeHandlers(host);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Bulk-apply to descendants.
|
|
// ============================================================================
|
|
|
|
function descendantsOf(rootID: string): ProjectNode[] {
|
|
// Build parent-child map from the flat list.
|
|
const byParent = new Map<string, ProjectNode[]>();
|
|
for (const p of allProjects) {
|
|
const parent = p.parent_id || "";
|
|
if (!byParent.has(parent)) byParent.set(parent, []);
|
|
byParent.get(parent)!.push(p);
|
|
}
|
|
const out: ProjectNode[] = [];
|
|
const walk = (id: string): void => {
|
|
const kids = byParent.get(id) || [];
|
|
for (const k of kids) {
|
|
out.push(k);
|
|
walk(k.id);
|
|
}
|
|
};
|
|
walk(rootID);
|
|
return out;
|
|
}
|
|
|
|
function openBulkModal(): void {
|
|
if (!selectedProjectID) return;
|
|
const targets = descendantsOf(selectedProjectID);
|
|
const list = document.getElementById("ap-bulk-target-list");
|
|
const modal = document.getElementById("ap-bulk-modal");
|
|
if (!list || !modal) return;
|
|
if (targets.length === 0) {
|
|
showFeedback(t("admin.approval_policies.bulk.no_descendants") || "Keine Unterprojekte vorhanden.", true);
|
|
return;
|
|
}
|
|
list.innerHTML = targets.map((p) => `
|
|
<li><span class="ap-bulk-target-title">${esc(p.title)}</span>${p.reference ? ` <span class="ap-bulk-target-ref">${esc(p.reference)}</span>` : ""}</li>
|
|
`).join("");
|
|
modal.style.display = "flex";
|
|
modal.dataset.targets = JSON.stringify(targets.map((p) => p.id));
|
|
}
|
|
|
|
function closeBulkModal(): void {
|
|
const modal = document.getElementById("ap-bulk-modal");
|
|
if (modal) modal.style.display = "none";
|
|
}
|
|
|
|
async function confirmBulk(): Promise<void> {
|
|
if (!selectedProjectID) return;
|
|
const modal = document.getElementById("ap-bulk-modal");
|
|
const msg = document.getElementById("ap-bulk-msg");
|
|
if (!modal || !msg) return;
|
|
const targets = JSON.parse(modal.dataset.targets || "[]") as string[];
|
|
msg.textContent = t("admin.approval_policies.bulk.modal.applying") || "Übernehme …";
|
|
try {
|
|
const resp = await fetch("/api/admin/approval-policies/apply-to-descendants", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
source_project_id: selectedProjectID,
|
|
target_project_ids: targets,
|
|
}),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await resp.text();
|
|
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${body}`;
|
|
return;
|
|
}
|
|
const out = await resp.json() as { writes: number; targets: number };
|
|
closeBulkModal();
|
|
showFeedback(
|
|
(t("admin.approval_policies.bulk.modal.done") || "Übernommen") +
|
|
` — ${out.writes} ${t("admin.approval_policies.bulk.modal.writes_label") || "Schreibvorgänge"} auf ${out.targets} ${t("admin.approval_policies.bulk.modal.targets_label") || "Projekte"}.`,
|
|
false,
|
|
);
|
|
// Re-fetch the source matrix so any cells the bulk-apply touched on
|
|
// descendants are reflected via inheritance attribution if applicable.
|
|
if (selectedProjectID) {
|
|
const matrix = await loadMatrix(selectedProjectID);
|
|
const host = document.getElementById("ap-matrix-host");
|
|
if (host) {
|
|
host.innerHTML = renderProjectMatrix(matrix);
|
|
bindCellChangeHandlers(host);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
msg.textContent = `${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Wire-up.
|
|
// ============================================================================
|
|
|
|
function wirePicker(): void {
|
|
const input = document.getElementById("ap-project-search") as HTMLInputElement | null;
|
|
if (!input) return;
|
|
input.addEventListener("input", () => renderProjectResults(input.value));
|
|
// Initial empty-search renders top-of-list.
|
|
renderProjectResults("");
|
|
}
|
|
|
|
function wireBulk(): void {
|
|
const btn = document.getElementById("ap-bulk-apply-btn");
|
|
const close = document.getElementById("ap-bulk-close");
|
|
const cancel = document.getElementById("ap-bulk-cancel");
|
|
const confirm = document.getElementById("ap-bulk-confirm");
|
|
if (btn) btn.addEventListener("click", openBulkModal);
|
|
if (close) close.addEventListener("click", closeBulkModal);
|
|
if (cancel) cancel.addEventListener("click", closeBulkModal);
|
|
if (confirm) confirm.addEventListener("click", () => void confirmBulk());
|
|
}
|
|
|
|
async function init(): Promise<void> {
|
|
initI18n();
|
|
initSidebar();
|
|
await Promise.all([loadPartnerUnits(), loadProjects()]);
|
|
await loadAllUnitPolicies();
|
|
renderUnits();
|
|
wirePicker();
|
|
wireBulk();
|
|
onLangChange(() => {
|
|
renderUnits();
|
|
if (selectedProjectID) {
|
|
void loadMatrix(selectedProjectID).then((matrix) => {
|
|
const host = document.getElementById("ap-matrix-host");
|
|
if (host) {
|
|
host.innerHTML = renderProjectMatrix(matrix);
|
|
bindCellChangeHandlers(host);
|
|
}
|
|
});
|
|
}
|
|
renderProjectResults((document.getElementById("ap-project-search") as HTMLInputElement | null)?.value || "");
|
|
});
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => void init());
|
|
} else {
|
|
void init();
|
|
}
|