feat(t-paliad-154) commit 4/5: admin /admin/approval-policies page

New TSX page shell + client orchestration + admin-index card + CSS for
the matrix + i18n keys (DE+EN).

Page structure:
- Section 1 'Partner-Unit-Standards': accordion list, each <details>
  block expandable into the 8-cell matrix for that partner unit.
- Section 2 'Projekt-spezifisch': search-driven project picker → matrix
  showing the EFFECTIVE policy per cell with attribution chips
  (Projekt / Geerbt / Standard) per source.
- Bulk-apply modal: 'Auf Unterprojekte anwenden' button per project; lists
  affected descendants; POST to /api/admin/approval-policies/apply-to-descendants.

Cell semantics:
- Select per cell with options: '— keine Regel —' (= DELETE), partner /
  of_counsel / associate / senior_pa / pa / 'Keine Genehmigung' (= 'none'
  sentinel, project-row only).
- Change → PUT for any value, DELETE for empty. Re-fetch the affected
  scope so attribution chips reflect the new state.

CSS: matrix grid on desktop (≥700px); two stacked sections (Fristen /
Termine) below 700px via media query — both rendered in DOM, CSS toggles.
All tokens are existing --color-* / --status-* / --hlc-*-rgb (no bare
--surface / --text-muted / --bg-subtle).

i18n: 42 new keys × 2 languages = 84 entries. Total i18n keys: 1924.

Build: bun run build clean (i18n codegen updated, IIFE wrapping enforced).
This commit is contained in:
m
2026-05-08 02:27:54 +02:00
parent 0f87d73b1b
commit 028423b32f
6 changed files with 1101 additions and 0 deletions

View File

@@ -38,6 +38,7 @@ import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
import { renderAdminBroadcasts } from "./src/admin-broadcasts";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
@@ -267,6 +268,7 @@ async function build() {
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
join(import.meta.dir, "src/client/paliadin.ts"),
join(import.meta.dir, "src/client/admin-paliadin.ts"),
@@ -385,6 +387,7 @@ async function build() {
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());

View File

@@ -0,0 +1,135 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// t-paliad-154 — admin approval-policy authoring page. Single page with
// two sections:
//
// 1. Partner-Unit-Standards: list of partner_units, each expandable into
// its 8-cell matrix (deadline + appointment × create / update /
// complete / delete). Edits hit /api/admin/partner-units/{id}/...
//
// 2. Projekt-spezifisch: project-tree picker → 8-cell matrix for the
// selected project, showing the EFFECTIVE policy per cell with an
// attribution chip (Projekt / Geerbt / Standard). Edits hit
// /api/projects/{id}/approval-policies/{entity}/{lifecycle}.
//
// Mobile shape: the matrix grid collapses to two stacked sections (Fristen,
// Termine) below 700px — driven by CSS, not by JS.
export function renderAdminApprovalPolicies(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<PWAHead />
<title data-i18n="admin.approval_policies.title">Genehmigungspflichten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/approval-policies" />
<BottomNav currentPath="/admin/approval-policies" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="admin.approval_policies.heading">Genehmigungspflichten</h1>
<p className="tool-subtitle" data-i18n="admin.approval_policies.subtitle">
4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.
</p>
</div>
<div id="ap-feedback" className="form-msg" style="display:none" />
{/* ============================================================
Section 1: Partner-Unit-Standards.
============================================================ */}
<h2 className="section-heading" data-i18n="admin.approval_policies.section.units">
Partner-Unit-Standards
</h2>
<p className="form-hint" data-i18n="admin.approval_policies.section.units.hint">
Standardregeln, die jedes Projekt erbt, das einer Partner Unit zugeordnet ist.
Bei mehreren Partner Units gewinnt die strengste Regel.
</p>
<div className="ap-units-list" id="ap-units-list">
<div className="ap-loading" data-i18n="admin.approval_policies.loading">L&auml;dt &hellip;</div>
</div>
{/* ============================================================
Section 2: Projekt-spezifisch.
============================================================ */}
<h2 className="section-heading" data-i18n="admin.approval_policies.section.projects">
Projekt-spezifisch
</h2>
<p className="form-hint" data-i18n="admin.approval_policies.section.projects.hint">
Eigene Regeln f&uuml;r ein Projekt. &Uuml;berschreiben Standards aus Partner Units und
geerbten Projektregeln.
</p>
<div className="ap-project-picker">
<label htmlFor="ap-project-search" data-i18n="admin.approval_policies.picker.label">
Projekt w&auml;hlen
</label>
<input
type="text"
id="ap-project-search"
className="ap-project-search"
data-i18n-placeholder="admin.approval_policies.picker.placeholder"
placeholder="Suchen..."
autocomplete="off"
/>
<div className="ap-project-results" id="ap-project-results" />
</div>
<div className="ap-project-matrix" id="ap-project-matrix" style="display:none">
<div className="ap-project-header">
<h3 id="ap-project-title" />
<button type="button" className="btn-secondary btn-small" id="ap-bulk-apply-btn"
data-i18n="admin.approval_policies.bulk.cta">
Auf Unterprojekte anwenden
</button>
</div>
<div className="ap-matrix-host" id="ap-matrix-host" />
</div>
</div>
</section>
<Footer />
</main>
{/* Bulk-apply confirm modal — populated client-side. */}
<div className="modal-overlay" id="ap-bulk-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="admin.approval_policies.bulk.modal.title">
Auf Unterprojekte anwenden
</h2>
<button className="modal-close" id="ap-bulk-close" type="button" aria-label="Close">&times;</button>
</div>
<div className="ap-bulk-body">
<p data-i18n="admin.approval_policies.bulk.modal.body">
Die folgenden Unterprojekte erhalten die effektive Matrix dieses Projekts als
projektspezifische Regeln. Bestehende projektspezifische Regeln werden
&uuml;berschrieben. Standards aus Partner Units bleiben unber&uuml;hrt.
</p>
<ul className="ap-bulk-target-list" id="ap-bulk-target-list" />
<p className="form-msg" id="ap-bulk-msg" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="ap-bulk-cancel"
data-i18n="admin.approval_policies.bulk.modal.cancel">Abbrechen</button>
<button type="button" className="btn-primary btn-cta-lime" id="ap-bulk-confirm"
data-i18n="admin.approval_policies.bulk.modal.confirm">&Uuml;bernehmen</button>
</div>
</div>
</div>
</div>
<script src="/assets/admin-approval-policies.js" defer />
</body>
</html>
);
}

