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.
This commit is contained in:
@@ -1180,6 +1180,36 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.tree.error": "Baumansicht konnte nicht geladen werden.",
|
||||
"projects.tree.deadlines.overdue": "\u00dcberf\u00e4llige Fristen",
|
||||
"projects.tree.deadlines.open": "Offene Fristen",
|
||||
"projects.tree.deadlines.subtree.tooltip": "Inkl. Unterprojekte",
|
||||
"projects.tree.deadlines.direct.tooltip": "Nur direkt auf diesem Projekt",
|
||||
"projects.tree.pin": "Anpinnen",
|
||||
"projects.tree.unpin": "Pin entfernen",
|
||||
"projects.tree.inherited.context": "Sichtbar wegen Unterprojekt",
|
||||
"projects.toolbar.search.placeholder": "Suchen \u2014 Titel, Referenz, ClientMatter\u2026",
|
||||
"projects.toolbar.view.tree": "Baum",
|
||||
"projects.toolbar.view.cards": "Karten",
|
||||
"projects.toolbar.view.flat": "Liste",
|
||||
"projects.toolbar.subtree_counts": "Inkl. Unterprojekte z\u00e4hlen",
|
||||
"projects.chip.all": "Alle",
|
||||
"projects.chip.mine": "Nur meine",
|
||||
"projects.chip.pinned": "Angepinnt",
|
||||
"projects.chip.status": "Status",
|
||||
"projects.chip.type": "Typ",
|
||||
"projects.chip.has_open_deadlines": "Mit aktiven Fristen",
|
||||
"projects.chip.status.active": "Aktiv",
|
||||
"projects.chip.status.archived": "Archiviert",
|
||||
"projects.chip.status.closed": "Abgeschlossen",
|
||||
"projects.chip.type.client": "Mandant",
|
||||
"projects.chip.type.litigation": "Streitsache",
|
||||
"projects.chip.type.patent": "Patent",
|
||||
"projects.chip.type.case": "Verfahren",
|
||||
"projects.chip.type.project": "Projekt",
|
||||
"projects.chip.multi.none": "Keine Auswahl",
|
||||
"projects.chip.multi.count": "{n} ausgew\u00e4hlt",
|
||||
"projects.empty.filtered.action": "Filter zur\u00fccksetzen",
|
||||
"projects.search.match.self": "Treffer",
|
||||
"projects.search.match.ancestor": "\u00dcber-Projekt eines Treffers",
|
||||
"projects.search.match.descendant": "Unterprojekt eines Treffers",
|
||||
"projects.detail.clientmatter.inherited": "Vom \u00dcberprojekt vererbt",
|
||||
"einstellungen.profil.email": "E-Mail",
|
||||
"einstellungen.profil.email.hint": "E-Mail kann nicht ge\u00e4ndert werden.",
|
||||
@@ -3096,6 +3126,36 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.tree.error": "Failed to load tree view.",
|
||||
"projects.tree.deadlines.overdue": "Overdue deadlines",
|
||||
"projects.tree.deadlines.open": "Open deadlines",
|
||||
"projects.tree.deadlines.subtree.tooltip": "Includes sub-projects",
|
||||
"projects.tree.deadlines.direct.tooltip": "This project only",
|
||||
"projects.tree.pin": "Pin",
|
||||
"projects.tree.unpin": "Unpin",
|
||||
"projects.tree.inherited.context": "Visible because of a sub-project",
|
||||
"projects.toolbar.search.placeholder": "Search — title, reference, ClientMatter…",
|
||||
"projects.toolbar.view.tree": "Tree",
|
||||
"projects.toolbar.view.cards": "Cards",
|
||||
"projects.toolbar.view.flat": "List",
|
||||
"projects.toolbar.subtree_counts": "Count sub-projects too",
|
||||
"projects.chip.all": "All",
|
||||
"projects.chip.mine": "Mine",
|
||||
"projects.chip.pinned": "Pinned",
|
||||
"projects.chip.status": "Status",
|
||||
"projects.chip.type": "Type",
|
||||
"projects.chip.has_open_deadlines": "With open deadlines",
|
||||
"projects.chip.status.active": "Active",
|
||||
"projects.chip.status.archived": "Archived",
|
||||
"projects.chip.status.closed": "Closed",
|
||||
"projects.chip.type.client": "Client",
|
||||
"projects.chip.type.litigation": "Litigation",
|
||||
"projects.chip.type.patent": "Patent",
|
||||
"projects.chip.type.case": "Case",
|
||||
"projects.chip.type.project": "Project",
|
||||
"projects.chip.multi.none": "Nothing selected",
|
||||
"projects.chip.multi.count": "{n} selected",
|
||||
"projects.empty.filtered.action": "Reset filters",
|
||||
"projects.search.match.self": "Match",
|
||||
"projects.search.match.ancestor": "Parent of a match",
|
||||
"projects.search.match.descendant": "Child of a match",
|
||||
"projects.detail.clientmatter.inherited": "Inherited from parent",
|
||||
"einstellungen.profil.email": "Email",
|
||||
"einstellungen.profil.email.hint": "Email cannot be changed.",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t, tDyn } from "./i18n";
|
||||
|
||||
// Tree view of paliad.projects rendered into a container element. Reads
|
||||
// /api/projects/tree once on first init and caches the response. Top two
|
||||
// levels expand by default; deeper nodes start collapsed and toggle via the
|
||||
// chevron.
|
||||
// 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;
|
||||
@@ -17,10 +17,22 @@ export interface ProjectTreeNode {
|
||||
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();
|
||||
|
||||
@@ -121,8 +133,12 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
const statusLabel = tDyn(`projects.filter.status.${node.status}`) || node.status;
|
||||
const cm = clientMatter(node);
|
||||
const ref = node.reference || "";
|
||||
const overdue = node.overdue_deadlines;
|
||||
const openCount = node.open_deadlines;
|
||||
|
||||
// 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>`
|
||||
@@ -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 = `<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";
|
||||
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";
|
||||
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>`;
|
||||
}
|
||||
|
||||
@@ -148,11 +175,24 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
? `<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="projekt-tree-row" tabindex="0">` +
|
||||
`<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>` +
|
||||
@@ -161,16 +201,32 @@ function renderNode(node: ProjectTreeNode, depth: number): string {
|
||||
`<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>`
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchTree(container: HTMLElement): Promise<ProjectTreeNode[] | null> {
|
||||
if (cache) return cache;
|
||||
// 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("/api/projects/tree");
|
||||
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;
|
||||
@@ -180,6 +236,7 @@ async function fetchTree(container: HTMLElement): Promise<ProjectTreeNode[] | nu
|
||||
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>`;
|
||||
@@ -192,6 +249,7 @@ function attachHandlers(container: HTMLElement) {
|
||||
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");
|
||||
|
||||
@@ -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 = `<div class="projekt-tree-loading">${esc(t("projects.tree.loading") || "Baum wird geladen…")}</div>`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
81
frontend/src/client/projects-flat.ts
Normal file
81
frontend/src/client/projects-flat.ts
Normal file
@@ -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 `<tr class="entity-row" data-id="${esc(p.id)}">
|
||||
<td class="entity-col-title">${esc(p.title)}</td>
|
||||
<td><span class="entity-type-chip entity-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
||||
<td class="entity-col-ref">${refCell}</td>
|
||||
<td class="entity-col-ref">${clientMatterCell}</td>
|
||||
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
|
||||
<td class="entity-col-updated">${fmtDate(p.updated_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.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<HTMLTableRowElement>(".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;
|
||||
}
|
||||
@@ -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<string>;
|
||||
type: Set<string>;
|
||||
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<HTMLButtonElement>(`.projects-chip[data-chip="${s}"]`);
|
||||
btn?.classList.toggle("is-active", state.chips.scope === s);
|
||||
});
|
||||
// Has-open-deadlines
|
||||
const hasBtn = document.querySelector<HTMLButtonElement>(`.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<HTMLButtonElement>(".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<string>) {
|
||||
const wrap = document.querySelector<HTMLDetailsElement>(`.projects-chip-multi[data-chip-multi="${name}"]`);
|
||||
if (!wrap) return;
|
||||
const summary = wrap.querySelector<HTMLElement>("summary");
|
||||
const inputs = wrap.querySelectorAll<HTMLInputElement>('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<ProjectFlatRow[] | null> {
|
||||
const unavailable = document.getElementById("entity-unavailable")!;
|
||||
const table = document.querySelector<HTMLElement>(".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 `<tr class="entity-row" data-id="${esc(p.id)}">
|
||||
<td class="entity-col-title">${esc(p.title)}</td>
|
||||
<td><span class="entity-type-chip entity-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
||||
<td class="entity-col-ref">${refCell}</td>
|
||||
<td class="entity-col-ref">${clientMatterCell}</td>
|
||||
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
|
||||
<td class="entity-col-updated">${fmtDate(p.updated_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.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<HTMLTableRowElement>(".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<HTMLButtonElement>(".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<HTMLDetailsElement>(".projects-chip-multi").forEach((wrap) => {
|
||||
const name = wrap.dataset.chipMulti!;
|
||||
const set = (name === "status" ? state.chips.status : state.chips.type);
|
||||
wrap.querySelectorAll<HTMLInputElement>('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<HTMLButtonElement>(".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();
|
||||
});
|
||||
|
||||
@@ -1436,6 +1436,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"
|
||||
@@ -1525,6 +1541,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"
|
||||
@@ -1583,6 +1600,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"
|
||||
@@ -1634,11 +1654,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"
|
||||
|
||||
@@ -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 "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
@@ -40,64 +46,61 @@ export function renderProjects(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-controls">
|
||||
<div className="glossar-search-wrap entity-search-wrap">
|
||||
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<div className="projects-toolbar">
|
||||
<div className="projects-search-wrap">
|
||||
<svg className="projects-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="projects-search"
|
||||
className="glossar-search"
|
||||
placeholder="Titel, Referenz oder ClientMatter..."
|
||||
data-i18n-placeholder="projects.search.placeholder"
|
||||
className="projects-search-input"
|
||||
placeholder="Suchen — Titel, Referenz, ClientMatter…"
|
||||
data-i18n-placeholder="projects.toolbar.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span className="glossar-count" id="projects-count" />
|
||||
<span className="projects-search-count" id="projects-count" />
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<div className="filter-group">
|
||||
<label className="filter-label" htmlFor="project-type" data-i18n="projects.filter.type">Typ</label>
|
||||
<select id="project-type" className="entity-select">
|
||||
<option value="" data-i18n="projects.filter.type.all">Alle Typen</option>
|
||||
<option value="client" data-i18n="projects.type.client">Mandant</option>
|
||||
<option value="litigation" data-i18n="projects.type.litigation">Streitsache</option>
|
||||
<option value="patent" data-i18n="projects.type.patent">Patent</option>
|
||||
<option value="case" data-i18n="projects.type.case">Verfahren</option>
|
||||
<option value="project" data-i18n="projects.type.project">Projekt</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label" htmlFor="project-status" data-i18n="projects.filter.status">Status</label>
|
||||
<select id="project-status" className="entity-select">
|
||||
<option value="" data-i18n="projects.filter.status.all">Alle Status</option>
|
||||
<option value="active" data-i18n="projects.filter.status.active">Aktiv</option>
|
||||
<option value="archived" data-i18n="projects.filter.status.archived">Archiviert</option>
|
||||
<option value="closed" data-i18n="projects.filter.status.closed">Abgeschlossen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label className="filter-label" htmlFor="project-view" data-i18n="projects.filter.view">Ansicht</label>
|
||||
<select id="project-view" className="entity-select">
|
||||
<option value="flat" data-i18n="projects.view.flat">Flache Liste</option>
|
||||
<option value="tree" data-i18n="projects.view.tree">Baumansicht</option>
|
||||
<option value="roots" data-i18n="projects.view.roots">Nur Wurzeln</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="projects-view-segment" role="tablist" aria-label="Ansicht">
|
||||
<button type="button" className="projects-view-btn" data-view="tree" data-i18n="projects.toolbar.view.tree">Baum</button>
|
||||
<button type="button" className="projects-view-btn" data-view="flat" data-i18n="projects.toolbar.view.flat">Liste</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="projects-chip-row" id="projects-chip-row" role="group" aria-label="Filter">
|
||||
<button type="button" className="projects-chip" data-chip="all" data-i18n="projects.chip.all">Alle</button>
|
||||
<button type="button" className="projects-chip" data-chip="mine" data-i18n="projects.chip.mine">Nur meine</button>
|
||||
<button type="button" className="projects-chip" data-chip="pinned" data-i18n="projects.chip.pinned">Angepinnt</button>
|
||||
<details className="projects-chip-multi" data-chip-multi="status">
|
||||
<summary className="projects-chip" data-i18n="projects.chip.status">Status</summary>
|
||||
<div className="projects-chip-panel" role="menu">
|
||||
<label><input type="checkbox" value="active" /><span data-i18n="projects.chip.status.active">Aktiv</span></label>
|
||||
<label><input type="checkbox" value="archived" /><span data-i18n="projects.chip.status.archived">Archiviert</span></label>
|
||||
<label><input type="checkbox" value="closed" /><span data-i18n="projects.chip.status.closed">Abgeschlossen</span></label>
|
||||
</div>
|
||||
</details>
|
||||
<details className="projects-chip-multi" data-chip-multi="type">
|
||||
<summary className="projects-chip" data-i18n="projects.chip.type">Typ</summary>
|
||||
<div className="projects-chip-panel" role="menu">
|
||||
<label><input type="checkbox" value="client" /><span data-i18n="projects.chip.type.client">Mandant</span></label>
|
||||
<label><input type="checkbox" value="litigation" /><span data-i18n="projects.chip.type.litigation">Streitsache</span></label>
|
||||
<label><input type="checkbox" value="patent" /><span data-i18n="projects.chip.type.patent">Patent</span></label>
|
||||
<label><input type="checkbox" value="case" /><span data-i18n="projects.chip.type.case">Verfahren</span></label>
|
||||
<label><input type="checkbox" value="project" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" className="projects-chip" data-chip="has_open_deadlines" data-i18n="projects.chip.has_open_deadlines">Mit aktiven Fristen</button>
|
||||
</div>
|
||||
|
||||
<div id="entity-unavailable" className="entity-unavailable" style="display:none">
|
||||
<p data-i18n="projects.unavailable">
|
||||
Projektverwaltung zurzeit nicht verfügbar — bitte Administrator kontaktieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="entity-table-wrap">
|
||||
<div className="entity-table-wrap" id="entity-table-wrap" style="display:none">
|
||||
<table className="entity-table" id="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -113,7 +116,7 @@ export function renderProjects(): string {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="projekt-tree-wrap" id="projekt-tree-wrap" style="display:none">
|
||||
<div className="projekt-tree-wrap" id="projekt-tree-wrap">
|
||||
<div id="projekt-tree-container" />
|
||||
</div>
|
||||
|
||||
@@ -127,6 +130,7 @@ export function renderProjects(): string {
|
||||
|
||||
<div className="entity-empty entity-empty-filtered" id="entity-empty-filtered" style="display:none">
|
||||
<p data-i18n="projects.empty.filtered">Keine Treffer für diese Filter.</p>
|
||||
<button type="button" id="projects-reset-filters" className="btn-secondary" data-i18n="projects.empty.filtered.action">Filter zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -11307,3 +11307,225 @@ dialog.quick-add-sheet::backdrop {
|
||||
min-height: 1px;
|
||||
max-width: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
/projects redesign (t-paliad-149) — toolbar, chips, pin star, search match
|
||||
============================================================================ */
|
||||
|
||||
/* Toolbar: search input + view-mode segment-control on one row.
|
||||
Wraps on narrow viewports so the segment control drops below the search. */
|
||||
.projects-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.projects-search-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 320px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.projects-search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.projects-search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.4rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.projects-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgb(var(--hlc-lime-rgb) / 0.25);
|
||||
}
|
||||
|
||||
.projects-search-count {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* View-mode segment-control */
|
||||
.projects-view-segment {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.projects-view-btn {
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.projects-view-btn:last-child { border-right: 0; }
|
||||
|
||||
.projects-view-btn:hover { background: var(--color-surface-muted); }
|
||||
|
||||
.projects-view-btn.is-active {
|
||||
background: rgb(var(--hlc-lime-rgb) / 0.2);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Chip filter row */
|
||||
.projects-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin: 0.25rem 0 1rem 0;
|
||||
}
|
||||
|
||||
.projects-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.projects-chip::-webkit-details-marker { display: none; }
|
||||
|
||||
.projects-chip:hover {
|
||||
background: var(--color-surface-muted);
|
||||
border-color: var(--color-border-strong, var(--color-border));
|
||||
}
|
||||
|
||||
.projects-chip.is-active {
|
||||
background: rgb(var(--hlc-lime-rgb) / 0.25);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.projects-chip-multi {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.projects-chip-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.08);
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.projects-chip-panel label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Pin star — always-visible (touch-friendly), per design §4.6.
|
||||
Stars are drawn via inline SVG; this rule sizes the button hit-target. */
|
||||
.projekt-tree-pin {
|
||||
flex: 0 0 auto;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
margin-left: 0.4rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.projekt-tree-pin svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.projekt-tree-pin:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.projekt-tree-pin.is-pinned {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.projekt-tree-pin:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Greyed-ancestor look for Scope=Mine / Scope=Pinned. The row stays
|
||||
clickable but reads as "context" rather than "your project." */
|
||||
.projekt-tree-row.is-inherited {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.projekt-tree-row.is-inherited .projekt-tree-pin {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Search match highlighting. Self-matches get a faint lime backdrop;
|
||||
ancestors / descendants stay neutral so the eye finds the actual hit. */
|
||||
.projekt-tree-row.is-match-self {
|
||||
background: rgb(var(--hlc-lime-rgb) / 0.15);
|
||||
}
|
||||
|
||||
/* Filtered-empty view gets an inline reset button. */
|
||||
.entity-empty-filtered {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.projects-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.projects-view-segment { align-self: flex-start; }
|
||||
}
|
||||
|
||||
321
internal/services/pin_service_test.go
Normal file
321
internal/services/pin_service_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for PinService and the BuildTreeWithOptions chip branches.
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
type pinTestEnv struct {
|
||||
t *testing.T
|
||||
pool *sqlx.DB
|
||||
pin *PinService
|
||||
projects *ProjectService
|
||||
userID uuid.UUID
|
||||
otherUserID uuid.UUID
|
||||
clientID uuid.UUID // root, directly-staffed
|
||||
caseID uuid.UUID // child of client, directly-staffed
|
||||
otherID uuid.UUID // root, NOT directly-staffed (visible only to admin)
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
func setupPinTest(t *testing.T) *pinTestEnv {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New() // standard user
|
||||
otherUserID := uuid.New() // separate user (used as creator of the "other" project)
|
||||
clientID := uuid.New()
|
||||
caseID := uuid.New()
|
||||
otherID := uuid.New()
|
||||
|
||||
cleanup := func() {
|
||||
c := context.Background()
|
||||
pool.ExecContext(c, `DELETE FROM paliad.user_pinned_projects WHERE user_id IN ($1, $2)`, userID, otherUserID)
|
||||
for _, pid := range []uuid.UUID{caseID, clientID, otherID} {
|
||||
pool.ExecContext(c, `DELETE FROM paliad.project_teams WHERE project_id = $1`, pid)
|
||||
pool.ExecContext(c, `DELETE FROM paliad.project_events WHERE project_id = $1`, pid)
|
||||
}
|
||||
pool.ExecContext(c, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, caseID, clientID)
|
||||
pool.ExecContext(c, `DELETE FROM paliad.projects WHERE id = $1`, otherID)
|
||||
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, userID, otherUserID)
|
||||
pool.ExecContext(c, `DELETE FROM auth.users WHERE id IN ($1, $2)`, userID, otherUserID)
|
||||
}
|
||||
cleanup()
|
||||
|
||||
for _, u := range []struct {
|
||||
id uuid.UUID
|
||||
email string
|
||||
role string
|
||||
}{
|
||||
{userID, "pin-test-user@hlc.com", "standard"},
|
||||
{otherUserID, "pin-test-other@hlc.com", "standard"},
|
||||
} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`, u.id, u.email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Pin Test', 'munich', $3, 'de')`,
|
||||
u.id, u.email, u.role); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build a small tree owned by userID (so userID is staffed direct on
|
||||
// clientID and caseID). otherID is owned by otherUserID — userID has
|
||||
// no visibility into it.
|
||||
for _, p := range []struct {
|
||||
id uuid.UUID
|
||||
typ string
|
||||
parent *uuid.UUID
|
||||
title string
|
||||
creator uuid.UUID
|
||||
staff uuid.UUID
|
||||
}{
|
||||
{clientID, "client", nil, "Pin Client", userID, userID},
|
||||
{caseID, "case", &clientID, "Pin Case", userID, userID},
|
||||
{otherID, "client", nil, "Other Client", otherUserID, otherUserID},
|
||||
} {
|
||||
var parent any
|
||||
if p.parent != nil {
|
||||
parent = *p.parent
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, 'active', $6)`,
|
||||
p.id, p.typ, parent, p.id.String(), p.title, p.creator); err != nil {
|
||||
t.Fatalf("seed paliad.projects %s: %v", p.id, err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', false, $2)`, p.id, p.staff); err != nil {
|
||||
t.Fatalf("seed project_teams %s: %v", p.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
pin := NewPinService(pool, projects)
|
||||
|
||||
return &pinTestEnv{
|
||||
t: t,
|
||||
pool: pool,
|
||||
pin: pin,
|
||||
projects: projects,
|
||||
userID: userID,
|
||||
otherUserID: otherUserID,
|
||||
clientID: clientID,
|
||||
caseID: caseID,
|
||||
otherID: otherID,
|
||||
cleanup: func() { cleanup(); pool.Close() },
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinService_PinAndIsPinned(t *testing.T) {
|
||||
env := setupPinTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Initially not pinned.
|
||||
if pinned, err := env.pin.IsPinned(ctx, env.userID, env.clientID); err != nil || pinned {
|
||||
t.Fatalf("IsPinned before = (%v, %v); want (false, nil)", pinned, err)
|
||||
}
|
||||
|
||||
// Pin succeeds and is idempotent.
|
||||
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Pin: %v", err)
|
||||
}
|
||||
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Pin (idempotent): %v", err)
|
||||
}
|
||||
|
||||
// IsPinned now true.
|
||||
if pinned, err := env.pin.IsPinned(ctx, env.userID, env.clientID); err != nil || !pinned {
|
||||
t.Fatalf("IsPinned after = (%v, %v); want (true, nil)", pinned, err)
|
||||
}
|
||||
|
||||
// PinnedSet contains exactly one entry.
|
||||
set, err := env.pin.PinnedSet(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("PinnedSet: %v", err)
|
||||
}
|
||||
if len(set) != 1 {
|
||||
t.Errorf("PinnedSet size = %d, want 1", len(set))
|
||||
}
|
||||
if _, ok := set[env.clientID]; !ok {
|
||||
t.Errorf("PinnedSet missing clientID")
|
||||
}
|
||||
|
||||
// ListPinned returns slice form.
|
||||
ids, err := env.pin.ListPinned(ctx, env.userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPinned: %v", err)
|
||||
}
|
||||
if len(ids) != 1 || ids[0] != env.clientID {
|
||||
t.Errorf("ListPinned = %v, want [clientID]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinService_PinInvisible(t *testing.T) {
|
||||
env := setupPinTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// userID cannot see otherID — Pin must return ErrNotVisible.
|
||||
if err := env.pin.Pin(ctx, env.userID, env.otherID); !errors.Is(err, ErrNotVisible) {
|
||||
t.Fatalf("Pin invisible = %v, want ErrNotVisible", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinService_UnpinIdempotent(t *testing.T) {
|
||||
env := setupPinTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Unpin without prior pin = no-op.
|
||||
if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Unpin (never pinned): %v", err)
|
||||
}
|
||||
|
||||
// Pin then unpin twice; no error.
|
||||
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Pin: %v", err)
|
||||
}
|
||||
if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Unpin: %v", err)
|
||||
}
|
||||
if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Unpin (idempotent): %v", err)
|
||||
}
|
||||
|
||||
if pinned, _ := env.pin.IsPinned(ctx, env.userID, env.clientID); pinned {
|
||||
t.Errorf("IsPinned after double-unpin = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinService_OwnerScope(t *testing.T) {
|
||||
env := setupPinTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// User A pins. User B's PinnedSet must be empty (RLS / explicit user_id).
|
||||
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Pin: %v", err)
|
||||
}
|
||||
|
||||
otherSet, err := env.pin.PinnedSet(ctx, env.otherUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("PinnedSet (other user): %v", err)
|
||||
}
|
||||
if len(otherSet) != 0 {
|
||||
t.Errorf("other user PinnedSet size = %d, want 0", len(otherSet))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTreeWithOptions_PinnedSetSurfaces(t *testing.T) {
|
||||
env := setupPinTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil {
|
||||
t.Fatalf("Pin: %v", err)
|
||||
}
|
||||
set, _ := env.pin.PinnedSet(ctx, env.userID)
|
||||
|
||||
tree, err := env.projects.BuildTreeWithOptions(ctx, env.userID, BuildTreeOptions{
|
||||
PinnedSet: set,
|
||||
IncludeSubtreeCounts: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTreeWithOptions: %v", err)
|
||||
}
|
||||
if len(tree) == 0 {
|
||||
t.Fatalf("tree empty; want clientID at root")
|
||||
}
|
||||
|
||||
var found *ProjectTreeNode
|
||||
for _, r := range tree {
|
||||
if r.ID == env.clientID {
|
||||
found = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("clientID not at root; tree=%+v", tree)
|
||||
}
|
||||
if !found.Pinned {
|
||||
t.Errorf("clientID Pinned=false, want true")
|
||||
}
|
||||
// Case sub-node should NOT be pinned (we only pinned the client).
|
||||
if len(found.Children) == 0 || found.Children[0].Pinned {
|
||||
t.Errorf("child node should not be Pinned; got %+v", found.Children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTreeWithOptions_ScopeMineGreysAncestors(t *testing.T) {
|
||||
env := setupPinTest(t)
|
||||
defer env.cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Remove the direct staffing on clientID so caseID becomes the only
|
||||
// directly-staffed project under the client. Then ScopeMine must keep
|
||||
// clientID as a greyed ancestor (InheritedVisibility=true).
|
||||
if _, err := env.pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`,
|
||||
env.clientID, env.userID); err != nil {
|
||||
t.Fatalf("delete client staffing: %v", err)
|
||||
}
|
||||
|
||||
tree, err := env.projects.BuildTreeWithOptions(ctx, env.userID, BuildTreeOptions{
|
||||
Scope: ScopeMine,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildTreeWithOptions ScopeMine: %v", err)
|
||||
}
|
||||
if len(tree) == 0 {
|
||||
t.Fatalf("ScopeMine tree empty; want clientID greyed-ancestor")
|
||||
}
|
||||
|
||||
var client *ProjectTreeNode
|
||||
for _, r := range tree {
|
||||
if r.ID == env.clientID {
|
||||
client = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatalf("clientID missing from ScopeMine tree")
|
||||
}
|
||||
if !client.InheritedVisibility {
|
||||
t.Errorf("clientID InheritedVisibility=false; want true (greyed ancestor)")
|
||||
}
|
||||
if len(client.Children) != 1 || client.Children[0].ID != env.caseID {
|
||||
t.Fatalf("expected single child caseID; got %+v", client.Children)
|
||||
}
|
||||
if client.Children[0].InheritedVisibility {
|
||||
t.Errorf("caseID is direct-staffed; should NOT have InheritedVisibility=true")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user