feat(t-paliad-154) commit 5/5: inbox empty-state nudge + form-time hints

Three remaining surfaces from the locked design (Q9 + Q13):

/inbox empty-state admin nudge (Q9):
- New conditional block (.inbox-admin-nudge) revealed only when:
  * /api/me reports global_role='global_admin'
  * the inbox tab returned zero rows
  * /api/admin/approval-policies/seeded reports any=false (no policies firm-wide)
- Card links to /admin/approval-policies. Hidden in every other case so the
  ordinary post-rollout state (admins with active policies) sees nothing.

Form-time 4-eye hint on /projects/{id}/deadlines/new + /appointments/new (Q13):
- New .approval-hint container above the Speichern button on each form;
  hidden by default.
- Client TS fires GET /api/projects/{id}/approval-policies/effective on
  page load + on project change, reveals the hint when required_role is
  non-null and not 'none'. Renders role label + source attribution
  ('· Standard: Munich Lit') so the user knows where the rule comes from.
- Hides in every 'no policy applies' case (no candidates / 'none' suppression
  / project change to a project with no policy / fetch error).

i18n: 6 new keys × 2 langs (3 inbox-nudge keys + 2 form-hint keys + the
inbox-nudge title/body/cta wired in inbox.tsx). Total i18n keys: 1929.

Dynamic-key call sites use tDyn (admin-approval-policies.ts +
deadlines-new.ts + appointments-new.ts) so the typed t() barrier stays
intact for static keys.

Build: bun run build clean, go build + vet + test clean (no DB tests
require TEST_DATABASE_URL — those run in CI).
This commit is contained in:
m
2026-05-08 02:31:35 +02:00
parent 028423b32f
commit 5df4285e1d
9 changed files with 223 additions and 7 deletions

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

@@ -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<EffectivePolicy[]> {
// ============================================================================
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 = `<span class="ap-source-chip ap-source-${esc(r.source)}">${esc(label)}${name}</span>`;
} else if (own) {

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

@@ -1653,6 +1653,11 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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 />