The /inbox surface drops "Genehmigungen" framing in favour of "Inbox" and renders the unified feed. - shape-list.ts: factor renderApprovalRow out of renderApprovalList so it can be reused alongside renderProjectEventInboxRow inside the new renderInboxList (row_action="inbox"). Project_event rows show a compact stream layout with an Öffnen link pointing at the right project tab (deadlines / appointments / notes). - filter-bar gets two new axes: unread_only (binary chip cluster) + inbox_focus (4-chip coarse cluster: Alles / Genehmigungen / +Termine / +Fristen). Both round-trip via url-codec; inbox_focus translates to (sources, project_event.event_types, approval_request.entity_types) at the bar's resolve step (applyInboxFocusOverlay). - FilterSpec gains a top-level unread_only flag; the bar writes it when the user toggles the chip; the server overlays the cursor. - /inbox header: new "Alles als gelesen markieren" button POSTs /api/inbox/mark-all-seen with up_to=<newest visible row> for race-safety against a second tab. - INBOX_AXES adds project + project_event_kind as advanced override chips so power users can still narrow per kind. - i18n: inbox.title.feed / inbox.heading.feed / inbox.action.mark_all_seen / inbox.action.open / inbox.empty.feed / views.bar.unread_only.* / views.bar.inbox_focus.* (DE + EN). - url-codec round-trip tests for the two new axes.
367 lines
12 KiB
TypeScript
367 lines
12 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";
|
|
import { openApprovalEditModal } from "./components/approval-edit-modal";
|
|
|
|
// /inbox client — t-paliad-249 unified inbox feed.
|
|
//
|
|
// The bar exposes:
|
|
// - inbox_focus: coarse Alles / Genehmigungen / +Termine / +Fristen
|
|
// - unread_only: Nur ungelesen / Alle (default: ungelesen)
|
|
// - time: last 30 days default; chip cluster + custom range
|
|
// - project: single-select autocomplete from visible projects
|
|
// - approval_viewer_role: Zur Genehmigung / Eigene / Alle sichtbaren
|
|
// - approval_status / approval_entity_type / project_event_kind: power-user overrides
|
|
// - sort / density: newest first default
|
|
//
|
|
// Row rendering: shape-list.ts with row_action="inbox" dispatches per
|
|
// row.kind. Approval rows keep approve/reject/revoke; project_event
|
|
// rows render compact with an Öffnen link.
|
|
|
|
const INBOX_AXES: AxisKey[] = [
|
|
"inbox_focus",
|
|
"unread_only",
|
|
"time",
|
|
"project",
|
|
"approval_viewer_role",
|
|
"approval_status",
|
|
"approval_entity_type",
|
|
"project_event_kind",
|
|
"sort",
|
|
"density",
|
|
];
|
|
|
|
// Last paint's newest row timestamp — used to pin mark-all-seen so a
|
|
// second tab can't race the cursor past items the user hasn't seen.
|
|
let newestVisibleAt: string | null = null;
|
|
|
|
let bar: BarHandle | null = null;
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
applyLegacyTabRedirect();
|
|
wireMarkAllSeen();
|
|
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("inbox.empty.feed");
|
|
newestVisibleAt = null;
|
|
void maybeShowAdminNudge();
|
|
return;
|
|
}
|
|
hideAdminNudge();
|
|
empty.style.display = "none";
|
|
|
|
// Remember the newest timestamp so mark-all-seen can pin the cursor
|
|
// to it (race-safety: a second tab adding a row between this paint
|
|
// and the click won't get wiped out).
|
|
newestVisibleAt = result.rows.reduce<string | null>((acc, r) => {
|
|
if (!acc) return r.event_date;
|
|
return r.event_date > acc ? r.event_date : acc;
|
|
}, null);
|
|
|
|
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
|
// RenderSpec sets row_action="inbox" so we get the unified dispatch
|
|
// (approval rows + project_event rows).
|
|
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);
|
|
}
|
|
|
|
// wireMarkAllSeen wires the page-header "Alles als gelesen markieren"
|
|
// button. POSTs the newest visible row's timestamp as `up_to` so a
|
|
// stale second tab can't rewind anyone else's cursor; on success the
|
|
// bar refreshes (rows newer than now disappear under unread_only) and
|
|
// the sidebar badge re-counts.
|
|
function wireMarkAllSeen(): void {
|
|
const btn = document.getElementById("inbox-mark-all-seen") as HTMLButtonElement | null;
|
|
if (!btn) return;
|
|
btn.addEventListener("click", async () => {
|
|
btn.disabled = true;
|
|
try {
|
|
const body = newestVisibleAt ? JSON.stringify({ up_to: newestVisibleAt }) : "{}";
|
|
const r = await fetch("/api/inbox/mark-all-seen", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
if (!r.ok) {
|
|
alert(t("approvals.error.internal"));
|
|
return;
|
|
}
|
|
await bar?.refresh();
|
|
await refreshInboxBadge();
|
|
} catch (_e) {
|
|
alert("Network error");
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function wireApprovalActions(host: HTMLElement): void {
|
|
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
|
const action = btn.dataset.action as
|
|
| "approve"
|
|
| "reject"
|
|
| "revoke"
|
|
| "suggest_changes"
|
|
| undefined;
|
|
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
|
const id = li?.dataset.requestId;
|
|
if (!action || !id) return;
|
|
btn.addEventListener("click", async () => {
|
|
if (action === "suggest_changes") {
|
|
await handleSuggestChanges(btn, id, li!);
|
|
return;
|
|
}
|
|
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; code?: string }));
|
|
alert(mapApprovalError(body.code || body.error || "internal"));
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
await bar?.refresh();
|
|
await refreshInboxBadge();
|
|
} catch (_e) {
|
|
alert("Network error");
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// handleSuggestChanges — t-paliad-216. Open the edit modal with the
|
|
// requester's original payload + pre_image pre-populated. If the user
|
|
// submits non-empty changes / note, POST to
|
|
// /api/approval-requests/{id}/suggest-changes; refresh the bar on success
|
|
// so the OLD row flips to changes_requested and the NEW pending row
|
|
// appears.
|
|
async function handleSuggestChanges(
|
|
btn: HTMLButtonElement,
|
|
requestID: string,
|
|
li: HTMLLIElement,
|
|
): Promise<void> {
|
|
// Read the row's detail blob off the data-attrs the shape-list stamped.
|
|
// shape-list serialises payload/pre_image inline; we fetch fresh via
|
|
// the per-row API to avoid relying on stale list data.
|
|
let payload: Record<string, unknown> | null = null;
|
|
let preImage: Record<string, unknown> | null = null;
|
|
let entityType: "deadline" | "appointment" = "deadline";
|
|
let lifecycleEvent = "update";
|
|
let projectTitle: string | undefined;
|
|
let requesterName: string | undefined;
|
|
let requestedAt: string | undefined;
|
|
try {
|
|
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
|
|
if (r.ok) {
|
|
const body = (await r.json()) as {
|
|
entity_type?: "deadline" | "appointment";
|
|
lifecycle_event?: string;
|
|
payload?: Record<string, unknown> | null;
|
|
pre_image?: Record<string, unknown> | null;
|
|
project_title?: string;
|
|
requester_name?: string;
|
|
requested_at?: string;
|
|
};
|
|
payload = body.payload ?? null;
|
|
preImage = body.pre_image ?? null;
|
|
if (body.entity_type === "appointment") entityType = "appointment";
|
|
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
|
|
projectTitle = body.project_title;
|
|
requesterName = body.requester_name;
|
|
requestedAt = body.requested_at;
|
|
}
|
|
} catch (_e) {
|
|
// Modal still opens with empty defaults if the fetch fails; the
|
|
// server-side schema validation catches a misshapen counter.
|
|
}
|
|
|
|
const result = await openApprovalEditModal({
|
|
entityType,
|
|
lifecycleEvent,
|
|
payload,
|
|
preImage,
|
|
projectTitle,
|
|
requesterName,
|
|
requestedAt,
|
|
});
|
|
if (!result) return; // cancel
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
const r = await fetch(`/api/approval-requests/${requestID}/suggest-changes`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
counter_payload: result.counterPayload,
|
|
note: result.note,
|
|
}),
|
|
});
|
|
const body = (await r.json().catch(() => ({}))) as {
|
|
error?: string;
|
|
code?: string;
|
|
new_request_id?: string;
|
|
};
|
|
if (!r.ok) {
|
|
alert(mapApprovalError(body.code || body.error || "internal"));
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
await bar?.refresh();
|
|
await refreshInboxBadge();
|
|
btn.disabled = false;
|
|
|
|
// Surface the new row's id on the OLD row's <li> so callers (e.g.
|
|
// tests, future inspection) can find it without re-querying.
|
|
if (body.new_request_id) {
|
|
li.dataset.spawnedRequestId = body.new_request_id;
|
|
}
|
|
} 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");
|
|
case "suggestion_requires_change": return t("approvals.error.suggestion_requires_change");
|
|
case "suggestion_lifecycle_invalid": return t("approvals.error.suggestion_lifecycle_invalid");
|
|
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 */ }
|
|
}
|