/inbox is the first surface to consume the universal FilterBar. The two-tab UI collapses into the bar's approval_viewer_role chip cluster (per Q4 lock-in 2026-05-08 21:47); status / entity_type / time chips are new affordances; density toggle gives the activity-feed look the brief asked for. Changes: - system_views.go: InboxSystemView + InboxRequesterSystemView render spec gains RowAction=approve so shape-list.ts knows which row layout to stamp (entity title + diff + approve/reject/revoke). - shape-list.ts: row_action='approve' branch — stamps the inbox-row markup the surface owned today; surface attaches click handlers via data-attrs on .views-approval-action / .views-approval-row. - inbox.tsx: tab row replaced with <div id='inbox-filter-bar'> + <div id='inbox-results'>. Heading + admin nudge unchanged. - client/inbox.ts: shrunk to mountFilterBar with axes [time, approval_viewer_role, approval_status, approval_entity_type, density, sort]. Action handlers run via fetch + bar.refresh(). Legacy ?tab=mine -> ?a_role=self_requested redirect on mount so bookmarks / sidebar bell still land on the right sub-view. Build clean: bun run build + go build/vet/test all pass.
212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
import { initI18n, t } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
|
import type { AxisKey } from "./filter-bar";
|
|
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
|
import { renderListShape } from "./views/shape-list";
|
|
|
|
// /inbox client — t-paliad-163 universal-filter migration.
|
|
//
|
|
// The bar owns every axis the old tab UI exposed plus more:
|
|
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
|
|
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
|
|
// - approval_status: chip cluster (default: pending)
|
|
// - approval_entity_type: chip pair (Frist / Termin)
|
|
// - time: chip cluster (Any default)
|
|
// - density: comfortable / compact
|
|
// - sort: date asc / desc
|
|
//
|
|
// Row rendering: shape-list.ts with row_action="approve" stamps the
|
|
// inbox markup (entity title, diff, approve/reject/revoke buttons).
|
|
// We wire action click handlers in onResult and refresh through the
|
|
// bar handle.
|
|
|
|
const INBOX_AXES: AxisKey[] = [
|
|
"time",
|
|
"approval_viewer_role",
|
|
"approval_status",
|
|
"approval_entity_type",
|
|
"density",
|
|
"sort",
|
|
];
|
|
|
|
let bar: BarHandle | null = null;
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
applyLegacyTabRedirect();
|
|
void hydrate();
|
|
});
|
|
|
|
// ?tab=pending-mine | mine -> ?a_role=approver_eligible | self_requested.
|
|
// Done client-side because /inbox serves a static dist file (no Go
|
|
// router involvement). Bookmarks from the sidebar bell + outbound
|
|
// emails keep landing on the right sub-view through the bar.
|
|
function applyLegacyTabRedirect(): void {
|
|
const url = new URL(window.location.href);
|
|
const tab = url.searchParams.get("tab");
|
|
if (!tab) return;
|
|
url.searchParams.delete("tab");
|
|
if (tab === "mine") {
|
|
url.searchParams.set("a_role", "self_requested");
|
|
} else if (tab === "pending-mine") {
|
|
url.searchParams.set("a_role", "approver_eligible");
|
|
}
|
|
history.replaceState(null, "", url.toString());
|
|
}
|
|
|
|
async function hydrate(): Promise<void> {
|
|
const host = document.getElementById("inbox-filter-bar");
|
|
const loading = document.getElementById("inbox-loading");
|
|
const results = document.getElementById("inbox-results");
|
|
const empty = document.getElementById("inbox-empty");
|
|
if (!host || !loading || !results || !empty) return;
|
|
|
|
const sys = await fetchInboxSystemView();
|
|
if (!sys) {
|
|
loading.style.display = "none";
|
|
empty.style.display = "";
|
|
empty.textContent = t("approvals.error.internal");
|
|
return;
|
|
}
|
|
|
|
bar = mountFilterBar(host, {
|
|
baseFilter: sys.Filter,
|
|
baseRender: sys.Render,
|
|
axes: INBOX_AXES,
|
|
surfaceKey: "inbox",
|
|
systemViewSlug: sys.Slug,
|
|
onResult: (result, effective) => paint(result, effective.render, results, empty, loading),
|
|
});
|
|
}
|
|
|
|
async function fetchInboxSystemView(): Promise<SystemView | null> {
|
|
try {
|
|
const r = await fetch("/api/views/system", { credentials: "include" });
|
|
if (!r.ok) return null;
|
|
const list = (await r.json()) as SystemView[];
|
|
return list.find((v) => v.Slug === "inbox") ?? null;
|
|
} catch (_e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function paint(
|
|
result: ViewRunResult,
|
|
render: RenderSpec,
|
|
results: HTMLElement,
|
|
empty: HTMLElement,
|
|
loading: HTMLElement,
|
|
): void {
|
|
loading.style.display = "none";
|
|
|
|
if (!result.rows || result.rows.length === 0) {
|
|
results.innerHTML = "";
|
|
empty.style.display = "";
|
|
empty.textContent = t("approvals.empty.pending_mine");
|
|
void maybeShowAdminNudge();
|
|
return;
|
|
}
|
|
hideAdminNudge();
|
|
empty.style.display = "none";
|
|
|
|
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
|
// RenderSpec sets row_action="approve" so we get the inbox markup.
|
|
renderListShape(results, result.rows, render);
|
|
|
|
// Wire action handlers on the freshly stamped DOM. The action
|
|
// POSTs land on the same endpoints the legacy /inbox used; on
|
|
// success we trigger a bar refresh so the new state propagates.
|
|
wireApprovalActions(results);
|
|
}
|
|
|
|
function wireApprovalActions(host: HTMLElement): void {
|
|
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
|
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
|
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
|
const id = li?.dataset.requestId;
|
|
if (!action || !id) return;
|
|
btn.addEventListener("click", async () => {
|
|
let note = "";
|
|
if (action === "reject") {
|
|
note = window.prompt(t("approvals.note.placeholder")) || "";
|
|
}
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await fetch(`/api/approval-requests/${id}/${action}`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ note }),
|
|
});
|
|
if (!r.ok) {
|
|
const body = await r.json().catch(() => ({} as { error?: string }));
|
|
alert(mapApprovalError(body.error || "internal"));
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
await bar?.refresh();
|
|
await refreshInboxBadge();
|
|
} catch (_e) {
|
|
alert("Network error");
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function mapApprovalError(key: string): string {
|
|
switch (key) {
|
|
case "self_approval_blocked": return t("approvals.error.self_approval");
|
|
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
|
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
|
case "not_authorized": return t("approvals.error.not_authorized");
|
|
case "request_not_pending": return t("approvals.error.request_not_pending");
|
|
default: return key;
|
|
}
|
|
}
|
|
|
|
// t-paliad-154 — show the admin-only "configure policies" nudge when:
|
|
// - current user is global_admin
|
|
// - inbox empty
|
|
// - no approval_policies row exists firm-wide
|
|
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) { /* keep hidden */ }
|
|
}
|
|
|
|
function hideAdminNudge(): void {
|
|
const nudge = document.getElementById("inbox-admin-nudge");
|
|
if (nudge) nudge.style.display = "none";
|
|
}
|
|
|
|
async function refreshInboxBadge(): Promise<void> {
|
|
const badge = document.getElementById("sidebar-inbox-badge");
|
|
if (!badge) return;
|
|
try {
|
|
const r = await fetch("/api/inbox/count", { credentials: "include" });
|
|
if (!r.ok) return;
|
|
const data = (await r.json()) as { count: number };
|
|
if (data.count > 0) {
|
|
badge.textContent = String(data.count);
|
|
badge.style.display = "";
|
|
} else {
|
|
badge.style.display = "none";
|
|
}
|
|
} catch (_e) { /* noop */ }
|
|
}
|