View File

@@ -10,6 +10,7 @@ const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" str
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>';
interface PlannedCard {
icon: string;
@@ -88,6 +89,11 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.broadcasts.title">Broadcasts</h2>
<p data-i18n="admin.card.broadcasts.desc">Versendete Massen-E-Mails an Teamauswahlen einsehen.</p>
</a>
<a href="/admin/approval-policies" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_SHIELD }} />
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>

View File

@@ -0,0 +1,540 @@
import { initI18n, onLangChange, t } 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;
required_role: string;
}
interface EffectivePolicy {
entity_type: string;
lifecycle_event: string;
required_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;
const ROLE_OPTIONS = [
"partner",
"of_counsel",
"associate",
"senior_pa",
"pa",
"none",
];
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 t("admin.approval_policies.lifecycle." + l) || l;
}
function entityLabel(e: string): string {
return t("admin.approval_policies.entity." + e) || e;
}
function roleLabel(r: string): string {
return t("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);
}
function renderRoleSelect(currentValue: string | null, dataAttrs: string): string {
const opts: string[] = [];
// "Keine Regel" sentinel — distinct from 'none' (which is the explicit
// suppression value). Empty string maps to DELETE.
opts.push(`<option value=""${currentValue === null ? " selected" : ""}>${esc(t("admin.approval_policies.role.no_rule") || "— keine Regel —")}</option>`);
for (const r of ROLE_OPTIONS) {
opts.push(`<option value="${esc(r)}"${currentValue === r ? " selected" : ""}>${esc(roleLabel(r))}</option>`);
}
return `<select class="ap-cell-select" ${dataAttrs}>${opts.join("")}</select>`;
}
function renderUnitMatrix(unit: PartnerUnit): string {
const rows = unitPolicies[unit.id] || [];
let cells = "";
for (const e of ENTITY_TYPES) {
cells += `<tr><th class="ap-matrix-rowhead">${esc(entityLabel(e))}</th>`;
for (const l of LIFECYCLES) {
const p = policyForCell(rows, e, l);
const v = p ? p.required_role : null;
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
cells += `<td class="ap-matrix-cell">${renderRoleSelect(v, attrs)}</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) {
const p = policyForCell(rows, e, l);
const v = p ? p.required_role : null;
const attrs = `data-scope="unit" data-unit-id="${escAttr(unit.id)}" data-entity="${esc(e)}" data-lifecycle="${esc(l)}"`;
stacked += `<div class="ap-matrix-row">
<span class="ap-matrix-row-label">${esc(lifecycleLabel(l))}</span>
${renderRoleSelect(v, attrs)}
</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;
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">
<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 v = r.required_role || null;
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)}"`;
let chip = "";
if (r.source && !own && r.required_role) {
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 = t(sourceKey) || r.source;
const name = r.source_name ? ` · ${esc(r.source_name)}` : "";
chip = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}</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">${renderRoleSelect(own ? v : null, attrs)}${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 {
scope.querySelectorAll<HTMLSelectElement>(".ap-cell-select").forEach((sel) => {
sel.addEventListener("change", () => void onCellChange(sel));
});
}
async function onCellChange(sel: HTMLSelectElement): Promise<void> {
const scope = sel.dataset.scope;
const entity = sel.dataset.entity || "";
const lifecycle = sel.dataset.lifecycle || "";
const value = sel.value;
let url = "";
if (scope === "unit") {
const unitID = sel.dataset.unitId || "";
url = `/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
} else if (scope === "project") {
const projectID = sel.dataset.projectId || "";
url = `/api/projects/${encodeURIComponent(projectID)}/approval-policies/${encodeURIComponent(entity)}/${encodeURIComponent(lifecycle)}`;
} else {
return;
}
try {
let resp: Response;
if (value === "") {
resp = await fetch(url, { method: "DELETE" });
} else {
resp = await fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ required_role: value }),
});
}
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);
// Re-fetch the affected scope so attribution chips reflect the new state.
if (scope === "unit") {
const unitID = sel.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);
}
}
} catch (err) {
showFeedback(`${t("admin.approval_policies.cell.error_msg") || "Fehler"}: ${err}`, true);
}
}
// ============================================================================
// 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();
}

View File

@@ -1611,6 +1611,48 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.feature_flags.desc": "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Versendete Massen-E-Mails an Teamauswahlen einsehen.",
"admin.card.approval_policies.title": "Genehmigungspflichten",
"admin.card.approval_policies.desc": "4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.",
"admin.approval_policies.title": "Genehmigungspflichten — Paliad",
"admin.approval_policies.heading": "Genehmigungspflichten",
"admin.approval_policies.subtitle": "4-Augen-Prüfung pro Projekt und Partner Unit konfigurieren.",
"admin.approval_policies.loading": "Lädt …",
"admin.approval_policies.section.units": "Partner-Unit-Standards",
"admin.approval_policies.section.units.hint": "Standardregeln, die jedes Projekt erbt, das einer Partner Unit zugeordnet ist. Bei mehreren Partner Units gewinnt die strengste Regel.",
"admin.approval_policies.section.projects": "Projekt-spezifisch",
"admin.approval_policies.section.projects.hint": "Eigene Regeln für ein Projekt. Überschreiben Standards aus Partner Units und geerbte Projektregeln.",
"admin.approval_policies.units.empty": "Keine Partner Units vorhanden.",
"admin.approval_policies.picker.label": "Projekt wählen",
"admin.approval_policies.picker.placeholder": "Suchen…",
"admin.approval_policies.picker.no_results": "Keine Treffer.",
"admin.approval_policies.entity.deadline": "Fristen",
"admin.approval_policies.entity.appointment": "Termine",
"admin.approval_policies.lifecycle.create": "Erstellen",
"admin.approval_policies.lifecycle.update": "Ändern",
"admin.approval_policies.lifecycle.complete": "Erledigen",
"admin.approval_policies.lifecycle.delete": "Löschen",
"admin.approval_policies.role.partner": "Partner",
"admin.approval_policies.role.of_counsel": "Of Counsel",
"admin.approval_policies.role.associate": "Associate",
"admin.approval_policies.role.senior_pa": "Senior PA",
"admin.approval_policies.role.pa": "PA",
"admin.approval_policies.role.none": "Keine Genehmigung",
"admin.approval_policies.role.no_rule": "— keine Regel —",
"admin.approval_policies.source.project": "Projekt",
"admin.approval_policies.source.ancestor": "Geerbt",
"admin.approval_policies.source.unit_default": "Standard",
"admin.approval_policies.cell.saved_msg": "Gespeichert.",
"admin.approval_policies.cell.error_msg": "Fehler",
"admin.approval_policies.bulk.cta": "Auf Unterprojekte anwenden",
"admin.approval_policies.bulk.no_descendants": "Keine Unterprojekte vorhanden.",
"admin.approval_policies.bulk.modal.title": "Auf Unterprojekte anwenden",
"admin.approval_policies.bulk.modal.body": "Die folgenden Unterprojekte erhalten die effektive Matrix dieses Projekts als projektspezifische Regeln. Bestehende projektspezifische Regeln werden überschrieben. Standards aus Partner Units bleiben unberührt.",
"admin.approval_policies.bulk.modal.cancel": "Abbrechen",
"admin.approval_policies.bulk.modal.confirm": "Übernehmen",
"admin.approval_policies.bulk.modal.applying": "Übernehme …",
"admin.approval_policies.bulk.modal.done": "Übernommen",
"admin.approval_policies.bulk.modal.writes_label": "Schreibvorgänge",
"admin.approval_policies.bulk.modal.targets_label": "Projekte",
"admin.email_templates.title": "Email-Templates — Paliad",
"admin.email_templates.heading": "Email-Templates",
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
@@ -3606,6 +3648,48 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.feature_flags.desc": "Enable features per office, partner unit or role.",
"admin.card.broadcasts.title": "Broadcasts",
"admin.card.broadcasts.desc": "Inspect bulk emails sent to team selections.",
"admin.card.approval_policies.title": "Approval Policies",
"admin.card.approval_policies.desc": "Configure 4-eye review per project and partner unit.",
"admin.approval_policies.title": "Approval Policies — Paliad",
"admin.approval_policies.heading": "Approval Policies",
"admin.approval_policies.subtitle": "Configure 4-eye review per project and partner unit.",
"admin.approval_policies.loading": "Loading …",
"admin.approval_policies.section.units": "Partner Unit Defaults",
"admin.approval_policies.section.units.hint": "Default rules that every project attached to a partner unit inherits. When multiple partner units apply, the strictest rule wins.",
"admin.approval_policies.section.projects": "Project-specific",
"admin.approval_policies.section.projects.hint": "Per-project rules. Override partner-unit defaults and inherited project rules.",
"admin.approval_policies.units.empty": "No partner units yet.",
"admin.approval_policies.picker.label": "Pick a project",
"admin.approval_policies.picker.placeholder": "Search…",
"admin.approval_policies.picker.no_results": "No matches.",
"admin.approval_policies.entity.deadline": "Deadlines",
"admin.approval_policies.entity.appointment": "Appointments",
"admin.approval_policies.lifecycle.create": "Create",
"admin.approval_policies.lifecycle.update": "Edit",
"admin.approval_policies.lifecycle.complete": "Complete",
"admin.approval_policies.lifecycle.delete": "Delete",
"admin.approval_policies.role.partner": "Partner",
"admin.approval_policies.role.of_counsel": "Of Counsel",
"admin.approval_policies.role.associate": "Associate",
"admin.approval_policies.role.senior_pa": "Senior PA",
"admin.approval_policies.role.pa": "PA",
"admin.approval_policies.role.none": "No approval",
"admin.approval_policies.role.no_rule": "— no rule —",
"admin.approval_policies.source.project": "Project",
"admin.approval_policies.source.ancestor": "Inherited",
"admin.approval_policies.source.unit_default": "Default",
"admin.approval_policies.cell.saved_msg": "Saved.",
"admin.approval_policies.cell.error_msg": "Error",
"admin.approval_policies.bulk.cta": "Apply to descendants",
"admin.approval_policies.bulk.no_descendants": "No descendants.",
"admin.approval_policies.bulk.modal.title": "Apply to descendants",
"admin.approval_policies.bulk.modal.body": "The descendants below receive this project's effective matrix as project-specific rules. Existing project-specific rules will be overwritten. Partner-unit defaults remain intact.",
"admin.approval_policies.bulk.modal.cancel": "Cancel",
"admin.approval_policies.bulk.modal.confirm": "Apply",
"admin.approval_policies.bulk.modal.applying": "Applying …",
"admin.approval_policies.bulk.modal.done": "Applied",
"admin.approval_policies.bulk.modal.writes_label": "writes",
"admin.approval_policies.bulk.modal.targets_label": "projects",
"admin.email_templates.title": "Email Templates — Paliad",
"admin.email_templates.heading": "Email Templates",
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",

View File

@@ -11823,3 +11823,336 @@ dialog.quick-add-sheet::backdrop {
align-items: stretch;
}
}
/* ============================================================================
* t-paliad-154 — admin /admin/approval-policies page.
* ========================================================================== */
.ap-units-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
.ap-unit-block {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
}
.ap-unit-summary {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
list-style: none;
user-select: none;
}
.ap-unit-summary::-webkit-details-marker {
display: none;
}
.ap-unit-summary::before {
content: "▸";
color: var(--color-text-muted);
transition: transform 120ms ease;
}
.ap-unit-block[open] > .ap-unit-summary::before {
transform: rotate(90deg);
}
.ap-unit-block[open] > .ap-unit-summary {
border-bottom: 1px solid var(--color-border);
}
.ap-unit-name {
flex: 1;
font-weight: 600;
color: var(--color-text);
}
.ap-unit-body {
padding: 1rem;
background: var(--color-bg-subtle);
}
/* Project picker. */
.ap-project-picker {
margin-bottom: 1rem;
}
.ap-project-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-strong);
border-radius: 6px;
font-size: 1rem;
background: var(--color-input-bg);
color: var(--color-text);
}
.ap-project-results {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 240px;
overflow-y: auto;
}
.ap-project-result {
text-align: left;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
color: var(--color-text);
}
.ap-project-result:hover {
background: var(--color-bg-lime-tint);
}
.ap-project-result-title {
flex: 1;
}
.ap-project-result-ref {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.ap-project-matrix {
margin-top: 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1rem;
}
.ap-project-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.ap-project-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--color-text);
}
.ap-matrix-host {
margin-top: 0.5rem;
}
/* Matrix table — desktop. */
.ap-matrix {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.ap-matrix thead th {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
font-weight: 500;
text-align: left;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-border);
}
.ap-matrix-rowhead {
font-weight: 600;
color: var(--color-text);
padding: 0.5rem 0.6rem;
width: 8rem;
background: var(--color-bg-subtle);
border-right: 1px solid var(--color-border);
}
.ap-matrix-cell {
padding: 0.4rem;
vertical-align: top;
border-bottom: 1px solid var(--color-border);
}
.ap-cell-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ap-cell-select {
width: 100%;
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border-strong);
border-radius: 5px;
background: var(--color-input-bg);
color: var(--color-text);
font-size: 0.85rem;
}
.ap-source-chip {
display: inline-block;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
color: var(--color-text-muted);
background: var(--color-surface-muted);
border: 1px solid var(--color-border);
}
.ap-source-chip.ap-source-project {
background: var(--color-bg-lime-tint);
color: var(--color-accent-dark);
border-color: var(--color-accent);
}
.ap-source-chip.ap-source-ancestor {
background: var(--status-blue-soft-bg);
color: var(--status-blue-soft-fg);
border-color: var(--status-blue-bg);
}
.ap-source-chip.ap-source-unit_default {
background: var(--status-neutral-bg);
color: var(--status-neutral-fg);
border-color: var(--color-border);
}
/* Mobile stacked sections — hidden on desktop, shown < 700px. */
.ap-matrix-stacked {
display: none;
}
@media (max-width: 700px) {
.ap-matrix {
display: none;
}
.ap-matrix-stacked {
display: block;
}
.ap-matrix-section {
margin-bottom: 1.25rem;
}
.ap-matrix-section h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: var(--color-text);
}
.ap-matrix-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border);
gap: 0.5rem;
}
.ap-matrix-row-label {
flex: 1;
font-size: 0.9rem;
color: var(--color-text);
}
.ap-matrix-row .ap-cell-select {
width: 50%;
}
.ap-matrix-row .ap-cell-wrap {
width: 50%;
}
}
/* Bulk-apply modal — list of affected descendants. */
.ap-bulk-target-list {
list-style: none;
margin: 0.5rem 0;
padding: 0;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 5px;
background: var(--color-bg-subtle);
}
.ap-bulk-target-list li {
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text);
}
.ap-bulk-target-list li:last-child {
border-bottom: none;
}
.ap-bulk-target-ref {
color: var(--color-text-muted);
font-size: 0.8rem;
margin-left: 0.5rem;
}
.ap-loading {
color: var(--color-text-muted);
padding: 0.75rem 1rem;
font-style: italic;
}
/* Form-time 4-eye hint above Speichern on /projects/{id}/deadlines/new + appointments/new. */
.approval-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin: 0.75rem 0;
background: var(--status-amber-bg);
color: var(--status-amber-fg);
border: 1px solid var(--status-amber-border);
border-radius: 6px;
font-size: 0.85rem;
}
.approval-hint-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: var(--status-amber-fg);
}
.approval-hint-icon svg {
width: 100%;
height: 100%;
}
/* Inbox empty-state admin nudge. */
.inbox-admin-nudge {
margin-top: 1.5rem;
padding: 1rem 1.25rem;
background: var(--color-surface);
border: 1px dashed var(--color-border-strong);
border-radius: 8px;
text-align: center;
}
.inbox-admin-nudge h3 {
margin: 0 0 0.4rem 0;
font-size: 1.05rem;
color: var(--color-text);
}
.inbox-admin-nudge p {
margin: 0 0 0.75rem 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}