shape-list.ts:
- Pending-row action group extends to four buttons. suggest_changes is
only rendered for lifecycle='update' rows (the backend rejects other
lifecycles with ErrSuggestionLifecycleInvalid).
- ApprovalAction union widened to "approve" | "reject" | "revoke" |
"suggest_changes". Disabled-reason logic shared with approve/reject
(viewer_can_approve gate).
- Status pill renders "Abgelehnt mit Vorschlag" for changes_requested
via the existing approval-pill--historic style — no new colour token.
- ApprovalDetail picks up counter_payload + next_request_id. When a
row is changes_requested AND a next_request_id is present, render a
back-link "→ Neuer Vorschlag von {name}" pointing at the new pending
row (server-side hydrated via correlated subquery on
previous_request_id, indexed by mig 103's partial index).
filter-bar/axes.ts:
- APPROVAL_STATUSES gains "changes_requested" — the chip shows up in
the /inbox filter cluster alongside pending/approved/rejected/revoked.
413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
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 (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":
|
|
return (row.detail.rule_code as string | undefined) ?? "—";
|
|
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<string, unknown> | null;
|
|
payload?: Record<string, unknown> | 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<string, unknown> | 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) {
|
|
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);
|
|
}
|
|
|
|
ul.appendChild(li);
|
|
}
|
|
return ul;
|
|
}
|
|
|
|
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
|
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
|
const after = (detail.payload || {}) as Record<string, unknown>;
|
|
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;
|