import { t, tDyn, getLang, type I18nKey } from "../i18n"; import type { ListRowAction, RenderSpec, ViewRow } from "./types"; import { formatDate, formatRelative, parseDateOnly } from "./format"; // shape-list: renders ViewRows as a table (density=comfortable) or a // compact one-line stream (density=compact). The "activity feed" look // is just density=compact + actor/time columns — see Q4 lock-in // 2026-05-07 (3 shapes; no separate "activity"). // // Row interaction is controlled by render.list.row_action // (t-paliad-163 schema bump). Default "navigate" keeps every existing // caller's contract — clicking a row goes to the per-kind detail // page. "approve" produces the approval-list layout for /inbox. // "complete_toggle" is wired in Phase 3 (/events). "none" suppresses // any row interaction (audit views). export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void { host.innerHTML = ""; const list = render.list ?? {}; const density = list.density ?? "comfortable"; const sort = list.sort ?? "date_asc"; const rowAction: ListRowAction = list.row_action ?? "navigate"; const sorted = [...rows].sort((a, b) => { const aT = sortKey(a.event_date); const bT = sortKey(b.event_date); return sort === "date_asc" ? aT - bT : bT - aT; }); if (rowAction === "approve") { host.appendChild(renderApprovalList(sorted)); return; } if (rowAction === "inbox") { host.appendChild(renderInboxList(sorted)); return; } if (density === "compact") { host.appendChild(renderCompact(sorted)); } else { host.appendChild(renderTable(sorted, list.columns ?? defaultColumns(rows))); } } function renderCompact(rows: ViewRow[]): HTMLElement { const ul = document.createElement("ul"); ul.className = "views-list views-list--compact"; for (const row of rows) { const li = document.createElement("li"); li.className = `views-list-row views-list-row--${row.kind}`; const time = document.createElement("span"); time.className = "views-list-time"; time.textContent = formatRelative(row.event_date, row.kind); li.appendChild(time); const kindIcon = document.createElement("span"); kindIcon.className = "views-list-kind"; kindIcon.textContent = kindLabel(row.kind); li.appendChild(kindIcon); const title = document.createElement("span"); title.className = "views-list-title"; title.textContent = row.title; li.appendChild(title); if (row.project_title) { const proj = document.createElement("span"); proj.className = "views-list-project"; proj.textContent = row.project_title; li.appendChild(proj); } if (row.actor_name) { const actor = document.createElement("span"); actor.className = "views-list-actor"; actor.textContent = row.actor_name; li.appendChild(actor); } if (row.subtitle) { const sub = document.createElement("span"); sub.className = "views-list-subtitle"; sub.textContent = row.subtitle; li.appendChild(sub); } ul.appendChild(li); } return ul; } function renderTable(rows: ViewRow[], columns: string[]): HTMLElement { const wrap = document.createElement("div"); wrap.className = "entity-table-wrap"; const table = document.createElement("table"); table.className = "entity-table views-list views-list--table entity-table--readonly"; const thead = document.createElement("thead"); const trHead = document.createElement("tr"); for (const col of columns) { const th = document.createElement("th"); th.textContent = t(("views.col." + col) as I18nKey); trHead.appendChild(th); } thead.appendChild(trHead); table.appendChild(thead); const tbody = document.createElement("tbody"); for (const row of rows) { const tr = document.createElement("tr"); tr.className = `views-table-row views-table-row--${row.kind}`; for (const col of columns) { const td = document.createElement("td"); td.textContent = formatColumn(row, col); tr.appendChild(td); } tbody.appendChild(tr); } table.appendChild(tbody); wrap.appendChild(table); return wrap; } function defaultColumns(rows: ViewRow[]): string[] { // Pick a sensible default column set from the kinds present in the // result. Keeps the UI honest when a user lands on a saved view that // has no explicit list.columns. const kinds = new Set(rows.map((r) => r.kind)); if (kinds.has("project_event") || kinds.has("approval_request")) { return ["time", "actor", "title", "project"]; } if (kinds.has("appointment")) { return ["date", "title", "project", "location"]; } return ["date", "title", "project", "status"]; } function formatColumn(row: ViewRow, col: string): string { switch (col) { case "date": return formatDate(row.event_date); case "time": return formatRelative(row.event_date, row.kind); case "title": return row.title; case "project": return row.project_title ?? "—"; case "actor": return row.actor_name ?? "—"; case "status": { const s = (row.detail.status as string | undefined) ?? ""; return s ? t(("deadlines.status." + s) as I18nKey) : "—"; } case "rule": { // t-paliad-258 — canonical "Name · Citation" pattern; fall back // to custom_rule_text + " · Custom" for Custom-mode deadlines. const lang = getLang(); const nameKey = lang === "en" ? "rule_name_en" : "rule_name"; const name = (row.detail[nameKey] as string | undefined) || (row.detail.rule_name as string | undefined) || ""; const cite = (row.detail.rule_code as string | undefined) ?? ""; if (name && cite) return `${name} · ${cite}`; if (name) return name; if (cite) return cite; const custom = (row.detail.custom_rule_text as string | undefined) ?? ""; if (custom.trim()) return `${custom} · Custom`; return "—"; } case "event_type": return (row.detail.event_type as string | undefined) ?? "—"; case "location": return (row.detail.location as string | undefined) ?? "—"; case "appointment_type": return (row.detail.appointment_type as string | undefined) ?? "—"; case "approval_status": return (row.detail.approval_status as string | undefined) ?? "—"; case "decided_by": return (row.detail.decider_name as string | undefined) ?? "—"; case "kind": return kindLabel(row.kind); default: return ""; } } function kindLabel(kind: string): string { return t(("views.kind." + kind) as I18nKey); } function sortKey(iso: string): number { const dateOnly = parseDateOnly(iso); if (dateOnly) return dateOnly.getTime(); return Date.parse(iso); } // ---------------------------------------------------------------------- // row_action = "approve" — approval inbox layout // // Stamps the markup the /inbox surface needs (data attrs + classes); // the surface (client/inbox.ts) wires the action handlers in onResult. // This keeps shape-list independent of any specific surface's wiring. // ---------------------------------------------------------------------- interface ApprovalDetail { status?: string; lifecycle_event?: string; entity_type?: string; entity_title?: string; pre_image?: Record | null; payload?: Record | null; required_role?: string; requester_name?: string; requester_kind?: "user" | "agent"; decider_name?: string; decision_note?: string; // counter_payload + next_request_id — populated on the OLD row of a // suggest-changes pair (t-paliad-216). The new row's id lets us // render a back-link "→ Neuer Vorschlag von {decider}". Both stay // unset on any non-changes_requested status. counter_payload?: Record | null; next_request_id?: string; // Per-viewer eligibility flags resolved server-side against the caller // (t-paliad-202). Used to grey out actions the server would reject. // Optional so an older payload still renders — falsy means "treat as // disabled" for the safety side (no false enables). viewer_can_approve?: boolean; viewer_is_requester?: boolean; } // Pending-row action set. suggest_changes was added in t-paliad-216 as // the fourth action — the approver authors a counter-proposal which // becomes a NEW pending row authored by them. type ApprovalAction = "approve" | "reject" | "revoke" | "suggest_changes"; function renderApprovalList(rows: ViewRow[]): HTMLElement { const ul = document.createElement("ul"); ul.className = "inbox-list views-approval-list"; for (const row of rows) { ul.appendChild(renderApprovalRow(row)); } return ul; } // renderApprovalRow stamps one
  • for an approval_request row. // Factored out of renderApprovalList in t-paliad-249 so the unified // inbox dispatch (renderInboxList) can reuse the exact same markup for // approval rows interleaved with project_event rows. export function renderApprovalRow(row: ViewRow): HTMLLIElement { const detail = (row.detail || {}) as ApprovalDetail; const li = document.createElement("li"); li.className = "inbox-row views-approval-row"; li.dataset.requestId = row.id; li.dataset.status = detail.status ?? ""; // Header: entity / lifecycle const head = document.createElement("div"); head.className = "inbox-row-head"; const title = document.createElement("div"); title.className = "inbox-row-title"; const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : ""; const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : ""; const entityTitle = detail.entity_title || row.title || "—"; title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`; head.appendChild(title); const meta = document.createElement("div"); meta.className = "inbox-row-meta"; const reqByLabel = t("approvals.requested_by"); const roleLabel = detail.required_role ? t(("approvals.required_role." + detail.required_role) as I18nKey) : ""; const requester = detail.requester_name || row.actor_name || ""; const requesterTag = detail.requester_kind === "agent" ? `${requester} ✨ ${t("approvals.agent.byline")}` : requester; const projectTitle = row.project_title ?? ""; const parts = [ projectTitle, `${reqByLabel} ${requesterTag}`, ]; if (roleLabel) parts.push(`${roleLabel}+`); parts.push(formatRelativeTime(row.event_date)); meta.textContent = parts.filter(Boolean).join(" · "); head.appendChild(meta); li.appendChild(head); // Diff for update / complete const diff = renderDiff(detail); if (diff) li.appendChild(diff); if (detail.decision_note) { const note = document.createElement("div"); note.className = "inbox-row-note"; note.textContent = detail.decision_note; li.appendChild(note); } // Action row — surface attaches handlers via data-attrs. const actions = document.createElement("div"); actions.className = "inbox-row-actions"; if (detail.status === "pending") { // All four actions are stamped on every pending row; the per-viewer // viewer_can_approve / viewer_is_requester flags (resolved server-side) // decide which are enabled vs. greyed out with a tooltip. m's ask // (2026-05-17): show what's possible but disable what isn't, rather // than alert-after-click. The server still enforces — disabled buttons // are a UI hint, not a security gate. // // suggest_changes is hidden for non-update lifecycles (the backend // returns ErrSuggestionLifecycleInvalid for create/complete/delete, // so we don't even render the button for them). actions.appendChild(approvalActionBtn("approve", detail)); if (detail.lifecycle_event === "update") { actions.appendChild(approvalActionBtn("suggest_changes", detail)); } actions.appendChild(approvalActionBtn("reject", detail)); actions.appendChild(approvalActionBtn("revoke", detail)); } else if (detail.status) { const pill = document.createElement("span"); pill.className = "approval-pill approval-pill--historic"; pill.textContent = t(("approvals.status." + detail.status) as I18nKey); if (detail.decider_name && detail.status !== "revoked") { const decided = document.createElement("span"); decided.className = "inbox-row-decided"; decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`; pill.appendChild(decided); } actions.appendChild(pill); } li.appendChild(actions); // Back-link from the OLD changes_requested row to the NEW pending // counter row (t-paliad-216). Hydrated server-side as // detail.next_request_id; the surface renders a link that scrolls // / filters to the new row. Falsy next_request_id = no link (e.g. // older rows pre-mig-103, or rows where the server hasn't joined the // back-pointer). if (detail.status === "changes_requested" && detail.next_request_id) { const link = document.createElement("a"); link.className = "inbox-row-next-request"; link.href = `#request-${detail.next_request_id}`; link.dataset.nextRequestId = detail.next_request_id; const deciderName = detail.decider_name || ""; link.textContent = t("approvals.suggest.next_request_link").replace("{name}", deciderName); li.appendChild(link); } return li; } // ---------------------------------------------------------------------- // row_action = "inbox" — unified inbox layout (t-paliad-249) // // Dispatches per row.kind so approval_request rows reuse the existing // approve/reject/revoke markup while project_event rows render as a // compact stream row (timestamp + actor + title + project chip + // Öffnen link to the underlying entity). // ---------------------------------------------------------------------- function renderInboxList(rows: ViewRow[]): HTMLElement { const ul = document.createElement("ul"); ul.className = "inbox-list inbox-list--unified"; for (const row of rows) { if (row.kind === "approval_request") { ul.appendChild(renderApprovalRow(row)); } else if (row.kind === "project_event") { ul.appendChild(renderProjectEventInboxRow(row)); } } return ul; } interface ProjectEventDetail { event_type?: string | null; description?: string | null; } function renderProjectEventInboxRow(row: ViewRow): HTMLLIElement { const detail = (row.detail || {}) as ProjectEventDetail; const li = document.createElement("li"); li.className = "inbox-row inbox-row--project-event"; li.dataset.eventId = row.id; if (detail.event_type) li.dataset.eventType = detail.event_type; const head = document.createElement("div"); head.className = "inbox-row-head"; const title = document.createElement("div"); title.className = "inbox-row-title"; // Prefer the row.title (server-side authored, project-aware); fall // back to a synthesised event-kind label so a malformed row never // produces an empty
  • . const kindLabelText = detail.event_type ? t(("event.title." + detail.event_type) as I18nKey) : ""; title.textContent = row.title || kindLabelText || "—"; head.appendChild(title); const meta = document.createElement("div"); meta.className = "inbox-row-meta"; const parts: string[] = []; if (row.project_title) parts.push(row.project_title); if (row.actor_name) parts.push(row.actor_name); parts.push(formatRelativeTime(row.event_date)); meta.textContent = parts.filter(Boolean).join(" · "); head.appendChild(meta); li.appendChild(head); if (detail.description) { const desc = document.createElement("div"); desc.className = "inbox-row-description"; desc.textContent = detail.description; li.appendChild(desc); } const actions = document.createElement("div"); actions.className = "inbox-row-actions"; const openLink = projectEventLink(row, detail); if (openLink) actions.appendChild(openLink); li.appendChild(actions); return li; } // projectEventLink builds an "Öffnen" anchor that points to the most // useful target for the event kind. Falls back to the project detail // page when the kind doesn't carry a richer pointer. // // Slice B can deepen this (e.g. note_created → scroll to note anchor); // keep it minimal for Slice A. function projectEventLink(row: ViewRow, detail: ProjectEventDetail): HTMLAnchorElement | null { if (!row.project_id) return null; const kind = detail.event_type ?? ""; const a = document.createElement("a"); a.className = "inbox-row-open"; a.textContent = t("inbox.action.open"); if (kind.startsWith("deadline_")) { a.href = `/projects/${row.project_id}#deadlines`; } else if (kind.startsWith("appointment_")) { a.href = `/projects/${row.project_id}#appointments`; } else if (kind === "note_created") { a.href = `/projects/${row.project_id}#notes`; } else { a.href = `/projects/${row.project_id}`; } return a; } function renderDiff(detail: ApprovalDetail): HTMLElement | null { const before = (detail.pre_image || {}) as Record; const after = (detail.payload || {}) as Record; const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])); if (keys.length === 0) return null; const wrap = document.createElement("div"); wrap.className = "inbox-row-diff"; for (const k of keys) { const line = document.createElement("div"); line.className = "inbox-row-diff-line"; const label = document.createElement("span"); label.className = "inbox-row-diff-key"; label.textContent = k; line.appendChild(label); const span = document.createElement("span"); span.className = "inbox-row-diff-values"; const fmt = (v: unknown) => v === null || v === undefined ? "—" : String(v); if (k in before && k in after) { span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`; } else if (k in before) { span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`; } else { span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`; } line.appendChild(span); wrap.appendChild(line); } return wrap; } function approvalActionBtn( action: ApprovalAction, detail: ApprovalDetail, ): HTMLButtonElement { const btn = document.createElement("button"); btn.type = "button"; btn.dataset.action = action; // suggest_changes shares the secondary style with revoke; reject is // danger (terminal "no"); approve is primary. const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary"; btn.className = `btn ${cls} inbox-row-action views-approval-action`; btn.textContent = t(("approvals.action." + action) as I18nKey); // approve / reject / suggest_changes share the canApprove eligibility // gate; revoke is requester-only. const reason = disabledReasonFor(action, detail); if (reason) { btn.disabled = true; btn.title = t(reason); } return btn; } function disabledReasonFor( action: ApprovalAction, detail: ApprovalDetail, ): I18nKey | null { if (action === "revoke") { return detail.viewer_is_requester ? null : "approvals.disabled.revoke_not_requester"; } // approve / reject / suggest_changes — same gate as the server's canApprove. if (detail.viewer_can_approve) return null; if (detail.viewer_is_requester) return "approvals.disabled.self_approval"; return "approvals.disabled.not_authorized"; } function formatRelativeTime(iso: string): string { const t0 = Date.parse(iso); if (isNaN(t0)) return iso; const diffMs = Date.now() - t0; const sec = Math.floor(diffMs / 1000); if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`; const min = Math.floor(sec / 60); if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`; const day = Math.floor(hr / 24); return getLang() === "de" ? `vor ${day}d` : `${day}d ago`; } // Suppress unused warning for tDyn — kept available for future axes. void tDyn;