Files
paliad/frontend/src/client/admin-approval-policies.ts
m aec6cf6104 refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column
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.
2026-05-08 17:15:05 +02:00

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, "&amp;").replace(/"/g, "&quot;");
}
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();
}