Compare commits
3 Commits
mai/rieman
...
mai/minkow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a41acee07 | ||
|
|
5d9c62d858 | ||
|
|
188d8ec9ba |
@@ -1,469 +0,0 @@
|
||||
# Universal filter + view-mode primitive across all entity-views
|
||||
|
||||
**Issue:** m/paliad#23 (t-paliad-163)
|
||||
**Inventor:** riemann (mai/riemann/inventor-universal)
|
||||
**Date:** 2026-05-08
|
||||
**Status:** READY FOR REVIEW — no code yet, design only.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the central position
|
||||
|
||||
m's framing is exactly right: "halfway there without custom views". The Custom Views substrate (t-paliad-144) is the missing primitive — it just hasn't been lifted from "a saved-view feature on /views/{slug}" up to "the bar that every list-shaped page reads from".
|
||||
|
||||
Concrete take:
|
||||
|
||||
- **Don't invent a new schema or a new query layer.** `internal/services/filter_spec.go` + `render_spec.go` + `view_service.go` already cover every axis the issue lists, and `POST /api/views/run` and `POST /api/views/{slug}/run` already accept ad-hoc spec overrides. The substrate's own comment says it: *"Phase B will route them here; Phase A1 leaves the wiring as a no-op for those pages."* (`internal/handlers/views.go:247`). t-paliad-163 is Phase B with a UX-shaped artifact at the front.
|
||||
- **Build one frontend `<FilterBar>` component** that consumes a `FilterSpec` + `RenderSpec` + a per-surface `axes[]` declaration, owns URL/local-state, and emits diffs. Drop it on every list-shaped surface. Each system page declares a base spec (= one of the existing `SystemView` definitions) and the supported axes.
|
||||
- **"Save current filter as named view" is one button** on the bar. It POSTs the effective spec to `/api/user-views`. The custom-view editor (`/views/new`, `/views/{slug}/edit`) becomes a power-user form for the same data the bar produces; the bar is the everyday entry point.
|
||||
- **/projects stays bespoke** (locked in t-paliad-149). Source⊥Shape orthogonality breaks for projects — they don't render as cards/calendar in the events sense, and `paliad.user_card_layouts` is a different primitive (per-card facts, not filters). The bar coexists with the `<details>`-chip cluster on /projects without subsuming it.
|
||||
|
||||
The migration is one surface at a time. /inbox first (no filter today, lowest blast radius), /events last (richest filter today, the proof point that the primitive can absorb it).
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live
|
||||
|
||||
Before designing on top of CLAUDE.md / memory / the issue body, I checked the live tree:
|
||||
|
||||
- **`paliad.user_views` (056) exists.** `paliad.user_card_layouts` (061) exists. **`paliad.user_view_layouts` does NOT exist** — the issue body's reference is a typo. Real names: `paliad.user_views` is the FilterSpec/RenderSpec store; `paliad.user_card_layouts` is the per-card-facts store for /projects only. `grep -rn user_view_layouts` returns nothing.
|
||||
- **`POST /api/views/run`** takes an inline `FilterSpec` and returns `ViewRunResult{rows, inaccessible_project_ids}` without touching the DB. (`internal/handlers/views.go:248`)
|
||||
- **`POST /api/views/{slug}/run`** accepts an optional `{filter: <override>}` body that overrides the saved/system spec for one run — does not mutate storage. (`internal/handlers/views.go:282`, `runRequest` at `:238`)
|
||||
- **5 SystemViews are already code-resident** (`dashboard`, `agenda`, `events`, `inbox`, `inbox-mine`) at `internal/services/system_views.go:35`-`156`. Their slugs are reserved against user-view collisions. Each carries a canonical `FilterSpec` + `RenderSpec`.
|
||||
- **3 render-shape components exist** in `frontend/src/client/views/`: `shape-list.ts`, `shape-cards.ts`, `shape-calendar.ts`. They take `(host, rows, render)` — pure config-driven dispatch.
|
||||
- **List shape supports density (compact|comfortable), 13 known columns, and sort.** Column registry at `internal/services/render_spec.go:99`: `["date","time","title","project","actor","status","rule","event_type","location","appointment_type","approval_status","decided_by","kind"]`. Sort: `date_asc | date_desc`.
|
||||
- **`attachEventTypeMultiSelectFilter`** in `frontend/src/client/event-types.ts` is a mature listbox-panel component (search + grouped checkboxes + URL round-trip + internal `onLangChange` subscription per t-paliad-117). The pattern to copy for project + appointment-type + status panels.
|
||||
- **`renderAgendaTimeline`** in `frontend/src/client/agenda-render.ts` is the day-grouped timeline used both by `/agenda` and dashboard inline; reusable.
|
||||
- **`.entity-table` row-click contract** is the project-wide rule (CLAUDE.md "Frontend conventions"). Any list-shape table must wire row-handlers that skip clicks on inner `<a>`/`<button>` and add `entity-table--readonly` when rows don't navigate. The bar must not regress this — it doesn't, because `shape-list.ts` already emits `entity-table--readonly` on its tables.
|
||||
|
||||
---
|
||||
|
||||
## 1. The 7 list-shaped surfaces today — what they each have
|
||||
|
||||
A factual map of who has what. The underlinings are the axes the issue calls out.
|
||||
|
||||
| Surface | Filter axes today | View modes | State store |
|
||||
|---|---|---|---|
|
||||
| **/agenda** (`client/agenda.ts`, 226 LoC) | type chip (deadlines/appointments/both), range chip (7/14/30/90d), event-type multi-select | timeline only | URL `?range=&types=&event_type=` |
|
||||
| **/events** (`client/events.ts`, 1083 LoC) — also `/deadlines`, `/appointments` via 302 redirect | type chip (deadline/appointment/all), status select (8 buckets), project select (single, with `__personal__` sentinel), event-type multi (deadline-only), appointment-type select (appointment-only) | cards / list / calendar | URL `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` |
|
||||
| **/inbox** (`client/inbox.ts`, 329 LoC) — both tabs | tab (pending-mine / mine), nothing else | list only | URL `?tab=` |
|
||||
| **/projects** (`client/projects.ts` + `client/projects-cards.ts`) | search input, 6 chips (scope/status/type/has-open-deadlines), `<details>` multi-select for status + type | tree / cards / flat | sessionStorage `paliad.projects.lastView` + URL overlay |
|
||||
| **/views/{slug}** (`client/views.ts`) | none in the viewer (only saved-spec); shape switcher (list/cards/calendar) | list / cards / calendar | URL path |
|
||||
| **dashboard** (`client/dashboard.ts`, inline Agenda + Letzte Aktivität) | none | inline timeline / inline list | none |
|
||||
| **/views/new \| /views/{slug}/edit** (`client/views-editor.ts`) | full FilterSpec form (sources / scope / time / shape / list density) | n/a — author surface | n/a |
|
||||
|
||||
The pattern m sees on `/inbox?tab=mine` is the natural endpoint of seven surfaces all building filters their own way: the surface that didn't have a filter author yet is also the surface with no filter chrome at all.
|
||||
|
||||
The good news: every axis on every surface is **already nameable in the FilterSpec / RenderSpec grammar** that `internal/services/filter_spec.go` ships. There's a one-to-one mapping; nothing has to be invented at the data layer.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the universal primitive is — `<FilterBar>`
|
||||
|
||||
A single TypeScript component, mounted on a host `<div>`, parameterised by:
|
||||
|
||||
```ts
|
||||
interface FilterBarOpts {
|
||||
// Base spec — usually a SystemView's FilterSpec, fetched from /api/views/system.
|
||||
// For /views/{slug}, this is the user-view's saved filter_spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface supports. Universal axes always render;
|
||||
// per-surface axes render iff present in this list.
|
||||
axes: AxisKey[];
|
||||
|
||||
// Optional fixed predicates the surface refuses to let users tweak.
|
||||
// E.g. /inbox forces sources=[approval_request], not relaxable.
|
||||
pinned?: PartialFilterSpec;
|
||||
|
||||
// Where to write rows when filter changes. The bar runs the spec via
|
||||
// /api/views/run and hands the result back here for shape rendering.
|
||||
onResult: (res: ViewRunResult, effective: { filter: FilterSpec; render: RenderSpec }) => void;
|
||||
|
||||
// Optional URL-param namespace (defaults to the empty namespace).
|
||||
// Useful for embedding the bar twice on one page (dashboard inline)
|
||||
// without colliding ?time= / ?time2=. Phase 4 ramps this up if needed.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Optional surface key — used as the localStorage key for view-mode
|
||||
// and density preferences ("paliad.bar.<surfaceKey>.prefs").
|
||||
surfaceKey: string;
|
||||
|
||||
// Optional sidebar slot — when present, "Save as view" + "Reset" are
|
||||
// rendered. Defaults to true on every surface except dashboard inline.
|
||||
showSaveAsView?: boolean;
|
||||
}
|
||||
|
||||
type AxisKey =
|
||||
| "project" // ← universal (always rendered if axes contains it; otherwise the chip is hidden)
|
||||
| "time" // ← universal
|
||||
| "personal_only" // ← universal
|
||||
| "deadline_status" // ← per-surface (deadline source only)
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "shape" // ← view-mode (list|cards|calendar)
|
||||
| "sort" // ← per-shape
|
||||
| "density" // ← list-shape only
|
||||
| "columns"; // ← list-shape only (advanced; popover with checkboxes)
|
||||
```
|
||||
|
||||
The bar's job:
|
||||
1. On mount, parse URL params (within `urlNamespace`) and `localStorage["paliad.bar.<surfaceKey>.prefs"]`, overlay them on `baseFilter` + `baseRender`, validate, and POST `/api/views/run` with the effective spec.
|
||||
2. Render chrome — chips for booleans / single-selects, popovers for multi-selects, segmented control for view-mode. Each control is a thin wrapper over an existing pattern (chip-row, multi-anchor + multi-panel, segment-control).
|
||||
3. On any change, re-validate, sync URL, sync localStorage (for prefs only — see §3), POST the spec again, hand the result + effective spec to `onResult`. The shape host renders.
|
||||
4. Expose two trailing actions (when `showSaveAsView`): **Speichern als Sicht** and **Zurücksetzen**.
|
||||
|
||||
What the bar is NOT:
|
||||
- Not a router. Pages still own their URL.
|
||||
- Not a layout system. Cards on /projects keep the `paliad.user_card_layouts` primitive (per-card facts) — that's orthogonal to filtering.
|
||||
- Not the renderer. The bar just hands `(rows, effectiveRender)` to one of `shape-list / shape-cards / shape-calendar`.
|
||||
- Not a substitute for the dedicated views editor. That stays for power-users who want full control (predicates, custom horizons, columns).
|
||||
|
||||
---
|
||||
|
||||
## 3. The 7 brief items — taking positions
|
||||
|
||||
### 3.1 Filter axes: which are universal, which are per-surface, how does the bar declare its supported axes?
|
||||
|
||||
**Universal** — render always when `axes` contains them (and the surface's pinned spec doesn't rule them out):
|
||||
- `project` — single-select with the existing `<select>` (Alle / Nur persönliche / each project, ltree-indented). On surfaces where multi-project would help later (system-wide views), the same control upgrades to a multi-select listbox-panel by adding a `multi: true` flag — postpone to phase C, single-select covers every surface today.
|
||||
- `time` — segmented chip group (`Heute · 7T · 30T · 90T · Alles · Anpassen`). Maps to `time.horizon`. "Anpassen" pops a date-range pair (`time.horizon = "custom"` + from/to). On /inbox the chip group reads "Heute · 7T · 30T · Alles" since approval queues are usually now-shaped — but the same control.
|
||||
- `personal_only` — boolean chip ("Nur eigene"). Active when `scope.personal_only=true`. Hidden when source set excludes deadline AND appointment (others don't honour personal_only).
|
||||
|
||||
**Per-surface** — declared in `axes`, controlled by which sources the spec uses:
|
||||
- `deadline_status` (chip cluster: "Offen · Überfällig · Erledigt · Alle") — only when `sources` includes deadline.
|
||||
- `deadline_event_type` (multi-select listbox-panel, reuses `attachEventTypeMultiSelectFilter`) — only when sources includes deadline.
|
||||
- `appointment_type` (single-select for now: hearing/meeting/consultation/deadline_hearing/Alle) — only when sources includes appointment.
|
||||
- `approval_viewer_role` (segmented chips: "Zur Genehmigung · Eigene Anfragen · Alle sichtbaren") — only when sources includes approval_request. This subsumes the /inbox tab.
|
||||
- `approval_status` (chip cluster: "Wartend · Entschieden · Alle") — only when sources includes approval_request.
|
||||
- `approval_entity_type` (chip pair: "Fristen · Termine") — only when sources includes approval_request.
|
||||
- `project_event_kind` (multi-select listbox-panel; the 13 `KnownProjectEventKinds`) — only when sources includes project_event. Powers the dashboard "Letzte Aktivität" filter.
|
||||
|
||||
**View-mode + per-shape** — declared in `axes`, but special:
|
||||
- `shape` — segmented chips (list/cards/calendar). Always rendered when `axes` contains `shape`; available shapes derived from `baseRender` + the surface's whitelist. The bar emits a transient render override (mirrors how `client/views.ts:171` does shape-switching today: it doesn't rerun, just re-renders).
|
||||
- `sort` — single-select (`date_asc | date_desc`).
|
||||
- `density` — segmented chip pair (Komfortabel / Kompakt) — list shape only, hidden otherwise.
|
||||
- `columns` — popover with checkbox list of `KnownListColumns` — list shape only, advanced opt-in.
|
||||
|
||||
**How the surface declares its axes:** an array. No higher-order component, no slot composition. Plain config. The bar's render is a switch over each axis key:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
baseFilter: agendaSystemView.filter,
|
||||
baseRender: agendaSystemView.render,
|
||||
axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"],
|
||||
surfaceKey: "agenda",
|
||||
onResult: ({rows, inaccessible_project_ids}, effective) => { ... },
|
||||
});
|
||||
```
|
||||
|
||||
Slot composition was considered. It's overkill — every existing chrome pattern paliad uses (chip cluster, multi-anchor popover, segmented control, `<select>`) is already in `frontend/src/styles/global.css`; there's nothing to plug or override. A flat axis-config keeps the bar a 600-LoC component, not a framework.
|
||||
|
||||
### 3.2 State model: URL vs in-memory vs hybrid
|
||||
|
||||
**Hybrid**, with a sharp split:
|
||||
|
||||
- **URL is canonical** for everything that affects which rows you see. That means: project (`?project=`), sources (`?sources=`), time (`?time=` for horizon, `?from=&to=` for custom), personal-only, every per-source predicate (`?deadline_status=`, `?event_type=`, `?appointment_type=`, `?approval_role=`, `?approval_status=`, `?approval_entity_type=`, `?project_event_kind=`), shape (`?shape=`), sort (`?sort=`). Bookmarkable, shareable, refresh-survives, deep-linkable from the dashboard or /inbox bell.
|
||||
- **localStorage holds preferences** that don't change rows: density (`?density=` is also a URL param when explicitly chosen, but absence falls through to localStorage default), default columns per surface (advanced opt-in), default shape per surface (only when the user has overridden the SystemView's default — first visit uses base). Keyed `paliad.bar.<surfaceKey>.prefs`. Mirrors the spirit of /projects' sessionStorage `paliad.projects.lastView` (t-paliad-149 Q1 lock-in) but at the right scope: the "what I prefer" sticks per surface, the "what this URL is showing" stays in the URL.
|
||||
- **No sessionStorage.** /projects' use was justified by tab restoration; for the bar, every interesting bit is in the URL (so back/forward + refresh + share both work). Adding a third tier would create the worst-of-three: state in URL ∪ session ∪ local, three places to look when something's off.
|
||||
|
||||
URL parameter names are stable and short. The bar exports a tiny URL codec (`encodeBarParams(filter, render) → URLSearchParams` and inverse) so the same params work whether the bar is on /agenda, /inbox, /events, or /views/{slug}.
|
||||
|
||||
The migration from /events' bespoke `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` to the bar's params is straightforward: each old param maps to a new one (or stays, when names already match — `?project_id`, `?personal_only`, `?event_type` are unchanged; `?type` becomes `?sources`; `?view` becomes `?shape`; `?status` and `?type_filter` become per-surface predicates). Server middleware on the legacy /events handler can rewrite old → new params for one release so existing bookmarks don't 404.
|
||||
|
||||
### 3.3 View-mode switcher — universal or per-surface? Sort-state ownership? Density?
|
||||
|
||||
**Universal.** The bar always owns the segmented `shape` control. The surface declares which shapes it whitelists (e.g. /inbox might whitelist `["list"]` and hide the switcher; /agenda might whitelist `["cards", "list", "calendar"]`). When the whitelist has only one entry the bar suppresses the chip; when ≥2 it renders.
|
||||
|
||||
**Sort lives in the bar's `RenderSpec.list.sort` / `cards.sort`.** Already exists in the schema. The list-shape table renderer is currently sort-by-config-only; promoting `<th>` clicks to update `RenderSpec.list.sort` is a one-line callback in the bar (`onListHeaderSort`) → server-side re-sort isn't needed because `shape-list.ts:16` already sorts in JS. **Sortable column headers become a list-shape feature owned by the bar**, not a per-surface concern.
|
||||
|
||||
**Density** is a list-shape config (`comfortable | compact`). The bar exposes the pair as a chip; `shape-list.ts` already supports both. Density on /inbox today is implicitly comfortable; toggling it to compact gives the user the activity-feed look on the inbox surface for free, which is the kind of small win the brief calls out.
|
||||
|
||||
**Multi-column sort** is out-of-scope for v1 — `shape-list.ts:16` does single-column sort, which matches every surface today. Add when a user asks.
|
||||
|
||||
### 3.4 Composability — drop-in API without forcing existing pages to refactor
|
||||
|
||||
The bar mounts onto an empty `<div>`. The surface's TSX changes are:
|
||||
- Replace the per-page filter chrome (chip cluster, selects, popovers, view-mode segment) with `<div id="filter-bar"></div>`.
|
||||
- Replace the per-page result rendering with `<div id="filter-bar-results"></div>`.
|
||||
- The page's `client/<surface>.ts` shrinks to: read `__PALIAD_<SURFACE>__` initial payload (or skip), call `mountFilterBar(host, opts)`, write `onResult` to dispatch into the matching shape component (already exist).
|
||||
|
||||
That's it. The page surface is reduced to ~50 LoC of orchestration around the bar; the bulk of `events.ts` (1083 LoC) drops to a baseline of ≈80 LoC after Phase 3 because the per-axis filter state, the project select populator, the language-hot-swap, the URL-sync, the type-visibility logic, the appointment-type filter logic, the calendar month-paging, and the cards-vs-list-vs-calendar dispatch all migrate into shared components: the bar (filter axes, view-mode, URL, language hot-swap), `shape-list.ts` (table), `shape-cards.ts` (cards), `shape-calendar.ts` (month grid).
|
||||
|
||||
The bar **does not own row interaction**. Row click → detail page is already a per-shape concern (`shape-list.ts` emits `entity-table--readonly`; the bar doesn't override that). Lifecycle actions (complete/reopen/approve/reject) are also per-shape — `shape-list.ts` will need a small extension to emit clickable-row tables on /events (so the existing complete-checkbox + reopen flow keeps working). That extension is one new render flag in `RenderSpec.list.row_action: "navigate" | "approve" | "complete-toggle" | "none"`, defaulting to navigate. Honest scope: this is a small `RenderSpec` schema bump (new optional field), not an axis change.
|
||||
|
||||
### 3.5 Reuse with the existing /views layout-spec — does the universal bar inherit, or does the spec become a special case of saved bar state?
|
||||
|
||||
**The latter.** m's hint ("halfway there without custom views") points at exactly this.
|
||||
|
||||
A **Custom View is the persisted form of a bar state.** When the user clicks "Speichern als Sicht" on /agenda, the bar gathers the effective `FilterSpec` + `RenderSpec`, prompts for name + slug + icon + show-count (a small modal — one form, four fields, mirroring `views-editor.ts`'s collectForm), and POSTs `/api/user-views`. The user is then redirected to `/views/{slug}` (or stays in place with a confirmation toast — see §3.7).
|
||||
|
||||
Conversely, **a SystemView is a code-resident bar state.** The bar already knows how to load one (`/api/views/system` → match slug). The "system pages" become surfaces whose default state happens to live in code instead of in `paliad.user_views`.
|
||||
|
||||
Implementation consequence:
|
||||
- `views-editor.ts` keeps existing for power users who want to edit predicates that the bar doesn't expose (e.g. pinning a `time.field = "created_at"` for an "audit-trail" view). The editor and the bar produce identical `FilterSpec` + `RenderSpec` JSON; they're alternate authoring UX.
|
||||
- `views.ts` (the `/views/{slug}` viewer) gains the bar above its rows. The bar renders with the saved spec as its base; the user can tweak axes (e.g. narrow the time horizon for a quick glance) — those tweaks are URL-overlays and don't mutate the saved spec until the user clicks "Aktualisieren" (a new affordance). This satisfies the brief's "halfway there" hint: today /views/{slug} renders a saved spec **statically**; with the bar, it becomes interactive without losing the saved-state semantics.
|
||||
|
||||
### 3.6 Migration path — phase one surface at a time, identify the hardest
|
||||
|
||||
The bar is shippable on one surface in one PR. Then each subsequent surface is its own small PR.
|
||||
|
||||
**Phase 1 — /inbox (the cold start).** Lowest blast radius: today /inbox has no filter chrome, only tabs. Replace tabs with the `approval_viewer_role` axis (the bar collapses two tabs into one chip cluster). Drop the bar with `axes: ["time", "approval_status", "approval_entity_type", "approval_viewer_role", "shape", "density", "sort"]`. Pin `sources: [approval_request]`. Density toggle gives the user a stream view m's "looks really bad" was diagnosing. URL contract: keep `?tab=` redirecting to `?approval_role=` for one release.
|
||||
|
||||
**Phase 2 — /agenda.** Already filter-shaped and the most readable orchestrator (226 LoC). Bar replaces the chip cluster + range chip + event-type popover. `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"]`. Default: shape="cards" (matching today's timeline default). The dashboard inline Agenda gets a stripped-down bar with `axes: ["time", "deadline_event_type"]` and `urlNamespace: "agenda"` (so the page-level bar on the dashboard doesn't collide with anything else if the dashboard adds another bar later for "Letzte Aktivität").
|
||||
|
||||
**Phase 3 — /events (the proof point).** Most complex filter today: type chip + status select + project select + personal-only + event-type multi + appointment-type select + cards/list/calendar. Every one of these axes is already nameable in FilterSpec/RenderSpec (verified §1). `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort", "density"]`. The 5-card summary above the table (Heute / Diese Woche / Nächste Woche / Später / Überfällig) becomes a bar-driven facet: clicking a card sets `time.horizon` (or for "Überfällig", a special `deadline_status: ["overdue"]` predicate). Identifying /events as the hardest surface up front means the primitive's axis registry has to be wide enough on day 1; the design above already names every needed axis, so Phase 1's primitive is forward-compatible.
|
||||
|
||||
**Phase 4 — dashboard inline lists (Agenda + Letzte Aktivität).** The dashboard composes two tiny bars: one for Agenda (cards/list, narrow time horizon, no save-as-view), one for Letzte Aktivität (project_event source, density=compact, no save-as-view). Both use `urlNamespace` to keep params tidy.
|
||||
|
||||
**Phase 5 — /views/{slug}.** Add the bar above the rows. Saved spec → bar's base; URL overlays are transient until "Aktualisieren" persists them. The custom-view editor (`/views/new`, `/views/{slug}/edit`) stays for power users; "Speichern als Sicht" from the bar is the everyday path.
|
||||
|
||||
**Out of phasing:** /projects stays bespoke. The bar coexists on the page only if a future task adds it — today the chip cluster + tree/cards/flat segment are doing fine, and Source⊥Shape orthogonality breaks for projects (no ProjectSource in the substrate; no TreeShape in the substrate). t-paliad-149's locked-in choice stands.
|
||||
|
||||
**Hardest surface, identified:** /events. Phase 3 is the proof point. By designing the bar's axis registry against /events on day 1 (not retrofitting), Phase 1 (/inbox) and Phase 2 (/agenda) ship without redesign churn.
|
||||
|
||||
### 3.7 "Save current filter as named view" — making it trivial
|
||||
|
||||
The bar's trailing action is a single button: **Speichern als Sicht**. Click → small modal:
|
||||
|
||||
```
|
||||
┌─ Sicht speichern ─────────────────────┐
|
||||
│ Name [_________________] │
|
||||
│ Slug [_________________] (opt) │
|
||||
│ Icon [▼ Auswählen ] │
|
||||
│ □ Anzahl in der Sidebar zeigen │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Speichern ] │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
If slug is empty, derive from name (kebab-case) and validate against the regex + reserved-slug list client-side (mirrors `views-editor.ts:179`). On 409 (slug taken), show inline error and let the user adjust. On success, two affordances:
|
||||
- A toast "Als Sicht 'Heute überfällig' gespeichert. Zur Sicht wechseln?" with a link to `/views/{slug}`.
|
||||
- The new view automatically appears in the **Meine Sichten** sidebar group (t-paliad-144) on next page load (or sooner, if the bar emits a window event the sidebar listens to).
|
||||
|
||||
This means: every list-shaped surface gets "save current filter as named view" for free. No per-surface plumbing.
|
||||
|
||||
**"Aktualisieren" on /views/{slug}** is the symmetric write-back: when the user is viewing a saved view and tweaks the bar, a "Aktualisieren" button appears next to "Speichern als Sicht". Click → PATCH `/api/user-views/{id}` with the effective spec. Confirmation toast.
|
||||
|
||||
**"Zurücksetzen"** clears the URL overlay and re-renders with the base spec only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Two harder questions worth surfacing now
|
||||
|
||||
### 4.1 The chip-vs-popover-vs-select tension
|
||||
|
||||
paliad has three patterns for "pick from a set" today:
|
||||
|
||||
- **Chip cluster** (e.g. /agenda type chip, /projects scope chip) — best for 2–4 mutually exclusive options. Always-visible, click-fast.
|
||||
- **`<select>`** (e.g. /events status, project, appointment-type) — best for 5–30 single-select options, especially when the option list is dynamic (project list grows).
|
||||
- **Listbox-panel popover** (e.g. event-type multi, /projects status/type `<details>`) — best for multi-select or for >30 options with search.
|
||||
|
||||
The bar must use the right pattern per axis to feel native, not regress one surface in service of another. My picks:
|
||||
|
||||
| Axis | Pattern | Why |
|
||||
|---|---|---|
|
||||
| project (single) | `<select>` | dynamic list; option count grows with the firm |
|
||||
| time | chip cluster + "Anpassen" overflow | 5 mutually exclusive presets cover 95% of usage |
|
||||
| personal_only | single chip | binary |
|
||||
| sources (when `axes` exposes it) | listbox-panel multi | 4 options but multi-select |
|
||||
| deadline_status | chip cluster | 4 options, mutually exclusive |
|
||||
| deadline_event_type | listbox-panel multi | 40+ options, search + grouped checkboxes (reuses event-types.ts pattern) |
|
||||
| appointment_type | chip cluster (4 + Alle) | small mutually-exclusive set |
|
||||
| approval_viewer_role | chip cluster | 3 mutually exclusive options |
|
||||
| approval_status | chip cluster | 4 options |
|
||||
| approval_entity_type | chip cluster | 2 options |
|
||||
| project_event_kind | listbox-panel multi | 13 options, multi-select |
|
||||
| shape | segmented control | 1-of-N, special UX (icon-only buttons) |
|
||||
| sort | `<select>` (small) | 2 options today, room for `title_asc/desc` later |
|
||||
| density | segmented control | binary, icon-shaped |
|
||||
|
||||
The point: the bar isn't one widget, it's a thin shell that delegates each axis to the right existing control. CSS reuse: `.agenda-chip` / `.events-view-btn` / `.akten-multi-trigger` / `.multi-anchor` / `.multi-panel` all stay; the bar just composes them.
|
||||
|
||||
### 4.2 Empty-state UX when an axis is invalid for the current sources
|
||||
|
||||
If the user clears all sources, every per-source axis becomes meaningless. Two options:
|
||||
- **Hide invalid axes.** Cleanest. Bar reacts to source changes by collapsing dependent chips. Risk: feels jumpy.
|
||||
- **Disable + tooltip.** Less jumpy but visually noisier.
|
||||
|
||||
Recommend **hide**, with one twist: the bar persists hidden-axis state in the URL anyway, so toggling sources back on restores the user's prior filter. This matches /events' existing behaviour (when type=appointment, event-type panel is hidden but its state persists in `?event_type=`).
|
||||
|
||||
---
|
||||
|
||||
## 5. RenderSpec extensions — one schema bump
|
||||
|
||||
The bar exposes capabilities that are already in `RenderSpec` (shape, sort, density, columns) plus one new field:
|
||||
|
||||
```go
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
RowAction ListRowAction `json:"row_action,omitempty"` // NEW — "navigate" (default) | "complete_toggle" | "approve" | "none"
|
||||
}
|
||||
```
|
||||
|
||||
`RowAction` lets `shape-list.ts` know whether to wire an `entity-table--readonly` or to attach the existing checkbox / reopen / approve / reject buttons. Default `navigate` keeps the contract stable; system pages explicitly set `complete_toggle` (events list) and `approve` (inbox list).
|
||||
|
||||
This is the only schema change. Every other axis is already in the spec.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard requirements from the brief — addressed
|
||||
|
||||
- **`.entity-table` row-click contract.** The bar's list-shape table is rendered by `shape-list.ts:80` which already emits `entity-table--readonly`. When `RowAction="navigate"` the bar adds a row-handler that does `window.location.href = detailRoute(row)` and skips clicks on inner `<a>`/`<button>` (mirrors the existing `events.ts:wireRowHandlers` pattern). Whole-card / whole-row click → JS row-handler, never `::before` overlays (CLAUDE.md frontend conventions, t-paliad-102).
|
||||
- **No hour estimates.** Throughout this design.
|
||||
- **DE+EN bilingual.** Every new label gets a key under `views.bar.*` (single new namespace; ~25 keys for axes + ~10 for save modal + ~10 for empty/loading/error states). Keys are added to `frontend/src/client/i18n.ts`'s registry at the appropriate phase.
|
||||
- **Mobile.** The bar collapses to a single horizontal scroll row on `≤768px` (mirrors `.frist-summary-cards` mobile pattern). The "Speichern als Sicht" + "Zurücksetzen" actions move into a `<details>` "Mehr" affordance on mobile to keep the scrollable strip clean. Re-imagining mobile-list-mode is out of scope per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 7. Trade-offs — the honest list
|
||||
|
||||
### What this design gains
|
||||
1. **One filter chrome across all list-shaped surfaces.** Users learn one bar, every surface respects it. Discoverability for "save as view" jumps from one surface (/views/new editor) to seven.
|
||||
2. **System pages become substrate clients.** `/api/views/run` (already shipped) becomes the canonical event-fetching path. Phase B from t-paliad-144 design lands.
|
||||
3. **`events.ts` shrinks ~10×.** Most of its 1083 lines are filter chrome + URL sync + view-mode dispatch — all now shared.
|
||||
4. **Save-as-view is universal.** Today only /views/new + /views/{slug}/edit can author saved views; after the migration, every page can.
|
||||
5. **/inbox gains filters and sort and density** as a free side effect of the migration — directly addressing m's "looks really bad" diagnosis.
|
||||
6. **Sortable column headers** become a substrate feature (small bar callback that updates `RenderSpec.list.sort`).
|
||||
7. **The schema barely moves** — one new optional field on `ListConfig`. Migrations not needed.
|
||||
|
||||
### What this design risks
|
||||
1. **One component holding many axes is at risk of bloat.** Mitigation: the bar is a flat axis-config (no slot composition, no HOC). 600 LoC ceiling enforced by the per-axis switch pattern. CSS reuse keeps the visual surface small.
|
||||
2. **The /events migration is the largest single PR.** 1083 LoC client → ≈100 LoC + ≈250 LoC of bar config + per-shape extensions. A regression on the 5-card summary or the deadline complete/reopen flow would be visible. Mitigation: Phase 3 is gated behind Phase 1 (/inbox) and Phase 2 (/agenda) shipping cleanly, and the design lands the `RowAction` schema bump in Phase 1 so `complete_toggle` is wired before /events arrives.
|
||||
3. **URL overlay on /views/{slug} creates two states.** Saved spec ≠ effective spec when the user has tweaked the bar. The "Aktualisieren" / "Speichern als Sicht" actions resolve which becomes canonical, but a user who navigates away with unsaved tweaks loses them. Mitigation: a `?dirty=1` URL marker + a small toast on first tweak ("Änderungen sind nicht gespeichert").
|
||||
4. **Two filter chromes coexist on /projects.** The bar doesn't subsume the chip cluster (Source⊥Shape break). Future visual unification would standardise the chip pattern between the two — out of scope here.
|
||||
5. **Hidden-axis URL state.** Persisting `?event_type=` even when sources excludes deadline can confuse a user reading their URL. Acceptable: matches /events' current behaviour and is reversible by toggling the source back. The alternative (pruning URL params on source change) loses the user's prior state on a quick re-toggle.
|
||||
6. **i18n hot-swap correctness.** Every dynamic populator must subscribe to `onLangChange` (the t-paliad-117 lesson). The bar handles this once internally for every axis; surfaces don't need to wire it per-page.
|
||||
7. **Default per-surface defaults can drift from SystemView.** The bar reads `localStorage` for prefs (e.g. preferred shape on /agenda). If a user toggles a pref then a SystemView default changes, the user's pref wins. Mitigation: `localStorage` only stores explicit overrides, not the base value, so changes to the SystemView's base flow through for users who haven't overridden.
|
||||
8. **Two storage primitives ("user_views" + "user_card_layouts") could be confusing.** Names are similar; they store different things. Mitigation: documentation. The bar only ever reads/writes `paliad.user_views`. /projects' card-layout is a separate, narrow concern that stays bespoke.
|
||||
|
||||
### Reversibility
|
||||
- The bar is purely additive. Phase 1 doesn't touch /agenda or /events. If after Phase 1 the bar feels wrong, /inbox can revert to its prior chrome by reverting one PR. Phase 2 only ships after Phase 1 holds.
|
||||
- The new `RenderSpec.list.row_action` field is optional with a `navigate` default; existing rows continue to render correctly.
|
||||
- The URL contract is preserved for /events for one release via a thin redirect middleware that maps old → new params; bookmarks don't 404.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m before lock-in
|
||||
|
||||
These are decisions where my recommendation might be challenged:
|
||||
|
||||
**Q1. State model: full URL-canonical, or do we accept localStorage for shape/density preferences?** I recommend hybrid: URL for filter axes, localStorage for shape + density prefs (per-surface). Keeps shareable URLs honest while letting "I always want compact density on /inbox" persist across sessions.
|
||||
|
||||
**Q2. Save-as-view modal vs slide-out vs inline.** I recommend modal — minimal surface, four fields, blocks the page. Alternatives: a slide-out (less interruption, more work) or an inline expansion of the "Speichern" button (cramped on mobile). Modal lines up with existing `<dialog>` usage on /admin.
|
||||
|
||||
**Q3. /events 5-card summary — keep, or fold into the bar?** I recommend keep (above the bar, unchanged). The cards encode urgency at a glance; collapsing them into the bar's `time` chip would lose the "9 / 3 / 2 / 5 / Überfällig 1" density. Clicking a card still updates the bar's time horizon (existing behaviour preserved).
|
||||
|
||||
**Q4. Tabs on /inbox — collapse into the `approval_viewer_role` chip cluster, or keep tabs as visual chrome above the bar?** I recommend collapse — one fewer place for state, the chip cluster is exactly the right control for 3 mutually exclusive options. Counter-argument: tabs are a strong visual hint of "two pages with the same shape". My counter-counter: the bar's chips are the same hint, less mid-air.
|
||||
|
||||
**Q5. URL parameter naming.** I recommend short, namespaced names: `?time=`, `?sources=`, `?project=`, `?personal=`, per-source predicate names (`?d_status=` for deadline.status, `?a_role=` for approval_request.viewer_role, `?pe_kind=` for project_event.event_types). Cargo-friendly to long names like `?deadline_status=` if m prefers — same axis, same wire format.
|
||||
|
||||
**Q6. "Speichern als Sicht" on the dashboard inline bars — show or hide?** I recommend hide. The dashboard composes two tiny bars; saving a sub-bar's spec as a custom view would feel disjoint from the dashboard concept. Power users can craft custom views via /views/new instead.
|
||||
|
||||
**Q7. Migration: do we keep `?type=` redirecting on /events for one release, or hard-cut?** I recommend keep for one release (small middleware in `internal/handlers/events_pages.go`) so existing bookmarks (Sidebar, internal docs, the /events sidebar links at `events.ts:838`) keep working through Phase 3.
|
||||
|
||||
**Q8. /views/{slug} — should the URL overlay tweak persist in localStorage as a "draft" until the user resets or saves?** I recommend no — URL is the only state, and a tweak that disappears on reload matches user expectation. The `?dirty=1` toast is enough. Alternative: a per-view-id `paliad.bar.view-{id}.draft` localStorage key that re-applies on re-visit — more powerful, more surprising.
|
||||
|
||||
**Q9. Sortable column headers — list shape only, or also rule for cards/calendar in a future phase?** I recommend list-shape only for v1. Cards and calendar have their own ordering semantics (group_by + within-group sort); promoting headers would over-complicate.
|
||||
|
||||
**Q10. Bar embedding twice on dashboard — `urlNamespace` worth the complexity, or single namespace and accept that dashboard's two bars share `?time=`?** I recommend `urlNamespace` for dashboard only (e.g. `?agenda_time=` and `?activity_time=`). Costs ~10 LoC, keeps two bars from colliding.
|
||||
|
||||
**Q11. Multi-project select — phase C, or fold into Phase 2?** I recommend phase C. Single-project covers every surface today; multi-project unlocks "all my Düsseldorf cases this week" type queries but no current page asks for it. Save complexity until a user does.
|
||||
|
||||
**Q12. EventTypeMultiSelect today supports `none` ("Ohne Typ") — keep or drop?** I recommend keep. The bar's deadline_event_type axis just wraps `attachEventTypeMultiSelectFilter`, so `none` works as-is. Honestly nothing to design here.
|
||||
|
||||
---
|
||||
|
||||
## 9. Scope boundaries (in + out)
|
||||
|
||||
### In scope
|
||||
- New `<FilterBar>` component + axis registry + URL codec.
|
||||
- One `RenderSpec.list.row_action` field with validator update.
|
||||
- Phase 1: /inbox surface + tests.
|
||||
- Documentation + i18n keys for the bar.
|
||||
- Phase 2..5 named in the migration path with clear gates between them — but each is its own PR and not part of "the inventor design has shipped" definition-of-done.
|
||||
|
||||
### Out of scope (per the brief + my reading)
|
||||
- New entity surfaces. Only the 7 named surfaces.
|
||||
- Backend SQL migrations beyond the one optional `RenderSpec.list.row_action` field. The bar runs through `/api/views/run` which already exists.
|
||||
- /projects redesign — t-paliad-149 stands.
|
||||
- Mobile-list-mode reimagining — separate workstream.
|
||||
- Multi-project selection — phase C, not v1.
|
||||
- Multi-column sort — when a user asks.
|
||||
- Internationalisation beyond DE + EN.
|
||||
|
||||
---
|
||||
|
||||
## 10. Files implementer will touch (Phase 1: /inbox)
|
||||
|
||||
To make the scope concrete:
|
||||
|
||||
**New:**
|
||||
- `frontend/src/components/FilterBar.tsx` — TSX wrapper with the host divs.
|
||||
- `frontend/src/client/filter-bar/index.ts` — `mountFilterBar` entry point.
|
||||
- `frontend/src/client/filter-bar/axes.ts` — per-axis render functions (one per `AxisKey`).
|
||||
- `frontend/src/client/filter-bar/url-codec.ts` — `encode/decode/diffWithBase`.
|
||||
- `frontend/src/client/filter-bar/save-modal.ts` — the "Speichern als Sicht" modal.
|
||||
- `frontend/src/client/filter-bar/types.ts` — `FilterBarOpts`, `AxisKey`.
|
||||
- `frontend/src/client/filter-bar/i18n.ts` — namespace registry helper.
|
||||
|
||||
**Modified (Phase 1):**
|
||||
- `frontend/src/inbox.tsx` — replace tab row with `<div id="filter-bar">` + `<div id="filter-bar-results">`.
|
||||
- `frontend/src/client/inbox.ts` — shrink to `mountFilterBar(host, {baseFilter: inboxSystemView, axes: [...], onResult: renderListShape})`.
|
||||
- `internal/handlers/inbox.go` — add `?approval_role=` redirect from old `?tab=` for one release. (The actual rows continue to come from `/api/views/run` via the bar.)
|
||||
- `internal/services/render_spec.go` — add `RowAction` field + validator + `KnownRowActions = ["navigate", "complete_toggle", "approve", "none"]`.
|
||||
- `frontend/src/client/views/types.ts` — TS mirror of the new `RowAction` field.
|
||||
- `frontend/src/client/views/shape-list.ts` — honour `RowAction` (navigate is the existing default; `approve` mounts approve/reject buttons; `complete_toggle` mounts the checkbox).
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — ~30 new keys under `views.bar.*`.
|
||||
- `frontend/src/styles/global.css` — bar layout + mobile rules. Reuses existing `.agenda-chip`, `.akten-multi-*`, `.frist-summary-card`, `.multi-anchor`/`.multi-panel`, `.events-view-btn` styles.
|
||||
|
||||
**Tests (Phase 1):**
|
||||
- `internal/services/render_spec_test.go` — add cases for `RowAction` validator (8 cases: each enum value + invalid + omitted + …).
|
||||
- `frontend/src/client/filter-bar/url-codec.test.ts` — round-trip encode/decode for every `AxisKey`.
|
||||
- `internal/handlers/inbox_redirect_test.go` — old-tab → new-axis redirect.
|
||||
|
||||
**Phase 2..5 file lists** are not enumerated here — each is a separate PR with its own surface refactor and follows the same shape (replace per-page chrome + URL sync, mount the bar, hand `onResult` to the existing shape components).
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended implementer
|
||||
|
||||
**Pattern-fluent Sonnet coder** is the right fit. Substrate is well-trodden:
|
||||
- Custom Views client + render shapes already exist (t-paliad-144).
|
||||
- Multi-select listbox-panel already exists (`event-types.ts`).
|
||||
- Chip-row pattern exists on `/agenda`, `/projects`, `/events`.
|
||||
- Save modal pattern exists on `/views/new` (`views-editor.ts`).
|
||||
- URL-sync pattern exists on every system page.
|
||||
|
||||
The first PR (Phase 1: /inbox + bar scaffolding + `RowAction` schema bump) is contained and reviewable in one window. Subsequent phases are smaller — they're "swap in the bar and delete page-local code".
|
||||
|
||||
I am happy to be the coder if m wants minimum context-switch — riemann has the live model of every piece of this design. Equally happy to hand off to a fresh Sonnet coder with this doc as the brief; the doc is intended to be self-contained for that path.
|
||||
|
||||
The head decides.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phasing summary (no estimates, just order)
|
||||
|
||||
1. /inbox migration + `<FilterBar>` scaffolding + `RowAction` schema bump.
|
||||
2. /agenda migration.
|
||||
3. /events migration (proof point — most complex filter today, biggest LoC delta).
|
||||
4. Dashboard inline bars (Agenda + Letzte Aktivität).
|
||||
5. /views/{slug} bar overlay + "Aktualisieren" affordance.
|
||||
|
||||
Each phase is its own PR. Phases must merge in order; m's merge gate at every step.
|
||||
|
||||
---
|
||||
|
||||
## 13. Why this is worth an inventor
|
||||
|
||||
m's last line in the brainstorm: *"worth an inventor?"*. Yes — and the reason is exactly what the design doc surfaces: the substrate already exists, the schema's right, the run endpoints are shipped, and 5 SystemViews are already declared. A coder coming in cold would either (a) not realise the substrate is there and reinvent it, or (b) realise and underestimate how much per-surface chrome can collapse into one bar. The inventor's job here was to read what's there, name the bar primitive, identify /events as the proof point, propose the one schema bump (`RowAction`) that makes /inbox shippable in Phase 1, and resist designing a layout-spec system that's already covered by `RenderSpec`.
|
||||
|
||||
Stop. DESIGN READY FOR REVIEW.
|
||||
@@ -1,350 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// 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ü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;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// 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({});
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
// 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 };
|
||||
@@ -288,6 +288,12 @@ interface ProjectOption {
|
||||
// (Slice 3b) can scope the cascade by the project's jurisdiction
|
||||
// without an extra fetch.
|
||||
proceeding_type_id?: number | null;
|
||||
// our_side carries which side the firm represents on this project
|
||||
// (t-paliad-164). When a user selects an Akte, the perspective chip
|
||||
// pre-locks to this value; a small hint above the strip flags the
|
||||
// pre-selection and the user can still click another chip to
|
||||
// override. NULL/undefined leaves the chip unset (free-pick).
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
@@ -2530,6 +2536,11 @@ function selectProject(project: ProjectOption) {
|
||||
writeStep1ContextToURL(currentStep1Context);
|
||||
renderStep1Summary();
|
||||
showStep2Card();
|
||||
// t-paliad-164: project.our_side predefines the perspective chip.
|
||||
// Only fires when the user hasn't already locked a perspective via
|
||||
// ?role= in the URL — the URL pick wins because it represents an
|
||||
// explicit choice (chip click or shared link).
|
||||
applyOurSidePredefine(project, /* replaceURL */ false);
|
||||
// Slice 3b: project's proceeding type narrows the B1 cascade if the
|
||||
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
|
||||
// a cascade already on screen (rare but possible via popstate) picks
|
||||
@@ -2551,6 +2562,12 @@ function clearStep1Context() {
|
||||
renderStep1Summary();
|
||||
hideStep2Card();
|
||||
triggerCascadeRefresh();
|
||||
// t-paliad-164: hint dies with the project context. We deliberately
|
||||
// leave the perspective chip itself alone — the user may want to
|
||||
// keep their pick when returning to Step 1; we only clear the
|
||||
// "vorgegeben durch Akte" annotation since there's no Akte anymore.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
}
|
||||
|
||||
function renderStep1Summary() {
|
||||
@@ -2626,6 +2643,10 @@ function initPathwayFork() {
|
||||
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
|
||||
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
|
||||
renderStep1Summary();
|
||||
// t-paliad-164: deep-link / refresh path. project loaded async, so
|
||||
// the predefine has to wait for cachedAkten. replace=true keeps
|
||||
// the URL clean — the user didn't navigate, they just refreshed.
|
||||
applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
|
||||
}
|
||||
renderAkteList("");
|
||||
// Cascade may already be on screen if the user landed with
|
||||
@@ -2657,6 +2678,11 @@ function initPathwayFork() {
|
||||
const next: Perspective = isClear ? null : ((chip.dataset.perspective as Perspective) ?? null);
|
||||
writePerspectiveToURL(next);
|
||||
applyPerspective(next);
|
||||
// t-paliad-164: any chip click is an explicit override; hide the
|
||||
// "vorgegeben durch Akte" hint so the bar reads as "user choice"
|
||||
// from here on.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2744,6 +2770,17 @@ function initPathwayFork() {
|
||||
renderStep1Summary();
|
||||
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
|
||||
applyPerspective(readPerspectiveFromURL());
|
||||
// t-paliad-164: restore the hint visibility from URL+project state.
|
||||
// The hint shows when the active URL perspective matches what the
|
||||
// current project's our_side would have predefined — i.e. the
|
||||
// "predefined-and-not-yet-overridden" state. Approximation: hint
|
||||
// visible iff project.our_side maps to currentPerspective.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) {
|
||||
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
|
||||
const expected = ourSideToPerspective(proj?.our_side);
|
||||
hint.hidden = !(proj && proj.our_side && expected === currentPerspective);
|
||||
}
|
||||
const path = readPathwayFromURL();
|
||||
const mode = readBModeFromURL();
|
||||
showPathway(path, mode);
|
||||
@@ -3382,6 +3419,45 @@ function applyPerspective(p: Perspective) {
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
// ourSideToPerspective maps the project-level "Wir vertreten" enum
|
||||
// onto the chip-strip Perspective. 'court' / 'both' map to null
|
||||
// (chip cleared) — court actions are neutral to the user's side and
|
||||
// "both" is explicit no-filter intent.
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
if (os === "claimant") return "claimant";
|
||||
if (os === "defendant") return "defendant";
|
||||
return null;
|
||||
}
|
||||
|
||||
// applyOurSidePredefine locks the perspective chip from
|
||||
// project.our_side when the user hasn't already explicitly picked
|
||||
// one. The URL is the "explicit pick" signal: if ?role= is present
|
||||
// at call time, the user (or a shared link) chose it and we don't
|
||||
// overwrite. When we do predefine, we write the same value to the
|
||||
// URL so back/forward + refresh round-trip cleanly, and we show the
|
||||
// "vorgegeben durch Akte" hint so the user knows where the
|
||||
// pre-selection came from. Clicking a chip clears the hint.
|
||||
//
|
||||
// `replaceURL=true` is for the deep-link / refresh path; `false` for
|
||||
// in-page project selection so back-button restores the empty state.
|
||||
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (!project || !project.our_side) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
// URL wins — user has an explicit pick. Don't clobber it; also no
|
||||
// hint, since the active perspective didn't come from the project.
|
||||
if (readPerspectiveFromURL() !== null) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
const next = ourSideToPerspective(project.our_side);
|
||||
writePerspectiveToURL(next, replaceURL);
|
||||
applyPerspective(next);
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
|
||||
// perspectiveAllowsParty returns true when a node tagged with `party`
|
||||
// should be visible under the current perspective. Neutral nodes
|
||||
// (party undefined / empty) always pass. "both" matches every
|
||||
|
||||
@@ -376,6 +376,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.claimant.title": "Klägerseite — versteckt typische Beklagten-Schriftsätze",
|
||||
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -875,6 +876,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.action.short.project_reparented": "ordnete Projekt neu zu",
|
||||
"dashboard.action.short.project_type_changed": "\u00e4nderte Projekt-Typ",
|
||||
"dashboard.action.short.status_changed": "\u00e4nderte Status",
|
||||
"dashboard.action.short.our_side_changed": "\u00e4nderte vertretene Seite",
|
||||
"dashboard.action.short.visibility_changed": "\u00e4nderte Sichtbarkeit",
|
||||
"dashboard.action.short.collaborators_updated": "aktualisierte Bearbeiter",
|
||||
"dashboard.action.short.note_created": "f\u00fcgte Notiz hinzu",
|
||||
@@ -896,6 +898,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.project_reparented": "Projekt umstrukturiert",
|
||||
"event.title.project_type_changed": "Projekt-Typ ge\u00e4ndert",
|
||||
"event.title.status_changed": "Status ge\u00e4ndert",
|
||||
"event.title.our_side_changed": "Vertretene Seite ge\u00e4ndert",
|
||||
"event.title.note_created": "Notiz hinzugef\u00fcgt",
|
||||
"event.title.deadline_created": "Frist angelegt",
|
||||
"event.title.deadline_updated": "Frist ge\u00e4ndert",
|
||||
@@ -1125,6 +1128,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Gericht",
|
||||
"projects.field.case_number": "Aktenzeichen (Gericht)",
|
||||
"projects.field.proceeding_type_id": "Verfahrensart",
|
||||
"projects.field.our_side": "Wir vertreten",
|
||||
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
|
||||
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
|
||||
"projects.field.our_side.claimant": "Klägerseite",
|
||||
"projects.field.our_side.defendant": "Beklagtenseite",
|
||||
"projects.field.our_side.court": "Gericht / Tribunal",
|
||||
"projects.field.our_side.both": "Beide Seiten",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Titel erforderlich",
|
||||
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
|
||||
@@ -2154,63 +2165,6 @@ 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: {
|
||||
@@ -2577,6 +2531,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.claimant.title": "Claimant side — hides typical defendant submissions",
|
||||
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.perspective.predefined_hint": "predefined from project",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -3059,6 +3014,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.action.short.project_reparented": "re-parented project",
|
||||
"dashboard.action.short.project_type_changed": "changed project type",
|
||||
"dashboard.action.short.status_changed": "changed status",
|
||||
"dashboard.action.short.our_side_changed": "changed represented side",
|
||||
"dashboard.action.short.visibility_changed": "changed visibility",
|
||||
"dashboard.action.short.collaborators_updated": "updated collaborators",
|
||||
"dashboard.action.short.note_created": "added note",
|
||||
@@ -3080,6 +3036,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.project_reparented": "Project re-parented",
|
||||
"event.title.project_type_changed": "Project type changed",
|
||||
"event.title.status_changed": "Status changed",
|
||||
"event.title.our_side_changed": "Represented side changed",
|
||||
"event.title.note_created": "Note added",
|
||||
"event.title.deadline_created": "Deadline created",
|
||||
"event.title.deadline_updated": "Deadline updated",
|
||||
@@ -3307,6 +3264,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Court",
|
||||
"projects.field.case_number": "Case number (court)",
|
||||
"projects.field.proceeding_type_id": "Proceeding type",
|
||||
"projects.field.our_side": "We represent",
|
||||
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
|
||||
"projects.field.our_side.unset": "Unknown / not set",
|
||||
"projects.field.our_side.claimant": "Claimant side",
|
||||
"projects.field.our_side.defendant": "Defendant side",
|
||||
"projects.field.our_side.court": "Court / tribunal",
|
||||
"projects.field.our_side.both": "Both sides",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Title required",
|
||||
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
|
||||
@@ -4333,62 +4298,6 @@ 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.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4464,6 +4373,12 @@ function translateEventDescription(eventType: string, description: string): stri
|
||||
// New format: "active → archived". Legacy: "Status active → archived".
|
||||
return translateArrowSlugs(body.replace(/^Status\s+/, ""), "projects.filter.status.");
|
||||
}
|
||||
if (eventType === "our_side_changed") {
|
||||
// Format: "<from> → <to>", where each side is one of
|
||||
// claimant / defendant / court / both / "none" (the sentinel for
|
||||
// NULL the service writes when the column is unset on either end).
|
||||
return translateArrowSlugs(body, "projects.field.our_side.");
|
||||
}
|
||||
if (eventType === "note_created") {
|
||||
// New format: just the parent slug. Legacy: "Note zu <slug> hinzugefügt".
|
||||
const m = body.match(/^Note zu (project|deadline|appointment) hinzugef[üu]gt$/i);
|
||||
|
||||
@@ -1,176 +1,122 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initI18n, t, getLang, type I18nKey } 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 — t-paliad-163 universal-filter migration.
|
||||
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
|
||||
// reject / revoke), and a small inline diff for update / complete / delete
|
||||
// lifecycle events.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
const INBOX_AXES: AxisKey[] = [
|
||||
"time",
|
||||
"approval_viewer_role",
|
||||
"approval_status",
|
||||
"approval_entity_type",
|
||||
"density",
|
||||
"sort",
|
||||
];
|
||||
type Lifecycle = "create" | "update" | "complete" | "delete";
|
||||
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
|
||||
type DecisionKind = "peer" | "admin_override";
|
||||
|
||||
let bar: BarHandle | null = null;
|
||||
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();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
applyLegacyTabRedirect();
|
||||
void hydrate();
|
||||
const url = new URL(window.location.href);
|
||||
const t = url.searchParams.get("tab");
|
||||
if (t === "mine") currentTab = "mine";
|
||||
bindTabs();
|
||||
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 fetchInboxSystemView(): Promise<SystemView | null> {
|
||||
try {
|
||||
const r = await fetch("/api/views/system", { credentials: "include" });
|
||||
if (!r.ok) return null;
|
||||
const list = (await r.json()) as SystemView[];
|
||||
return list.find((v) => v.Slug === "inbox") ?? null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function paint(
|
||||
result: ViewRunResult,
|
||||
render: RenderSpec,
|
||||
results: HTMLElement,
|
||||
empty: HTMLElement,
|
||||
loading: HTMLElement,
|
||||
): void {
|
||||
loading.style.display = "none";
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
results.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.empty.pending_mine");
|
||||
void maybeShowAdminNudge();
|
||||
return;
|
||||
}
|
||||
hideAdminNudge();
|
||||
empty.style.display = "none";
|
||||
|
||||
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
||||
// RenderSpec sets row_action="approve" so we get the inbox markup.
|
||||
renderListShape(results, result.rows, render);
|
||||
|
||||
// Wire action handlers on the freshly stamped DOM. The action
|
||||
// POSTs land on the same endpoints the legacy /inbox used; on
|
||||
// success we trigger a bar refresh so the new state propagates.
|
||||
wireApprovalActions(results);
|
||||
}
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | 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 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
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[] = [];
|
||||
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 ?? [];
|
||||
}
|
||||
} catch (_e) {
|
||||
// Network errors fall through to empty render.
|
||||
}
|
||||
loading.style.display = "none";
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = t(
|
||||
currentTab === "pending-mine"
|
||||
? "approvals.empty.pending_mine"
|
||||
: "approvals.empty.mine"
|
||||
);
|
||||
empty.style.display = "";
|
||||
void maybeShowAdminNudge();
|
||||
return;
|
||||
}
|
||||
hideAdminNudge();
|
||||
for (const row of rows) list.appendChild(renderRow(row));
|
||||
}
|
||||
|
||||
// t-paliad-154 — show the admin-only "configure policies" nudge when:
|
||||
// - current user is global_admin
|
||||
// - inbox empty
|
||||
// - no approval_policies row exists firm-wide
|
||||
// - 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.
|
||||
async function maybeShowAdminNudge(): Promise<void> {
|
||||
const nudge = document.getElementById("inbox-admin-nudge");
|
||||
if (!nudge) return;
|
||||
@@ -186,7 +132,9 @@ async function maybeShowAdminNudge(): Promise<void> {
|
||||
if (data.any) return;
|
||||
|
||||
nudge.style.display = "";
|
||||
} catch (_e) { /* keep hidden */ }
|
||||
} catch (_e) {
|
||||
// Network failure → keep nudge hidden.
|
||||
}
|
||||
}
|
||||
|
||||
function hideAdminNudge(): void {
|
||||
@@ -194,7 +142,175 @@ function hideAdminNudge(): void {
|
||||
if (nudge) nudge.style.display = "none";
|
||||
}
|
||||
|
||||
async function refreshInboxBadge(): Promise<void> {
|
||||
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() {
|
||||
const badge = document.getElementById("sidebar-inbox-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
@@ -207,5 +323,7 @@ async function refreshInboxBadge(): Promise<void> {
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ProjectFormState {
|
||||
grantDate: string;
|
||||
court: string;
|
||||
caseNumber: string;
|
||||
ourSide: string;
|
||||
}
|
||||
|
||||
let parentCandidates: ProjectMini[] = [];
|
||||
@@ -178,6 +179,17 @@ export function readPayload(
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
if (desc) payload.description = desc;
|
||||
else if (!opts.omitEmpty) payload.description = "";
|
||||
@@ -214,6 +226,8 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-grant-date").value = isoToDate(p.grant_date as string | null | undefined);
|
||||
get("project-court").value = String(p.court ?? "");
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
|
||||
import { t, type I18nKey } from "../i18n";
|
||||
import type { 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);
|
||||
@@ -27,11 +19,6 @@ 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 {
|
||||
@@ -175,166 +162,3 @@ 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;
|
||||
|
||||
@@ -71,13 +71,10 @@ 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 {
|
||||
|
||||
@@ -153,6 +153,20 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-description" data-i18n="projects.field.description">Notizen</label>
|
||||
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projects.field.description.placeholder" />
|
||||
|
||||
@@ -273,6 +273,14 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
|
||||
@@ -652,6 +652,7 @@ export type I18nKey =
|
||||
| "dashboard.action.short.fristen_imported"
|
||||
| "dashboard.action.short.note_created"
|
||||
| "dashboard.action.short.notiz_created"
|
||||
| "dashboard.action.short.our_side_changed"
|
||||
| "dashboard.action.short.partei_added"
|
||||
| "dashboard.action.short.partei_removed"
|
||||
| "dashboard.action.short.project_archived"
|
||||
@@ -905,6 +906,7 @@ export type I18nKey =
|
||||
| "deadlines.perspective.defendant.short"
|
||||
| "deadlines.perspective.defendant.title"
|
||||
| "deadlines.perspective.label"
|
||||
| "deadlines.perspective.predefined_hint"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.proceeding.reselect"
|
||||
@@ -1108,6 +1110,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
| "event.title.project_created"
|
||||
| "event.title.project_reparented"
|
||||
@@ -1746,6 +1749,14 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
@@ -1929,60 +1940,6 @@ 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"
|
||||
|
||||
@@ -5,20 +5,13 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /inbox — t-paliad-163 universal-filter migration.
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
export function renderInbox(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -45,11 +38,18 @@ export function renderInbox(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="inbox-filter-bar" />
|
||||
<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 className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</div>
|
||||
<div className="entity-empty" id="inbox-empty" style="display:none" />
|
||||
<div id="inbox-results" />
|
||||
<ul className="inbox-list" id="inbox-list" />
|
||||
|
||||
{/* t-paliad-154 — admin-only nudge surfaced when:
|
||||
- the user is global_admin
|
||||
|
||||
@@ -1981,6 +1981,22 @@ input[type="range"]::-moz-range-thumb {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-164 — "vorgegeben durch Akte" hint shown next to the
|
||||
* perspective chips when project.our_side has predefined the chip.
|
||||
* Italic, muted, with a subtle leading bullet so it reads as
|
||||
* meta-info rather than a chip. The user can still click another
|
||||
* chip to override; the hint quietly disappears when they do. */
|
||||
.fristen-perspective-hint {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--color-muted, #666);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-perspective-hint::before {
|
||||
content: "·\00a0";
|
||||
}
|
||||
|
||||
.fristen-inbox-bar-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted, #666);
|
||||
@@ -13235,166 +13251,3 @@ 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;
|
||||
}
|
||||
|
||||
4
internal/db/migrations/072_projects_our_side.down.sql
Normal file
4
internal/db/migrations/072_projects_our_side.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Reverse t-paliad-164: drop the our_side column + check constraint.
|
||||
|
||||
ALTER TABLE paliad.projects DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS our_side;
|
||||
42
internal/db/migrations/072_projects_our_side.up.sql
Normal file
42
internal/db/migrations/072_projects_our_side.up.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- t-paliad-164 / m's 2026-05-08 21:42 dogfood feedback: when the user
|
||||
-- selects an Akte in the Determinator (Slice 3c perspective chip),
|
||||
-- the chip should already be locked to the firm's known side instead
|
||||
-- of asking the user to re-pick something the project already knows.
|
||||
--
|
||||
-- Add a project-level our_side text column. NULL = unknown / not set
|
||||
-- (default), so existing projects stay neutral and the Determinator
|
||||
-- falls back to free-pick. The chip values mirror event_categories.
|
||||
-- party so the Determinator can predefine the chip without mapping.
|
||||
--
|
||||
-- 'court' is allowed for completeness (paliad runs internal projects
|
||||
-- where the firm represents the court / a tribunal-side stakeholder
|
||||
-- — rare but real); the Determinator currently only acts on
|
||||
-- claimant / defendant.
|
||||
--
|
||||
-- Idempotent so re-runs against a partially-applied state stay safe
|
||||
-- (live tracker is at v71; paliad has been bitten by collisions
|
||||
-- twice this week, see m/paliad#15 commits and dirac's mig 070).
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS our_side text;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_our_side_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL
|
||||
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this project. Used by the '
|
||||
'Fristenrechner Determinator (Slice 3c) to predefine the '
|
||||
'perspective chip from the project context. Allowed: claimant, '
|
||||
'defendant, court, both. NULL = unknown / not set; Determinator '
|
||||
'falls back to free-pick.';
|
||||
@@ -156,6 +156,13 @@ type Project struct {
|
||||
CaseNumber *string `db:"case_number" json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
|
||||
// OurSide is which side the firm represents on this project. Used
|
||||
// by the Fristenrechner Determinator to predefine the perspective
|
||||
// chip from the project context (t-paliad-164). NULL = unknown /
|
||||
// not set; Determinator falls back to free-pick. Allowed values:
|
||||
// claimant, defendant, court, both.
|
||||
OurSide *string `db:"our_side" json:"our_side,omitempty"`
|
||||
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
|
||||
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number, matter_number,
|
||||
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
|
||||
proceeding_type_id, metadata, ai_summary, created_at, updated_at`
|
||||
proceeding_type_id, our_side, metadata, ai_summary, created_at, updated_at`
|
||||
|
||||
// CreateProjectInput is the payload for Create.
|
||||
type CreateProjectInput struct {
|
||||
@@ -121,6 +121,7 @@ type CreateProjectInput struct {
|
||||
Court *string `json:"court,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProjectInput is the partial-update payload.
|
||||
@@ -144,6 +145,7 @@ type UpdateProjectInput struct {
|
||||
Court *string `json:"court,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
}
|
||||
|
||||
// ListFilter narrows List results. Zero-value → no filter.
|
||||
@@ -819,14 +821,19 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
|
||||
// path is NOT NULL but the trigger populates it; supply a placeholder
|
||||
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
|
||||
if input.OurSide != nil {
|
||||
if err := validateOurSide(*input.OurSide); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number,
|
||||
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id, metadata, created_at, updated_at)
|
||||
court, case_number, proceeding_type_id, our_side, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, '{}'::jsonb, $21, $21)`,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, '{}'::jsonb, $22, $22)`,
|
||||
id, input.Type, input.ParentID,
|
||||
input.Title, input.Reference, input.Description, status,
|
||||
userID,
|
||||
@@ -834,6 +841,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
|
||||
input.PatentNumber, input.FilingDate, input.GrantDate,
|
||||
input.Court, input.CaseNumber, input.ProceedingTypeID,
|
||||
nullableOurSide(input.OurSide),
|
||||
now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert project: %w", err)
|
||||
@@ -967,6 +975,12 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
if input.ProceedingTypeID != nil {
|
||||
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
|
||||
}
|
||||
if input.OurSide != nil {
|
||||
if err := validateOurSide(*input.OurSide); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("our_side", nullableOurSide(input.OurSide))
|
||||
}
|
||||
if typeChanged {
|
||||
for _, col := range typeSpecificColumns(current.Type) {
|
||||
appendSet(col, nil)
|
||||
@@ -1012,6 +1026,32 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// our_side change: log when the value (or its set/unset state) actually
|
||||
// flips. Description follows the same value-only "old → new" pattern as
|
||||
// status_changed; frontend renderer maps the slugs to localized labels
|
||||
// (claimant / defendant / court / both / "—" for NULL).
|
||||
if input.OurSide != nil {
|
||||
nextOS := strings.TrimSpace(*input.OurSide)
|
||||
prevOS := ""
|
||||
if current.OurSide != nil {
|
||||
prevOS = *current.OurSide
|
||||
}
|
||||
if nextOS != prevOS {
|
||||
from := prevOS
|
||||
if from == "" {
|
||||
from = "none"
|
||||
}
|
||||
to := nextOS
|
||||
if to == "" {
|
||||
to = "none"
|
||||
}
|
||||
desc := fmt.Sprintf("%s → %s", from, to)
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, id, userID, "our_side_changed", "Represented side changed", descPtr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update project: %w", err)
|
||||
}
|
||||
@@ -1518,6 +1558,35 @@ func validateProjectStatus(s string) error {
|
||||
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
|
||||
}
|
||||
|
||||
// validateOurSide checks the project-level "represented side" enum
|
||||
// (t-paliad-164). Empty string is the explicit "clear" sentinel —
|
||||
// callers pass the value as-is from the form payload, and the helper
|
||||
// accepts it so an Update can null the column. The DB-level CHECK
|
||||
// constraint enforces the same set; this validation gives a clearer
|
||||
// error than relying on the constraint to fire.
|
||||
func validateOurSide(s string) error {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "", "claimant", "defendant", "court", "both":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
|
||||
}
|
||||
|
||||
// nullableOurSide returns nil for an empty / whitespace value so the
|
||||
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
|
||||
// Update payload contract: empty string from the form clears the
|
||||
// column, a value sets it.
|
||||
func nullableOurSide(p *string) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(*p)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func sortByOrder(xs []models.Project, order map[uuid.UUID]int) {
|
||||
// Insertion sort — ancestor lists are short (<20).
|
||||
for i := 1; i < len(xs); i++ {
|
||||
|
||||
@@ -45,23 +45,10 @@ type RenderSpec struct {
|
||||
// ListConfig is the per-shape config for shape=list. Powers both the
|
||||
// /events table look (density=comfortable) and the activity-feed look
|
||||
// (density=compact + actor/time columns).
|
||||
//
|
||||
// RowAction tells shape-list which row interaction to wire when the
|
||||
// universal <FilterBar> renders the table. "navigate" (the default and
|
||||
// the contract for the existing /agenda/dashboard surfaces) routes a
|
||||
// row click to a per-kind detail page. "complete_toggle" is the
|
||||
// /events deadline-row pattern (checkbox + reopen button). "approve"
|
||||
// is the /inbox approver row (approve/reject buttons + revoke). "none"
|
||||
// is read-only (audit views, retrospective lists).
|
||||
//
|
||||
// shape-list.ts honours this when emitting the table's `entity-table`
|
||||
// classes — `entity-table--readonly` plus `none` skips the navigate
|
||||
// handler entirely.
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
RowAction ListRowAction `json:"row_action,omitempty"`
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
}
|
||||
|
||||
// CardsConfig is the per-shape config for shape=cards.
|
||||
@@ -91,29 +78,6 @@ const (
|
||||
DensityCompact ListDensity = "compact"
|
||||
)
|
||||
|
||||
// ListRowAction identifies which row interaction the list-shape renderer
|
||||
// should wire. Defaults to RowActionNavigate when empty so existing
|
||||
// SystemView definitions and saved user views continue to render rows
|
||||
// that route to the per-kind detail page.
|
||||
type ListRowAction string
|
||||
|
||||
const (
|
||||
RowActionNavigate ListRowAction = "navigate"
|
||||
RowActionCompleteToggle ListRowAction = "complete_toggle"
|
||||
RowActionApprove ListRowAction = "approve"
|
||||
RowActionNone ListRowAction = "none"
|
||||
)
|
||||
|
||||
// KnownRowActions is the registry the validator checks against. Adding a
|
||||
// new action = add a const above AND append here AND extend
|
||||
// shape-list.ts's switch.
|
||||
var KnownRowActions = []ListRowAction{
|
||||
RowActionNavigate,
|
||||
RowActionCompleteToggle,
|
||||
RowActionApprove,
|
||||
RowActionNone,
|
||||
}
|
||||
|
||||
type CardsGroupBy string
|
||||
|
||||
const (
|
||||
@@ -184,9 +148,6 @@ func (c *ListConfig) validate() error {
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
|
||||
}
|
||||
if c.RowAction != "" && !slices.Contains(KnownRowActions, c.RowAction) {
|
||||
return fmt.Errorf("%w: unknown list.row_action %q", ErrInvalidInput, c.RowAction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -75,26 +75,6 @@ func TestRenderSpec_CalendarViewEnum(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_RowActionEnum(t *testing.T) {
|
||||
for _, action := range KnownRowActions {
|
||||
t.Run(string(action), func(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: action}}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("known row_action %q must validate: %v", action, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: "delete"}}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown row_action must reject, got %v", err)
|
||||
}
|
||||
// Empty defaults to navigate at the renderer level — schema accepts.
|
||||
empty := RenderSpec{Shape: ShapeList, List: &ListConfig{}}
|
||||
if err := empty.Validate(); err != nil {
|
||||
t.Fatalf("empty row_action must validate (defaults to navigate): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_RoundTrip(t *testing.T) {
|
||||
original := RenderSpec{
|
||||
Shape: ShapeList,
|
||||
|
||||
@@ -101,13 +101,8 @@ func EventsSystemView() SystemView {
|
||||
}
|
||||
|
||||
// InboxSystemView returns the SystemView definition for /inbox — the
|
||||
// 4-eye approval surface (the "Zur Genehmigung" view). The "Eigene
|
||||
// Anfragen" sibling view is selected via the bar's
|
||||
// approval_viewer_role axis (chip cluster on the same surface).
|
||||
//
|
||||
// RowAction = RowActionApprove → shape-list.ts renders the approval
|
||||
// row layout (entity title + diff + approve/reject/revoke buttons)
|
||||
// and the surface wires action handlers via the rendered data-attrs.
|
||||
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
|
||||
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
|
||||
func InboxSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "inbox",
|
||||
@@ -127,17 +122,14 @@ func InboxSystemView() SystemView {
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
RowAction: RowActionApprove,
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InboxRequesterSystemView is the "Eigene Anfragen" sibling view of
|
||||
// /inbox. Reachable via the bar's approval_viewer_role chip ("Eigene
|
||||
// Anfragen") on the /inbox surface, or as its own URL on /views/inbox-mine.
|
||||
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
|
||||
func InboxRequesterSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "inbox-mine",
|
||||
@@ -156,9 +148,8 @@ func InboxRequesterSystemView() SystemView {
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
RowAction: RowActionApprove,
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user