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:
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user