Merge: t-paliad-163 Phase 1 — universal <FilterBar> primitive + /inbox migration

Three slices on mai/riemann/inventor-universal:

  d5a01e6  Slice 1 — RenderSpec.list.row_action + validator + tests
  de4e133  Slice 2 — <FilterBar> scaffolding (axes / url-codec / save-modal)
  4670cd6  Slice 3 — /inbox migrates to <FilterBar>; tabs collapse to chips

What ships (Phase 1):

- A new frontend/src/client/filter-bar/ module:
    types.ts        — Spec + RenderSpec + AxisDeclaration types
    axes.ts         — registry of supported filter axes
    url-codec.ts    — URL ↔ FilterSpec serialization (round-tripping)
    save-modal.ts   — "Speichern als Sicht" dialog
    index.ts        — <FilterBar> mounts
  Plus a url-codec.test.ts golden table.
- /inbox surface migrates to the bar:
    Top-level "Zur Genehmigung / Meine Anfragen" tabs collapse into the
    bar's `approval_viewer_role` chip cluster (incoming / outgoing /
    both). One control, three mutually exclusive options. Stateful via
    `?role=` URL param.
    Bookmark-friendly: legacy `?tab=mine` + `?tab=pending-mine` redirect
    to `?role=outgoing` and `?role=incoming` respectively for one
    release.
    Sortable column headers on the result list (list-shape only;
    cards/calendar shape-modes defer their own ordering to the spec).
- RenderSpec.list gains `row_action` ("navigate" | "expand" | "none")
  so list-shape surfaces declare row click behaviour explicitly. The
  validator + tests cover the new field.
- system_views.go gains the inbox SystemView definitions so the bar
  reads its base spec from the same registry that custom views use.

m's locked positions (commit `1e23745` design doc; m's greenlight
2026-05-08 21:47): all 11 default picks honoured. Q4 = collapse
tabs to chips ✓.

Phase 2 surfaces (port /agenda → bar; port /events → bar; port
/deadlines → bar; port /appointments → bar) follow as separate PRs.

Refs m/paliad#23.
This commit is contained in:
m
2026-05-08 22:03:51 +02:00
17 changed files with 2468 additions and 297 deletions

View File

