Files
paliad/frontend/src/client/inbox.ts
m 4670cd660a feat(inbox): migrate to <FilterBar> — t-paliad-163 Slice 3
/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.
2026-05-08 21:59:44 +02:00

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 */ }
}