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:
m
2026-05-07 22:29:39 +02:00
parent 8412328dec
commit a5f7b5009b
8 changed files with 1259 additions and 249 deletions

View File

@@ -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.",

View File

@@ -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);
}
}

View 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) : "&mdash;";
const clientMatterCell = clientMatter ? esc(clientMatter) : "&mdash;";
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;
}

View File

@@ -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) : "&mdash;";
const clientMatterCell = clientMatter ? esc(clientMatter) : "&mdash;";
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();
});

View File

@@ -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"

View File

@@ -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&uuml;gbar &mdash; 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&uuml;r diese Filter.</p>
<button type="button" id="projects-reset-filters" className="btn-secondary" data-i18n="projects.empty.filtered.action">Filter zur&uuml;cksetzen</button>
</div>
</div>
</section>

View File

@@ -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; }
}

View 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")
}
}