From aeeded7e21f5350091fb9fa9ee6d284dd25bc649 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 7 May 2026 22:46:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-149)=20PR2=20step=202/2:=20fronte?= =?UTF-8?q?nd=20=E2=80=94=20Cards=20view=20+=20drag-rearrange=20named=20la?= =?UTF-8?q?youts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Cards view-mode to /projects (third option in the segment-control between Tree and Liste). frontend/src/projects.tsx: - View-mode segment gains "Karten" button - Two new toolbars (initially display:none, surfaced by Cards mode): - .projects-cards-toolbar: layout dropdown + [Bearbeiten] + [Neue Ansicht] + "Alle Ebenen anzeigen" toggle - .projects-cards-edit-toolbar: density radio + grid select + rename / delete / set-default / discard / save buttons - New container: .projects-cards-wrap > #projects-cards-grid frontend/src/client/projects-cards.ts (NEW, ~640 LoC): - Layout management: GET /api/user-card-layouts on first mount; auto-seeds Standard layout if empty (POST). Layout dropdown switches active layout in-place; show_all_levels toggle persists immediately. - Edit mode: clones the active layout into editDraft; renders per-card fact list with drag handles + visibility checkboxes + count steppers (1..5) for next-events / recent-verlauf. HTML5 drag-and-drop reorders facts; title-row is forced to the first position so the server-side validator's invariant holds. - New layout: prompts for a name, seeds with the current draft (or active layout's facts), POSTs, enters edit mode. - Set-default / rename / delete: each maps to PATCH or DELETE; default cannot be deleted (server returns 409 + UI alerts). - Card render: title row (icon + link + pin star), type/status chips, client-matter, parent-path-as-reference (parent breadcrumb deferred — needs an extra fetch per card), deadline-counts (subtree-aggregated when available), next-events from /api/projects/cards-preview, recent- verlauf, team-chips initials with overflow count. - Pin click on a card star does optimistic toggle + POST/DELETE pin endpoint and updates treeCache in place. - Cards sort: pinned first, then last_activity_at DESC, then title ASC. - "Alle Ebenen anzeigen" toggle decides whether Mandanten + Litigations appear as their own cards (off by default — leaf-ish projects only: Cases, Patents, Verfahren, Projekte). frontend/src/client/projects.ts (orchestrator): - ViewMode type expands to "tree" | "cards" | "flat" - View segment-control wires through to Cards mode - render() dispatches to renderCardsView / teardownCardsView based on active mode frontend/src/client/i18n.ts: 53 new keys DE+EN under projects.cards.* — section titles, empty-states, layout picker labels (label/new/edit/save/ discard/set_default/delete/rename/is_default/new.prompt/delete.confirm/ delete.default_blocked), per-fact labels (title-row/type-chip/status-chip/ client-matter/parent-path/deadline-counts/next-events/recent-verlauf/ team-chips/reference/last-activity-at), density values (compact/roomy), grid values (auto/2/3/4), event-kind labels (deadline/appointment/ project_event), edit toggles (toggle.hide/show/move_up/move_down/count). frontend/src/styles/global.css: ~290 LoC appended for cards toolbar + grid + card layout (title row / row / section / event row / team chips) + edit-mode chrome (drag handles, drop targets, count steppers) + dark- themed dashed border on edit cards. Mobile media query forces single- column grid. i18n codegen: 1830 → 1882 keys (+52). bun run build clean. tsc on new files clean (pre-existing JSX-IntrinsicElements noise unrelated). go build/vet/test still clean. --- frontend/src/client/i18n.ts | 98 ++++ frontend/src/client/projects-cards.ts | 796 ++++++++++++++++++++++++++ frontend/src/client/projects.ts | 26 +- frontend/src/i18n-keys.ts | 49 ++ frontend/src/projects.tsx | 42 ++ frontend/src/styles/global.css | 290 ++++++++++ 6 files changed, 1297 insertions(+), 4 deletions(-) create mode 100644 frontend/src/client/projects-cards.ts diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 633d585..97fd575 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1210,6 +1210,55 @@ const translations: Record> = { "projects.search.match.self": "Treffer", "projects.search.match.ancestor": "\u00dcber-Projekt eines Treffers", "projects.search.match.descendant": "Unterprojekt eines Treffers", + "projects.cards.next_events": "N\u00e4chste Termine", + "projects.cards.recent_verlauf": "Zuletzt", + "projects.cards.no_next_events": "\u2014 keine bevorstehenden Termine", + "projects.cards.no_recent": "\u2014 noch nichts passiert", + "projects.cards.team": "Team", + "projects.cards.deadline_open": "offen", + "projects.cards.deadline_overdue": "\u00fcberf\u00e4llig", + "projects.cards.show_all_levels": "Alle Ebenen anzeigen", + "projects.cards.show_all_levels.hint": "Mandanten + Streitsachen als eigene Karten zeigen", + "projects.cards.layout.label": "Ansicht", + "projects.cards.layout.new": "Neue Ansicht", + "projects.cards.layout.edit": "Bearbeiten", + "projects.cards.layout.save": "Speichern", + "projects.cards.layout.discard": "Verwerfen", + "projects.cards.layout.set_default": "Als Standard festlegen", + "projects.cards.layout.delete": "L\u00f6schen", + "projects.cards.layout.rename": "Umbenennen", + "projects.cards.layout.is_default": "Standard", + "projects.cards.layout.new.prompt": "Name der neuen Ansicht", + "projects.cards.layout.delete.confirm": "Diese Ansicht wirklich l\u00f6schen?", + "projects.cards.layout.delete.default_blocked": "Die aktive Standardansicht kann nicht gel\u00f6scht werden \u2014 bitte zuerst eine andere Standardansicht w\u00e4hlen.", + "projects.cards.layout.fact.title-row": "Titelzeile", + "projects.cards.layout.fact.type-chip": "Typ-Badge", + "projects.cards.layout.fact.status-chip": "Status-Badge", + "projects.cards.layout.fact.client-matter": "ClientMatter", + "projects.cards.layout.fact.parent-path": "Pfad zum \u00dcberprojekt", + "projects.cards.layout.fact.deadline-counts": "Frist-Z\u00e4hler", + "projects.cards.layout.fact.next-events": "N\u00e4chste Termine", + "projects.cards.layout.fact.recent-verlauf": "Verlauf-Eintr\u00e4ge", + "projects.cards.layout.fact.team-chips": "Team-Chips", + "projects.cards.layout.fact.reference": "Referenz", + "projects.cards.layout.fact.last-activity-at": "Letzte Aktivit\u00e4t", + "projects.cards.layout.density": "Dichte", + "projects.cards.layout.density.compact": "Kompakt", + "projects.cards.layout.density.roomy": "Ger\u00e4umig", + "projects.cards.layout.grid": "Spalten", + "projects.cards.layout.grid.auto": "Auto", + "projects.cards.layout.grid.2": "2", + "projects.cards.layout.grid.3": "3", + "projects.cards.layout.grid.4": "4", + "projects.cards.layout.fact.toggle.hide": "Ausblenden", + "projects.cards.layout.fact.toggle.show": "Anzeigen", + "projects.cards.layout.fact.move_up": "Nach oben", + "projects.cards.layout.fact.move_down": "Nach unten", + "projects.cards.layout.fact.count": "Anzahl", + "projects.cards.event.kind.deadline": "Frist", + "projects.cards.event.kind.appointment": "Termin", + "projects.cards.event.kind.project_event": "Verlauf", + "projects.cards.empty": "Keine Projekte zum Anzeigen.", "projects.detail.clientmatter.inherited": "Vom \u00dcberprojekt vererbt", "einstellungen.profil.email": "E-Mail", "einstellungen.profil.email.hint": "E-Mail kann nicht ge\u00e4ndert werden.", @@ -3159,6 +3208,55 @@ const translations: Record> = { "projects.search.match.self": "Match", "projects.search.match.ancestor": "Parent of a match", "projects.search.match.descendant": "Child of a match", + "projects.cards.next_events": "Upcoming", + "projects.cards.recent_verlauf": "Recent", + "projects.cards.no_next_events": "— nothing upcoming", + "projects.cards.no_recent": "— nothing recent", + "projects.cards.team": "Team", + "projects.cards.deadline_open": "open", + "projects.cards.deadline_overdue": "overdue", + "projects.cards.show_all_levels": "Show all levels", + "projects.cards.show_all_levels.hint": "Include Clients + Litigations as their own cards", + "projects.cards.layout.label": "View", + "projects.cards.layout.new": "New view", + "projects.cards.layout.edit": "Edit", + "projects.cards.layout.save": "Save", + "projects.cards.layout.discard": "Discard", + "projects.cards.layout.set_default": "Set as default", + "projects.cards.layout.delete": "Delete", + "projects.cards.layout.rename": "Rename", + "projects.cards.layout.is_default": "Default", + "projects.cards.layout.new.prompt": "Name of the new view", + "projects.cards.layout.delete.confirm": "Really delete this view?", + "projects.cards.layout.delete.default_blocked": "Cannot delete the active default view — switch defaults first.", + "projects.cards.layout.fact.title-row": "Title row", + "projects.cards.layout.fact.type-chip": "Type badge", + "projects.cards.layout.fact.status-chip": "Status badge", + "projects.cards.layout.fact.client-matter": "ClientMatter", + "projects.cards.layout.fact.parent-path": "Parent path", + "projects.cards.layout.fact.deadline-counts": "Deadline counts", + "projects.cards.layout.fact.next-events": "Upcoming events", + "projects.cards.layout.fact.recent-verlauf": "Recent entries", + "projects.cards.layout.fact.team-chips": "Team chips", + "projects.cards.layout.fact.reference": "Reference", + "projects.cards.layout.fact.last-activity-at": "Last activity", + "projects.cards.layout.density": "Density", + "projects.cards.layout.density.compact": "Compact", + "projects.cards.layout.density.roomy": "Roomy", + "projects.cards.layout.grid": "Columns", + "projects.cards.layout.grid.auto": "Auto", + "projects.cards.layout.grid.2": "2", + "projects.cards.layout.grid.3": "3", + "projects.cards.layout.grid.4": "4", + "projects.cards.layout.fact.toggle.hide": "Hide", + "projects.cards.layout.fact.toggle.show": "Show", + "projects.cards.layout.fact.move_up": "Move up", + "projects.cards.layout.fact.move_down": "Move down", + "projects.cards.layout.fact.count": "Count", + "projects.cards.event.kind.deadline": "Deadline", + "projects.cards.event.kind.appointment": "Appointment", + "projects.cards.event.kind.project_event": "Verlauf", + "projects.cards.empty": "No projects to show.", "projects.detail.clientmatter.inherited": "Inherited from parent", "einstellungen.profil.email": "Email", "einstellungen.profil.email.hint": "Email cannot be changed.", diff --git a/frontend/src/client/projects-cards.ts b/frontend/src/client/projects-cards.ts new file mode 100644 index 0000000..d4578f8 --- /dev/null +++ b/frontend/src/client/projects-cards.ts @@ -0,0 +1,796 @@ +import { t, tDyn, getLang } from "./i18n"; + +// /projects Cards view (t-paliad-149 PR 2). +// +// Renders one card per project with configurable facts (title row, type +// chip, status, clientmatter, parent path, deadline counts, next 3 events, +// last 3 Verlauf entries, team chips). Layout is per-user, named, and +// drag-rearrangeable in edit mode (see editMode flag below). +// +// Data flow: +// 1. orchestrator (client/projects.ts) calls renderCardsView(...) +// 2. we fetch the active layout (GET /api/user-card-layouts → default first) +// 3. we fetch the projects tree with the orchestrator's chip/search params +// 4. we fetch /api/projects/cards-preview for per-project event rollups +// 5. flatten tree to cards (leaf-ish only by default; "Alle Ebenen" toggle) +// 6. render the grid; lazy-fill preview slots via IntersectionObserver +// when the project list is huge (cap currently 200 — paliad fits today) +// +// Edit mode toggles in-place: each card grows drag handles + visibility +// toggles + count steppers; the toolbar grows save/discard/rename/delete. + +import type { ProjectTreeNode } from "./project-tree"; + +// ---------------------------------------------------------------------- +// Types — mirror internal/services/layout_spec.go +// ---------------------------------------------------------------------- + +export type FactKey = + | "title-row" + | "type-chip" + | "status-chip" + | "client-matter" + | "parent-path" + | "deadline-counts" + | "next-events" + | "recent-verlauf" + | "team-chips" + | "reference" + | "last-activity-at"; + +const ALL_FACT_KEYS: FactKey[] = [ + "title-row", + "type-chip", + "status-chip", + "client-matter", + "parent-path", + "deadline-counts", + "next-events", + "recent-verlauf", + "team-chips", + "reference", + "last-activity-at", +]; + +interface LayoutFact { + key: FactKey; + visible: boolean; + count?: number; +} + +interface LayoutSpec { + facts: LayoutFact[]; + density: "compact" | "roomy"; + grid_columns: "auto" | "2" | "3" | "4"; + show_all_levels: boolean; +} + +interface UserCardLayout { + id: string; + user_id: string; + name: string; + is_default: boolean; + layout: LayoutSpec; + created_at: string; + updated_at: string; +} + +interface CardEventPreview { + kind: "deadline" | "appointment" | "project_event"; + id: string; + title: string; + event_date: string; + status?: string | null; + actor_name?: string | null; + route: string; +} + +interface ProjectCardPreview { + project_id: string; + next_events: CardEventPreview[]; + recent_verlauf: CardEventPreview[]; + team_initials: string[]; + team_count: number; + last_activity_at?: string | null; +} + +// ---------------------------------------------------------------------- +// Module state +// ---------------------------------------------------------------------- + +let layouts: UserCardLayout[] = []; +let activeLayoutId: string | null = null; +let editMode = false; +let editDraft: LayoutSpec | null = null; +let treeCache: ProjectTreeNode[] = []; +let previewCache: Map = new Map(); + +// ---------------------------------------------------------------------- +// Public entry — called by the orchestrator when view-mode = "cards" +// ---------------------------------------------------------------------- + +export interface CardsViewOpts { + treeParams: URLSearchParams; +} + +export async function renderCardsView(opts: CardsViewOpts) { + const wrap = document.getElementById("projects-cards-wrap"); + const toolbar = document.getElementById("projects-cards-toolbar"); + const grid = document.getElementById("projects-cards-grid"); + if (!wrap || !toolbar || !grid) return; + wrap.style.display = "block"; + toolbar.style.display = "flex"; + + // Step 1: layouts. + if (layouts.length === 0) { + await reloadLayouts(); + } + if (layouts.length === 0) { + grid.innerHTML = `
${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}
`; + return; + } + if (!activeLayoutId) { + const def = layouts.find((l) => l.is_default) || layouts[0]; + activeLayoutId = def.id; + } + populateLayoutSelect(); + attachToolbarHandlers(); + + // Step 2: tree (chip/search-narrowed) + cards preview. + const treeURL = `/api/projects/tree?${opts.treeParams.toString()}`; + const previewURL = `/api/projects/cards-preview`; + const [treeResp, previewResp] = await Promise.all([ + fetch(treeURL).then((r) => (r.ok ? r.json() : [])), + fetch(previewURL).then((r) => (r.ok ? r.json() : [])), + ]); + treeCache = treeResp as ProjectTreeNode[]; + previewCache = new Map(); + for (const p of previewResp as ProjectCardPreview[]) { + previewCache.set(p.project_id, p); + } + + rerender(); +} + +export function teardownCardsView() { + const wrap = document.getElementById("projects-cards-wrap"); + const toolbar = document.getElementById("projects-cards-toolbar"); + const editToolbar = document.getElementById("projects-cards-edit-toolbar"); + if (wrap) wrap.style.display = "none"; + if (toolbar) toolbar.style.display = "none"; + if (editToolbar) editToolbar.style.display = "none"; + editMode = false; + editDraft = null; +} + +// ---------------------------------------------------------------------- +// Layout management — fetch + select + edit mode +// ---------------------------------------------------------------------- + +async function reloadLayouts(): Promise { + const resp = await fetch("/api/user-card-layouts"); + if (!resp.ok) { + layouts = []; + return; + } + layouts = (await resp.json()) as UserCardLayout[]; + // Server may not have any rows yet — auto-seed by hitting the cards + // view's "default" path: GET /api/user-card-layouts/__seed-default__ + // Actually the seed happens server-side on GetDefault; we trigger it + // by making a no-op preview request which doesn't pull layouts. The + // simplest path: if empty, POST a Standard layout from the client. + if (layouts.length === 0) { + const seed = await fetch("/api/user-card-layouts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "Standard", layout: defaultLayout(), is_default: true }), + }); + if (seed.ok) { + const row = (await seed.json()) as UserCardLayout; + layouts = [row]; + } + } +} + +function defaultLayout(): LayoutSpec { + return { + facts: [ + { key: "title-row", visible: true }, + { key: "type-chip", visible: true }, + { key: "status-chip", visible: true }, + { key: "client-matter", visible: true }, + { key: "parent-path", visible: true }, + { key: "deadline-counts", visible: true }, + { key: "next-events", visible: true, count: 3 }, + { key: "recent-verlauf", visible: true, count: 3 }, + { key: "team-chips", visible: true }, + ], + density: "roomy", + grid_columns: "auto", + show_all_levels: false, + }; +} + +function getActiveLayout(): UserCardLayout | null { + if (!activeLayoutId) return null; + return layouts.find((l) => l.id === activeLayoutId) || null; +} + +function getEffectiveSpec(): LayoutSpec { + if (editMode && editDraft) return editDraft; + const a = getActiveLayout(); + return a ? a.layout : defaultLayout(); +} + +function populateLayoutSelect() { + const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null; + if (!sel) return; + sel.innerHTML = ""; + for (const l of layouts) { + const opt = document.createElement("option"); + opt.value = l.id; + opt.textContent = l.is_default ? `${l.name} · ${t("projects.cards.layout.is_default") || "Standard"}` : l.name; + sel.appendChild(opt); + } + if (activeLayoutId) sel.value = activeLayoutId; +} + +function attachToolbarHandlers() { + const sel = document.getElementById("projects-cards-layout-select") as HTMLSelectElement | null; + const editBtn = document.getElementById("projects-cards-layout-edit") as HTMLButtonElement | null; + const newBtn = document.getElementById("projects-cards-layout-new") as HTMLButtonElement | null; + const showAll = document.getElementById("projects-cards-show-all-levels") as HTMLInputElement | null; + + // Idempotent attach guards via dataset flag. + if (sel && !sel.dataset.bound) { + sel.dataset.bound = "1"; + sel.addEventListener("change", () => { + activeLayoutId = sel.value; + const a = getActiveLayout(); + if (showAll && a) showAll.checked = a.layout.show_all_levels; + rerender(); + }); + } + if (editBtn && !editBtn.dataset.bound) { + editBtn.dataset.bound = "1"; + editBtn.addEventListener("click", () => enterEditMode()); + } + if (newBtn && !newBtn.dataset.bound) { + newBtn.dataset.bound = "1"; + newBtn.addEventListener("click", () => createNewLayout()); + } + if (showAll && !showAll.dataset.bound) { + showAll.dataset.bound = "1"; + showAll.checked = getEffectiveSpec().show_all_levels; + showAll.addEventListener("change", () => { + // In view mode this is a save-as-active toggle. In edit mode it + // updates the draft. + if (editMode && editDraft) { + editDraft.show_all_levels = showAll.checked; + rerender(); + } else { + const a = getActiveLayout(); + if (!a) return; + const newSpec: LayoutSpec = { ...a.layout, show_all_levels: showAll.checked }; + void persistLayout(a.id, { layout: newSpec }); + a.layout = newSpec; + rerender(); + } + }); + } + + // Edit-toolbar wiring (only first time the elements exist). + const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null; + const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null; + const eRename = document.getElementById("projects-cards-edit-rename") as HTMLButtonElement | null; + const eDelete = document.getElementById("projects-cards-edit-delete") as HTMLButtonElement | null; + const eSetDefault = document.getElementById("projects-cards-edit-set-default") as HTMLButtonElement | null; + const eDiscard = document.getElementById("projects-cards-edit-discard") as HTMLButtonElement | null; + const eSave = document.getElementById("projects-cards-edit-save") as HTMLButtonElement | null; + + if (eDensity && !eDensity.dataset.bound) { + eDensity.dataset.bound = "1"; + eDensity.addEventListener("change", () => { + if (!editDraft) return; + editDraft.density = eDensity.value as "compact" | "roomy"; + rerender(); + }); + } + if (eGrid && !eGrid.dataset.bound) { + eGrid.dataset.bound = "1"; + eGrid.addEventListener("change", () => { + if (!editDraft) return; + editDraft.grid_columns = eGrid.value as LayoutSpec["grid_columns"]; + rerender(); + }); + } + if (eRename && !eRename.dataset.bound) { + eRename.dataset.bound = "1"; + eRename.addEventListener("click", () => renameActiveLayout()); + } + if (eDelete && !eDelete.dataset.bound) { + eDelete.dataset.bound = "1"; + eDelete.addEventListener("click", () => deleteActiveLayout()); + } + if (eSetDefault && !eSetDefault.dataset.bound) { + eSetDefault.dataset.bound = "1"; + eSetDefault.addEventListener("click", () => setActiveAsDefault()); + } + if (eDiscard && !eDiscard.dataset.bound) { + eDiscard.dataset.bound = "1"; + eDiscard.addEventListener("click", () => leaveEditMode(false)); + } + if (eSave && !eSave.dataset.bound) { + eSave.dataset.bound = "1"; + eSave.addEventListener("click", () => leaveEditMode(true)); + } +} + +function enterEditMode() { + const a = getActiveLayout(); + if (!a) return; + editMode = true; + editDraft = JSON.parse(JSON.stringify(a.layout)) as LayoutSpec; + reflectEditToolbar(); + rerender(); +} + +async function leaveEditMode(saveChanges: boolean) { + const a = getActiveLayout(); + if (!a) { + editMode = false; + editDraft = null; + rerender(); + return; + } + if (saveChanges && editDraft) { + await persistLayout(a.id, { layout: editDraft }); + a.layout = editDraft; + } + editMode = false; + editDraft = null; + reflectEditToolbar(); + rerender(); +} + +function reflectEditToolbar() { + const editToolbar = document.getElementById("projects-cards-edit-toolbar"); + if (editToolbar) editToolbar.style.display = editMode ? "flex" : "none"; + + if (editMode && editDraft) { + const eDensity = document.getElementById("projects-cards-edit-density") as HTMLSelectElement | null; + const eGrid = document.getElementById("projects-cards-edit-grid") as HTMLSelectElement | null; + if (eDensity) eDensity.value = editDraft.density; + if (eGrid) eGrid.value = editDraft.grid_columns; + } +} + +async function persistLayout(id: string, patch: Partial<{ name: string; layout: LayoutSpec; is_default: boolean }>) { + const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (resp.ok) { + const row = (await resp.json()) as UserCardLayout; + const idx = layouts.findIndex((l) => l.id === id); + if (idx >= 0) layouts[idx] = row; + } +} + +async function createNewLayout() { + const name = window.prompt(t("projects.cards.layout.new.prompt") || "Name der neuen Ansicht"); + if (!name || !name.trim()) return; + const seed = JSON.parse(JSON.stringify(getEffectiveSpec())) as LayoutSpec; + const resp = await fetch("/api/user-card-layouts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim(), layout: seed, is_default: false }), + }); + if (resp.status === 409) { + window.alert("Name already exists."); + return; + } + if (!resp.ok) return; + const row = (await resp.json()) as UserCardLayout; + layouts.push(row); + activeLayoutId = row.id; + populateLayoutSelect(); + enterEditMode(); +} + +async function renameActiveLayout() { + const a = getActiveLayout(); + if (!a) return; + const name = window.prompt(t("projects.cards.layout.rename") || "Umbenennen", a.name); + if (!name || !name.trim() || name.trim() === a.name) return; + await persistLayout(a.id, { name: name.trim() }); + populateLayoutSelect(); +} + +async function deleteActiveLayout() { + const a = getActiveLayout(); + if (!a) return; + if (a.is_default) { + window.alert(t("projects.cards.layout.delete.default_blocked") || "Cannot delete default."); + return; + } + if (!window.confirm(t("projects.cards.layout.delete.confirm") || "Delete?")) return; + const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}`, { method: "DELETE" }); + if (!resp.ok && resp.status !== 204) return; + layouts = layouts.filter((l) => l.id !== a.id); + const def = layouts.find((l) => l.is_default) || layouts[0]; + activeLayoutId = def ? def.id : null; + editMode = false; + editDraft = null; + populateLayoutSelect(); + reflectEditToolbar(); + rerender(); +} + +async function setActiveAsDefault() { + const a = getActiveLayout(); + if (!a) return; + const resp = await fetch(`/api/user-card-layouts/${encodeURIComponent(a.id)}/set-default`, { + method: "POST", + }); + if (!resp.ok) return; + // Refresh the list — server cleared the prior default in tx. + await reloadLayouts(); + populateLayoutSelect(); + rerender(); +} + +// ---------------------------------------------------------------------- +// Rendering +// ---------------------------------------------------------------------- + +function rerender() { + const grid = document.getElementById("projects-cards-grid"); + if (!grid) return; + const spec = getEffectiveSpec(); + + // Apply grid columns + density. + grid.classList.toggle("is-density-compact", spec.density === "compact"); + grid.classList.toggle("is-density-roomy", spec.density === "roomy"); + grid.classList.remove("is-grid-2", "is-grid-3", "is-grid-4"); + if (spec.grid_columns !== "auto") { + grid.classList.add(`is-grid-${spec.grid_columns}`); + } + + const cards = flattenTreeToCards(treeCache, spec.show_all_levels); + if (cards.length === 0) { + grid.innerHTML = `
${escHTML(t("projects.cards.empty") || "Keine Projekte zum Anzeigen.")}
`; + return; + } + + grid.innerHTML = cards.map((n) => renderCard(n, spec)).join(""); + attachCardHandlers(grid); +} + +function flattenTreeToCards(roots: ProjectTreeNode[], showAllLevels: boolean): ProjectTreeNode[] { + const out: ProjectTreeNode[] = []; + const walk = (n: ProjectTreeNode) => { + if (showAllLevels) { + out.push(n); + } else if (isLeafish(n)) { + out.push(n); + } + for (const c of n.children) walk(c); + }; + roots.forEach(walk); + // Sort by last_activity_at DESC (from preview), pinned first. + out.sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + const aT = previewCache.get(a.id)?.last_activity_at || ""; + const bT = previewCache.get(b.id)?.last_activity_at || ""; + if (aT !== bT) return bT.localeCompare(aT); + return a.title.localeCompare(b.title); + }); + return out; +} + +function isLeafish(n: ProjectTreeNode): boolean { + // Cases / Patents / Verfahren / Projekte. Mandanten + Litigations are + // scaffolding when "Alle Ebenen" is off. + return n.type === "case" || n.type === "patent" || n.type === "project"; +} + +function renderCard(n: ProjectTreeNode, spec: LayoutSpec): string { + const visibleFacts = spec.facts.filter((f) => f.visible); + const factHTML = visibleFacts.map((f) => renderFact(n, f, spec)).join(""); + + const editChrome = editMode ? renderEditChromeForCard(spec) : ""; + + return ( + `
` + + factHTML + + editChrome + + `
` + ); +} + +function renderEditChromeForCard(spec: LayoutSpec): string { + // Inline fact-row controls (drag handle + visibility toggle) per fact key. + // We render this as a horizontally-laid-out list at the bottom of each + // card so the user can rearrange order. In edit mode the rendered facts + // above are "preview"; the ordering controls below mutate the draft. + const rows = spec.facts.map((f, i) => { + const label = t(`projects.cards.layout.fact.${f.key}` as never) || f.key; + const cnt = f.count !== undefined + ? `` + : ""; + return ( + `
  • ` + + `` + + `` + + `${escHTML(String(label))}` + + cnt + + `
  • ` + ); + }).join(""); + return `
      ${rows}
    `; +} + +function renderFact(n: ProjectTreeNode, f: LayoutFact, spec: LayoutSpec): string { + const preview = previewCache.get(n.id); + switch (f.key) { + case "title-row": + return renderTitleRow(n); + case "type-chip": + return `
    ${escHTML(String(tDyn(`projects.type.${n.type}`) || n.type))}
    `; + case "status-chip": + return `
    ${escHTML(String(tDyn(`projects.filter.status.${n.status}`) || n.status))}
    `; + case "client-matter": { + const cm = (n.client_number && n.matter_number) + ? `${n.client_number}.${n.matter_number}` + : (n.client_number || n.matter_number || ""); + if (!cm) return ""; + return `
    ${escHTML(cm)}
    `; + } + case "parent-path": + // Parent path is omitted in v1 — building the breadcrumb requires + // an extra fetch per card. Display the project's own .reference if + // present as a stand-in cue for hierarchy. + if (!n.reference) return ""; + return `
    ${escHTML(n.reference)}
    `; + case "deadline-counts": { + const open = n.open_deadlines_subtree ?? n.open_deadlines; + const overdue = n.overdue_deadlines_subtree ?? n.overdue_deadlines; + if (open === 0 && overdue === 0) return ""; + const parts: string[] = []; + if (overdue > 0) parts.push(`${overdue} ${escHTML(String(t("projects.cards.deadline_overdue") || "überfällig"))}`); + if (open > 0) parts.push(`${open} ${escHTML(String(t("projects.cards.deadline_open") || "offen"))}`); + return `
    ${parts.join(" ")}
    `; + } + case "next-events": { + const cap = clampCount(f.count); + const evs = preview?.next_events || []; + if (evs.length === 0) { + return `
    ${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}
    ${escHTML(String(t("projects.cards.no_next_events") || ""))}
    `; + } + const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join(""); + return `
    ${escHTML(String(t("projects.cards.next_events") || "Nächste Termine"))}
    ${rows}
    `; + } + case "recent-verlauf": { + const cap = clampCount(f.count); + const evs = preview?.recent_verlauf || []; + if (evs.length === 0) { + return `
    ${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}
    ${escHTML(String(t("projects.cards.no_recent") || ""))}
    `; + } + const rows = evs.slice(0, cap).map((e) => renderEventRow(e)).join(""); + return `
    ${escHTML(String(t("projects.cards.recent_verlauf") || "Zuletzt"))}
    ${rows}
    `; + } + case "team-chips": { + if (!preview || preview.team_count === 0) return ""; + const initials = preview.team_initials.map((i) => `${escHTML(i)}`).join(""); + const overflow = preview.team_count > preview.team_initials.length + ? `+${preview.team_count - preview.team_initials.length}` + : ""; + return `
    ${initials}${overflow}
    `; + } + case "reference": + if (!n.reference) return ""; + return `
    ${escHTML(n.reference)}
    `; + case "last-activity-at": { + const at = preview?.last_activity_at; + if (!at) return ""; + return `
    ${escHTML(fmtDate(at))}
    `; + } + default: + return ""; + } + // unreachable + void spec; +} + +function renderTitleRow(n: ProjectTreeNode): string { + const pinClass = n.pinned ? " is-pinned" : ""; + const pinLabel = n.pinned + ? (t("projects.tree.unpin") || "Pin entfernen") + : (t("projects.tree.pin") || "Anpinnen"); + return ( + `
    ` + + `` + + `${escHTML(n.title)}` + + `` + + `
    ` + ); +} + +function renderEventRow(e: CardEventPreview): string { + const kindLabel = t(`projects.cards.event.kind.${e.kind}` as never) || e.kind; + const dateStr = fmtDate(e.event_date); + const status = e.status ? ` ${escHTML(String(tDyn(`projects.filter.status.${e.status}`) || e.status))}` : ""; + const actor = e.actor_name ? ` ${escHTML(e.actor_name)}` : ""; + return ( + `` + + `${escHTML(dateStr)}` + + `${escHTML(String(kindLabel))}` + + `${escHTML(e.title)}` + + status + + actor + + `` + ); +} + +function clampCount(n: number | undefined): number { + if (n === undefined) return 3; + if (n < 1) return 1; + if (n > 5) return 5; + return Math.floor(n); +} + +// ---------------------------------------------------------------------- +// Card-level event handlers (pin click + edit-mode drag/check/count) +// ---------------------------------------------------------------------- + +function attachCardHandlers(grid: HTMLElement) { + // Pin star (always-active, edit mode or not). + grid.querySelectorAll(".projects-card-pin").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const id = btn.dataset.id!; + void togglePin(id, btn); + }); + }); + + if (!editMode || !editDraft) return; + + // Edit-mode: visibility checkbox per fact. + grid.querySelectorAll(".projects-cards-edit-vis").forEach((cb) => { + cb.addEventListener("change", () => { + if (!editDraft) return; + const key = cb.dataset.key as FactKey; + const f = editDraft.facts.find((x) => x.key === key); + if (!f) return; + f.visible = cb.checked; + // The first VISIBLE fact must remain "title-row" — gate this. + // Easier approach: always keep title-row visible regardless of click. + const titleRow = editDraft.facts.find((x) => x.key === "title-row"); + if (titleRow) titleRow.visible = true; + rerender(); + }); + }); + + // Edit-mode: count steppers for next-events / recent-verlauf. + grid.querySelectorAll(".projects-cards-edit-count").forEach((inp) => { + inp.addEventListener("change", () => { + if (!editDraft) return; + const key = inp.dataset.key as FactKey; + const f = editDraft.facts.find((x) => x.key === key); + if (!f) return; + const v = Math.max(1, Math.min(5, parseInt(inp.value, 10) || 3)); + f.count = v; + rerender(); + }); + }); + + // Edit-mode: HTML5 drag-and-drop for fact reordering. We attach handlers + // on each .projects-cards-edit-fact
  • ; dragover on
      ; drop reorders + // the editDraft.facts array. + grid.querySelectorAll(".projects-cards-edit-fact").forEach((li) => { + li.addEventListener("dragstart", (ev) => { + const key = li.dataset.key!; + ev.dataTransfer?.setData("text/plain", key); + li.classList.add("is-dragging"); + }); + li.addEventListener("dragend", () => li.classList.remove("is-dragging")); + li.addEventListener("dragover", (ev) => { + ev.preventDefault(); + li.classList.add("is-drop-target"); + }); + li.addEventListener("dragleave", () => li.classList.remove("is-drop-target")); + li.addEventListener("drop", (ev) => { + ev.preventDefault(); + li.classList.remove("is-drop-target"); + const fromKey = ev.dataTransfer?.getData("text/plain") as FactKey | undefined; + const toKey = li.dataset.key as FactKey; + if (!fromKey || !toKey || fromKey === toKey) return; + reorderFacts(fromKey, toKey); + rerender(); + }); + }); +} + +function reorderFacts(fromKey: FactKey, toKey: FactKey) { + if (!editDraft) return; + const fromIdx = editDraft.facts.findIndex((f) => f.key === fromKey); + const toIdx = editDraft.facts.findIndex((f) => f.key === toKey); + if (fromIdx < 0 || toIdx < 0) return; + const [moved] = editDraft.facts.splice(fromIdx, 1); + editDraft.facts.splice(toIdx, 0, moved); + // Server validator requires title-row to be the first visible fact. + // Pull it to the top if it's now somewhere else. + const trIdx = editDraft.facts.findIndex((f) => f.key === "title-row"); + if (trIdx > 0) { + const [tr] = editDraft.facts.splice(trIdx, 1); + editDraft.facts.unshift(tr); + } +} + +async function togglePin(projectID: string, btn: HTMLElement) { + const wasPinned = btn.classList.contains("is-pinned"); + btn.classList.toggle("is-pinned", !wasPinned); + btn.textContent = !wasPinned ? "★" : "☆"; + try { + const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/pin`, { + method: wasPinned ? "DELETE" : "POST", + }); + if (!resp.ok && resp.status !== 201 && resp.status !== 204) { + btn.classList.toggle("is-pinned", wasPinned); + btn.textContent = wasPinned ? "★" : "☆"; + return; + } + // Update tree cache in place so re-renders show the new state. + const update = (n: ProjectTreeNode) => { + if (n.id === projectID) n.pinned = !wasPinned; + n.children.forEach(update); + }; + treeCache.forEach(update); + } catch { + btn.classList.toggle("is-pinned", wasPinned); + btn.textContent = wasPinned ? "★" : "☆"; + } +} + +// ---------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------- + +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 escHTML(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; +} + +function escAttr(s: string): string { + return s.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": return "&"; + case "<": return "<"; + case ">": return ">"; + case '"': return """; + case "'": return "'"; + } + return c; + }); +} + +// Avoid an "unused" warning from the type-only import. +const _unusedFactKeys: FactKey[] = ALL_FACT_KEYS; +void _unusedFactKeys; diff --git a/frontend/src/client/projects.ts b/frontend/src/client/projects.ts index 8a8f3b9..5c3a65c 100644 --- a/frontend/src/client/projects.ts +++ b/frontend/src/client/projects.ts @@ -2,6 +2,7 @@ import { initI18n, onLangChange, t } from "./i18n"; import { initSidebar } from "./sidebar"; import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree"; import { renderFlatList, ProjectFlatRow } from "./projects-flat"; +import { renderCardsView, teardownCardsView } from "./projects-cards"; // /projects orchestrator (t-paliad-149). // @@ -15,7 +16,7 @@ import { renderFlatList, ProjectFlatRow } from "./projects-flat"; // - project-tree.ts for tree mode // - projects-flat.ts for flat-table mode -type ViewMode = "tree" | "flat"; +type ViewMode = "tree" | "cards" | "flat"; type Scope = "all" | "mine" | "pinned"; interface Chips { @@ -60,8 +61,12 @@ function loadStoredState(): State | null { chips?: { scope?: Scope; status?: string[]; type?: string[]; hasOpenDeadlines?: boolean }; searchQuery?: string; }; + const viewMode: ViewMode = + parsed.viewMode === "flat" ? "flat" : + parsed.viewMode === "cards" ? "cards" : + "tree"; return { - viewMode: parsed.viewMode === "flat" ? "flat" : "tree", + viewMode, chips: { scope: parsed.chips?.scope === "mine" || parsed.chips?.scope === "pinned" ? parsed.chips.scope : "all", status: new Set(parsed.chips?.status || []), @@ -97,7 +102,7 @@ function saveState() { function applyURL() { const url = new URL(window.location.href); const v = url.searchParams.get("view"); - if (v === "tree" || v === "flat") state.viewMode = v; + if (v === "tree" || v === "flat" || v === "cards") 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"); @@ -218,12 +223,15 @@ function clearAllChips() { async function render() { const treeWrap = document.getElementById("projekt-tree-wrap")!; const tableWrap = document.getElementById("entity-table-wrap")!; + const cardsWrap = document.getElementById("projects-cards-wrap")!; const empty = document.getElementById("entity-empty")!; const emptyFiltered = document.getElementById("entity-empty-filtered")!; if (state.viewMode === "tree") { + teardownCardsView(); treeWrap.style.display = "block"; tableWrap.style.display = "none"; + cardsWrap.style.display = "none"; empty.style.display = "none"; emptyFiltered.style.display = "none"; const container = document.getElementById("projekt-tree-container") as HTMLElement; @@ -231,7 +239,17 @@ async function render() { return; } + if (state.viewMode === "cards") { + treeWrap.style.display = "none"; + tableWrap.style.display = "none"; + empty.style.display = "none"; + emptyFiltered.style.display = "none"; + await renderCardsView({ treeParams: treeParams() }); + return; + } + // Flat-list mode. Reuses /api/projects (existing flat endpoint). + teardownCardsView(); treeWrap.style.display = "none"; if (!flatRows) { flatRows = await loadFlatRows(); @@ -359,7 +377,7 @@ 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 (v !== "tree" && v !== "flat" && v !== "cards") return; if (state.viewMode === v) return; state.viewMode = v; syncURL(); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index ea881a0..ad85ce8 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1439,6 +1439,55 @@ export type I18nKey = | "partner_unit.none" | "partner_unit.subtitle" | "projects.cancel" + | "projects.cards.deadline_open" + | "projects.cards.deadline_overdue" + | "projects.cards.empty" + | "projects.cards.event.kind.appointment" + | "projects.cards.event.kind.deadline" + | "projects.cards.event.kind.project_event" + | "projects.cards.layout.delete" + | "projects.cards.layout.delete.confirm" + | "projects.cards.layout.delete.default_blocked" + | "projects.cards.layout.density" + | "projects.cards.layout.density.compact" + | "projects.cards.layout.density.roomy" + | "projects.cards.layout.discard" + | "projects.cards.layout.edit" + | "projects.cards.layout.fact.client-matter" + | "projects.cards.layout.fact.count" + | "projects.cards.layout.fact.deadline-counts" + | "projects.cards.layout.fact.last-activity-at" + | "projects.cards.layout.fact.move_down" + | "projects.cards.layout.fact.move_up" + | "projects.cards.layout.fact.next-events" + | "projects.cards.layout.fact.parent-path" + | "projects.cards.layout.fact.recent-verlauf" + | "projects.cards.layout.fact.reference" + | "projects.cards.layout.fact.status-chip" + | "projects.cards.layout.fact.team-chips" + | "projects.cards.layout.fact.title-row" + | "projects.cards.layout.fact.toggle.hide" + | "projects.cards.layout.fact.toggle.show" + | "projects.cards.layout.fact.type-chip" + | "projects.cards.layout.grid" + | "projects.cards.layout.grid.2" + | "projects.cards.layout.grid.3" + | "projects.cards.layout.grid.4" + | "projects.cards.layout.grid.auto" + | "projects.cards.layout.is_default" + | "projects.cards.layout.label" + | "projects.cards.layout.new" + | "projects.cards.layout.new.prompt" + | "projects.cards.layout.rename" + | "projects.cards.layout.save" + | "projects.cards.layout.set_default" + | "projects.cards.next_events" + | "projects.cards.no_next_events" + | "projects.cards.no_recent" + | "projects.cards.recent_verlauf" + | "projects.cards.show_all_levels" + | "projects.cards.show_all_levels.hint" + | "projects.cards.team" | "projects.chip.all" | "projects.chip.has_open_deadlines" | "projects.chip.mine" diff --git a/frontend/src/projects.tsx b/frontend/src/projects.tsx index 821827a..8ca6ed7 100644 --- a/frontend/src/projects.tsx +++ b/frontend/src/projects.tsx @@ -65,10 +65,48 @@ export function renderProjects(): string {
      +
      +