diff --git a/cmd/server/main.go b/cmd/server/main.go index 50805b2..c8bb33a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -161,6 +161,7 @@ func main() { Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc), UserView: services.NewUserViewService(pool), Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc), + Pin: services.NewPinService(pool, projectSvc), } // t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL diff --git a/docs/design-projects-page-2026-05-07.md b/docs/design-projects-page-2026-05-07.md new file mode 100644 index 0000000..159b983 --- /dev/null +++ b/docs/design-projects-page-2026-05-07.md @@ -0,0 +1,1250 @@ +# Projects page redesign — tree-first, filter chips, search-primary, view selector + +**Status:** READY FOR REVIEW (godel, inventor, 2026-05-07) — 4 surfaced questions answered by m, see §12 +**Issue:** m/paliad#10 (t-paliad-149) +**Branch:** `mai/godel/inventor-projects-page` + +## m's locks (2026-05-07 22:08) + +m answered the 4 surfaced questions: +- **Q1 default landing:** Last-viewed restore (sessionStorage; first-ever visit falls back to Tree + Alle + top-level only) +- **New-Q20 Cards default content:** Rich (~9 facts) +- **New-Q21 Cards customisation:** Full drag-rearrange + named layouts (needs new `paliad.user_card_layouts` table — migration 061) +- **Q13 search shape:** Both simultaneously (in-place tree/cards filter on page + global Cmd-K palette stays as the from-anywhere shortcut) + +These 4 are LOCKED. The other 17 recommendations remain READY-FOR-REVIEW; +m may challenge any in the review pass. + +## 0. TL;DR + +`/projects` gets **three view modes** (Tree | Flat | Cards), tree-first by default +(rooted at clients, descendants navigable), behind a chip row (Alle / Nur meine / +Angepinnt / Status / Typ / Mit aktiven Fristen) with **AND-across-chips, +OR-within-multi-select** combinatorics, plus a single prominent search input that +filters the active view (in-place for tree; substring filter for cards / flat). +Pinning is a per-user dot table (`paliad.user_pinned_projects`, migration 060) with +a star marker on every row/card + an "Angepinnt" filter chip. + +**Cards view** (m's addition 2026-05-07 22:00) is a quick-overview surface: +each card shows configurable facts about one project — title + type + status, +deadline counts, **next 3 events**, **last 3 events / Verlauf entries** — all +hoverable for detail and clickable to drill in. Card content + layout are +user-customisable: v1 ships **two presets (Kompakt / Geräumig) + a small +"shown facts" checklist**; full per-user drag-rearrange deferred to v2. +Per-user prefs live in `localStorage` in v1 (no new table). + +The existing `/api/projects/tree` endpoint and `client/project-tree.ts` are +reused verbatim for the Tree rendering primitive — they already do the right +shape (visibility-scoped, deadline-count-aggregated, expand/collapse, +click-to-navigate). This redesign adds **a chip-driven filter overlay**, **a +search filter**, **pinning**, **a mobile drill-in mode**, and **the Cards +view** on top. + +**Q15 decision (delegated to inventor by m):** option (a) — bespoke `/projects` +selector with hardcoded system chips. Rationale in §6. Custom Views (t-144) +stays event-shaped; projects are scope, not events; conflating the two would +require a new project DataSource + tree RenderShape that nothing else needs. + +**4 surfaced questions for m** (via AskUserQuestion this session): +- Q1 default landing + view mode (Tree | Cards as default?) +- New-Q20 Cards default content (which facts on a card by default) +- New-Q21 Cards customisation scope (presets only | drag-customise | none) +- Q13 search shape (in-place vs Cmd-K modal) + +Other 17 issue-body and inventor follow-up questions are answered with +recommendations + rationale below; m can challenge any of them in the review. + +## 1. Problem + premises verified live + +m's framing 2026-05-07 21:52: + +> "The Projects Page needs to become nicer. … default view to be a view of all clients +> — and one can filter very nicely. 'All' / 'Only mine' / Pinned / by status. … The tree +> view is much nicer and more natural than the flat view. The search should be the main +> thing for 'ad-hoc' view, for favourites / pinned or other views we need to have a selector." + +### 1.1 What exists today + +- `frontend/src/projects.tsx` (140 LoC) — page shell with three filters + (Typ / Status / Ansicht: flach|baum|wurzeln) and a free-text search input. + Default view = flat. Tree is one of three options. +- `frontend/src/client/projects.ts` (238 LoC) — list-page client. Reads + `/api/projects` flat list, filters in JS, renders ``. + Tree mode delegates to `client/project-tree.ts`. +- `frontend/src/client/project-tree.ts` (289 LoC) — tree renderer. Reads + `/api/projects/tree` once + caches. Top-2-levels open by default; deeper + collapsed. `sessionStorage` persists overrides per-node. Click row → navigate. + Type icons (client/litigation/patent/case/project) defined inline. Deadline + counts (open + overdue) shown as per-node badges (NOT subtree-aggregated). +- `internal/handlers/projects.go:245` — `GET /api/projects/tree` returns the + full nested tree (`ProjectService.BuildTree`). +- `internal/services/project_service.go:319` — `BuildTree` does + visibility-scoped path-ordered SELECT + per-project deadline-count JOIN + + in-memory stitching. Subtree aggregation **not** done in v1; counts are + per-node only. +- `frontend/src/styles/global.css:5563` — `.projekt-tree-*` CSS rules. Chip + patterns already exist as `.fristen-forum-chip` and `.fristen-search-chip` + (reusable shape; rename for projects-page namespace). + +### 1.2 What does NOT exist today + +- **No pin / favourite table.** Sidebar has a `paliad-sidebar-pinned` + localStorage key but that's the sidebar pin (sticky-open), unrelated to + project pinning. +- **No "mine" semantics endpoint.** Project membership comes from + `paliad.project_teams` (direct) ± inherited via t-139's `can_see_project`. + No service method returns "projects I'm directly on the team of." +- **No subtree-aggregated counts.** Existing tree shows per-node deadline + counts; aggregating up the tree is a TODO in `BuildTree` per the t-139 lock. +- **No Custom Views project source.** `internal/services/filter_spec.go` + declares `AllSources = {deadline, appointment, project_event, + approval_request}`. No `project` source. RenderShape is `{list, cards, + calendar}` — no `tree` shape. + +### 1.3 Premises that survive into the design + +1. The tree primitive is already correct shape. Reuse it. +2. The flat-list view stays available (issue says so, and a flat list is + still the right call for narrow searches: "give me every Verfahren in + status=open across all clients"). Just stops being the default. +3. Visibility (`paliad.can_see_project`) already extends through ancestors + + descendants + partner-unit derivation (t-139 shipped). Nothing to add + on the visibility front. +4. Migration tracker is at 059 on main (058 = paliadin_poc t-146, 059 = profession_vs_responsibility t-148, both shipped 2026-05-07). Next available is 060 (PR 1) and 061 (PR 2). +5. The Custom Views substrate (t-144) is **NOT** the right place for projects + — see §6. + +## 2. Locked direction (m, 2026-05-07) + +- Default view: tree, rooted at clients, with all descendants navigable. +- Chip row at top: Alle / Nur meine / Angepinnt / nach Status / further axes + inventor proposes. +- Search is the primary ad-hoc tool — prominent, not buried. +- A "view selector" for favourites / pinned / saved views (Q15: inventor + decides whether this is bespoke or piggy-backs on Custom Views). +- Tree-first; flat-list view stays available but isn't the default. + +## 3. Sub-design 1 — Filter axes + chip set + combinatorics + +### 3.1 v1 chip set (Q2) + +Recommended chips (left to right in the toolbar): + +| Chip | Value(s) | Default | +|---|---|---| +| **Alle** | (resets all chips) | active on first visit | +| **Nur meine** | toggle: on / off | off | +| **Angepinnt** | toggle: on / off | off | +| **Status** | multi-select panel: Aktiv / Archiviert / Abgeschlossen | nothing selected = no narrowing | +| **Typ** | multi-select panel: Mandant / Streitsache / Patent / Verfahren / Projekt | nothing selected = no narrowing | +| **Mit aktiven Fristen** | toggle: on / off | off | + +The first 3 are toggles (single click). The next 2 are multi-select dropdowns +(reuse the `EventTypeMultiSelect` component shape from t-088 — popover with +checkbox list + "Alle" / "Ohne" specials). The last is a simple toggle. + +Deferred to v2 (worth flagging but not in v1): +- "Bevorstehende Termine" (next 7d) — overlaps "Mit aktiven Fristen" enough + for v1. +- "Mit offenem Approval-Request" — coupling to t-138 not worth a v1 chip. +- "Recent activity" sort — covered as Q16 below; defer. +- "Wo ich Lead bin" — finer-grained than "Nur meine"; rename if m wants it. + +### 3.2 Combinatorics (Q3) + +**AND across chips.** Each chip narrows. Rationale: every chip is intent- +narrowing language ("only my … pinned … active deadlines …"). OR across chips +would force the user to think in set-union language, which patent paralegals +don't. + +**OR within multi-select.** A user picks "Status: Aktiv + Abgeschlossen" to +see both. AND across the multi-select would only work for orthogonal +dimensions; it doesn't make sense within one axis. + +**Empty multi-select = no narrowing.** Closing the Status panel without +ticking any box means "I don't care about status" — the chip shows base label +("Status"); ticking ≥1 means "narrow to these" — the chip shows +"Status: 2 ausgewählt" (or single-label if exactly one). + +### 3.3 "Nur meine" semantics (the load-bearing one) + +**Recommendation: direct membership only** in v1, NOT inherited. + +- Direct = there is a row in `paliad.project_teams` where + `(project_id, user_id) = (this, me)`. +- Inherited / derived would include ancestor-staffed + partner-unit-derived + (per t-139). That's the same set as `can_see_project` (visibility), and at + that point "Nur meine" = "visible to me", which is just the default tree. + +So "Nur meine" must be **strictly narrower than visibility** to be useful. +Direct-only is the obvious meaning. A user staffed on a `Patent` row sees the +parent `Litigation` and `Client` in their tree (visibility cascades up) but +"Nur meine" filters to the `Patent` row only — the ancestors collapse out. + +Edge case: if a user has direct membership only on a deep `Case` row, "Nur +meine" shows that Case as a top-level orphan (no ancestor visible). The +alternative is to keep the ancestor chain visible-but-greyed (so they +understand which Mandant the Case sits under). Recommendation: **keep +ancestors visible-but-greyed** — they're already in the visibility scope, +and the chain provides indispensable context. Greyed = `opacity: 0.55` on +the row, no badges, no pin star, click still navigates. + +This needs a new endpoint or a `?scope=mine` parameter on `/api/projects/tree` +that returns: +- All directly-staffed projects. +- Their ancestors (greyed; client returns flag `inherited_visibility: true`). +- No descendants of directly-staffed projects unless they're also directly- + staffed. + +### 3.4 Empty-state (Q14) + +If chips narrow the tree to zero rows: +> "Keine Projekte für diese Filter. **[Filter zurücksetzen]** **[+ Neues Projekt]**" + +Both buttons inline. The first resets all chips (= "Alle" active); the second +goes to `/projects/new`. + +## 4. Sub-design 2 — Tree view + UX + pinning + +### 4.1 Default landing (Q1) — LOCKED last-viewed restore (m 2026-05-07) + +Storage key: `paliad.projects.lastView` in sessionStorage. Shape: + +```ts +{ + viewMode: "tree" | "cards" | "flat", // last selected view-mode segment + chips: { // last applied chip state + scope: "all" | "mine" | "pinned", + status: string[], + type: string[], + hasOpenDeadlines: boolean, + }, + searchQuery: "", // empty = no active search + // tree-mode only: + treeExpanded: string[], // explicit expand overrides per node + // cards-mode only: + cardLayoutId: "uuid", // active named layout (server is source of truth) + // flat-mode only: + flatColumns: string[], // future-proof; v1 unused +} +``` + +URL params (`?view=…`, `?chips=…`, `?q=…`, `?focus=…`, `?expand=…`) override +the sessionStorage state on load. So: +- Bookmarks / share-links → URL takes precedence. +- Plain navigation back to `/projects` → sessionStorage restores the last view. + +**First-ever visit fallback** (no sessionStorage entry, no URL params): Tree +view, "Alle" chip, top-level only expanded (Mandanten visible, descendants +collapsed). This is option (a) from the surfaced question — the predictable +fallback for first-time users. + +When sessionStorage is cleared (private mode, browser cleanup), behaviour +falls back to first-ever-visit defaults. + +Edge case: if the user's last view was Cards but the active layout has been +deleted from another device, fall back to the user's current default layout. + +### 4.2 Tree state persistence (Q4) + +Recommendation: **session-only via `sessionStorage` (current behaviour) + +`?expand=` URL param for share-links + `?focus=` for deep-link**. + +Rationale: a per-user persistent expand state is one more table-row per +project (`paliad.user_project_expanded`) for very low return — the user has +to learn that expand state survives across sessions, which most users won't +even notice. SessionStorage already fits this need (open Mandant, navigate +into Patent, come back, Mandant is still open). Persistent state can land +in v2 if anyone asks. + +The URL param overlay lets share-links and palette-deep-links land at the +right node: +- `/projects?focus=` — opens with ancestors expanded + node scrolled + into view + briefly highlighted (lime accent flash, 600ms). +- `/projects?expand=uuid1,uuid2,uuid3` — explicit subset of expanded nodes. + Useful for "show this exact tree shape to someone." + +### 4.3 Deep-link to a node (Q5) + +`/projects?focus=` — see above. Also exposed as a programmatic helper +for sidebar links + breadcrumb navigation: anywhere we link to "see this +project's parent in the tree" we use `?focus=`. + +### 4.4 Lazy-loading (Q6) + +Recommendation: **no lazy-loading in v1.** The current `/api/projects/tree` +returns the full visible tree in one round-trip (`BuildTree`). Live data: +even global_admins see ≤ 200 projects today; the response is < 100 KB. +The tree renders instantly on m's local stack. + +If/when paliad scales past ~1000 projects, the cleanest path is: +- Server still returns the tree skeleton (id + parent_id + title + path) in + one shot. +- Per-row counts (open_deadlines, overdue_deadlines, subtree-aggregated) + become separate `/api/projects/tree/counts?ids=…` requests, lazy-fetched + per visible viewport batch. + +Not v1. + +### 4.5 Inline counts per node (Q7) + +Recommendation: **subtree-aggregated counts by default**, with a "nur direkt" +toggle (parallel to t-139's pattern on /projects/{id}). + +A Mandant row's badge "12 offene Fristen" reflects the union of its own + +all descendants', not just its own (Mandanten typically have 0 direct +deadlines — they're scaffolding rows). Without aggregation, every Mandant +row reads "0 / 0" which is misleading. + +Aggregation pattern from t-139 already exists in `BuildTree`: +- `open_deadlines` and `overdue_deadlines` are per-node today. +- New columns `open_deadlines_subtree` and `overdue_deadlines_subtree` + aggregated in the same query (sum over `path <@ p.path`). +- Display: by default, badge shows subtree count. Hovering the badge tooltip: + "12 (eigene: 0 / aus 4 Unterprojekten: 12)". +- Toggle in toolbar: "[ ] Nur direkte Fristen zählen" — switches both badges + to per-node only. + +Same treatment for `Termine` if we add a Termine badge (recommendation: not +in v1; deadline badges are enough; Termine count would clutter rows). + +### 4.6 Action affordances per row (Q8) + +Recommendation: **always-visible pin star on every row + click-row-to-open +detail + no right-click context menu in v1**. + +- **Pin star (⋆ filled / ☆ empty):** small button, far-right of the row, before + the status chip. Click toggles pin. Always visible (not hover-revealed) — + m is on a touch surface ~30% of the time per the PWA strategy, hover is + unreliable. +- **Row click:** navigates to `/projects/{id}` (already implemented). +- **Toggle chevron click:** expands / collapses (already implemented). +- **No right-click menu in v1.** Bulk operations (close, archive, move) are + out of scope per the issue. If a v2 wants per-row actions, an inline `⋯` + button on the right wins over right-click (works on touch, more + discoverable). + +Mobile: at ≤ 768px, switch to drill-in mode (Q17). Pin star is still on the +row but the row is full-width and tappable. + +### 4.7 Pinning storage (Q9, Q11) + +New table `paliad.user_pinned_projects`. Migration 060 (next available). + +```sql +CREATE TABLE paliad.user_pinned_projects ( + user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE, + project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, + pinned_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, project_id) +); + +CREATE INDEX user_pinned_projects_user_idx + ON paliad.user_pinned_projects (user_id, pinned_at DESC); + +ALTER TABLE paliad.user_pinned_projects ENABLE ROW LEVEL SECURITY; + +CREATE POLICY user_pinned_projects_owner_all + ON paliad.user_pinned_projects FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +- Per-user (Q11 confirmed). Pinning is personal working state, not project- + team metadata. RLS is symmetric: only the owner reads or writes. +- ON DELETE CASCADE on both sides. Project deletion removes pin rows; + user deletion removes their pins. +- No `display_order` column — pins are sorted by `pinned_at DESC` (most + recent first). User-defined ordering can land in v2 if needed. + +API: +- `POST /api/projects/{id}/pin` → 201, idempotent. Body: empty. +- `DELETE /api/projects/{id}/pin` → 204, idempotent. +- Pin state surfaces inline in `/api/projects/tree` (new field `pinned: bool`). +- `GET /api/user-pinned-projects` returns the flat list of pinned IDs (used + by the sidebar widget if we want one — defer to v2). + +### 4.8 Pin display (Q10) — surfaced to m + +Two reasonable shapes: + +- **(a) Star marker only.** Pinned projects get a filled-star marker in the + row; "Angepinnt" filter chip filters the tree to pinned-only (other rows + hidden). Tree shape preserved. +- **(b) Pinned subtree pulled to top.** Above all client subtrees, a pseudo- + group `⋆ Angepinnt (4)` containing flat references to pinned projects (with + a small parent-chain breadcrumb so you know which Mandant). Real client + subtrees follow. +- **(c) Both: star marker on real rows AND a "⋆ Angepinnt" pseudo-group at + top, always visible.** + +Recommendation **(a)** — keeps the tree simple, the chip handles the "pinned +only" view. (b)/(c) double-render some projects which is confusing. + +But m should pick. Surfacing. + +### 4.9 Mobile (Q17) + +Recommendation: **drill-in mode at ≤ 768px**. + +- Tap a row → push the row's children as a new "page" (animated slide-in). +- Header replaces with a back-arrow + breadcrumb chip ("Siemens AG ›"). +- Tap back → pop one level. +- Search and chips stay at the top of the current level. + +Indented-list mode (the desktop tree shoehorned onto mobile) is awkward — +deeply nested children get illegible at <600px. Drill-in is what every +mobile file manager / mail-folder app does. + +Implementation: client-side only. Server still returns the full tree. +At ≤ 768px, the renderer keeps a `currentNode` pointer; rendering slices +the tree starting at that pointer; back-arrow walks up via `parent_id`. + +## 5. Sub-design 3 — Search shape + integration + +### 5.1 Search shape (Q13) — LOCKED both simultaneously (m 2026-05-07) + +The /projects page input does **in-place filter on the active view**: + +- **Tree mode:** non-matching rows hide; ancestors of matches stay visible + (greyed `opacity: 0.55`) so context is preserved; matching substring + highlights in lime on the title. +- **Cards mode:** non-matching cards hide; substring highlights in lime on + the title and parent path. +- **Flat mode:** standard table-row substring filter (existing behaviour). + +The global Cmd-K palette stays unchanged — `Cmd-K` / `Ctrl-K` still opens +the universal search-overlay from anywhere in the app, including from +`/projects`. Users get both: the page input narrows the current view; Cmd-K +jumps to anywhere across courts / glossary / projects / tools. + +Implementation note: the page input does NOT also open Cmd-K when focused. +Tabbing into the input doesn't trigger the overlay. The two surfaces stay +visually and behaviourally distinct (page input is inline; Cmd-K is overlay). + +### 5.2 Search scope (Q12) + +Recommendation: **title + parent path + reference + clientmatter**. + +- Title is the obvious one. +- Parent path: type "Müller" → match the Case "14-vs-Müller" (already in + title, ✓), AND match every project under a Mandant titled "Müller AG" + (descendant-by-ancestor-title). This is what users mean by "give me Müller + stuff." +- Reference: matches the per-project `reference` field. +- ClientMatter: `client_number.matter_number`. + +Backend implementation: server-side, postgres `pg_trgm` similarity query + +ancestor-title boost. Endpoint: + +`GET /api/projects/tree?q=` — returns the tree filtered to nodes that +match OR have a matching ancestor OR have a matching descendant. + +Client-side echo: the input also does a local substring filter on the cached +tree for instant feedback (debounced 200ms before hitting the server). The +server response trumps the local echo when it arrives. + +### 5.3 Empty-state (Q14) + +Same as §3.4. "Keine Projekte für diese Filter. [Filter zurücksetzen] +[+ Neues Projekt]." Plus, if the user has typed a search query, an extra +"Suche im Glossar / Gerichten / Glossar?" link to the Cmd-K palette. + +### 5.4 Performance (Q19) + +Debounce 200ms (standard). Cap server results at 200 nodes for any query; +beyond that, show "200+ Treffer — bitte Suche präzisieren" footer. + +## 5b. Sub-design 5 — Project Cards view (m's addition 2026-05-07 22:00) + +m's framing: + +> "I would also like to have 'Project Cards' View where we can customize how +> we show the project card. Like what facts do we show and how the layout is. +> I could imagine a card where we also see the next three events and the +> last three, hoverable and clickable. So people get a really quick overview." + +Cards is a **third view mode** alongside Tree and Flat. It is reached via a +view-mode segment-control at the top of the page (Tree | Cards | Flat). View +mode persists in `localStorage` (`paliad.projects.viewMode`). + +### 5b.1 What a card represents + +One project per card. Cards is a **flat grid** ordered by: +- Pinned first (within their group) when "Angepinnt" chip is off. +- Then by `last_activity_at DESC` (most-recently-touched first; "last_activity_at" + = `MAX(updated_at across own + descendants' deadlines / appointments / + project_events)` — already computable from existing tables, optionally + cached as `paliad.projects.last_activity_at` updated by triggers if perf + warrants). + +Chips narrow the set the same way they narrow Tree / Flat. Search filters cards +by substring in the same fields (title + parent path + reference + clientmatter). + +By default, Cards shows **leaf-ish projects only** (Cases, Patents, Verfahren, +Projekte) — Mandanten and Litigation rows are scaffolding and don't have direct +events / deadlines themselves under the t-139 hierarchy model. A toggle +`[ ] Alle Ebenen anzeigen` shows Mandanten / Litigation cards too (with their +subtree-aggregated counts and subtree event preview). Toggle persists in +localStorage. + +### 5b.2 Card anatomy — default content + +``` +┌─────────────────────────────────────────────────┐ +│ ⋆ EP 1234567 — Verteidigung ⋯ │ ← Title row (pin star, ⋯ menu, type icon) +│ [Patent] · [Aktiv] · 12345.678 │ ← Type + Status + ClientMatter chips +│ │ +│ Müller AG › Müller vs. Apple │ ← Parent path (clickable breadcrumb) +│ │ +│ ▸ 3 offene Fristen ▸ 1 überfällig │ ← Deadline counts (subtree-aggregated) +│ │ +│ Nächste Termine │ +│ ░ 15 Mai · Klageerwiderung (Frist) │ ← Hoverable + clickable event row +│ ░ 22 Mai · Mündliche Verhandlung (Termin) │ +│ ░ 03 Jun · Erwiderung (Frist) │ +│ │ +│ Zuletzt │ +│ ▣ heute · Frist erstellt von Anna Schmidt │ ← Verlauf entries from project_events +│ ▣ gestern · Termin verschoben │ +│ ▣ 03 Mai · Genehmigung erteilt │ +│ │ +│ Team: AS · MK · +2 │ ← Avatar / initials chips of staffed team +└─────────────────────────────────────────────────┘ +``` + +Hover on any "Nächste Termine" or "Zuletzt" row → tooltip with the full title, +context, and deep-link metadata. Click → navigate to the deadline / appointment / +project detail (with `?focus=` on the relevant entity). + +Default visible facts (all on by default): +1. Title + type icon + pin star + ⋯ menu +2. Type chip +3. Status chip +4. ClientMatter +5. Parent path breadcrumb +6. Deadline counts (subtree) +7. Next 3 events (Fristen + Termine, sorted asc, top 3) +8. Last 3 Verlauf entries (project_events sorted desc, top 3) +9. Team chips (initials, capped at 3 + "+N") + +### 5b.3 Customisation scope — LOCKED option (c) full drag-rearrange + named layouts (m 2026-05-07) + +m chose **full per-user customisation with named layouts**. Implementation: + +**New table — migration 061:** + +```sql +CREATE TABLE paliad.user_card_layouts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE, + name text NOT NULL, -- "Standard", "Kompakt", "Was steht an", … + is_default boolean NOT NULL DEFAULT false, + layout_json jsonb NOT NULL, -- ordered facts list + density + grid + counts + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (user_id, name) +); + +-- At most one default layout per user (partial unique index) +CREATE UNIQUE INDEX user_card_layouts_default + ON paliad.user_card_layouts (user_id) + WHERE is_default = true; + +ALTER TABLE paliad.user_card_layouts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY user_card_layouts_owner_all + ON paliad.user_card_layouts FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +**Layout JSON schema (server-side validated):** + +```ts +type CardLayout = { + facts: Array<{ + key: "title-row" | "type-chip" | "status-chip" | "client-matter" + | "parent-path" | "deadline-counts" | "next-events" + | "recent-verlauf" | "team-chips" | "reference" | "last-activity-at", + visible: boolean, + // Per-fact extras (only meaningful for some keys): + count?: number, // for next-events / recent-verlauf, 1..5 + }>, + density: "compact" | "roomy", + gridColumns: "auto" | 2 | 3 | 4, + showAllLevels: boolean, // include Mandanten / Litigation rows as cards +}; +``` + +The `facts` array's order is the render order top-to-bottom within a card. +Validator on the server rejects: +- Unknown fact keys +- Duplicate keys (each appears at most once) +- `count` outside [1, 5] +- More than one fact occupying the "title-row" slot (it must be the first + visible row) + +**Service layer — `CardLayoutService` (~120 LoC):** +- `List(ctx, userID) ([]*CardLayout, error)` — sorted by `name ASC`, default first +- `Get(ctx, userID, id) (*CardLayout, error)` +- `GetDefault(ctx, userID) (*CardLayout, error)` — auto-creates the seed + "Standard" layout (the rich content set) on first call +- `Create(ctx, userID, name, layout) (*CardLayout, error)` — `is_default = false` + unless this is the user's first +- `Update(ctx, userID, id, name?, layout?, is_default?) error` — flipping + `is_default = true` clears the flag on the user's previous default in tx +- `Delete(ctx, userID, id) error` — cannot delete the default; UI gates this + +**API surface (PR 2):** +- `GET /api/user-card-layouts` → 200 with `[…]` +- `POST /api/user-card-layouts` → 201 with the new row (body: name + layout) +- `PATCH /api/user-card-layouts/{id}` → 200 (body: any of name / layout / is_default) +- `DELETE /api/user-card-layouts/{id}` → 204 +- `POST /api/user-card-layouts/{id}/set-default` → 204 (sugar over PATCH) + +**Frontend customisation UI:** + +- Top of Cards view: layout dropdown: + ``` + Layout: [Standard ▾] [+ Neues Layout] [⚙ Bearbeiten] + ``` +- The dropdown lists all the user's named layouts. Picking one switches + the active layout immediately (POST `/api/user-card-layouts/{id}/set-default` + updates the server-side default; `localStorage` mirrors for instant pickup + on next load). +- "+ Neues Layout" prompts for a name (modal), seeds with the current + active layout's `layout_json`, opens edit mode on the new layout. +- "⚙ Bearbeiten" puts cards into **edit mode**: + - Each card grows a faint outline. + - Each fact within a card grows a drag handle (≡) + visibility toggle (eye/eye-off). + - Drop zones appear between facts — drag a fact up or down to reorder. + - For `next-events` / `recent-verlauf`, a count stepper [-] [3] [+] appears. + - Top-of-page bar: density radio (Kompakt / Geräumig), gridColumns + select (Auto / 2 / 3 / 4), "Alle Ebenen anzeigen" toggle. + - Bottom-of-page: "Speichern" / "Verwerfen" / "Als Standard festlegen" + buttons. +- HTML5 drag-and-drop is enough (Chromium / Safari / Firefox all support + draggable `
  • ` elements with `dataTransfer`). No third-party DnD library. +- Mobile: edit mode shows a long-press-and-drag affordance per fact + (`pointerdown` + 500ms hold). + +**v2 escape hatches:** firm-wide layout templates ("Was unsere Senior-PAs +sehen"); per-project-type defaults (different layout for Mandant cards vs +Case cards); cross-user sharing; export/import as JSON. + +### 5b.4 Default content — LOCKED option (α) rich (m 2026-05-07) + +The seed "Standard" layout (auto-created on first cards-view visit) carries +all 9 facts shown in §5b.2: + +1. Title row (title + type icon + pin star + ⋯ menu) — `key: "title-row"`, always-visible & always-first +2. Type chip — `key: "type-chip"` +3. Status chip — `key: "status-chip"` +4. ClientMatter — `key: "client-matter"` +5. Parent path breadcrumb — `key: "parent-path"` +6. Deadline counts (subtree) — `key: "deadline-counts"` +7. Next 3 events — `key: "next-events"`, `count: 3` +8. Last 3 Verlauf entries — `key: "recent-verlauf"`, `count: 3` +9. Team chips — `key: "team-chips"` + +Density: `roomy`. Grid: `auto`. `showAllLevels: false`. + +The user can hide any fact (except `title-row`), reorder them, change counts, +and create alternative named layouts. The seed itself can be edited — there's +no "factory reset" beyond deleting the seed and creating a fresh one with the +same layout via the editor. + +### 5b.5 Event preview implementation + +Cards mode needs per-project event rollups. Two approaches: + +1. **Embed in `/api/projects/tree` response.** Each node carries + `next_events: [...3]` and `recent_verlauf: [...3]`. Pros: one round-trip; + tree response is already the canonical primitive. Cons: ~6 extra rows × + N projects = N×6 rows even when the user hasn't switched to Cards yet. +2. **Separate `/api/projects/cards-preview` endpoint, called only on Cards + mode mount.** Lazy. Cons: second round-trip. Pros: doesn't bloat the tree + payload. + +Recommendation: **(2) separate endpoint, lazy-fetched on Cards mode**. Tree +mode payload stays lean. The endpoint does a single SQL query (deadlines + +appointments + project_events filtered to visible projects, partitioned by +project_id, top 3 each direction). Cached server-side per user (5min TTL) +because the data doesn't change rapidly. + +Endpoint shape: + +``` +GET /api/projects/cards-preview +→ 200 [ + { + project_id: "uuid", + next_events: [ { kind, id, title, event_date, …project_id…, route } …3 ], + recent_verlauf: [ { kind, id, title, event_date, actor_id, actor_name, route } …3 ], + team_initials: ["AS", "MK", …], + team_count: 5, + last_activity_at: "2026-05-07T14:30:00Z", + }, … + ] +``` + +`route` is the navigation target (`/projects/{pid}?focus=` for +deadlines/appointments; `/projects/{pid}?tab=verlauf&focus=` for +project_events). Computed server-side so the client doesn't hardcode URLs. + +### 5b.6 Hover affordance + +Each event row in a card is `` @@ -130,13 +146,24 @@ function renderNode(node: ProjectTreeNode, depth: number): string { const icon = typeIcons[node.type] || typeIcons.project; + // Pin star — always-visible (touch-friendly per design §4.6). + const pinned = !!node.pinned; + const pinLabel = pinned + ? (t("projects.tree.unpin") || "Pin entfernen") + : (t("projects.tree.pin") || "Anpinnen"); + const pinStar = ``; + + const subtreeHint = useSubtree + ? (t("projects.tree.deadlines.subtree.tooltip") || "Inkl. Unterprojekte") + : (t("projects.tree.deadlines.direct.tooltip") || "Nur direkt"); + let badges = ""; if (overdue > 0) { - const label = t("projects.tree.deadlines.overdue") || "überfällig"; + const label = (t("projects.tree.deadlines.overdue") || "überfällig") + " — " + subtreeHint; badges += `${overdue}`; } if (openCount > 0 && overdue === 0) { - const label = t("projects.tree.deadlines.open") || "offene Fristen"; + const label = (t("projects.tree.deadlines.open") || "offene Fristen") + " — " + subtreeHint; badges += `${openCount}`; } @@ -148,11 +175,24 @@ function renderNode(node: ProjectTreeNode, depth: number): string { ? `
      ${node.children.map((c) => renderNode(c, depth + 1)).join("")}
    ` : ""; + // Modifier classes for the row: + // - is-inherited: greyed-ancestor under Scope=Mine / Scope=Pinned (design §3.3) + // - is-match-self / is-match-ancestor / is-match-descendant: search highlighting + const modifiers: string[] = []; + if (node.inherited_visibility) modifiers.push("is-inherited"); + if (node.match_kind === "self") modifiers.push("is-match-self"); + if (node.match_kind === "ancestor") modifiers.push("is-match-ancestor"); + if (node.match_kind === "descendant") modifiers.push("is-match-descendant"); + const rowClass = ["projekt-tree-row", ...modifiers].join(" "); + const inheritedHint = node.inherited_visibility + ? ` title="${esc(t("projects.tree.inherited.context") || "Sichtbar wegen Unterprojekt")}"` + : ""; + return ( `
  • ` + - `
    ` + + `
    ` + toggle + `${icon}` + `${esc(node.title)}` + @@ -161,16 +201,32 @@ function renderNode(node: ProjectTreeNode, depth: number): string { `` + badges + `${esc(statusLabel)}` + + pinStar + `
    ` + childMarkup + `
  • ` ); } -async function fetchTree(container: HTMLElement): Promise { - if (cache) return cache; +// Lucide-style filled / outline star for the pin toggle. +const starFilled = + ``; +const starOutline = + ``; + +// fetchTree calls /api/projects/tree with the orchestrator's query params. +// The cache key is the params string — any change reloads. Pass an empty +// URLSearchParams for the legacy "all visible projects" call. +async function fetchTree(container: HTMLElement, params: URLSearchParams): Promise { + const key = params.toString(); + if (cache && cacheParams === key) return cache; + const url = key ? `/api/projects/tree?${key}` : "/api/projects/tree"; try { - const resp = await fetch("/api/projects/tree"); + const resp = await fetch(url); if (resp.status === 503) { container.innerHTML = `
    ${esc(t("projects.unavailable") || "Projektverwaltung zurzeit nicht verfügbar")}
    `; return null; @@ -180,6 +236,7 @@ async function fetchTree(container: HTMLElement): Promise${esc(t("projects.tree.error") || "Baumansicht konnte nicht geladen werden.")}`; @@ -192,6 +249,7 @@ function attachHandlers(container: HTMLElement) { const row = node.querySelector(":scope > .projekt-tree-row"); if (!row) return; const toggle = row.querySelector(".projekt-tree-toggle"); + const pinBtn = row.querySelector(".projekt-tree-pin"); const id = node.dataset.id!; const depth = Number(node.dataset.depth || "0"); @@ -199,9 +257,23 @@ function attachHandlers(container: HTMLElement) { window.location.href = `/projects/${id}`; }; + // Pin toggle — POST/DELETE, optimistic update on the row, hard refresh + // of the cache afterwards so subtree counts (if visible) stay coherent. + if (pinBtn) { + pinBtn.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + const data = cache && findById(cache, id); + if (!data) return; + void togglePin(data, pinBtn); + }); + } + if (toggle && toggle.classList.contains("is-leaf")) { - // No children — entire row navigates. - row.addEventListener("click", navigate); + row.addEventListener("click", (e) => { + if ((e.target as HTMLElement).closest(".projekt-tree-pin")) return; + navigate(); + }); row.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); @@ -223,8 +295,10 @@ function attachHandlers(container: HTMLElement) { } row.addEventListener("click", (e) => { - // Clicking the row (but not the toggle) navigates. - if ((e.target as HTMLElement).closest(".projekt-tree-toggle")) return; + // Clicking the row (but not the toggle / pin) navigates. + const target = e.target as HTMLElement; + if (target.closest(".projekt-tree-toggle")) return; + if (target.closest(".projekt-tree-pin")) return; navigate(); }); row.addEventListener("keydown", (e) => { @@ -243,6 +317,41 @@ function attachHandlers(container: HTMLElement) { }); } +async function togglePin(node: ProjectTreeNode, btn: HTMLElement) { + const wasPinned = !!node.pinned; + // Optimistic flip — visually toggle the star immediately so the user + // sees feedback even on slow networks. + node.pinned = !wasPinned; + btn.classList.toggle("is-pinned", node.pinned); + btn.innerHTML = node.pinned ? starFilled : starOutline; + const newLabel = node.pinned + ? (t("projects.tree.unpin") || "Pin entfernen") + : (t("projects.tree.pin") || "Anpinnen"); + btn.setAttribute("aria-label", newLabel); + btn.setAttribute("title", newLabel); + + try { + const method = wasPinned ? "DELETE" : "POST"; + const resp = await fetch(`/api/projects/${encodeURIComponent(node.id)}/pin`, { method }); + if (!resp.ok && resp.status !== 201 && resp.status !== 204) { + // Revert on failure. + node.pinned = wasPinned; + btn.classList.toggle("is-pinned", node.pinned); + btn.innerHTML = node.pinned ? starFilled : starOutline; + return; + } + // Success — invalidate cache so the next chip-driven refresh + // (e.g. user clicks "Angepinnt") gets fresh server state. + cache = null; + cacheParams = ""; + } catch { + // Revert on network error. + node.pinned = wasPinned; + btn.classList.toggle("is-pinned", node.pinned); + btn.innerHTML = node.pinned ? starFilled : starOutline; + } +} + function findById(nodes: ProjectTreeNode[], id: string): ProjectTreeNode | null { for (const n of nodes) { if (n.id === id) return n; @@ -253,6 +362,7 @@ function findById(nodes: ProjectTreeNode[], id: string): ProjectTreeNode | null } let mountContainer: HTMLElement | null = null; +let mountParams: URLSearchParams = new URLSearchParams(); function rerender() { if (!mountContainer || !cache) return; @@ -265,20 +375,35 @@ function rerender() { attachHandlers(mountContainer); } -export async function initProjectTree(container: HTMLElement) { +// initProjectTree mounts the tree at `container`. The optional params encode +// the orchestrator's chip / search state — see /api/projects/tree handler. +// Empty params → legacy "every visible project" behaviour. +export async function initProjectTree(container: HTMLElement, params?: URLSearchParams) { mountContainer = container; + mountParams = params ? new URLSearchParams(params) : new URLSearchParams(); + // Honour the orchestrator's subtree_counts param when the tree renders + // its badges. Default true. + const sc = mountParams.get("subtree_counts"); + useSubtreeCounts = sc === null ? true : sc === "true"; + // If params changed, the cache is stale. + if (cache && cacheParams !== mountParams.toString()) { + cache = null; + } if (!cache) { container.innerHTML = `
    ${esc(t("projects.tree.loading") || "Baum wird geladen…")}
    `; - const data = await fetchTree(container); + const data = await fetchTree(container, mountParams); if (!data) return; } rerender(); } -export function refreshProjectTree() { +// refreshProjectTree forces a fresh fetch — used by the orchestrator +// after a chip change or after a pin toggle invalidates the cache. +export function refreshProjectTree(params?: URLSearchParams) { cache = null; + cacheParams = ""; if (mountContainer) { - void initProjectTree(mountContainer); + void initProjectTree(mountContainer, params || mountParams); } } diff --git a/frontend/src/client/projects-flat.ts b/frontend/src/client/projects-flat.ts new file mode 100644 index 0000000..6daf8d9 --- /dev/null +++ b/frontend/src/client/projects-flat.ts @@ -0,0 +1,81 @@ +import { tDyn, getLang } from "./i18n"; + +// Flat-list (table) rendering for /projects. +// Extracted from the pre-t-paliad-149 client/projects.ts so the orchestrator +// can mount/unmount table view alongside the tree view without code duplication. + +export interface ProjectFlatRow { + id: string; + type: string; + parent_id?: string | null; + path: string; + title: string; + reference?: string | null; + status: string; + client_number?: string | null; + matter_number?: string | null; + updated_at: string; +} + +interface RenderOpts { + rows: ProjectFlatRow[]; +} + +// renderFlatList writes the table rows + wires row-click navigation. +// Caller is responsible for showing/hiding the wrapping table element. +export function renderFlatList(opts: RenderOpts) { + const tbody = document.getElementById("projects-body")!; + tbody.innerHTML = opts.rows + .map((p) => { + const typeLabel = tDyn(`projects.type.${p.type}`) || p.type; + const statusLabel = tDyn(`projects.filter.status.${p.status}`) || p.status; + const clientMatter = + p.client_number && p.matter_number + ? `${p.client_number}.${p.matter_number}` + : p.client_number || p.matter_number || ""; + const refCell = p.reference ? esc(p.reference) : "—"; + const clientMatterCell = clientMatter ? esc(clientMatter) : "—"; + return ` + ${esc(p.title)} + ${esc(typeLabel)} + ${refCell} + ${clientMatterCell} + ${esc(statusLabel)} + ${fmtDate(p.updated_at)} + `; + }) + .join(""); + + // F-23: when every visible row shares the same status, hide the column to + // cut redundant noise. The toggle re-runs on every filter change, so the + // column comes back as soon as the rows mix again. + const statusUnique = new Set(opts.rows.map((p) => p.status)).size; + const table = document.getElementById("entity-table"); + table?.classList.toggle("entity-table--hide-status", statusUnique <= 1); + + tbody.querySelectorAll(".entity-row").forEach((row) => { + row.addEventListener("click", () => { + const id = row.dataset.id!; + window.location.href = `/projects/${id}`; + }); + }); +} + +function fmtDate(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + return iso; + } +} + +function esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; +} diff --git a/frontend/src/client/projects.ts b/frontend/src/client/projects.ts index 5bcab46..8a8f3b9 100644 --- a/frontend/src/client/projects.ts +++ b/frontend/src/client/projects.ts @@ -1,71 +1,300 @@ -import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n"; +import { initI18n, onLangChange, t } from "./i18n"; import { initSidebar } from "./sidebar"; -import { initProjectTree, rerenderProjectTree } from "./project-tree"; +import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree"; +import { renderFlatList, ProjectFlatRow } from "./projects-flat"; -// /projekte list page client. Reads v2 shape from /api/projects. -interface Project { - id: string; - type: string; - parent_id?: string | null; - path: string; - title: string; - reference?: string | null; - status: string; - client_number?: string | null; - matter_number?: string | null; - updated_at: string; +// /projects orchestrator (t-paliad-149). +// +// Owns: +// - chip state (scope + status + type + pinned + has_open_deadlines) +// - search term (in-place filter, server-side) +// - view mode (tree | flat). Cards lands in PR 2. +// - last-view restore + URL params (Q1 lock-in: last-viewed restore). +// +// Delegates rendering to: +// - project-tree.ts for tree mode +// - projects-flat.ts for flat-table mode + +type ViewMode = "tree" | "flat"; +type Scope = "all" | "mine" | "pinned"; + +interface Chips { + scope: Scope; + status: Set; + type: Set; + hasOpenDeadlines: boolean; } -let allRows: Project[] = []; -let typeFilter = ""; -let statusFilter = ""; -let viewMode: "flat" | "tree" | "roots" = parseInitialView(); -let searchQuery = ""; -let loadedOK = false; - -// Honour ?view=flat|tree|roots from the URL so dashboard links and bookmarks -// land on the right layout. Anything else falls back to "flat". -function parseInitialView(): "flat" | "tree" | "roots" { - const v = new URLSearchParams(window.location.search).get("view"); - if (v === "tree" || v === "roots" || v === "flat") return v; - return "flat"; +interface State { + viewMode: ViewMode; + chips: Chips; + searchQuery: string; } -async function loadProjekte() { +const STORAGE_KEY = "paliad.projects.lastView"; +const SEARCH_DEBOUNCE_MS = 250; + +let state: State = defaultState(); +let flatRows: ProjectFlatRow[] | null = null; +let searchDebounce: number | null = null; + +function defaultState(): State { + return { + viewMode: "tree", + chips: { + scope: "all", + status: new Set(), + type: new Set(), + hasOpenDeadlines: false, + }, + searchQuery: "", + }; +} + +function loadStoredState(): State | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { + viewMode?: ViewMode; + chips?: { scope?: Scope; status?: string[]; type?: string[]; hasOpenDeadlines?: boolean }; + searchQuery?: string; + }; + return { + viewMode: parsed.viewMode === "flat" ? "flat" : "tree", + chips: { + scope: parsed.chips?.scope === "mine" || parsed.chips?.scope === "pinned" ? parsed.chips.scope : "all", + status: new Set(parsed.chips?.status || []), + type: new Set(parsed.chips?.type || []), + hasOpenDeadlines: !!parsed.chips?.hasOpenDeadlines, + }, + searchQuery: typeof parsed.searchQuery === "string" ? parsed.searchQuery : "", + }; + } catch { + return null; + } +} + +function saveState() { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ + viewMode: state.viewMode, + chips: { + scope: state.chips.scope, + status: [...state.chips.status], + type: [...state.chips.type], + hasOpenDeadlines: state.chips.hasOpenDeadlines, + }, + searchQuery: state.searchQuery, + })); + } catch { + /* private mode, quota — ignore */ + } +} + +// applyURL overlays ?view=, ?scope=, ?status=, ?type=, ?has_open_deadlines=, +// ?q= onto the current state. URL > sessionStorage > defaults. +function applyURL() { + const url = new URL(window.location.href); + const v = url.searchParams.get("view"); + if (v === "tree" || v === "flat") state.viewMode = v; + const sc = url.searchParams.get("scope"); + if (sc === "mine" || sc === "pinned" || sc === "all") state.chips.scope = sc; + const status = url.searchParams.get("status"); + if (status !== null) { + state.chips.status = new Set(status.split(",").map((s) => s.trim()).filter(Boolean)); + } + const type = url.searchParams.get("type"); + if (type !== null) { + state.chips.type = new Set(type.split(",").map((s) => s.trim()).filter(Boolean)); + } + const has = url.searchParams.get("has_open_deadlines"); + if (has === "true" || has === "false") state.chips.hasOpenDeadlines = has === "true"; + const q = url.searchParams.get("q"); + if (q !== null) state.searchQuery = q; +} + +function syncURL() { + const url = new URL(window.location.href); + // Clear all known params, then re-set only the non-default ones (keeps URLs short). + ["view", "scope", "status", "type", "has_open_deadlines", "q"].forEach((k) => url.searchParams.delete(k)); + if (state.viewMode !== "tree") url.searchParams.set("view", state.viewMode); + if (state.chips.scope !== "all") url.searchParams.set("scope", state.chips.scope); + if (state.chips.status.size > 0) url.searchParams.set("status", [...state.chips.status].join(",")); + if (state.chips.type.size > 0) url.searchParams.set("type", [...state.chips.type].join(",")); + if (state.chips.hasOpenDeadlines) url.searchParams.set("has_open_deadlines", "true"); + if (state.searchQuery.trim()) url.searchParams.set("q", state.searchQuery.trim()); + window.history.replaceState({}, "", url.toString()); +} + +// Build the query string the tree endpoint expects. Same shape as the URL +// state but always written (we don't omit "all" because the server expects +// ?subtree_counts=true to get the new field). +function treeParams(): URLSearchParams { + const p = new URLSearchParams(); + if (state.chips.scope !== "all") p.set("scope", state.chips.scope); + if (state.chips.status.size > 0) p.set("status", [...state.chips.status].join(",")); + if (state.chips.type.size > 0) p.set("type", [...state.chips.type].join(",")); + if (state.chips.hasOpenDeadlines) p.set("has_open_deadlines", "true"); + if (state.searchQuery.trim()) p.set("q", state.searchQuery.trim()); + p.set("subtree_counts", "true"); + return p; +} + +function reflectChipsToDOM() { + // Scope toggles + const scopes: Scope[] = ["all", "mine", "pinned"]; + scopes.forEach((s) => { + const btn = document.querySelector(`.projects-chip[data-chip="${s}"]`); + btn?.classList.toggle("is-active", state.chips.scope === s); + }); + // Has-open-deadlines + const hasBtn = document.querySelector(`.projects-chip[data-chip="has_open_deadlines"]`); + hasBtn?.classList.toggle("is-active", state.chips.hasOpenDeadlines); + + // Multi-select panels + reflectMulti("status", state.chips.status); + reflectMulti("type", state.chips.type); + + // View mode segment-control + document.querySelectorAll(".projects-view-btn").forEach((btn) => { + btn.classList.toggle("is-active", btn.dataset.view === state.viewMode); + }); + + // Search input value (when restoring state on init) + const searchInput = document.getElementById("projects-search") as HTMLInputElement | null; + if (searchInput && searchInput.value !== state.searchQuery) { + searchInput.value = state.searchQuery; + } +} + +function reflectMulti(name: string, set: Set) { + const wrap = document.querySelector(`.projects-chip-multi[data-chip-multi="${name}"]`); + if (!wrap) return; + const summary = wrap.querySelector("summary"); + const inputs = wrap.querySelectorAll('input[type="checkbox"]'); + inputs.forEach((cb) => { cb.checked = set.has(cb.value); }); + if (summary) { + summary.classList.toggle("is-active", set.size > 0); + const baseLabel = t(`projects.chip.${name}` as never) || (name === "status" ? "Status" : "Typ"); + if (set.size === 0) { + summary.textContent = String(baseLabel); + } else if (set.size === 1) { + const sole = [...set][0]; + const labelKey = `projects.chip.${name}.${sole}` as never; + const label = t(labelKey) || sole; + summary.textContent = `${baseLabel}: ${label}`; + } else { + const tmpl = t("projects.chip.multi.count" as never) || "{n} ausgewählt"; + summary.textContent = `${baseLabel}: ${String(tmpl).replace("{n}", String(set.size))}`; + } + } +} + +function setScope(s: Scope) { + state.chips.scope = s; + postChipChange(); +} + +function toggleHasOpen() { + state.chips.hasOpenDeadlines = !state.chips.hasOpenDeadlines; + postChipChange(); +} + +function postChipChange() { + syncURL(); + saveState(); + reflectChipsToDOM(); + void render(); +} + +function clearAllChips() { + state = { ...state, chips: defaultState().chips, searchQuery: "" }; + postChipChange(); + const searchInput = document.getElementById("projects-search") as HTMLInputElement | null; + if (searchInput) searchInput.value = ""; +} + +async function render() { + const treeWrap = document.getElementById("projekt-tree-wrap")!; + const tableWrap = document.getElementById("entity-table-wrap")!; + const empty = document.getElementById("entity-empty")!; + const emptyFiltered = document.getElementById("entity-empty-filtered")!; + + if (state.viewMode === "tree") { + treeWrap.style.display = "block"; + tableWrap.style.display = "none"; + empty.style.display = "none"; + emptyFiltered.style.display = "none"; + const container = document.getElementById("projekt-tree-container") as HTMLElement; + await initProjectTree(container, treeParams()); + return; + } + + // Flat-list mode. Reuses /api/projects (existing flat endpoint). + treeWrap.style.display = "none"; + if (!flatRows) { + flatRows = await loadFlatRows(); + } + if (!flatRows) { + tableWrap.style.display = "none"; + return; + } + const filtered = filterFlatRows(flatRows); + const count = document.getElementById("projects-count")!; + count.textContent = `${filtered.length} / ${flatRows.length}`; + if (flatRows.length === 0) { + tableWrap.style.display = "none"; + empty.style.display = "block"; + emptyFiltered.style.display = "none"; + return; + } + if (filtered.length === 0) { + tableWrap.style.display = "none"; + empty.style.display = "none"; + emptyFiltered.style.display = "block"; + return; + } + tableWrap.style.display = ""; + empty.style.display = "none"; + emptyFiltered.style.display = "none"; + renderFlatList({ rows: filtered }); +} + +async function loadFlatRows(): Promise { const unavailable = document.getElementById("entity-unavailable")!; - const table = document.querySelector(".entity-table-wrap")!; try { const resp = await fetch("/api/projects"); if (resp.status === 503) { unavailable.style.display = "block"; - table.style.display = "none"; - document.getElementById("entity-empty")!.style.display = "none"; - return; + return null; } if (!resp.ok) { unavailable.style.display = "block"; - table.style.display = "none"; - return; + return null; } - allRows = await resp.json(); - loadedOK = true; - render(); + return (await resp.json()) as ProjectFlatRow[]; } catch { unavailable.style.display = "block"; - table.style.display = "none"; + return null; } } -function getFiltered(): Project[] { - // Tree view is handled by the dedicated tree module. Filters and search - // here only apply to the flat list. - let rows = allRows; - if (viewMode === "roots") rows = rows.filter((p) => !p.parent_id); - if (typeFilter) rows = rows.filter((p) => p.type === typeFilter); - if (statusFilter) rows = rows.filter((p) => p.status === statusFilter); - if (searchQuery) { - const q = searchQuery.toLowerCase(); - rows = rows.filter((p) => { +function filterFlatRows(rows: ProjectFlatRow[]): ProjectFlatRow[] { + let out = rows; + if (state.chips.status.size > 0) { + out = out.filter((p) => state.chips.status.has(p.status)); + } + if (state.chips.type.size > 0) { + out = out.filter((p) => state.chips.type.has(p.type)); + } + // Note: scope=mine / scope=pinned / has_open_deadlines are not applied + // to the flat-list view — those need server-side support and the flat + // endpoint /api/projects is unchanged from pre-redesign. The chips simply + // narrow status + type in flat mode; tree mode honours all chips. + if (state.searchQuery.trim()) { + const q = state.searchQuery.toLowerCase(); + out = out.filter((p) => { const haystack = [ p.title, p.reference || "", @@ -77,162 +306,100 @@ function getFiltered(): Project[] { return haystack.includes(q); }); } - return rows; -} - -function render() { - if (!loadedOK) return; - const tbody = document.getElementById("projects-body")!; - const empty = document.getElementById("entity-empty")!; - const emptyFiltered = document.getElementById("entity-empty-filtered")!; - const tableWrap = document.getElementById("entity-table-wrap")!; - const treeWrap = document.getElementById("projekt-tree-wrap")!; - const count = document.getElementById("projects-count")!; - - if (viewMode === "tree") { - // Tree view is rendered by project-tree.ts; reflect the toggle state here - // and let it handle its own data fetch (separate /api/projects/tree call). - tbody.innerHTML = ""; - tableWrap.style.display = "none"; - empty.style.display = allRows.length === 0 ? "block" : "none"; - emptyFiltered.style.display = "none"; - treeWrap.style.display = allRows.length === 0 ? "none" : "block"; - // Match the flat-view "X / Y" format so the counter reads consistently - // when toggling between views (F-39). Tree view shows everything, so the - // numerator equals the total. - count.textContent = `${allRows.length} / ${allRows.length}`; - if (allRows.length > 0) { - const container = document.getElementById("projekt-tree-container") as HTMLElement; - void initProjectTree(container); - } - return; - } - - treeWrap.style.display = "none"; - - const filtered = getFiltered(); - count.textContent = `${filtered.length} / ${allRows.length}`; - - if (allRows.length === 0) { - tbody.innerHTML = ""; - tableWrap.style.display = "none"; - empty.style.display = "block"; - emptyFiltered.style.display = "none"; - return; - } - if (filtered.length === 0) { - tbody.innerHTML = ""; - tableWrap.style.display = "none"; - empty.style.display = "none"; - emptyFiltered.style.display = "block"; - return; - } - - tableWrap.style.display = ""; - empty.style.display = "none"; - emptyFiltered.style.display = "none"; - - tbody.innerHTML = filtered - .map((p) => { - const typeLabel = tDyn(`projects.type.${p.type}`) || p.type; - const statusLabel = tDyn(`projects.filter.status.${p.status}`) || p.status; - const clientMatter = - p.client_number && p.matter_number - ? `${p.client_number}.${p.matter_number}` - : p.client_number || p.matter_number || ""; - // Empty cells render an em-dash to match the rest of the app (F-28). - const refCell = p.reference ? esc(p.reference) : "—"; - const clientMatterCell = clientMatter ? esc(clientMatter) : "—"; - return ` - ${esc(p.title)} - ${esc(typeLabel)} - ${refCell} - ${clientMatterCell} - ${esc(statusLabel)} - ${fmtDate(p.updated_at)} - `; - }) - .join(""); - - // F-23: when every visible row shares the same status, hide the column to - // cut redundant noise. The toggle re-runs on every filter change, so the - // column comes back as soon as the rows mix again. - const statusUnique = new Set(filtered.map((p) => p.status)).size; - const table = document.getElementById("entity-table"); - table?.classList.toggle("entity-table--hide-status", statusUnique <= 1); - - tbody.querySelectorAll(".entity-row").forEach((row) => { - row.addEventListener("click", () => { - const id = row.dataset.id!; - window.location.href = `/projects/${id}`; - }); - }); -} - -function fmtDate(iso: string): string { - try { - const d = new Date(iso); - return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }); - } catch { - return iso; - } -} - -function esc(s: string): string { - const d = document.createElement("div"); - d.textContent = s; - return d.innerHTML; + return out; } function initSearch() { - const input = document.getElementById("projects-search") as HTMLInputElement; + const input = document.getElementById("projects-search") as HTMLInputElement | null; + if (!input) return; input.addEventListener("input", () => { - searchQuery = input.value.trim(); - render(); + if (searchDebounce !== null) { + window.clearTimeout(searchDebounce); + } + searchDebounce = window.setTimeout(() => { + state.searchQuery = input.value; + syncURL(); + saveState(); + void render(); + }, SEARCH_DEBOUNCE_MS); }); } -function initFilters() { - const typeSel = document.getElementById("project-type") as HTMLSelectElement; - const status = document.getElementById("project-status") as HTMLSelectElement; - const view = document.getElementById("project-view") as HTMLSelectElement; - view.value = viewMode; - typeSel.addEventListener("change", () => { - typeFilter = typeSel.value; - render(); +function initChips() { + document.querySelectorAll(".projects-chip[data-chip]").forEach((btn) => { + const chip = btn.dataset.chip!; + if (chip === "all") { + btn.addEventListener("click", () => clearAllChips()); + } else if (chip === "mine") { + btn.addEventListener("click", () => setScope(state.chips.scope === "mine" ? "all" : "mine")); + } else if (chip === "pinned") { + btn.addEventListener("click", () => setScope(state.chips.scope === "pinned" ? "all" : "pinned")); + } else if (chip === "has_open_deadlines") { + btn.addEventListener("click", () => toggleHasOpen()); + } }); - status.addEventListener("change", () => { - statusFilter = status.value; - render(); - }); - view.addEventListener("change", () => { - viewMode = view.value as "flat" | "tree" | "roots"; - syncViewQuery(); - render(); + + // Multi-select panels — wire each checkbox change. + document.querySelectorAll(".projects-chip-multi").forEach((wrap) => { + const name = wrap.dataset.chipMulti!; + const set = (name === "status" ? state.chips.status : state.chips.type); + wrap.querySelectorAll('input[type="checkbox"]').forEach((cb) => { + cb.addEventListener("change", () => { + if (cb.checked) set.add(cb.value); else set.delete(cb.value); + postChipChange(); + }); + }); }); + + const reset = document.getElementById("projects-reset-filters"); + if (reset) reset.addEventListener("click", () => clearAllChips()); } -// Mirror viewMode into ?view= so the URL is shareable. Default "flat" stays -// implicit (drop the param) to keep the canonical path clean. -function syncViewQuery() { - const url = new URL(window.location.href); - if (viewMode === "flat") url.searchParams.delete("view"); - else url.searchParams.set("view", viewMode); - window.history.replaceState({}, "", url.toString()); +function initViewSegment() { + document.querySelectorAll(".projects-view-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const v = btn.dataset.view as ViewMode; + if (v !== "tree" && v !== "flat") return; + if (state.viewMode === v) return; + state.viewMode = v; + syncURL(); + saveState(); + reflectChipsToDOM(); + void render(); + }); + }); } document.addEventListener("DOMContentLoaded", () => { initI18n(); initSidebar(); + // Q1 lock-in: last-viewed restore. URL > sessionStorage > defaults. + const stored = loadStoredState(); + if (stored) state = stored; + applyURL(); + reflectChipsToDOM(); initSearch(); - initFilters(); + initChips(); + initViewSegment(); onLangChange(() => { - render(); - if (viewMode === "tree") rerenderProjectTree(); + reflectChipsToDOM(); + if (state.viewMode === "tree") { + rerenderProjectTree(); + } else { + void render(); + } + }); + void render(); + // The pin handler in project-tree.ts mutates the per-node cache and then + // invalidates it, so subsequent chip changes refetch with fresh pin data. + // When the user navigates back to /projects via popstate (in-app links), + // re-apply URL state. + window.addEventListener("popstate", () => { + state = loadStoredState() || defaultState(); + applyURL(); + reflectChipsToDOM(); + refreshProjectTree(treeParams()); + flatRows = null; + void render(); }); - loadProjekte(); }); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 70127bf..ea881a0 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1439,6 +1439,22 @@ export type I18nKey = | "partner_unit.none" | "partner_unit.subtitle" | "projects.cancel" + | "projects.chip.all" + | "projects.chip.has_open_deadlines" + | "projects.chip.mine" + | "projects.chip.multi.count" + | "projects.chip.multi.none" + | "projects.chip.pinned" + | "projects.chip.status" + | "projects.chip.status.active" + | "projects.chip.status.archived" + | "projects.chip.status.closed" + | "projects.chip.type" + | "projects.chip.type.case" + | "projects.chip.type.client" + | "projects.chip.type.litigation" + | "projects.chip.type.patent" + | "projects.chip.type.project" | "projects.col.clientmatter" | "projects.col.office" | "projects.col.ref" @@ -1528,6 +1544,7 @@ export type I18nKey = | "projects.detail.verlauf.loadMore" | "projects.detail.verlauf.loadingMore" | "projects.empty.filtered" + | "projects.empty.filtered.action" | "projects.empty.hint" | "projects.empty.title" | "projects.error.forbidden" @@ -1586,6 +1603,9 @@ export type I18nKey = | "projects.neu.title" | "projects.new" | "projects.onboarding.required" + | "projects.search.match.ancestor" + | "projects.search.match.descendant" + | "projects.search.match.self" | "projects.search.placeholder" | "projects.status.active" | "projects.status.archived" @@ -1637,11 +1657,21 @@ export type I18nKey = | "projects.team.units.members" | "projects.team.units.select" | "projects.title" + | "projects.toolbar.search.placeholder" + | "projects.toolbar.subtree_counts" + | "projects.toolbar.view.cards" + | "projects.toolbar.view.flat" + | "projects.toolbar.view.tree" + | "projects.tree.deadlines.direct.tooltip" | "projects.tree.deadlines.open" | "projects.tree.deadlines.overdue" + | "projects.tree.deadlines.subtree.tooltip" | "projects.tree.error" + | "projects.tree.inherited.context" | "projects.tree.loading" + | "projects.tree.pin" | "projects.tree.toggle" + | "projects.tree.unpin" | "projects.type.case" | "projects.type.client" | "projects.type.litigation" diff --git a/frontend/src/projects.tsx b/frontend/src/projects.tsx index 92e6ef8..821827a 100644 --- a/frontend/src/projects.tsx +++ b/frontend/src/projects.tsx @@ -4,8 +4,14 @@ import { BottomNav } from "./components/BottomNav"; import { Footer } from "./components/Footer"; import { PWAHead } from "./components/PWAHead"; -// Renders the /projekte list page. File + export name stays `Akten` for build -// pipeline compatibility; labels + data bindings are v2 (t-paliad-024). +// /projects page (t-paliad-149 redesign): +// - Tree view by default (rooted at clients, descendants navigable) +// - Chip filter row: Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen +// - Single prominent search input (in-place filter on the active view) +// - View-mode segment-control: Tree | Liste (Cards added in PR 2) +// +// All client behaviour lives in client/projects.ts orchestrator + the +// shape modules (project-tree.ts, projects-flat.ts). export function renderProjects(): string { return "" + ( @@ -40,64 +46,61 @@ export function renderProjects(): string { -
    -
    - +
    +
    + - +
    -
    -
    - - -
    - -
    - - -
    - -
    - - -
    +
    + +
    +
    + + + +
    + Status +
    + + + +
    +
    +
    + Typ +
    + + + + + +
    +
    + +
    + -
    + -