refactor(projects-detail/projektbaum): reuse the /projects tree component

m's 2026-05-08 21:28: "The Projektbaum inside a Project in the tab
with the Unterordner should just be the same as the Tree in Projects.
It has symbols, everything. That should be a shared component."

Drop the inline mini-tree renderer (renderTreeNode / loadProjectTree /
~50 lines of duplicate logic) in client/projects-detail.ts and mount
the existing client/project-tree.ts module into the tab's container.
The shared component carries:
  - per-type icons (Mandant / Litigation / Patent / Case)
  - pin star (touch-friendly)
  - overdue / open-deadline badges with subtree counts
  - status chip + type chip
  - expand / collapse toggles
  - inherited-visibility marking
  - search highlighting (no-op when no search params are passed)

Current project highlight: set aria-current="true" on the matching
.projekt-tree-node after mount. The shared CSS already styles
.projekt-tree-node[aria-current="true"] > .projekt-tree-row with the
lime accent (global.css :5853).

Removed the now-dead mini-tree CSS block that was also accidentally
overriding .projekt-tree-title from the real tree (later-defined rule
won the cascade and erased the shared title weight).

loadChildren() still fetches /api/projects/<id>/children for the
empty-state gate ("Keine untergeordneten Projekte" when this node has
no direct children) and the create-link parent_id pre-fill — both
predicates depend on direct children, not the visible tree.
This commit is contained in:
m
2026-05-08 21:31:16 +02:00
parent 0b47343aa3
commit 936aca5925
2 changed files with 34 additions and 90 deletions

View File

@@ -1,6 +1,7 @@
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree";
import {
loadParentCandidates,
initParentPicker,
@@ -206,8 +207,9 @@ let deadlines: Deadline[] = [];
let appointments: Appointment[] = [];
let ancestors: ProjectMini[] = [];
let children: ProjectMini[] = [];
type TreeNode = ProjectMini & { children?: TreeNode[] };
let projectTree: TreeNode[] = [];
// projects-cards' /projects tree owns rendering inside the Projektbaum
// tab too — see initProjectTreeTab below. Local children[] still feeds
// the empty-state gate + the create-link parent_id pre-fill.
let teamMembers: ProjectTeamMember[] = [];
// t-paliad-139 — additional Team-tab sections.
let descendantStaffed: ProjectTeamMember[] = [];
@@ -1553,15 +1555,15 @@ async function loadChildren(id: string) {
} catch {
children = [];
}
// Full visible tree drives the rendering.
try {
const resp = await fetch(`/api/projects/tree?subtree_counts=false`);
if (resp.ok) projectTree = ((await resp.json()) as TreeNode[]) ?? [];
} catch {
projectTree = [];
}
}
// renderChildren is the Projektbaum tab's mount point. m's 2026-05-08
// 21:28: "should just be the same as the Tree in Projects. It has
// symbols, everything." Reuse the /projects tree component
// (project-tree.ts) verbatim — type icons, pin stars, deadline badges,
// expand/collapse, search highlighting all come along for free. The
// current project is highlighted via a CSS modifier we add to its
// data-id row after the tree mounts.
function renderChildren() {
const root = document.getElementById("project-tree")!;
const empty = document.getElementById("children-empty")!;
@@ -1569,30 +1571,22 @@ function renderChildren() {
// CTA next to the empty message is "create sub-project", which would be
// misleading if the tree itself has other branches.
empty.style.display = children.length ? "none" : "";
if (!projectTree.length) {
root.innerHTML = "";
return;
}
const currentId = project?.id ?? "";
root.innerHTML = `<ul class="projekt-tree-list">${projectTree.map((n) => renderTreeNode(n, currentId, 0)).join("")}</ul>`;
}
function renderTreeNode(node: TreeNode, currentId: string, depth: number): string {
const isCurrent = node.id === currentId;
const kids = node.children ?? [];
const childHTML = kids.length
? `<ul class="projekt-tree-list">${kids.map((k) => renderTreeNode(k, currentId, depth + 1)).join("")}</ul>`
: "";
const itemClass = `projekt-tree-item${isCurrent ? " projekt-tree-item--current" : ""}`;
const linkInner =
`<span class="entity-type-chip entity-type-${esc(node.type)}">${esc(tDyn("projects.type." + node.type) || node.type)}</span>` +
`<span class="projekt-tree-title">${esc(node.title)}</span>` +
(node.reference ? `<span class="projekt-tree-ref">${esc(node.reference)}</span>` : "") +
`<span class="entity-status-chip entity-status-${esc(node.status)}">${esc(tDyn("projects.filter.status." + node.status) || node.status)}</span>`;
const row = isCurrent
? `<span class="projekt-tree-link projekt-tree-link--current" aria-current="true">${linkInner}</span>`
: `<a href="/projects/${esc(node.id)}" class="projekt-tree-link">${linkInner}</a>`;
return `<li class="${itemClass}">${row}${childHTML}</li>`;
// Mount the shared tree. initProjectTree fetches /api/projects/tree on
// first call and caches; subsequent tab-switches re-render from cache.
// Set aria-current on the row matching this project — the shared tree
// already styles aria-current=true with a lime highlight (global.css
// .projekt-tree-node[aria-current="true"] > .projekt-tree-row).
void initProjectTree(root).then(() => {
const currentId = project?.id ?? "";
if (!currentId) return;
root.querySelectorAll<HTMLLIElement>(".projekt-tree-node").forEach((li) => {
if (li.dataset.id === currentId) {
li.setAttribute("aria-current", "true");
} else {
li.removeAttribute("aria-current");
}
});
});
}
function initChildAddLink() {

View File

@@ -9748,63 +9748,13 @@ dialog.quick-add-sheet::backdrop {
opacity: 0.55;
}
/* Projektbaum / Project Tree — full visible hierarchy on /projects/<id>. */
.projekt-tree {
margin-top: 0.5rem;
}
.projekt-tree-list {
list-style: none;
padding-left: 0;
margin: 0;
}
.projekt-tree-list .projekt-tree-list {
padding-left: 1.5rem;
border-left: 1px dashed var(--color-border-subtle, #e4e4e7);
margin-left: 0.6rem;
}
.projekt-tree-item {
margin: 0.15rem 0;
}
.projekt-tree-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
text-decoration: none;
color: var(--color-text, var(--hlc-midnight));
line-height: 1.3;
}
.projekt-tree-link:hover,
.projekt-tree-link:focus-visible {
background: var(--color-bg-subtle);
}
.projekt-tree-link--current {
background: var(--color-cta-lime-soft, #f0fad9);
border: 1px solid var(--color-cta-lime, #c6f41c);
font-weight: 600;
cursor: default;
}
.projekt-tree-link--current:hover {
background: var(--color-cta-lime-soft, #f0fad9);
}
.projekt-tree-title {
font-weight: 500;
}
.projekt-tree-ref {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
color: var(--color-text-muted);
}
/* Projektbaum tab inside /projects/<id> reuses the /projects tree
component (client/project-tree.ts) — see initProjectTree(root) in
client/projects-detail.ts. The earlier inline mini-tree CSS at this
block was dead code that also accidentally overrode .projekt-tree-title,
so it was removed when m's "should be the same as the Tree in
Projects" landed (2026-05-08 21:28). The current-node highlight
already lives at .projekt-tree-node[aria-current="true"] above. */
@media (max-width: 640px) {
.projekt-breadcrumb {