feat(t-paliad-125): sort project pickers by tree path with depth indent

The /events Project filter dropdown was sorted by `updated_at DESC`, so a
recently-touched Case appeared above its parent Client and cousins
interleaved unrelated branches — m's report (2026-05-04): "Siemens cases
come directly after 'mandant vs Gegner' and are not under 'Siemens-AG'".

Backend: switch ProjectService.List to ORDER BY p.path so every
descendant immediately follows its ancestor — the same ordering BuildTree
produces. Both callers (handleListProjects, searchProjects) gain a
stable, hierarchical default that matches user expectation.

Frontend: add project-indent.ts shared helper and apply NBSP indent
prefix in every <select> picker fed by /api/projects: events filter,
/deadlines/new, /appointments/new, checklist new-instance modal,
Fristenrechner save modal. NBSP avoids browser whitespace collapse
inside <option> labels. Multi-parent repetition is out of scope (data
model has singular parent_id today).

Tests: project_list_order_test pins the path-order contract against a
seeded mixed-recency tree.
This commit is contained in:
m
2026-05-04 19:30:37 +02:00
parent 062630ca38
commit 4d7c74994a
8 changed files with 183 additions and 7 deletions

View File

@@ -1,10 +1,12 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
}
let allProjects: Project[] = [];
@@ -30,8 +32,9 @@ function populateProjects() {
`<option value="">${esc(t("appointments.field.akte.none"))}</option>`,
];
for (const a of allProjects) {
const indent = projectIndent(a.path);
opts.push(
`<option value="${esc(a.id)}">${esc(a.reference || "")}${esc(a.title)}</option>`,
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")}${esc(a.title)}</option>`,
);
}
sel.innerHTML = opts.join("");

View File

@@ -1,5 +1,6 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface ChecklistItem {
labelDE: string;
@@ -48,6 +49,7 @@ interface AkteSummary {
id: string;
reference?: string | null;
title: string;
path: string;
}
let template: Checklist | null = null;
@@ -230,7 +232,7 @@ function renderAkteOptions() {
projects.forEach((a) => {
const opt = document.createElement("option");
opt.value = a.id;
opt.textContent = `${a.reference || ""}${a.title}`;
opt.textContent = `${projectIndent(a.path)}${a.reference || ""}${a.title}`;
sel.appendChild(opt);
});
}

View File

@@ -1,6 +1,7 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { attachEventTypePicker, type PickerHandle } from "./event-types";
import { projectIndent } from "./project-indent";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
@@ -9,6 +10,7 @@ interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
}
interface DeadlineRule {
@@ -51,8 +53,9 @@ async function loadProjects() {
for (const p of projects) {
const isSelected = preselectedProjectID === p.id ? " selected" : "";
const ref = p.reference || "";
const indent = projectIndent(p.path);
options.push(
`<option value="${esc(p.id)}"${isSelected}>${esc(ref)} \u2014 ${esc(p.title)}</option>`,
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
);
}
sel.innerHTML = options.join("");

View File

@@ -7,6 +7,7 @@ import {
type EventType,
type FilterHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
// EventsPage shared client (t-paliad-110). Drives /deadlines and
// /appointments off the same shell — the route handler injects
@@ -78,6 +79,7 @@ interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
}
interface Me {
@@ -881,8 +883,9 @@ function populateProjectFilter() {
`<option value="${PERSONAL}">${esc(t("appointments.filter.akte.personal"))}</option>`,
];
for (const a of allProjects) {
const indent = projectIndent(a.path);
options.push(
`<option value="${esc(a.id)}">${esc(a.reference || "")}${esc(a.title)}</option>`,
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")}${esc(a.title)}</option>`,
);
}
sel.innerHTML = options.join("");

View File

@@ -3,6 +3,7 @@
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface AdjustmentHoliday {
Date: string;
@@ -206,6 +207,7 @@ interface ProjectOption {
id: string;
reference?: string | null;
title: string;
path: string;
}
function escAttr(s: string): string {
@@ -293,9 +295,10 @@ async function openSaveModal() {
sel.innerHTML = projects
.map((p) => {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref
? `${escHtml(ref)} \u2014 ${escHtml(p.title)}`
: escHtml(p.title);
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
: `${indent}${escHtml(p.title)}`;
return `<option value="${escAttr(p.id)}">${label}</option>`;
})
.join("");

View File

@@ -0,0 +1,26 @@
// Helpers for rendering /api/projects rows as a flat indented list in
// `<select>` pickers (events filter, /deadlines/new, /appointments/new,
// Fristenrechner save modal, …). Backend `ProjectService.List` returns
// rows ordered by `path`, so a parent always precedes its descendants.
// Indenting by the path's dot-segment depth recreates the tree shape
// inside an HTML <option>.
//
// ` ` (NBSP) is used because some browsers collapse plain leading
// spaces inside <option> labels.
const INDENT_UNIT = "  ";
// projectDepth returns the 0-based depth of a project from its `path`
// (dot-separated UUIDs of the ancestor chain including self). Roots are
// depth 0; a child is depth 1; etc. A missing/empty path falls back to 0
// so we still render a usable label for legacy data.
export function projectDepth(path: string | null | undefined): number {
if (!path) return 0;
return path.split(".").length - 1;
}
// projectIndent returns the leading whitespace prefix for a project label
// at the given depth. Each level of nesting adds two NBSPs.
export function projectIndent(path: string | null | undefined): string {
return INDENT_UNIT.repeat(projectDepth(path));
}