feat(properties): list view renders parent/child as a depth-first tree
Deploy to LXC / deploy (push) Successful in 16s
Validate / validate (push) Successful in 30s

The flat list ordered by updatedAt scattered apartments away from
their building. Server-side flatten now does a depth-first walk —
parents render immediately above their children, alphabetical at
each level — and tags each row with its depth. The UI indents the
name column by 1.5rem per level and prefixes children with "└" for
a visible parent/child line.

Orphan rows (parent_id pointing outside the live company set) fall
back to root depth so nothing is silently dropped, even though the
restrict-on-delete FK should prevent that case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 15:02:46 +07:00
parent c61be187e6
commit 90207135c8
2 changed files with 49 additions and 3 deletions
+46 -2
View File
@@ -1,9 +1,53 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { listProperties } from '$lib/server/services/properties'; import { listProperties } from '$lib/server/services/properties';
import type { Property } from '$lib/server/db/schema/properties';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export type PropertyRow = Property & { depth: number };
/**
* Reorder the flat company-scoped property list into a depth-first traversal
* so parents render immediately above their children. Within each level we
* sort by name. Orphan rows (parent_id points outside the visible set —
* shouldn't happen with the current restrict-on-delete policy, but defended
* here so the UI never silently drops a row) are appended at the end as roots.
*/
function flattenTree(rows: Property[]): PropertyRow[] {
const byParent = new Map<string | null, Property[]>();
for (const r of rows) {
const key = r.parentId;
const list = byParent.get(key);
if (list) list.push(r);
else byParent.set(key, [r]);
}
for (const list of byParent.values()) {
list.sort((a, b) => a.name.localeCompare(b.name));
}
const out: PropertyRow[] = [];
const visible = new Set(rows.map((r) => r.id));
function walk(parentId: string | null, depth: number): void {
const list = byParent.get(parentId) ?? [];
for (const r of list) {
out.push({ ...r, depth });
walk(r.id, depth + 1);
}
}
walk(null, 0);
if (out.length < rows.length) {
const seen = new Set(out.map((r) => r.id));
for (const r of rows) {
if (!seen.has(r.id) && (!r.parentId || !visible.has(r.parentId))) {
out.push({ ...r, depth: 0 });
}
}
}
return out;
}
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company. Pick one from the sidebar.'); if (!locals.company) throw error(400, 'No active company. Pick one from the sidebar.');
const rows = await listProperties(locals.company.id); const flat = await listProperties(locals.company.id);
return { properties: rows }; return { properties: flattenTree(flat) };
}; };
+3 -1
View File
@@ -42,7 +42,9 @@
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.properties as p} {#each data.properties as p}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30"> <tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100"> <td class="py-2 pr-4 text-sm font-medium text-gray-900 dark:text-gray-100"
style:padding-left="{1 + p.depth * 1.5}rem">
{#if p.depth > 0}<span class="mr-1 select-none text-gray-400 dark:text-gray-500"></span>{/if}
<a href="/properties/{p.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{p.name}</a> <a href="/properties/{p.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{p.name}</a>
</td> </td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{p.kind ?? '—'}</td> <td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{p.kind ?? '—'}</td>