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.
414 lines
16 KiB
TypeScript
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();
|
|
}
|