feat(projects-detail): "Untergeordnet" tab → "Projektbaum" with full visible hierarchy

m typed in another pane: "The project view where there is a tab
'Untergeordnet' I want a 'Project Tree' instead. And it always shows
all siblings, all parents and all children of that entity." (Forwarded
by klaus / youpcorg/head, msg #1570.)

Tab label
  DE: Untergeordnet → Projektbaum
  EN: Sub-projects → Project Tree
  i18n key kept as projects.detail.tab.kinder for back-compat (legacy
  bookmarks + create-sub-project CTA still keyed on 'kinder').

Tree content
  Was: direct children only (one /api/projects/<id>/children call).
  Now: full visible project hierarchy via /api/projects/tree?subtree_counts=false,
  rendered as nested <ul> with the current node highlighted with a
  lime-soft background + current-color border. The dashed left border
  on nested levels makes parent → child relationships scannable.
  Visibility is RLS-scoped (the tree endpoint already filters to projects
  the user can see).

Empty state
  "Keine untergeordneten Projekte" still renders when the current node
  has zero direct children — that is what the "+ Untervorhaben anlegen"
  CTA next to it actually creates. Showing it for "tree has no other
  branches" would have been wrong.

The standalone /api/projects/<id>/children call stays — it gates the
empty state and pre-fills parent_id on the create form.
This commit is contained in:
m
2026-05-08 19:46:55 +02:00
parent caa76d2925
commit e9e7d5c27c
4 changed files with 108 additions and 23 deletions

View File

@@ -1095,7 +1095,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.save": "Speichern",
"projects.detail.tab.verlauf": "Verlauf",
"projects.detail.tab.team": "Team",
"projects.detail.tab.kinder": "Untergeordnet",
"projects.detail.tab.kinder": "Projektbaum",
"projects.detail.tab.parteien": "Parteien",
"projects.detail.tab.fristen": "Fristen",
"projects.detail.tab.termine": "Termine",
@@ -3166,7 +3166,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.save": "Save",
"projects.detail.tab.verlauf": "Activity",
"projects.detail.tab.team": "Team",
"projects.detail.tab.kinder": "Sub-projects",
"projects.detail.tab.kinder": "Project Tree",
"projects.detail.tab.parteien": "Parties",
"projects.detail.tab.fristen": "Deadlines",
"projects.detail.tab.termine": "Appointments",

View File

@@ -206,6 +206,8 @@ let deadlines: Deadline[] = [];
let appointments: Appointment[] = [];
let ancestors: ProjectMini[] = [];
let children: ProjectMini[] = [];
type TreeNode = ProjectMini & { children?: TreeNode[] };
let projectTree: TreeNode[] = [];
let teamMembers: ProjectTeamMember[] = [];
// t-paliad-139 — additional Team-tab sections.
let descendantStaffed: ProjectTeamMember[] = [];
@@ -1534,38 +1536,63 @@ function renderBreadcrumb() {
el.innerHTML = crumbs.join(BREADCRUMB_CHEVRON);
}
// ----- Children -----------------------------------------------------------
// ----- Project Tree (Projektbaum) -----------------------------------------
// Renders the full visible project hierarchy with the current node highlighted.
// One round-trip to /api/projects/tree gets every project the user can see;
// the renderer walks the tree and produces a nested <ul> with the current
// node visually marked. Direct children of the current node still drive the
// "no sub-projects" empty state, since that is the actionable signal for
// the "+ Untervorhaben anlegen" CTA.
async function loadChildren(id: string) {
// Direct children kept for the "Keine untergeordneten Projekte" empty state
// and the create-new pre-fill (parent_id from the current node).
try {
const resp = await fetch(`/api/projects/${id}/children`);
if (resp.ok) children = ((await resp.json()) as ProjectMini[]) ?? [];
} 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 = [];
}
}
function renderChildren() {
const list = document.getElementById("children-list")!;
const root = document.getElementById("project-tree")!;
const empty = document.getElementById("children-empty")!;
if (!children.length) {
list.innerHTML = "";
empty.style.display = "";
// Empty state only when the current node has zero direct children — the
// 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;
}
empty.style.display = "none";
list.innerHTML = children
.map(
(c) => `<li class="projekt-child-item">
<a href="/projects/${esc(c.id)}" class="projekt-child-link">
<span class="entity-type-chip entity-type-${esc(c.type)}">${esc(tDyn("projects.type." + c.type) || c.type)}</span>
<span class="projekt-child-title">${esc(c.title)}</span>
${c.reference ? `<span class="projekt-child-ref">${esc(c.reference)}</span>` : ""}
<span class="entity-status-chip entity-status-${esc(c.status)}">${esc(tDyn("projects.filter.status." + c.status) || c.status)}</span>
</a>
</li>`,
)
.join("");
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>`;
}
function initChildAddLink() {

View File

@@ -73,7 +73,7 @@ export function renderProjectsDetail(): string {
<nav className="entity-tabs" id="project-tabs">
<a className="entity-tab" data-tab="history" href="#" data-i18n="projects.detail.tab.verlauf">Verlauf</a>
<a className="entity-tab" data-tab="team" href="#" data-i18n="projects.detail.tab.team">Team</a>
<a className="entity-tab" data-tab="children" href="#" data-i18n="projects.detail.tab.kinder">Untergeordnet</a>
<a className="entity-tab" data-tab="children" href="#" data-i18n="projects.detail.tab.kinder">Projektbaum</a>
<a className="entity-tab" data-tab="parties" href="#" data-i18n="projects.detail.tab.parteien">Parteien</a>
<a className="entity-tab" data-tab="deadlines" href="#" data-i18n="projects.detail.tab.fristen">Fristen</a>
<a className="entity-tab" data-tab="appointments" href="#" data-i18n="projects.detail.tab.termine">Termine</a>
@@ -250,14 +250,14 @@ export function renderProjectsDetail(): string {
</div>
</section>
{/* Children (Untergeordnet) */}
{/* Project Tree (Projektbaum) — full visible hierarchy with current node highlighted */}
<section className="entity-tab-panel" id="tab-children" style="display:none">
<div className="party-controls">
<a id="child-add-link" className="btn-primary btn-cta-lime btn-small" href="/projects/new" data-i18n="projects.detail.kinder.add">
Untervorhaben anlegen
</a>
</div>
<ul id="children-list" className="projekt-children-list" />
<div id="project-tree" className="projekt-tree" />
<p className="entity-events-empty" id="children-empty" style="display:none" data-i18n="projects.detail.kinder.empty">
Keine untergeordneten Projekte.
</p>

View File

@@ -9307,6 +9307,64 @@ 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);
}
@media (max-width: 640px) {
.projekt-breadcrumb {
flex-wrap: nowrap;