Merge: t-paliad-154 — approval-policy authoring UI (migration 062 paliad.approval_policies unit-defaults + 'none' sentinel + tree-walking resolver + 88 unit-default seed rows + paliad.policy_audit_log; ApprovalService rewire with resolver delegation + scope-split CRUD + audit emission; HTTP handlers admin APIs + form-hint endpoint + audit-log union; /admin/approval-policies admin page + admin-index card + form-time hints on deadline/appointment new pages + inbox empty-state nudge for admins; 13 m-locked design decisions honoured verbatim per docs/design-approval-policy-ui-2026-05-07.md §2)

This commit is contained in:
m
2026-05-08 02:33:25 +02:00
22 changed files with 3535 additions and 65 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

@@ -86,6 +86,14 @@ export function renderAppointmentsNew(): string {
<p className="form-msg" id="appointment-new-msg" />
{/* t-paliad-154 — form-time 4-eye hint. */}
<div className="approval-hint" id="appointment-approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
}} />
<span id="appointment-approval-hint-text" />
</div>
<div className="form-actions">
<a href="/events?type=appointment" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.neu.submit">Termin anlegen</button>

View File

@@ -0,0 +1,540 @@
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;
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 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);
}
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 = tDyn(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

@@ -1,4 +1,4 @@
import { initI18n, t } from "./i18n";
import { initI18n, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
@@ -107,6 +107,46 @@ async function submitForm(ev: Event) {
}
}
// t-paliad-154 — form-time 4-eye hint, mirroring deadlines-new.ts.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("appointment-approval-hint");
const text = document.getElementById("appointment-approval-hint-text");
if (!hint || !text) return;
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement | null)?.value || "";
if (!projectID) {
hint.style.display = "none";
return;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=appointment&lifecycle=create`,
{ credentials: "include" },
);
if (!resp.ok) {
hint.style.display = "none";
return;
}
const eff = await resp.json() as {
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
if (!eff.required_role || eff.required_role === "none") {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";
text.textContent = (t("appointments.form.approval_hint") || "4-Augen-Prüfung erforderlich")
+ ` · ${roleLabel}${sourceLabel}`;
hint.style.display = "";
} catch {
hint.style.display = "none";
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
@@ -114,4 +154,8 @@ document.addEventListener("DOMContentLoaded", async () => {
populateProjects();
preFillStart();
document.getElementById("appointment-new-form")!.addEventListener("submit", submitForm);
void refreshApprovalHint();
document.getElementById("appointment-project")?.addEventListener("change", () => {
void refreshApprovalHint();
});
});

View File

@@ -1,4 +1,4 @@
import { initI18n, t } from "./i18n";
import { initI18n, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypePicker, type PickerHandle } from "./event-types";
import { projectIndent } from "./project-indent";
@@ -171,6 +171,49 @@ async function loadMe() {
}
}
// t-paliad-154 — fetch the effective approval policy for (project,
// deadline, create) and reveal the form-time hint when it applies.
// Hidden when no policy applies. Re-runs on project change so the hint
// updates if the user picks a different project mid-form.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("deadline-approval-hint");
const text = document.getElementById("deadline-approval-hint-text");
if (!hint || !text) return;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
if (!projectID) {
hint.style.display = "none";
return;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=deadline&lifecycle=create`,
{ credentials: "include" },
);
if (!resp.ok) {
hint.style.display = "none";
return;
}
const eff = await resp.json() as {
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
if (!eff.required_role || eff.required_role === "none") {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + eff.required_role) || eff.required_role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";
text.textContent = (t("deadlines.form.approval_hint") || "4-Augen-Prüfung erforderlich")
+ ` · ${roleLabel}${sourceLabel}`;
hint.style.display = "";
} catch {
hint.style.display = "none";
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
@@ -187,4 +230,9 @@ document.addEventListener("DOMContentLoaded", async () => {
currentUserAdmin,
});
}
// Wire approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
void refreshApprovalHint();
});
});

