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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user