@@ -0,0 +1,350 @@
// Per-axis renderers for the FilterBar — t-paliad-163.
//
// Each axis is a small, self-contained render function that takes the
// current BarState slice and a callback. The bar's mountFilterBar
// composes them in the order declared on the surface.
//
// Reuses existing CSS classes wherever possible:
// - .agenda-chip / .agenda-chip-active (chip cluster pattern)
// - .filter-group (label + control wrapping)
// - .akten-multi-trigger / .multi-anchor / .multi-panel
//
// New classes are scoped under .filter-bar-* so they don't bleed.
import { t, tDyn, type I18nKey } from "../i18n";
import type { BarState, AxisKey } from "./types";
export interface AxisCtx {
// Read the current value for this axis.
get<K extends keyof BarState>(key: K): BarState[K];
// Patch one or more axis values + trigger re-run.
patch(delta: Partial<BarState>): void;
}
// renderAxis returns the HTML element for a single axis. The bar's
// mountFilterBar appends the result to its internal toolbar. Returns
// null when the axis is ignored (e.g. surface didn't declare it).
export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
switch (axis) {
case "time": return renderTimeAxis(ctx);
case "project": return null; // populated lazily — see attachProjectAxis below
case "personal_only": return renderPersonalOnlyAxis(ctx);
case "approval_viewer_role": return renderApprovalRoleAxis(ctx);
case "approval_status": return renderApprovalStatusAxis(ctx);
case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
case "deadline_status": return renderDeadlineStatusAxis(ctx);
case "appointment_type": return renderAppointmentTypeAxis(ctx);
case "shape": return renderShapeAxis(ctx);
case "density": return renderDensityAxis(ctx);
case "sort": return renderSortAxis(ctx);
// Per-source predicates that need their own widgets and a roundtrip
// through fetched option lists. Phase 2+ will fill these in by
// wiring the existing event-types / project-list components.
case "deadline_event_type":
case "project_event_kind":
return null;
}
}
// ----------------------------------------------------------------------
// time — chip cluster (presets + Anpassen)
// ----------------------------------------------------------------------
const TIME_PRESETS: Array<{ value: BarState["time"] extends infer T ? (T extends { horizon: infer H } ? H : never) : never; key: I18nKey }> = [
{ value: "next_7d", key: "views.bar.time.next_7d" },
{ value: "next_30d", key: "views.bar.time.next_30d" },
{ value: "next_90d", key: "views.bar.time.next_90d" },
{ value: "past_30d", key: "views.bar.time.past_30d" },
{ value: "any", key: "views.bar.time.any" },
];
function renderTimeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.time");
const row = chipRow();
const current = ctx.get("time")?.horizon ?? "any";
for (const preset of TIME_PRESETS) {
const chip = chipBtn(t(preset.key), preset.value === current);
chip.addEventListener("click", () => {
if (preset.value === "any") {
ctx.patch({ time: undefined });
} else {
ctx.patch({ time: { horizon: preset.value } });
}
});
row.appendChild(chip);
}
// Custom range — placeholder chip; opens a small popover with two
// <input type="date"> in Phase 2. For Phase 1 we render the chip
// disabled with a tooltip so the affordance is discoverable.
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
customChip.classList.add("filter-bar-chip-pending");
customChip.title = t("views.bar.time.custom.coming_soon");
customChip.disabled = true;
row.appendChild(customChip);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// personal_only — single chip (binary)
// ----------------------------------------------------------------------
function renderPersonalOnlyAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.personal");
const chip = chipBtn(t("views.bar.personal.on"), !!ctx.get("personal_only"));
chip.addEventListener("click", () => {
ctx.patch({ personal_only: !ctx.get("personal_only") });
});
wrap.appendChild(chip);
return wrap;
}
// ----------------------------------------------------------------------
// approval_viewer_role — chip cluster (3 mutually exclusive)
// ----------------------------------------------------------------------
const APPROVAL_ROLES: Array<{ value: NonNullable<BarState["approval_viewer_role"]>; key: I18nKey }> = [
{ value: "approver_eligible", key: "views.bar.approval_role.approver_eligible" },
{ value: "self_requested", key: "views.bar.approval_role.self_requested" },
{ value: "any_visible", key: "views.bar.approval_role.any_visible" },
];
function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_role");
const row = chipRow();
const current = ctx.get("approval_viewer_role") ?? "approver_eligible";
for (const role of APPROVAL_ROLES) {
const chip = chipBtn(t(role.key), role.value === current);
chip.addEventListener("click", () => {
ctx.patch({ approval_viewer_role: role.value });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// approval_status — chip cluster (multi-select)
// ----------------------------------------------------------------------
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "pending", key: "views.bar.approval_status.pending" },
{ value: "approved", key: "views.bar.approval_status.approved" },
{ value: "rejected", key: "views.bar.approval_status.rejected" },
{ value: "revoked", key: "views.bar.approval_status.revoked" },
];
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_status")?.length);
all.addEventListener("click", () => ctx.patch({ approval_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("approval_status") ?? []);
for (const status of APPROVAL_STATUSES) {
const chip = chipBtn(t(status.key), current.has(status.value));
chip.addEventListener("click", () => {
if (current.has(status.value)) current.delete(status.value);
else current.add(status.value);
ctx.patch({ approval_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// approval_entity_type — chip pair (multi-select; deadline / appointment)
// ----------------------------------------------------------------------
const APPROVAL_ENTITY_TYPES: Array<{ value: string; key: I18nKey }> = [
{ value: "deadline", key: "views.bar.approval_entity.deadline" },
{ value: "appointment", key: "views.bar.approval_entity.appointment" },
];
function renderApprovalEntityTypeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.approval_entity");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_entity_type")?.length);
all.addEventListener("click", () => ctx.patch({ approval_entity_type: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("approval_entity_type") ?? []);
for (const ent of APPROVAL_ENTITY_TYPES) {
const chip = chipBtn(t(ent.key), current.has(ent.value));
chip.addEventListener("click", () => {
if (current.has(ent.value)) current.delete(ent.value);
else current.add(ent.value);
ctx.patch({ approval_entity_type: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// deadline_status — chip cluster (multi-select)
// ----------------------------------------------------------------------
const DEADLINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "pending", key: "views.bar.deadline_status.pending" },
{ value: "completed", key: "views.bar.deadline_status.completed" },
];
function renderDeadlineStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.deadline_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("deadline_status")?.length);
all.addEventListener("click", () => ctx.patch({ deadline_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("deadline_status") ?? []);
for (const s of DEADLINE_STATUSES) {
const chip = chipBtn(t(s.key), current.has(s.value));
chip.addEventListener("click", () => {
if (current.has(s.value)) current.delete(s.value);
else current.add(s.value);
ctx.patch({ deadline_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// appointment_type — chip cluster (multi-select)
// ----------------------------------------------------------------------
const APPOINTMENT_TYPES: Array<{ value: string; key: I18nKey }> = [
{ value: "hearing", key: "views.bar.appointment_type.hearing" },
{ value: "meeting", key: "views.bar.appointment_type.meeting" },
{ value: "consultation", key: "views.bar.appointment_type.consultation" },
{ value: "deadline_hearing", key: "views.bar.appointment_type.deadline_hearing" },
];
function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.appointment_type");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("appointment_type")?.length);
all.addEventListener("click", () => ctx.patch({ appointment_type: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("appointment_type") ?? []);
for (const ty of APPOINTMENT_TYPES) {
const chip = chipBtn(t(ty.key), current.has(ty.value));
chip.addEventListener("click", () => {
if (current.has(ty.value)) current.delete(ty.value);
else current.add(ty.value);
ctx.patch({ appointment_type: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// shape — segmented control (list / cards / calendar)
// ----------------------------------------------------------------------
const SHAPES: Array<{ value: NonNullable<BarState["shape"]>; key: I18nKey }> = [
{ value: "list", key: "views.bar.shape.list" },
{ value: "cards", key: "views.bar.shape.cards" },
{ value: "calendar", key: "views.bar.shape.calendar" },
];
function renderShapeAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.shape");
const row = chipRow();
row.classList.add("filter-bar-segment");
const current = ctx.get("shape");
for (const sh of SHAPES) {
const chip = chipBtn(t(sh.key), sh.value === current);
chip.addEventListener("click", () => ctx.patch({ shape: sh.value }));
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// density — segmented pair (comfortable / compact)
// ----------------------------------------------------------------------
const DENSITIES: Array<{ value: NonNullable<BarState["density"]>; key: I18nKey }> = [
{ value: "comfortable", key: "views.bar.density.comfortable" },
{ value: "compact", key: "views.bar.density.compact" },
];
function renderDensityAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.density");
const row = chipRow();
row.classList.add("filter-bar-segment");
const current = ctx.get("density") ?? "comfortable";
for (const d of DENSITIES) {
const chip = chipBtn(t(d.key), d.value === current);
chip.addEventListener("click", () => ctx.patch({ density: d.value }));
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// sort — small <select>
// ----------------------------------------------------------------------
const SORTS: Array<{ value: NonNullable<BarState["sort"]>; key: I18nKey }> = [
{ value: "date_asc", key: "views.bar.sort.date_asc" },
{ value: "date_desc", key: "views.bar.sort.date_desc" },
];
function renderSortAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.sort");
const sel = document.createElement("select");
sel.className = "entity-select filter-bar-select";
for (const s of SORTS) {
const opt = document.createElement("option");
opt.value = s.value;
opt.textContent = t(s.key);
sel.appendChild(opt);
}
sel.value = ctx.get("sort") ?? "date_asc";
sel.addEventListener("change", () => ctx.patch({ sort: sel.value as NonNullable<BarState["sort"]> }));
wrap.appendChild(sel);
return wrap;
}
// Suppress unused warning for tDyn — it's available for future axes
// (deadline_event_type) that need dynamic enum labels.
void tDyn;
// ----------------------------------------------------------------------
// shared helpers — group + chip + row
// ----------------------------------------------------------------------
function group(labelKey: I18nKey): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "filter-group filter-bar-group";
const label = document.createElement("span");
label.className = "filter-bar-label";
label.textContent = t(labelKey);
wrap.appendChild(label);
return wrap;
}
function chipRow(): HTMLElement {
const row = document.createElement("div");
row.className = "filter-bar-chip-row";
return row;
}
function chipBtn(text: string, active: boolean): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "agenda-chip filter-bar-chip" + (active ? " agenda-chip-active" : "");
btn.textContent = text;
return btn;
}

View File

@@ -0,0 +1,325 @@
// FilterBar — the universal filter + view-mode primitive
// (t-paliad-163). One client component every list-shaped paliad surface
// mounts.
//
// Lifecycle:
// 1. Caller hands in baseFilter + baseRender + axes + onResult.
// 2. We parse URL params (within urlNamespace) and localStorage prefs,
// overlay them on the base spec to compute the effective spec.
// 3. We render the toolbar (one chip cluster / popover / select per
// axis, plus trailing actions).
// 4. We POST /api/views/{slug}/run with the effective spec as override
// and hand the result + effective spec to onResult. The surface's
// shape host renders.
// 5. Every axis interaction patches BarState, re-encodes the URL,
// re-runs the spec.
//
// The bar is a closed loop — surfaces don't see FilterSpec/RenderSpec
// directly, just BarState diffs and the final ViewRunResult. That keeps
// the substrate's validation invariants in one place (the bar).
import { onLangChange, t } from "../i18n";
import type { FilterSpec, RenderSpec, ViewRunResult } from "../views/types";
import {
parseBar,
encodeBar,
} from "./url-codec";
import { renderAxis, type AxisCtx } from "./axes";
import { openSaveModal } from "./save-modal";
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
export type { MountOpts, BarHandle, AxisKey } from "./types";
const PREFS_PREFIX = "paliad.bar.";
interface PrefsBlob {
shape?: string;
density?: string;
sort?: string;
}
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
let state: BarState = {};
const ns = opts.urlNamespace;
// Hydrate state: URL > localStorage prefs > base.
const urlParams = new URLSearchParams(window.location.search);
state = parseBar(urlParams, ns);
hydratePrefs(state, opts.surfaceKey);
// Toolbar shell.
const toolbar = document.createElement("div");
toolbar.className = "filter-bar";
host.appendChild(toolbar);
// Trailing actions: Save as view + Reset (when not suppressed).
const showSave = opts.showSaveAsView !== false;
// Run + render orchestration.
let runVersion = 0;
let lastEffective: EffectiveSpec | null = null;
const runAndRender = async () => {
const effective = computeEffective(opts.baseFilter, opts.baseRender, state);
lastEffective = effective;
const myVersion = ++runVersion;
try {
const r = await fetch(`/api/views/${encodeURIComponent(opts.systemViewSlug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filter: effective.filter }),
});
if (myVersion !== runVersion) return; // a newer click superseded us
if (!r.ok) {
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
return;
}
const result = (await r.json()) as ViewRunResult;
opts.onResult(result, effective);
} catch (_e) {
if (myVersion !== runVersion) return;
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
}
};
// Axis context — all axis renderers patch state through here.
const ctx: AxisCtx = {
get<K extends keyof BarState>(key: K) { return state[key]; },
patch(delta) {
state = { ...state, ...delta };
// Coerce empties so URL stays clean.
for (const k of Object.keys(delta) as (keyof BarState)[]) {
const v = state[k];
if (Array.isArray(v) && v.length === 0) delete state[k];
if (v === undefined || v === null || v === false) delete state[k];
}
// personal_only false should also be deleted (handled above as
// falsy, but explicit for clarity).
if (state.personal_only === false) delete state.personal_only;
syncURL();
syncPrefs();
renderToolbar();
void runAndRender();
},
};
// First paint.
const renderToolbar = () => {
toolbar.innerHTML = "";
for (const axis of opts.axes) {
const el = renderAxis(axis as AxisKey, ctx);
if (el) toolbar.appendChild(el);
}
if (showSave) {
const trailing = document.createElement("div");
trailing.className = "filter-bar-trailing";
const resetBtn = document.createElement("button");
resetBtn.type = "button";
resetBtn.className = "btn-secondary btn-small filter-bar-reset";
resetBtn.textContent = t("views.bar.action.reset");
resetBtn.disabled = !isDirty(state);
resetBtn.addEventListener("click", () => handle.reset());
trailing.appendChild(resetBtn);
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "btn-primary btn-small filter-bar-save";
saveBtn.textContent = t("views.bar.action.save_as_view");
saveBtn.addEventListener("click", async () => {
if (!lastEffective) return;
const result = await openSaveModal(lastEffective.filter, lastEffective.render);
if (result) {
window.location.href = `/views/${encodeURIComponent(result.view.slug)}`;
}
});
trailing.appendChild(saveBtn);
toolbar.appendChild(trailing);
}
};
const syncURL = () => {
const params = new URLSearchParams(window.location.search);
encodeBar(state, params, ns);
const qs = params.toString();
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
history.replaceState(null, "", url);
};
const syncPrefs = () => {
const blob: PrefsBlob = {};
if (state.shape) blob.shape = state.shape;
if (state.density) blob.density = state.density;
if (state.sort) blob.sort = state.sort;
try {
if (Object.keys(blob).length === 0) {
localStorage.removeItem(PREFS_PREFIX + opts.surfaceKey);
} else {
localStorage.setItem(PREFS_PREFIX + opts.surfaceKey, JSON.stringify(blob));
}
} catch { /* private mode / quota — ignore */ }
};
// Re-render labels on language change without losing state. The
// existing onLangChange API is register-only (no off-handler). We
// gate via a `destroyed` flag so a torn-down bar's callback no-ops.
let destroyed = false;
onLangChange(() => {
if (destroyed) return;
renderToolbar();
});
const handle: BarHandle = {
reset() {
state = {};
syncURL();
syncPrefs();
renderToolbar();
void runAndRender();
},
async refresh() {
await runAndRender();
},
getEffective() {
if (lastEffective) return lastEffective;
return computeEffective(opts.baseFilter, opts.baseRender, state);
},
destroy() {
destroyed = true;
toolbar.remove();
},
};
renderToolbar();
void runAndRender();
return handle;
}
// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
// in render axes the URL didn't already pin. URL wins over prefs.
function hydratePrefs(state: BarState, surfaceKey: string): void {
let blob: PrefsBlob;
try {
const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
if (!raw) return;
blob = JSON.parse(raw) as PrefsBlob;
} catch { return; }
if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
state.shape = blob.shape;
}
if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
state.density = blob.density;
}
if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
state.sort = blob.sort;
}
}
// computeEffective overlays the BarState onto the base FilterSpec +
// RenderSpec to produce the spec that gets POSTed to the substrate.
//
// Server-side validator (FilterSpec.Validate) is the final gate; we
// produce shapes the validator will accept, but defer to it for the
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
export function computeEffective(
base: FilterSpec,
baseRender: RenderSpec,
state: BarState,
): EffectiveSpec {
// Deep-clone to avoid mutating the caller's base. JSON round-trip is
// fine here — every field on FilterSpec is a primitive / array /
// object literal (no class instances, no Date, no functions).
const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;
if (state.time) {
filter.time = {
...filter.time,
horizon: state.time.horizon,
from: state.time.horizon === "custom" ? state.time.from : undefined,
to: state.time.horizon === "custom" ? state.time.to : undefined,
};
}
if (state.project) {
if (state.project.mode === "personal") {
filter.scope = {
...filter.scope,
personal_only: true,
// When personal_only takes over, leave projects on the base
// mode (typically all_visible). Validator rejects ScopeExplicit
// + personal_only so we don't overwrite the mode here.
};
} else if (state.project.id) {
filter.scope = {
...filter.scope,
projects: { mode: "explicit", ids: [state.project.id] },
};
}
}
if (state.personal_only) {
filter.scope = { ...filter.scope, personal_only: true };
}
// Per-source predicates. Build the predicates map idempotently;
// never inject a predicate for a source the spec doesn't list.
const sources = new Set(filter.sources);
filter.predicates = filter.predicates ?? {};
if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
const cur = filter.predicates.deadline ?? {};
const next = { ...cur };
if (state.deadline_status) next.status = state.deadline_status;
if (state.deadline_event_type) {
next.event_types = state.deadline_event_type.ids;
next.include_untyped = state.deadline_event_type.include_untyped;
}
filter.predicates.deadline = next;
}
if (sources.has("appointment") && state.appointment_type) {
const cur = filter.predicates.appointment ?? {};
filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
}
if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
const cur = filter.predicates.approval_request ?? {};
const next = { ...cur };
if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
if (state.approval_status) next.status = state.approval_status;
if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
filter.predicates.approval_request = next;
}
if (sources.has("project_event") && state.project_event_kind) {
const cur = filter.predicates.project_event ?? {};
filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
}
// Render overlays.
if (state.shape) render.shape = state.shape;
if (state.sort) {
if (render.shape === "list" || (state.shape === "list" && !render.list)) {
render.list = { ...(render.list ?? {}), sort: state.sort };
}
if (render.shape === "cards" || state.shape === "cards") {
render.cards = { ...(render.cards ?? {}), sort: state.sort };
}
}
if (state.density && (render.shape === "list" || state.shape === "list")) {
render.list = { ...(render.list ?? {}), density: state.density };
}
return { filter, render };
}
// isDirty — used to enable the Reset button only when there's something
// to reset to.
function isDirty(state: BarState): boolean {
for (const k of Object.keys(state) as (keyof BarState)[]) {
const v = state[k];
if (v === undefined || v === null || v === false) continue;
if (Array.isArray(v) && v.length === 0) continue;
return true;
}
return false;
}

View File

@@ -0,0 +1,146 @@
// Save-as-view modal for the FilterBar. Mirrors the create form on
// /views/new (frontend/src/client/views-editor.ts:168) but as a modal
// so the user can save the bar's current effective spec without
// leaving the page they're filtering on.
//
// On success, the new view appears in the "Meine Sichten" sidebar
// group on next render (the sidebar polls /api/user-views on init).
import { t } from "../i18n";
import type { FilterSpec, RenderSpec, UserView } from "../views/types";
export interface SaveModalResult {
view: UserView;
}
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
export function openSaveModal(filter: FilterSpec, render: RenderSpec): Promise<SaveModalResult | null> {
return new Promise((resolve) => {
const dialog = document.createElement("dialog");
dialog.className = "filter-bar-save-modal";
dialog.innerHTML = `
<form method="dialog" class="filter-bar-save-form">
<h2>${t("views.bar.save.heading")}</h2>
<label class="filter-bar-save-field">
<span>${t("views.bar.save.field.name")}</span>
<input type="text" name="name" required maxlength="100" autocomplete="off" />
</label>
<label class="filter-bar-save-field">
<span>${t("views.bar.save.field.slug")}</span>
<input type="text" name="slug" required maxlength="63" autocomplete="off" pattern="[a-z0-9][a-z0-9-]*" />
<small>${t("views.bar.save.field.slug_hint")}</small>
</label>
<label class="filter-bar-save-checkbox">
<input type="checkbox" name="show_count" />
<span>${t("views.bar.save.field.show_count")}</span>
</label>
<p class="filter-bar-save-error" hidden></p>
<div class="filter-bar-save-actions">
<button type="button" class="btn-secondary" data-action="cancel">${t("views.bar.save.cancel")}</button>
<button type="submit" class="btn-primary">${t("views.bar.save.confirm")}</button>
</div>
</form>
`;
document.body.appendChild(dialog);
const form = dialog.querySelector<HTMLFormElement>(".filter-bar-save-form")!;
const errorEl = dialog.querySelector<HTMLParagraphElement>(".filter-bar-save-error")!;
const nameInput = form.elements.namedItem("name") as HTMLInputElement;
const slugInput = form.elements.namedItem("slug") as HTMLInputElement;
const showCount = form.elements.namedItem("show_count") as HTMLInputElement;
const cancelBtn = dialog.querySelector<HTMLButtonElement>('[data-action="cancel"]')!;
// Auto-derive slug from name as the user types — but only until
// they touch the slug field manually.
let slugDirty = false;
nameInput.addEventListener("input", () => {
if (!slugDirty) slugInput.value = derivedSlug(nameInput.value);
});
slugInput.addEventListener("input", () => { slugDirty = true; });
const cleanup = () => {
dialog.close();
dialog.remove();
};
cancelBtn.addEventListener("click", () => {
cleanup();
resolve(null);
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.hidden = true;
errorEl.textContent = "";
const name = nameInput.value.trim();
const slug = slugInput.value.trim();
if (!name) {
showError(errorEl, t("views.bar.save.error.name_required"));
return;
}
if (!SLUG_REGEX.test(slug)) {
showError(errorEl, t("views.bar.save.error.slug_format"));
return;
}
const payload = {
name,
slug,
filter_spec: filter,
render_spec: render,
show_count: showCount.checked,
};
try {
const r = await fetch("/api/user-views", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (r.status === 409) {
showError(errorEl, t("views.bar.save.error.slug_taken"));
return;
}
if (!r.ok) {
const body = await r.json().catch(() => ({} as { error?: string }));
showError(errorEl, body.error || `${r.status}: ${r.statusText}`);
return;
}
const view = (await r.json()) as UserView;
cleanup();
resolve({ view });
} catch (_e) {
showError(errorEl, t("views.bar.save.error.network"));
}
});
dialog.addEventListener("cancel", () => {
cleanup();
resolve(null);
});
dialog.showModal();
nameInput.focus();
});
}
function showError(el: HTMLElement, msg: string): void {
el.textContent = msg;
el.hidden = false;
}
function derivedSlug(name: string): string {
return name
.toLowerCase()
.replace(/[äÄ]/g, "ae")
.replace(/[öÖ]/g, "oe")
.replace(/[üÜ]/g, "ue")
.replace(/[ß]/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63);
}

View File

@@ -0,0 +1,132 @@
// FilterBar types — t-paliad-163. Mirrors the Go FilterSpec/RenderSpec
// shapes from internal/services/{filter_spec,render_spec}.go via
// client/views/types.ts. The FilterBar is the universal frontend
// primitive that consumes a base FilterSpec + RenderSpec, declares
// which axes the surface supports, and emits diffs back through
// onResult after running the spec via /api/views/run.
import type { FilterSpec, RenderSpec, RenderShape, ViewRunResult, ListRowAction } from "../views/types";
// AxisKey — every filter dimension the bar can render. Declared per
// surface in mountFilterBar's `axes` array. See design §3.1 for the
// universal-vs-per-surface split.
export type AxisKey =
| "time"
| "project"
| "personal_only"
| "deadline_status"
| "deadline_event_type"
| "appointment_type"
| "approval_viewer_role"
| "approval_status"
| "approval_entity_type"
| "project_event_kind"
| "shape"
| "sort"
| "density";
// Effective spec — the result of overlaying URL + localStorage prefs
// on top of the base spec. Handed back to onResult so the surface can
// dispatch into the matching shape renderer with the right config.
export interface EffectiveSpec {
filter: FilterSpec;
render: RenderSpec;
}
// Per-axis state — what the URL codec round-trips. Each axis's value
// type is bounded to the FilterSpec/RenderSpec subset it touches.
export interface BarState {
// Universal
time?: TimeOverlay;
project?: ProjectOverlay;
personal_only?: boolean;
// Per-source
deadline_status?: string[];
deadline_event_type?: { ids: string[]; include_untyped: boolean };
appointment_type?: string[];
approval_viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
approval_status?: string[];
approval_entity_type?: string[];
project_event_kind?: string[];
// Render
shape?: RenderShape;
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
}
export interface TimeOverlay {
horizon: "next_7d" | "next_30d" | "next_90d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
from?: string; // ISO 8601 — only when horizon === "custom"
to?: string;
}
export interface ProjectOverlay {
// The bar's project chip is single-select today; Phase C upgrades
// to multi-select. "personal" is a sentinel — the legacy /events
// contract reserved this name, we keep it so old bookmarks still
// resolve to the right state.
mode: "single" | "personal";
id?: string;
}
// MountOpts — the public API.
export interface MountOpts {
// Base spec. Usually a SystemView's FilterSpec+RenderSpec, fetched
// from /api/views/system on the surface and passed in here. For
// /views/{slug}, the saved user-view's spec.
baseFilter: FilterSpec;
baseRender: RenderSpec;
// Which axes the surface exposes. Order is preserved in the rendered
// chrome — surfaces use this to control left-to-right grouping.
axes: AxisKey[];
// URL parameter namespace. When set, every URL key is prefixed
// (`?<ns>_time=`, `?<ns>_project=`, …). Used when two bars share a
// page (dashboard inline lists). Defaults to no prefix.
urlNamespace?: string;
// Surface key for localStorage prefs (density, default shape).
// Required so two surfaces don't share preferences.
surfaceKey: string;
// Whether to render "Speichern als Sicht" + "Zur&uuml;cksetzen"
// trailing actions. Defaults to true. Set false on the dashboard
// inline bars (per design Q6).
showSaveAsView?: boolean;
// Slug of the surface's underlying system view (or saved user view).
// POSTed to /api/views/{slug}/run with the override body. Required —
// the bar runs through that endpoint, never the ad-hoc /api/views/run,
// so the substrate's reserved-slug path stays the canonical entry.
systemViewSlug: string;
// When true, the bar exposes an "Aktualisieren" affordance that
// PATCHes /api/user-views/{userViewId} with the effective spec.
// Set on /views/{slug} where the user is viewing a saved view.
userViewId?: string;
// Called every time the spec changes (mount, URL change, axis
// interaction). The surface dispatches to the matching shape
// renderer with the rows from /api/views/{slug}/run.
onResult(result: ViewRunResult, effective: EffectiveSpec): void;
// Optional — surface-specific row-action override. Phase 1: /inbox
// pins this to "approve"; /events Phase 3 pins to "complete_toggle".
// Future: sourced from the spec's render.list.row_action when set.
rowAction?: ListRowAction;
}
// Bar handle — what mountFilterBar returns. Pages can call .reset()
// from page-level controls (e.g. an empty-state "Filter zurücksetzen"
// button), or .destroy() if the page tears down.
export interface BarHandle {
reset(): void;
refresh(): Promise<void>;
destroy(): void;
// Read-only effective spec at this moment (post URL + localStorage
// overlay). Pages use this to construct deep-link URLs etc.
getEffective(): EffectiveSpec;
}

View File

@@ -0,0 +1,102 @@
// Unit tests for the FilterBar URL codec. Round-trip discipline:
// every BarState shape parseBar produces must encode back to the same
// URL params, and vice versa. Run with `bun test`.
import { test, expect, describe } from "bun:test";
import { parseBar, encodeBar } from "./url-codec";
import type { BarState } from "./types";
function roundTrip(state: BarState, ns?: string): BarState {
const params = new URLSearchParams();
encodeBar(state, params, ns);
return parseBar(params, ns);
}
describe("filter-bar/url-codec", () => {
test("empty state round-trips to empty", () => {
expect(roundTrip({})).toEqual({});
});
test("time horizon round-trips", () => {
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
}
});
test("custom time horizon round-trips with from + to", () => {
const state: BarState = { time: { horizon: "custom", from: "2026-01-01", to: "2026-12-31" } };
expect(roundTrip(state)).toEqual(state);
});
test("project sentinel + uuid round-trip", () => {
expect(roundTrip({ project: { mode: "personal" } })).toEqual({ project: { mode: "personal" } });
expect(roundTrip({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } }))
.toEqual({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } });
});
test("personal_only flag round-trips", () => {
expect(roundTrip({ personal_only: true })).toEqual({ personal_only: true });
expect(roundTrip({})).toEqual({});
});
test("deadline_event_type honours legacy 'none' sentinel", () => {
const state: BarState = { deadline_event_type: { ids: ["a", "b"], include_untyped: true } };
expect(roundTrip(state)).toEqual(state);
const state2: BarState = { deadline_event_type: { ids: [], include_untyped: true } };
expect(roundTrip(state2)).toEqual(state2);
const state3: BarState = { deadline_event_type: { ids: ["a"], include_untyped: false } };
expect(roundTrip(state3)).toEqual(state3);
});
test("approval_request triple round-trips together", () => {
const state: BarState = {
approval_viewer_role: "approver_eligible",
approval_status: ["pending", "approved"],
approval_entity_type: ["deadline"],
};
expect(roundTrip(state)).toEqual(state);
});
test("namespace prefix isolates two bars on the same page", () => {
const a: BarState = { time: { horizon: "next_7d" } };
const b: BarState = { time: { horizon: "next_30d" } };
const params = new URLSearchParams();
encodeBar(a, params, "agenda");
encodeBar(b, params, "activity");
expect(parseBar(params, "agenda")).toEqual(a);
expect(parseBar(params, "activity")).toEqual(b);
// Without namespace neither bar's keys are visible.
expect(parseBar(params)).toEqual({});
});
test("render axes round-trip", () => {
const state: BarState = { shape: "cards", sort: "date_desc", density: "compact" };
expect(roundTrip(state)).toEqual(state);
});
test("encode is idempotent — re-encoding same state replaces, doesn't accumulate", () => {
const state: BarState = { time: { horizon: "next_7d" }, deadline_status: ["pending"] };
const params = new URLSearchParams();
encodeBar(state, params);
encodeBar(state, params);
expect(params.get("d_status")).toBe("pending");
// Only one entry per key.
expect(params.getAll("d_status")).toHaveLength(1);
});
test("encode replaces stale keys when state shrinks", () => {
const params = new URLSearchParams();
encodeBar({ deadline_status: ["pending"], approval_viewer_role: "self_requested" }, params);
encodeBar({ deadline_status: ["completed"] }, params);
expect(params.get("d_status")).toBe("completed");
expect(params.has("a_role")).toBe(false);
});
test("parse drops unknown enum values silently (forward-compat)", () => {
const params = new URLSearchParams();
params.set("a_role", "future_role_we_dont_know_yet");
params.set("shape", "kanban");
params.set("density", "huge");
expect(parseBar(params)).toEqual({});
});
});

View File

@@ -0,0 +1,188 @@
// FilterBar URL codec — t-paliad-163. Encodes BarState ↔ URL
// parameters with optional namespace prefix (?<ns>_<key>=).
//
// The bar treats the URL as canonical for everything that affects
// which rows you see. Round-trip discipline: anything written by
// encodeBar must parse back identically via parseBar so deep-links
// and refresh both yield the same effective spec.
//
// Empty / default values are NOT written — the URL stays clean for
// users who don't tweak. The page's base spec is the implicit baseline.
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
const PERSONAL_PROJECT_SENTINEL = "personal";
// parseBar reads URL params into a BarState. Unknown values are
// dropped silently (forward-compat with future axes).
export function parseBar(params: URLSearchParams, ns?: string): BarState {
const k = (key: string) => (ns ? `${ns}_${key}` : key);
const out: BarState = {};
// time
const time = params.get(k("time"));
if (time) {
const horizon = parseHorizon(time);
if (horizon) {
const overlay: TimeOverlay = { horizon };
if (horizon === "custom") {
const from = params.get(k("from"));
const to = params.get(k("to"));
if (from) overlay.from = from;
if (to) overlay.to = to;
}
out.time = overlay;
}
}
// project
const project = params.get(k("project"));
if (project) {
if (project === PERSONAL_PROJECT_SENTINEL) {
out.project = { mode: "personal" };
} else {
out.project = { mode: "single", id: project };
}
}
// personal_only
if (params.get(k("personal")) === "1") {
out.personal_only = true;
}
// deadline.status
const dStatus = params.get(k("d_status"));
if (dStatus) out.deadline_status = parseCSV(dStatus);
// deadline.event_types — preserves the legacy /events contract
// where "none" inside the CSV means include_untyped=true.
const dEvent = params.get(k("d_event_type"));
if (dEvent) {
const tokens = parseCSV(dEvent);
const ids: string[] = [];
let untyped = false;
for (const tok of tokens) {
if (tok === "none") untyped = true;
else ids.push(tok);
}
out.deadline_event_type = { ids, include_untyped: untyped };
}
// appointment.types
const appType = params.get(k("app_type"));
if (appType) out.appointment_type = parseCSV(appType);
// approval_request.viewer_role
const aRole = params.get(k("a_role"));
if (aRole === "approver_eligible" || aRole === "self_requested" || aRole === "any_visible") {
out.approval_viewer_role = aRole;
}
// approval_request.status
const aStatus = params.get(k("a_status"));
if (aStatus) out.approval_status = parseCSV(aStatus);
// approval_request.entity_types
const aEntity = params.get(k("a_entity_type"));
if (aEntity) out.approval_entity_type = parseCSV(aEntity);
// project_event.event_types
const peKind = params.get(k("pe_kind"));
if (peKind) out.project_event_kind = parseCSV(peKind);
// render.shape
const shape = params.get(k("shape"));
if (shape === "list" || shape === "cards" || shape === "calendar") out.shape = shape;
// render.list.sort / render.cards.sort — the bar treats sort as one axis
const sort = params.get(k("sort"));
if (sort === "date_asc" || sort === "date_desc") out.sort = sort;
// render.list.density
const density = params.get(k("density"));
if (density === "comfortable" || density === "compact") out.density = density;
return out;
}
// encodeBar writes BarState back into URL params, mutating the
// passed-in URLSearchParams. Empty / undefined values are omitted.
// The caller controls how the result is applied (history.replaceState
// with the page pathname unchanged).
export function encodeBar(state: BarState, params: URLSearchParams, ns?: string): void {
const k = (key: string) => (ns ? `${ns}_${key}` : key);
// Clear every key the bar owns first, then re-write the non-empty ones.
for (const key of [
"time", "from", "to", "project", "personal",
"d_status", "d_event_type",
"app_type",
"a_role", "a_status", "a_entity_type",
"pe_kind",
"shape", "sort", "density",
]) {
params.delete(k(key));
}
if (state.time) {
params.set(k("time"), state.time.horizon);
if (state.time.horizon === "custom") {
if (state.time.from) params.set(k("from"), state.time.from);
if (state.time.to) params.set(k("to"), state.time.to);
}
}
if (state.project) {
if (state.project.mode === "personal") {
params.set(k("project"), PERSONAL_PROJECT_SENTINEL);
} else if (state.project.id) {
params.set(k("project"), state.project.id);
}
}
if (state.personal_only) params.set(k("personal"), "1");
if (state.deadline_status?.length) params.set(k("d_status"), state.deadline_status.join(","));
if (state.deadline_event_type) {
const parts = [...state.deadline_event_type.ids];
if (state.deadline_event_type.include_untyped) parts.push("none");
if (parts.length) params.set(k("d_event_type"), parts.join(","));
}
if (state.appointment_type?.length) params.set(k("app_type"), state.appointment_type.join(","));
if (state.approval_viewer_role) params.set(k("a_role"), state.approval_viewer_role);
if (state.approval_status?.length) params.set(k("a_status"), state.approval_status.join(","));
if (state.approval_entity_type?.length) params.set(k("a_entity_type"), state.approval_entity_type.join(","));
if (state.project_event_kind?.length) params.set(k("pe_kind"), state.project_event_kind.join(","));
if (state.shape) params.set(k("shape"), state.shape);
if (state.sort) params.set(k("sort"), state.sort);
if (state.density) params.set(k("density"), state.density);
}
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
switch (s) {
case "next_7d":
case "next_30d":
case "next_90d":
case "past_30d":
case "past_90d":
case "any":
case "all":
case "custom":
return s;
default:
return null;
}
}
function parseCSV(s: string): string[] {
return s.split(",").map((x) => x.trim()).filter(Boolean);
}
export { PERSONAL_PROJECT_SENTINEL };
// Re-exported so consumers don't need to import ProjectOverlay just
// to construct one in tests.
export type { ProjectOverlay };

View File

@@ -2167,6 +2167,63 @@ const translations: Record<Lang, Record<string, string>> = {
"views.editor.error.sources_required": "Mindestens eine Quelle wählen.",
"views.editor.error.load_failed": "Ansicht konnte nicht geladen werden.",
"views.editor.error.delete_failed": "Ansicht konnte nicht gelöscht werden.",
// Universal FilterBar — t-paliad-163. Mounted on every list-shaped
// surface (starts with /inbox in Phase 1; /agenda + /events follow).
"views.bar.label.time": "Zeitraum",
"views.bar.label.personal": "Eigene",
"views.bar.label.approval_role": "Sicht",
"views.bar.label.approval_status": "Status",
"views.bar.label.approval_entity": "Art",
"views.bar.label.deadline_status": "Frist-Status",
"views.bar.label.appointment_type": "Termin-Typ",
"views.bar.label.shape": "Darstellung",
"views.bar.label.density": "Dichte",
"views.bar.label.sort": "Sortierung",
"views.bar.common.all": "Alle",
"views.bar.time.next_7d": "7 Tage",
"views.bar.time.next_30d": "30 Tage",
"views.bar.time.next_90d": "90 Tage",
"views.bar.time.past_30d": "Letzte 30 T.",
"views.bar.time.any": "Beliebig",
"views.bar.time.custom": "Anpassen",
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
"views.bar.personal.on": "Nur eigene",
"views.bar.approval_role.approver_eligible": "Zur Genehmigung",
"views.bar.approval_role.self_requested": "Eigene Anfragen",
"views.bar.approval_role.any_visible": "Alle sichtbaren",
"views.bar.approval_status.pending": "Wartend",
"views.bar.approval_status.approved": "Genehmigt",
"views.bar.approval_status.rejected": "Abgelehnt",
"views.bar.approval_status.revoked": "Zurückgezogen",
"views.bar.approval_entity.deadline": "Frist",
"views.bar.approval_entity.appointment": "Termin",
"views.bar.deadline_status.pending": "Offen",
"views.bar.deadline_status.completed": "Erledigt",
"views.bar.appointment_type.hearing": "Verhandlung",
"views.bar.appointment_type.meeting": "Besprechung",
"views.bar.appointment_type.consultation": "Beratung",
"views.bar.appointment_type.deadline_hearing": "Mündliche Verhandlung",
"views.bar.shape.list": "Liste",
"views.bar.shape.cards": "Karten",
"views.bar.shape.calendar": "Kalender",
"views.bar.density.comfortable": "Bequem",
"views.bar.density.compact": "Kompakt",
"views.bar.sort.date_asc": "Datum aufsteigend",
"views.bar.sort.date_desc": "Datum absteigend",
"views.bar.action.reset": "Zurücksetzen",
"views.bar.action.save_as_view": "Als Sicht speichern",
"views.bar.save.heading": "Sicht speichern",
"views.bar.save.field.name": "Name",
"views.bar.save.field.slug": "Slug",
"views.bar.save.field.slug_hint": "Wird Teil der URL: /views/<slug>",
"views.bar.save.field.show_count": "Anzahl in der Sidebar zeigen",
"views.bar.save.cancel": "Abbrechen",
"views.bar.save.confirm": "Speichern",
"views.bar.save.error.name_required": "Bitte Namen vergeben.",
"views.bar.save.error.slug_format": "Slug muss mit einem Buchstaben oder einer Ziffer beginnen und darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.",
"views.bar.save.error.slug_taken": "Dieser Slug ist bereits vergeben.",
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
},
en: {
@@ -4302,6 +4359,62 @@ const translations: Record<Lang, Record<string, string>> = {
"views.editor.error.sources_required": "Pick at least one source.",
"views.editor.error.load_failed": "Could not load this view.",
"views.editor.error.delete_failed": "Could not delete this view.",
// Universal FilterBar — t-paliad-163.
"views.bar.label.time": "Time",
"views.bar.label.personal": "Mine",
"views.bar.label.approval_role": "View",
"views.bar.label.approval_status": "Status",
"views.bar.label.approval_entity": "Kind",
"views.bar.label.deadline_status": "Deadline status",
"views.bar.label.appointment_type": "Appointment type",
"views.bar.label.shape": "Display",
"views.bar.label.density": "Density",
"views.bar.label.sort": "Sort",
"views.bar.common.all": "All",
"views.bar.time.next_7d": "7 days",
"views.bar.time.next_30d": "30 days",
"views.bar.time.next_90d": "90 days",
"views.bar.time.past_30d": "Past 30 d.",
"views.bar.time.any": "Any",
"views.bar.time.custom": "Custom",
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
"views.bar.personal.on": "Mine only",
"views.bar.approval_role.approver_eligible": "To approve",
"views.bar.approval_role.self_requested": "My requests",
"views.bar.approval_role.any_visible": "All visible",
"views.bar.approval_status.pending": "Pending",
"views.bar.approval_status.approved": "Approved",
"views.bar.approval_status.rejected": "Rejected",
"views.bar.approval_status.revoked": "Revoked",
"views.bar.approval_entity.deadline": "Deadline",
"views.bar.approval_entity.appointment": "Appointment",
"views.bar.deadline_status.pending": "Open",
"views.bar.deadline_status.completed": "Completed",
"views.bar.appointment_type.hearing": "Hearing",
"views.bar.appointment_type.meeting": "Meeting",
"views.bar.appointment_type.consultation": "Consultation",
"views.bar.appointment_type.deadline_hearing": "Oral hearing",
"views.bar.shape.list": "List",
"views.bar.shape.cards": "Cards",
"views.bar.shape.calendar": "Calendar",
"views.bar.density.comfortable": "Comfortable",
"views.bar.density.compact": "Compact",
"views.bar.sort.date_asc": "Date ascending",
"views.bar.sort.date_desc": "Date descending",
"views.bar.action.reset": "Reset",
"views.bar.action.save_as_view": "Save as view",
"views.bar.save.heading": "Save view",
"views.bar.save.field.name": "Name",
"views.bar.save.field.slug": "Slug",
"views.bar.save.field.slug_hint": "Becomes part of the URL: /views/<slug>",
"views.bar.save.field.show_count": "Show count in sidebar",
"views.bar.save.cancel": "Cancel",
"views.bar.save.confirm": "Save",
"views.bar.save.error.name_required": "Please supply a name.",
"views.bar.save.error.slug_format": "Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.",
"views.bar.save.error.slug_taken": "This slug is already in use.",
"views.bar.save.error.network": "Network error — please retry.",
},
};

View File

@@ -1,122 +1,176 @@
import { initI18n, t, getLang, type I18nKey } from "./i18n";
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. Two tabs (pending-mine / mine), action buttons (approve /
// reject / revoke), and a small inline diff for update / complete / delete
// lifecycle events.
// /inbox client — t-paliad-163 universal-filter migration.
//
// State is URL-driven via ?tab= so back/forward buttons work and the bell
// badge can deep-link to either tab. The badge in the sidebar (id
// sidebar-inbox-badge) is updated by the shared global polling loop in
// sidebar.ts; this module just keeps the page content in sync.
// 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.
type Lifecycle = "create" | "update" | "complete" | "delete";
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
type DecisionKind = "peer" | "admin_override";
const INBOX_AXES: AxisKey[] = [
"time",
"approval_viewer_role",
"approval_status",
"approval_entity_type",
"density",
"sort",
];
interface ApprovalRequestView {
id: string;
project_id: string;
project_title: string;
entity_type: "deadline" | "appointment";
entity_id: string;
entity_title?: string;
lifecycle_event: Lifecycle;
pre_image?: Record<string, unknown> | null;
payload?: Record<string, unknown> | null;
required_role: string;
status: RequestStatus;
requested_at: string;
requested_by: string;
requester_name: string;
decided_at?: string;
decided_by?: string;
decider_name?: string;
decision_kind?: DecisionKind;
decision_note?: string;
// t-paliad-161: 'user' (direct create) or 'agent' (Paliadin-drafted).
// 'agent' rows render with a sparkle ✨ next to the requester's name.
requester_kind?: "user" | "agent";
agent_turn_id?: string;
}
type Tab = "pending-mine" | "mine";
let currentTab: Tab = "pending-mine";
initI18n();
initSidebar();
let bar: BarHandle | null = null;
document.addEventListener("DOMContentLoaded", () => {
const url = new URL(window.location.href);
const t = url.searchParams.get("tab");
if (t === "mine") currentTab = "mine";
bindTabs();
refresh();
initI18n();
initSidebar();
applyLegacyTabRedirect();
void hydrate();
});
function bindTabs() {
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab as Tab) || "pending-mine";
if (tab === currentTab) return;
currentTab = tab;
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
history.replaceState({}, "", url.toString());
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
refresh();
});
// ?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 refresh() {
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
if (!loading || !empty || !list) return;
loading.style.display = "";
empty.style.display = "none";
list.innerHTML = "";
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
let rows: ApprovalRequestView[] = [];
async function fetchInboxSystemView(): Promise<SystemView | null> {
try {
const r = await fetch(path, { credentials: "include" });
if (r.ok) {
// Defensive: a Go `nil` slice serialises as JSON `null`, not `[]`.
// Coerce so `rows.length` never throws (t-paliad-160 §D regression
// hardening). Server-side handler also forces `[]`, but keep the
// client guard for older / cached deploys.
const body = (await r.json()) as ApprovalRequestView[] | null;
rows = body ?? [];
}
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) {
// Network errors fall through to empty render.
return null;
}
}
function paint(
result: ViewRunResult,
render: RenderSpec,
results: HTMLElement,
empty: HTMLElement,
loading: HTMLElement,
): void {
loading.style.display = "none";
if (rows.length === 0) {
empty.textContent = t(
currentTab === "pending-mine"
? "approvals.empty.pending_mine"
: "approvals.empty.mine"
);
if (!result.rows || result.rows.length === 0) {
results.innerHTML = "";
empty.style.display = "";
empty.textContent = t("approvals.empty.pending_mine");
void maybeShowAdminNudge();
return;
}
hideAdminNudge();
for (const row of rows) list.appendChild(renderRow(row));
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:
// - the current user is global_admin
// - the inbox is empty
// - no approval_policies row exists firm-wide (matrix is dormant)
//
// All three checks are AND-ed. Anonymous users + non-admins + active-policy
// admins all skip the nudge.
// - 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;
@@ -132,9 +186,7 @@ async function maybeShowAdminNudge(): Promise<void> {
if (data.any) return;
nudge.style.display = "";
} catch (_e) {
// Network failure → keep nudge hidden.
}
} catch (_e) { /* keep hidden */ }
}
function hideAdminNudge(): void {
@@ -142,175 +194,7 @@ function hideAdminNudge(): void {
if (nudge) nudge.style.display = "none";
}
function renderRow(row: ApprovalRequestView): HTMLLIElement {
const li = document.createElement("li");
li.className = "inbox-row";
// Header: project / entity / lifecycle / required-role
const head = document.createElement("div");
head.className = "inbox-row-head";
const title = document.createElement("div");
title.className = "inbox-row-title";
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
const entityTitle = row.entity_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 = t(("approvals.required_role." + row.required_role) as I18nKey);
// t-paliad-161 ✨: when the request was drafted by Paliadin, surface
// that next to the requester's name. Reads as "von Anna ✨ Paliadin".
const requesterTag = row.requester_kind === "agent"
? `${row.requester_name}${t("approvals.agent.byline")}`
: row.requester_name;
meta.textContent = `${row.project_title} · ${reqByLabel} ${requesterTag} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
head.appendChild(meta);
li.appendChild(head);
// Diff for update / complete (date-bearing fields)
const diff = renderDiff(row);
if (diff) li.appendChild(diff);
// Decision note if any
if (row.decision_note) {
const note = document.createElement("div");
note.className = "inbox-row-note";
note.textContent = row.decision_note;
li.appendChild(note);
}
// Action row
const actions = document.createElement("div");
actions.className = "inbox-row-actions";
if (row.status === "pending" && currentTab === "pending-mine") {
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
} else if (row.status === "pending" && currentTab === "mine") {
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
} else {
// historic — show status pill
const pill = document.createElement("span");
pill.className = "approval-pill approval-pill--historic";
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
if (row.decider_name && row.status !== "revoked") {
const decided = document.createElement("span");
decided.className = "inbox-row-decided";
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
pill.appendChild(decided);
}
actions.appendChild(pill);
}
li.appendChild(actions);
return li;
}
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
const before = (row.pre_image || {}) as Record<string, unknown>;
const after = (row.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 actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
btn.textContent = t(("approvals.action." + action) as I18nKey);
btn.addEventListener("click", onClick);
return btn;
}
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
let note = "";
if (action === "reject") {
note = window.prompt(t("approvals.note.placeholder")) || "";
}
let r: Response;
try {
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note }),
});
} catch (_e) {
alert("Network error");
return;
}
if (!r.ok) {
const body = await r.json().catch(() => ({}));
const errKey = (body && body.error) || "internal";
const msg = mapApprovalError(errKey);
alert(msg);
return;
}
refresh();
// Update sidebar bell count.
refreshInboxBadge();
}
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;
}
}
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`;
}
// Update the sidebar inbox badge (shared with sidebar.ts polling).
async function refreshInboxBadge() {
async function refreshInboxBadge(): Promise<void> {
const badge = document.getElementById("sidebar-inbox-badge");
if (!badge) return;
try {
@@ -323,7 +207,5 @@ async function refreshInboxBadge() {
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
} catch (_e) { /* noop */ }
}

View File

@@ -1,17 +1,25 @@
import { t, type I18nKey } from "../i18n";
import type { RenderSpec, ViewRow } from "./types";
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);
@@ -19,6 +27,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
return sort === "date_asc" ? aT - bT : bT - aT;
});
if (rowAction === "approve") {
host.appendChild(renderApprovalList(sorted));
return;
}
if (density === "compact") {
host.appendChild(renderCompact(sorted));
} else {
@@ -162,3 +175,166 @@ function sortKey(iso: string): number {
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;
}
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") {
// The bar's approval_viewer_role distinguishes which actions are
// appropriate. The surface inspects the active role and decides
// which buttons to keep — but for default rendering we stamp all
// three with role-class hints and let the surface filter.
actions.appendChild(actionBtn("approve"));
actions.appendChild(actionBtn("reject"));
actions.appendChild(actionBtn("revoke"));
} 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);
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 actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.action = action;
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);
return btn;
}
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;

View File

@@ -71,10 +71,13 @@ export interface FilterSpec {
export type RenderShape = "list" | "cards" | "calendar";
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
export interface ListConfig {
columns?: string[];
sort?: "date_asc" | "date_desc";
density?: "comfortable" | "compact";
row_action?: ListRowAction;
}
export interface CardsConfig {

View File

@@ -1942,6 +1942,60 @@ export type I18nKey =
| "unit_role.paralegal"
| "unit_role.senior_pa"
| "views.action.edit"
| "views.bar.action.reset"
| "views.bar.action.save_as_view"
| "views.bar.appointment_type.consultation"
| "views.bar.appointment_type.deadline_hearing"
| "views.bar.appointment_type.hearing"
| "views.bar.appointment_type.meeting"
| "views.bar.approval_entity.appointment"
| "views.bar.approval_entity.deadline"
| "views.bar.approval_role.any_visible"
| "views.bar.approval_role.approver_eligible"
| "views.bar.approval_role.self_requested"
| "views.bar.approval_status.approved"
| "views.bar.approval_status.pending"
| "views.bar.approval_status.rejected"
| "views.bar.approval_status.revoked"
| "views.bar.common.all"
| "views.bar.deadline_status.completed"
| "views.bar.deadline_status.pending"
| "views.bar.density.comfortable"
| "views.bar.density.compact"
| "views.bar.label.appointment_type"
| "views.bar.label.approval_entity"
| "views.bar.label.approval_role"
| "views.bar.label.approval_status"
| "views.bar.label.deadline_status"
| "views.bar.label.density"
| "views.bar.label.personal"
| "views.bar.label.shape"
| "views.bar.label.sort"
| "views.bar.label.time"
| "views.bar.personal.on"
| "views.bar.save.cancel"
| "views.bar.save.confirm"
| "views.bar.save.error.name_required"
| "views.bar.save.error.network"
| "views.bar.save.error.slug_format"
| "views.bar.save.error.slug_taken"
| "views.bar.save.field.name"
| "views.bar.save.field.show_count"
| "views.bar.save.field.slug"
| "views.bar.save.field.slug_hint"
| "views.bar.save.heading"
| "views.bar.shape.calendar"
| "views.bar.shape.cards"
| "views.bar.shape.list"
| "views.bar.sort.date_asc"
| "views.bar.sort.date_desc"
| "views.bar.time.any"
| "views.bar.time.custom"
| "views.bar.time.custom.coming_soon"
| "views.bar.time.next_30d"
| "views.bar.time.next_7d"
| "views.bar.time.next_90d"
| "views.bar.time.past_30d"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"

View File

@@ -5,13 +5,20 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Approval inbox page (t-paliad-138). Two-tab UI:
// - "Zur Genehmigung": requests where the caller is qualified to approve
// - "Meine Anfragen": requests submitted by the caller
// /inbox — t-paliad-163 universal-filter migration.
//
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
// hydration and re-renders.
// The page is a thin shell around two host divs: one for the
// <FilterBar> primitive and one for the result list. The bar takes
// care of every axis (approval_viewer_role chip cluster replaces the
// two-tab UI; status / entity_type / time chips are new affordances).
// Rows render via shape-list.ts with row_action="approve" — the
// inbox-specific markup that produces the diff + approve/reject/revoke
// buttons. Action handlers are wired in client/inbox.ts.
//
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
// to ?a_role=self_requested before the bar mounts so old bookmarks
// (sidebar bell, Genehmigungen email links) keep landing on the
// expected sub-view.
export function renderInbox(): string {
return "<!DOCTYPE html>" + (
@@ -38,18 +45,11 @@ export function renderInbox(): string {
</p>
</div>
<div className="agenda-controls">
<div className="agenda-filter-group" role="group">
<div className="agenda-chip-row" id="inbox-tab-row">
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
</div>
</div>
</div>
<div id="inbox-filter-bar" />
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">L&auml;dt &hellip;</div>
<div className="entity-empty" id="inbox-empty" style="display:none" />
<ul className="inbox-list" id="inbox-list" />
<div id="inbox-results" />
{/* t-paliad-154 — admin-only nudge surfaced when:
- the user is global_admin

View File

@@ -13251,3 +13251,166 @@ dialog.quick-add-sheet::backdrop {
width: 16px;
height: 16px;
}
/* ----------------------------------------------------------------------
Universal FilterBar — t-paliad-163.
Mounts on every list-shaped surface (starting with /inbox in Phase 1).
Reuses .agenda-chip + .filter-group + .entity-select for legacy
parity; wraps them with .filter-bar* scoping so the bar can be
styled independently if a surface needs to override.
---------------------------------------------------------------------- */
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.85rem 1.1rem;
padding: 0.75rem 1rem;
margin: 0 0 1rem 0;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.75rem;
}
.filter-bar-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
align-items: stretch;
}
.filter-bar-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #6b7280);
}
.filter-bar-chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.filter-bar-chip-row.filter-bar-segment {
flex-wrap: nowrap;
gap: 0;
padding: 0.15rem;
background: var(--color-surface-muted, #f5f5f5);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 999px;
}
.filter-bar-segment .filter-bar-chip {
background: transparent;
border: 1px solid transparent;
}
.filter-bar-segment .filter-bar-chip.agenda-chip-active {
background: var(--color-surface, #ffffff);
border-color: var(--color-border, #e5e7eb);
}
.filter-bar-chip-pending {
opacity: 0.55;
cursor: not-allowed;
}
.filter-bar-select {
min-width: 8rem;
}
.filter-bar-trailing {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.4rem;
}
@media (max-width: 768px) {
.filter-bar {
gap: 0.6rem 0.7rem;
padding: 0.6rem;
margin: 0 0 0.75rem 0;
}
.filter-bar-trailing {
margin-left: 0;
width: 100%;
justify-content: flex-end;
}
.filter-bar-chip-row {
flex-wrap: wrap;
}
}
/* Save-as-view modal — anchored as a native <dialog>. */
.filter-bar-save-modal::backdrop {
background: rgba(15, 23, 42, 0.4);
}
.filter-bar-save-modal {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.85rem;
padding: 0;
max-width: 28rem;
width: calc(100% - 2rem);
background: var(--color-surface, #ffffff);
color: var(--color-text, #111827);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
}
.filter-bar-save-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1.1rem 1.25rem 1.25rem;
margin: 0;
}
.filter-bar-save-form h2 {
margin: 0 0 0.35rem 0;
font-size: 1.15rem;
}
.filter-bar-save-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.filter-bar-save-field span {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
}
.filter-bar-save-field input {
padding: 0.45rem 0.6rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 0.4rem;
background: var(--color-surface, #ffffff);
color: var(--color-text, #111827);
font: inherit;
}
.filter-bar-save-field small {
font-size: 0.7rem;
color: var(--color-text-muted, #6b7280);
}
.filter-bar-save-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.filter-bar-save-error {
margin: 0;
color: var(--status-red-fg, #c54);
font-size: 0.85rem;
}
.filter-bar-save-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}