View File

@@ -1615,6 +1615,53 @@ 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",
"inbox.empty.admin_nudge.title": "Noch keine Genehmigungspflichten konfiguriert?",
"inbox.empty.admin_nudge.body": "Lege fest, welche Lifecycle-Events 4-Augen-Prüfung erfordern.",
"inbox.empty.admin_nudge.cta": "Genehmigungspflichten konfigurieren",
"deadlines.form.approval_hint": "4-Augen-Prüfung erforderlich",
"appointments.form.approval_hint": "4-Augen-Prüfung erforderlich",
"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.",
@@ -3614,6 +3661,53 @@ 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",
"inbox.empty.admin_nudge.title": "No approval policies configured yet?",
"inbox.empty.admin_nudge.body": "Set which lifecycle events require 4-eye review.",
"inbox.empty.admin_nudge.cta": "Configure approval policies",
"deadlines.form.approval_hint": "4-eye review required",
"appointments.form.approval_hint": "4-eye review required",
"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

@@ -92,11 +92,45 @@ async function refresh() {
: "approvals.empty.mine"
);
empty.style.display = "";
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
for (const row of rows) list.appendChild(renderRow(row));
}
// t-paliad-154 — show the admin-only "configure policies" nudge when:
// - the current user is global_admin
// - the inbox is empty
// - no approval_policies row exists firm-wide (matrix is dormant)
//
// All three checks are AND-ed. Anonymous users + non-admins + active-policy
// admins all skip the nudge.
async function maybeShowAdminNudge(): Promise<void> {
const nudge = document.getElementById("inbox-admin-nudge");
if (!nudge) return;
try {
const meR = await fetch("/api/me", { credentials: "include" });
if (!meR.ok) return;
const me = (await meR.json()) as { global_role?: string };
if (me.global_role !== "global_admin") return;
const seedR = await fetch("/api/admin/approval-policies/seeded", { credentials: "include" });
if (!seedR.ok) return;
const data = (await seedR.json()) as { any: boolean };
if (data.any) return;
nudge.style.display = "";
} catch (_e) {
// Network failure → keep nudge hidden.
}
}
function hideAdminNudge(): void {
const nudge = document.getElementById("inbox-admin-nudge");
if (nudge) nudge.style.display = "none";
}
function renderRow(row: ApprovalRequestView): HTMLLIElement {
const li = document.createElement("li");
li.className = "inbox-row";

View File

@@ -80,6 +80,16 @@ export function renderDeadlinesNew(): string {
<p className="form-msg" id="deadline-new-msg" />
{/* t-paliad-154 — form-time 4-eye hint. Hidden by default;
revealed by client TS when an effective policy applies
to the chosen project. */}
<div className="approval-hint" id="deadline-approval-hint" style="display:none">
<span className="approval-hint-icon" dangerouslySetInnerHTML={{
__html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
}} />
<span id="deadline-approval-hint-text" />
</div>
<div className="form-actions">
<a href="/events?type=deadline" id="deadline-new-cancel" className="btn-cancel" data-i18n="deadlines.neu.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="deadlines.neu.submit">Frist anlegen</button>

View File

@@ -9,6 +9,46 @@
// `data-i18n*` attributes in TSX/TS sources.
export type I18nKey =
| "admin.approval_policies.bulk.cta"
| "admin.approval_policies.bulk.modal.applying"
| "admin.approval_policies.bulk.modal.body"
| "admin.approval_policies.bulk.modal.cancel"
| "admin.approval_policies.bulk.modal.confirm"
| "admin.approval_policies.bulk.modal.done"
| "admin.approval_policies.bulk.modal.targets_label"
| "admin.approval_policies.bulk.modal.title"
| "admin.approval_policies.bulk.modal.writes_label"
| "admin.approval_policies.bulk.no_descendants"
| "admin.approval_policies.cell.error_msg"
| "admin.approval_policies.cell.saved_msg"
| "admin.approval_policies.entity.appointment"
| "admin.approval_policies.entity.deadline"
| "admin.approval_policies.heading"
| "admin.approval_policies.lifecycle.complete"
| "admin.approval_policies.lifecycle.create"
| "admin.approval_policies.lifecycle.delete"
| "admin.approval_policies.lifecycle.update"
| "admin.approval_policies.loading"
| "admin.approval_policies.picker.label"
| "admin.approval_policies.picker.no_results"
| "admin.approval_policies.picker.placeholder"
| "admin.approval_policies.role.associate"
| "admin.approval_policies.role.no_rule"
| "admin.approval_policies.role.none"
| "admin.approval_policies.role.of_counsel"
| "admin.approval_policies.role.pa"
| "admin.approval_policies.role.partner"
| "admin.approval_policies.role.senior_pa"
| "admin.approval_policies.section.projects"
| "admin.approval_policies.section.projects.hint"
| "admin.approval_policies.section.units"
| "admin.approval_policies.section.units.hint"
| "admin.approval_policies.source.ancestor"
| "admin.approval_policies.source.project"
| "admin.approval_policies.source.unit_default"
| "admin.approval_policies.subtitle"
| "admin.approval_policies.title"
| "admin.approval_policies.units.empty"
| "admin.audit.col.actor"
| "admin.audit.col.description"
| "admin.audit.col.event"
@@ -59,6 +99,8 @@ export type I18nKey =
| "admin.broadcasts.loading"
| "admin.broadcasts.subtitle"
| "admin.broadcasts.title"
| "admin.card.approval_policies.desc"
| "admin.card.approval_policies.title"
| "admin.card.audit.desc"
| "admin.card.audit.title"
| "admin.card.broadcasts.desc"
@@ -335,6 +377,7 @@ export type I18nKey =
| "appointments.filter.to"
| "appointments.filter.type"
| "appointments.filter.type.all"
| "appointments.form.approval_hint"
| "appointments.kalender.empty"
| "appointments.kalender.heading"
| "appointments.kalender.list"
@@ -785,6 +828,7 @@ export type I18nKey =
| "deadlines.flag.inf_amend"
| "deadlines.flag.rev_amend"
| "deadlines.flag.rev_cci"
| "deadlines.form.approval_hint"
| "deadlines.heading"
| "deadlines.kalender.empty"
| "deadlines.kalender.heading"
@@ -1165,6 +1209,9 @@ export type I18nKey =
| "glossar.suggest.success"
| "glossar.suggest.title"
| "glossar.title"
| "inbox.empty.admin_nudge.body"
| "inbox.empty.admin_nudge.cta"
| "inbox.empty.admin_nudge.title"
| "index.checklisten.desc"
| "index.checklisten.title"
| "index.cost.desc"

View File

@@ -49,6 +49,21 @@ export function renderInbox(): string {
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">L&auml;dt &hellip;</div>
<div className="entity-empty" id="inbox-empty" style="display:none" />
<ul className="inbox-list" id="inbox-list" />
{/* t-paliad-154 — admin-only nudge surfaced when:
- the user is global_admin
- the inbox is empty (no pending / mine)
- no approval_policies row exists firm-wide
Hidden in all other cases. Wires via /api/admin/approval-policies/seeded. */}
<div className="inbox-admin-nudge" id="inbox-admin-nudge" style="display:none">
<h3 data-i18n="inbox.empty.admin_nudge.title">Noch keine Genehmigungspflichten konfiguriert?</h3>
<p data-i18n="inbox.empty.admin_nudge.body">
Lege fest, welche Lifecycle-Events 4-Augen-Pr&uuml;fung erfordern.
</p>
<a href="/admin/approval-policies" className="btn-primary btn-cta-lime" data-i18n="inbox.empty.admin_nudge.cta">
Genehmigungspflichten konfigurieren
</a>
</div>
</div>
</section>
<Footer />

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;
}