diff --git a/frontend/src/appointments-new.tsx b/frontend/src/appointments-new.tsx index 4eb4cd7..b49f321 100644 --- a/frontend/src/appointments-new.tsx +++ b/frontend/src/appointments-new.tsx @@ -86,6 +86,14 @@ export function renderAppointmentsNew(): string {

+ {/* t-paliad-154 — form-time 4-eye hint. */} +

+
Abbrechen diff --git a/frontend/src/client/admin-approval-policies.ts b/frontend/src/client/admin-approval-policies.ts index 705d9ae..bb66ee5 100644 --- a/frontend/src/client/admin-approval-policies.ts +++ b/frontend/src/client/admin-approval-policies.ts @@ -1,4 +1,4 @@ -import { initI18n, onLangChange, t } from "./i18n"; +import { initI18n, onLangChange, t, tDyn } from "./i18n"; import { initSidebar } from "./sidebar"; // t-paliad-154 — admin approval-policy authoring page orchestration. @@ -135,15 +135,15 @@ async function loadMatrix(projectID: string): Promise { // ============================================================================ function lifecycleLabel(l: string): string { - return t("admin.approval_policies.lifecycle." + l) || l; + return tDyn("admin.approval_policies.lifecycle." + l) || l; } function entityLabel(e: string): string { - return t("admin.approval_policies.entity." + e) || e; + return tDyn("admin.approval_policies.entity." + e) || e; } function roleLabel(r: string): string { - return t("admin.approval_policies.role." + r) || r; + return tDyn("admin.approval_policies.role." + r) || r; } function policyForCell(rows: UnitPolicy[], entity: string, lifecycle: string): UnitPolicy | undefined { @@ -240,7 +240,7 @@ function renderProjectMatrix(rows: EffectivePolicy[]): string { const sourceKey = r.source === "ancestor" ? "admin.approval_policies.source.ancestor" : r.source === "unit_default" ? "admin.approval_policies.source.unit_default" : "admin.approval_policies.source.project"; - const label = t(sourceKey) || r.source; + const label = tDyn(sourceKey) || r.source; const name = r.source_name ? ` · ${esc(r.source_name)}` : ""; chip = `${esc(label)}${name}`; } else if (own) { diff --git a/frontend/src/client/appointments-new.ts b/frontend/src/client/appointments-new.ts index d42b7b0..f719c76 100644 --- a/frontend/src/client/appointments-new.ts +++ b/frontend/src/client/appointments-new.ts @@ -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 { + 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(); + }); }); diff --git a/frontend/src/client/deadlines-new.ts b/frontend/src/client/deadlines-new.ts index f9b9688..1235ba5 100644 --- a/frontend/src/client/deadlines-new.ts +++ b/frontend/src/client/deadlines-new.ts @@ -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 { + 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(); + }); }); diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index c53f14d..d1c3749 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1653,6 +1653,11 @@ const translations: Record> = { "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.", @@ -3690,6 +3695,11 @@ const translations: Record> = { "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.", diff --git a/frontend/src/client/inbox.ts b/frontend/src/client/inbox.ts index 7ad1179..65a8724 100644 --- a/frontend/src/client/inbox.ts +++ b/frontend/src/client/inbox.ts @@ -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 { + 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"; diff --git a/frontend/src/deadlines-new.tsx b/frontend/src/deadlines-new.tsx index c0038b7..e0f5f9c 100644 --- a/frontend/src/deadlines-new.tsx +++ b/frontend/src/deadlines-new.tsx @@ -80,6 +80,16 @@ export function renderDeadlinesNew(): string {

+ {/* t-paliad-154 — form-time 4-eye hint. Hidden by default; + revealed by client TS when an effective policy applies + to the chosen project. */} +

+
Abbrechen diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index ad85ce8..a2a4fdf 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -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" diff --git a/frontend/src/inbox.tsx b/frontend/src/inbox.tsx index 4eeb6ea..66a4ab4 100644 --- a/frontend/src/inbox.tsx +++ b/frontend/src/inbox.tsx @@ -49,6 +49,21 @@ export function renderInbox(): string {
Lädt …