Rewrite atop the unified openModal() primitive (Slice A). Drops the
per-modal ESC + focus + backdrop + close-button handlers — the
primitive owns them.
New three-section body per design §2:
1. Editable fields. Every editable column on the entity, per m's Q1
Reading A lock-in:
deadline: title, due_date, original_due_date, warning_date,
rule_code, description, notes, event_type_ids
(attached via the existing event-types picker).
appointment: title, start_at, end_at, location, appointment_type,
description.
2. Read-only context. Project title, requester, requested_at, current
approval status. Renders as a definition-list with muted dt/dd
pairs so the eye lands on the editable section first.
3. Vorschlagskommentar (note). Always present, prominent.
Block labels matching /deadlines/new + views editor — reuses the
existing .form-field shapes for typography + spacing parity with the
rest of the app (m's Q6 lock-in).
inbox.ts gains projectTitle / requesterName / requestedAt hydration
from the per-row API response so the context section has data to
render. Falls back gracefully when missing.
Submit-button gate (in the openModal primary handler): refuses when no
field is dirty AND the note is empty. Mirrors the server's
ErrSuggestionRequiresChange.
CSS .approval-suggest-* classes added to global.css alongside the
modal primitive block (committed in Slice A).
317 lines
11 KiB
TypeScript
317 lines
11 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-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"
|
|
| "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 */ }
|
|
}
|