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:
@@ -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());
|
||||
|
||||
135
frontend/src/admin-approval-policies.tsx
Normal file
135
frontend/src/admin-approval-policies.tsx
Normal 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 — 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ü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ädt …</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ür ein Projekt. Ü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ä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">×</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
|
||||
überschrieben. Standards aus Partner Units bleiben unberü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">Übernehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/admin-approval-policies.js" defer />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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üfung pro Projekt und Partner Unit konfigurieren.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
@@ -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>
|
||||
|
||||
540
frontend/src/client/admin-approval-policies.ts
Normal file
540
frontend/src/client/admin-approval-policies.ts
Normal 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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean): void {
|
||||
const el = document.getElementById("ap-feedback");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Loaders.
|
||||
// ============================================================================
|
||||
|
||||
async function loadPartnerUnits(): Promise<void> {
|
||||
const resp = await fetch("/api/partner-units");
|
||||
if (!resp.ok) {
|
||||
partnerUnits = [];
|
||||
return;
|
||||
}
|
||||
partnerUnits = (await resp.json()) as PartnerUnit[];
|
||||
}
|
||||
|
||||
async function loadUnitPolicies(unitID: string): Promise<UnitPolicy[]> {
|
||||
const resp = await fetch(`/api/admin/partner-units/${encodeURIComponent(unitID)}/approval-policies`);
|
||||
if (!resp.ok) return [];
|
||||
return (await resp.json()) as UnitPolicy[];
|
||||
}
|
||||
|
||||
async function loadAllUnitPolicies(): Promise<void> {
|
||||
const out: Record<string, UnitPolicy[]> = {};
|
||||
for (const u of partnerUnits) {
|
||||
out[u.id] = await loadUnitPolicies(u.id);
|
||||
}
|
||||
unitPolicies = out;
|
||||
}
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
const resp = await fetch("/api/projects/tree");
|
||||
if (!resp.ok) {
|
||||
allProjects = [];
|
||||
return;
|
||||
}
|
||||
const tree = (await resp.json()) as ProjectNode[];
|
||||
allProjects = flattenTree(tree);
|
||||
}
|
||||
|
||||
function flattenTree(nodes: ProjectNode[]): ProjectNode[] {
|
||||
const out: ProjectNode[] = [];
|
||||
const walk = (n: ProjectNode): void => {
|
||||
out.push(n);
|
||||
if (n.children) n.children.forEach(walk);
|
||||
};
|
||||
nodes.forEach(walk);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadMatrix(projectID: string): Promise<EffectivePolicy[]> {
|
||||
const resp = await fetch(`/api/admin/approval-policies/matrix?project_id=${encodeURIComponent(projectID)}`);
|
||||
if (!resp.ok) return [];
|
||||
return (await resp.json()) as EffectivePolicy[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rendering — partner unit accordion.
|
||||
// ============================================================================
|
||||
|
||||
function lifecycleLabel(l: string): string {
|
||||
return tDyn("admin.approval_policies.lifecycle." + l) || l;
|
||||
}
|
||||
|
||||
function entityLabel(e: string): string {
|
||||
return tDyn("admin.approval_policies.entity." + e) || e;
|
||||
}
|
||||
|
||||
function roleLabel(r: string): string {
|
||||
return tDyn("admin.approval_policies.role." + r) || r;
|
||||
}
|
||||
|
||||
function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): UnitPolicy | undefined {
|
||||
return rows.find((p) => p.entity_type === entity && p.lifecycle_event === lifecycle);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -49,6 +49,21 @@ export function renderInbox(): string {
|
||||
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</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ü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 />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user