Files
paliad/frontend/src/client/project-tree.ts
m a5f7b5009b feat(t-paliad-149) PR1 step 2/3: frontend rewrite — chips + pin star + last-view restore
frontend/src/projects.tsx — strip the legacy 3-select toolbar; replace with
search input + view-mode segment-control (Tree | Liste) + chip filter row
(Alle / Nur meine / Angepinnt / Status / Typ / Mit aktiven Fristen). Tree
container is the default visible mount; flat-table hidden until view mode
toggles.

frontend/src/client/projects.ts — orchestrator. Owns chip + search + view-
mode state. Last-viewed restore from sessionStorage (Q1 lock-in), URL params
override on load, syncURL on every state change. Debounced search (250ms).
Multi-select panels via <details> for status/type. Delegates rendering to
project-tree.ts (tree mode) or projects-flat.ts (flat mode).

frontend/src/client/projects-flat.ts (NEW) — extracted table render from the
old projects.ts so the orchestrator can mount/unmount cleanly.

frontend/src/client/project-tree.ts — extends ProjectTreeNode shape with
pinned, inherited_visibility, match_kind, *_subtree fields. Renders pin
star button (always-visible per design §4.6 — touch-friendly), greyed-
ancestor opacity for InheritedVisibility=true, lime backdrop on
match_kind=self. Pin click does optimistic toggle + POST/DELETE
/api/projects/{id}/pin then invalidates the tree cache.

frontend/src/styles/global.css — toolbar + chips + pin star + greyed-
ancestor + match highlighting. ~200 LoC appended.

frontend/src/client/i18n.ts — 29 new keys DE+EN under projects.toolbar.*,
projects.chip.*, projects.tree.deadlines.*, projects.tree.pin/unpin,
projects.search.match.*, projects.empty.filtered.action.

internal/services/pin_service_test.go (NEW) — live-DB tests for PinService
(pin/unpin/idempotent/owner-scope/visibility-gate) + 2 BuildTreeWithOptions
cases (PinnedSet surfaces, ScopeMine greys ancestors). Skips without
TEST_DATABASE_URL; pure-Go path runs clean.

Frontend bun build clean. go build / vet / test (short) clean.
2026-05-07 22:29:39 +02:00

414 lines
16 KiB
TypeScript

import { t, tDyn } from "./i18n";
// Tree view of paliad.projects rendered into a container element.
// t-paliad-149 redesign: tree fetches with the orchestrator's chip / search
// state encoded as query params on /api/projects/tree, so the cache invalidates
// when the orchestrator calls refreshProjectTree({ params }).
export interface ProjectTreeNode {
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;
open_deadlines: number;
overdue_deadlines: number;
// t-paliad-149: subtree-aggregated counts populated when ?subtree_counts=true
// (the new default). Per-node fields above stay populated regardless.
open_deadlines_subtree?: number;
overdue_deadlines_subtree?: number;
// t-paliad-149: pin state on /api/projects/tree response.
pinned?: boolean;
// t-paliad-149: greyed-ancestor flag (Scope=Mine / Scope=Pinned).
inherited_visibility?: boolean;
// t-paliad-149: search match kind. Empty when no search active.
match_kind?: "self" | "ancestor" | "descendant" | "";
children: ProjectTreeNode[];
}
let cache: ProjectTreeNode[] | null = null;
let cacheParams = "";
let useSubtreeCounts = true;
const expandedKey = "paliad.projectTree.expanded";
let expanded = loadExpanded();
function loadExpanded(): Set<string> {
try {
const raw = sessionStorage.getItem(expandedKey);
if (!raw) return new Set();
const arr = JSON.parse(raw) as string[];
return new Set(arr);
} catch {
return new Set();
}
}
function saveExpanded() {
try {
sessionStorage.setItem(expandedKey, JSON.stringify([...expanded]));
} catch {
/* private mode etc. — ignore */
}
}
function isExpanded(node: ProjectTreeNode, depth: number): boolean {
if (expanded.has(node.id)) return true;
if (expanded.has(`!${node.id}`)) return false;
return depth < 2;
}
function setExpanded(node: ProjectTreeNode, depth: number, open: boolean) {
expanded.delete(node.id);
expanded.delete(`!${node.id}`);
const defaultOpen = depth < 2;
if (open !== defaultOpen) {
expanded.add(open ? node.id : `!${node.id}`);
}
saveExpanded();
}
const typeIcons: Record<string, string> = {
// Lucide-style 24x24 icons; recolour via currentColor.
client:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<rect x="2" y="7" width="20" height="14" rx="2"/>` +
`<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>` +
`</svg>`,
litigation:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M16 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/>` +
`<path d="M2 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/>` +
`<path d="M7 21h10"/>` +
`<path d="M12 3v18"/>` +
`<path d="M3 7h18"/>` +
`</svg>`,
patent:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/>` +
`<path d="M3 7v12a2 2 0 0 0 2 2h0"/>` +
`<path d="M21 3a2 2 0 0 0-2 2v14"/>` +
`<line x1="9" y1="9" x2="15" y2="9"/>` +
`<line x1="9" y1="13" x2="15" y2="13"/>` +
`<line x1="9" y1="17" x2="13" y2="17"/>` +
`</svg>`,
case:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M14 13l-7 7-3-3 7-7"/>` +
`<path d="M11.5 7.5l5 5"/>` +
`<path d="M16 3l5 5-3 3-5-5z"/>` +
`<path d="M5 21h6"/>` +
`</svg>`,
project:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>` +
`</svg>`,
};
const chevron =
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<polyline points="9 18 15 12 9 6"/>` +
`</svg>`;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function clientMatter(n: ProjectTreeNode): string {
if (n.client_number && n.matter_number) {
return `${n.client_number}.${n.matter_number}`;
}
return n.client_number || n.matter_number || "";
}
function renderNode(node: ProjectTreeNode, depth: number): string {
const hasChildren = node.children.length > 0;
const open = hasChildren && isExpanded(node, depth);
const typeLabel = tDyn(`projects.type.${node.type}`) || node.type;
const statusLabel = tDyn(`projects.filter.status.${node.status}`) || node.status;
const cm = clientMatter(node);
const ref = node.reference || "";
// Subtree-aggregated counts when available, per-node otherwise (the
// chip-row toolbar can flip the orchestrator's subtree_counts param).
const useSubtree = useSubtreeCounts && (node.open_deadlines_subtree !== undefined);
const overdue = useSubtree ? (node.overdue_deadlines_subtree ?? 0) : node.overdue_deadlines;
const openCount = useSubtree ? (node.open_deadlines_subtree ?? 0) : node.open_deadlines;
const toggle = hasChildren
? `<button class="projekt-tree-toggle${open ? " is-open" : ""}" type="button" aria-label="${esc(t("projects.tree.toggle") || "Aufklappen / Zuklappen")}">${chevron}</button>`
: `<span class="projekt-tree-toggle is-leaf" aria-hidden="true"></span>`;
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 = `<button class="projekt-tree-pin${pinned ? " is-pinned" : ""}" type="button" data-action="pin" aria-label="${esc(pinLabel)}" title="${esc(pinLabel)}">${pinned ? starFilled : starOutline}</button>`;
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") + " — " + subtreeHint;
badges += `<span class="projekt-tree-badge projekt-tree-badge-overdue" title="${esc(label)}">${overdue}</span>`;
}
if (openCount > 0 && overdue === 0) {
const label = (t("projects.tree.deadlines.open") || "offene Fristen") + " — " + subtreeHint;
badges += `<span class="projekt-tree-badge projekt-tree-badge-open" title="${esc(label)}">${openCount}</span>`;
}
const meta: string[] = [];
if (ref) meta.push(`<span class="projekt-tree-ref">${esc(ref)}</span>`);
if (cm) meta.push(`<span class="projekt-tree-cm">${esc(cm)}</span>`);
const childMarkup = hasChildren && open
? `<ul class="projekt-tree-children" role="group">${node.children.map((c) => renderNode(c, depth + 1)).join("")}</ul>`
: "";
// 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 (
`<li class="projekt-tree-node" data-id="${esc(node.id)}" data-depth="${depth}" role="treeitem"` +
(hasChildren ? ` aria-expanded="${open ? "true" : "false"}"` : "") +
`>` +
`<div class="${rowClass}" tabindex="0"${inheritedHint}>` +
toggle +
`<span class="projekt-tree-icon projekt-tree-icon-${esc(node.type)}">${icon}</span>` +
`<span class="projekt-tree-title">${esc(node.title)}</span>` +
`<span class="projekt-tree-type-chip entity-type-chip entity-type-${esc(node.type)}">${esc(typeLabel)}</span>` +
(meta.length ? `<span class="projekt-tree-meta">${meta.join(" · ")}</span>` : "") +
`<span class="projekt-tree-spacer"></span>` +
badges +
`<span class="projekt-tree-status entity-status-chip entity-status-${esc(node.status)}">${esc(statusLabel)}</span>` +
pinStar +
`</div>` +
childMarkup +
`</li>`
);
}
// Lucide-style filled / outline star for the pin toggle.
const starFilled =
`<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true">` +
`<polygon points="12 2 15 9 22 10 17 15 18 22 12 18 6 22 7 15 2 10 9 9"/>` +
`</svg>`;
const starOutline =
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true">` +
`<polygon points="12 2 15 9 22 10 17 15 18 22 12 18 6 22 7 15 2 10 9 9"/>` +
`</svg>`;
// 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<ProjectTreeNode[] | null> {
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(url);
if (resp.status === 503) {
container.innerHTML = `<div class="projekt-tree-unavailable" data-i18n="projects.unavailable">${esc(t("projects.unavailable") || "Projektverwaltung zurzeit nicht verfügbar")}</div>`;
return null;
}
if (!resp.ok) {
container.innerHTML = `<div class="projekt-tree-error">${esc(t("projects.tree.error") || "Baumansicht konnte nicht geladen werden.")}</div>`;
return null;
}
cache = (await resp.json()) as ProjectTreeNode[];
cacheParams = key;
return cache;
} catch {
container.innerHTML = `<div class="projekt-tree-error">${esc(t("projects.tree.error") || "Baumansicht konnte nicht geladen werden.")}</div>`;
return null;
}
}
function attachHandlers(container: HTMLElement) {
container.querySelectorAll<HTMLLIElement>(".projekt-tree-node").forEach((node) => {
const row = node.querySelector<HTMLElement>(":scope > .projekt-tree-row");
if (!row) return;
const toggle = row.querySelector<HTMLElement>(".projekt-tree-toggle");
const pinBtn = row.querySelector<HTMLElement>(".projekt-tree-pin");
const id = node.dataset.id!;
const depth = Number(node.dataset.depth || "0");
const navigate = () => {
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")) {
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();
navigate();
}
});
return;
}
if (toggle) {
toggle.addEventListener("click", (e) => {
e.stopPropagation();
const data = cache && findById(cache, id);
if (!data) return;
const isOpen = node.getAttribute("aria-expanded") === "true";
setExpanded(data, depth, !isOpen);
rerender();
});
}
row.addEventListener("click", (e) => {
// 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) => {
if (e.key === "Enter") {
e.preventDefault();
navigate();
} else if (e.key === " ") {
e.preventDefault();
const data = cache && findById(cache, id);
if (!data) return;
const isOpen = node.getAttribute("aria-expanded") === "true";
setExpanded(data, depth, !isOpen);
rerender();
}
});
});
}
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;
const found = findById(n.children, id);
if (found) return found;
}
return null;
}
let mountContainer: HTMLElement | null = null;
let mountParams: URLSearchParams = new URLSearchParams();
function rerender() {
if (!mountContainer || !cache) return;
if (cache.length === 0) {
mountContainer.innerHTML = `<div class="projekt-tree-empty">${esc(t("projects.empty.filtered") || "Keine Treffer.")}</div>`;
return;
}
mountContainer.innerHTML =
`<ul class="projekt-tree-root" role="tree">${cache.map((n) => renderNode(n, 0)).join("")}</ul>`;
attachHandlers(mountContainer);
}
// 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 = `<div class="projekt-tree-loading">${esc(t("projects.tree.loading") || "Baum wird geladen…")}</div>`;
const data = await fetchTree(container, mountParams);
if (!data) return;
}
rerender();
}
// 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, params || mountParams);
}
}
// Re-render the visible tree on language change so labels follow.
export function rerenderProjectTree() {
rerender();
}