feat(properties): parent picker, breadcrumb, sub-properties tab

Phase 3 of the sub-property hierarchy feature.

- New/edit forms grow a "Parent property" select. Edit-side options
  exclude the current property and its descendants so the picker
  itself can't create a cycle; service-layer assertNoCycle is the
  belt-and-braces guard if a malicious form bypasses the dropdown.
- New form accepts ?parent=<id> as a preselect so "Add sub-property"
  links from the parent's tab land in a pre-wired form.
- Property detail layout: breadcrumb (Parent › Child) when parent
  is set, plus a new "Sub-properties (N)" tab.
- Dedicated Sub-properties tab lists direct children with a
  + New sub-property button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:54:36 +07:00
parent 3b34458a99
commit 3106286629
8 changed files with 257 additions and 38 deletions
@@ -1,4 +1,7 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import { getProperty } from '$lib/server/services/properties'; import { getProperty } from '$lib/server/services/properties';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
@@ -6,5 +9,40 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company'); if (!locals.company) throw error(400, 'No active company');
const property = await getProperty(locals.company.id, params.id); const property = await getProperty(locals.company.id, params.id);
if (!property) throw error(404, 'Property not found'); if (!property) throw error(404, 'Property not found');
return { property };
// Fetch parent (for the breadcrumb) and the child count (for the tab badge)
// in a single round trip each — both are tiny single-row queries.
const [parent, childCountRow] = await Promise.all([
property.parentId
? db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(
and(
eq(properties.id, property.parentId),
eq(properties.companyId, locals.company.id),
isNull(properties.deletedAt)
)
)
.limit(1)
.then((r) => r[0] ?? null)
: Promise.resolve(null),
db
.select({ n: sql<number>`count(*)::int` })
.from(properties)
.where(
and(
eq(properties.parentId, property.id),
eq(properties.companyId, locals.company.id),
isNull(properties.deletedAt)
)
)
.then((r) => r[0])
]);
return {
property,
parent,
childCount: childCountRow?.n ?? 0
};
}; };
@@ -7,6 +7,10 @@
const tabs = $derived([ const tabs = $derived([
{ href: `/properties/${data.property.id}`, label: 'Overview' }, { href: `/properties/${data.property.id}`, label: 'Overview' },
{
href: `/properties/${data.property.id}/sub-properties`,
label: data.childCount > 0 ? `Sub-properties (${data.childCount})` : 'Sub-properties'
},
{ href: `/properties/${data.property.id}/rooms`, label: 'Rooms' }, { href: `/properties/${data.property.id}/rooms`, label: 'Rooms' },
{ href: `/properties/${data.property.id}/assets`, label: 'Assets' }, { href: `/properties/${data.property.id}/assets`, label: 'Assets' },
{ href: `/properties/${data.property.id}/accounts`, label: 'Accounts' }, { href: `/properties/${data.property.id}/accounts`, label: 'Accounts' },
@@ -18,9 +22,19 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
{#if data.parent}
<nav aria-label="Breadcrumb" class="text-xs text-gray-500 dark:text-gray-400">
<a href="/properties/{data.parent.id}" class="hover:text-gray-700 dark:hover:text-gray-200">
{data.parent.name}
</a>
<span class="mx-1.5"></span>
<span class="text-gray-700 dark:text-gray-300">{data.property.name}</span>
</nav>
{:else}
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">
{data.property.kind ?? 'Property'} {data.property.kind ?? 'Property'}
</div> </div>
{/if}
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100"> <h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">
{data.property.name} {data.property.name}
</h1> </h1>
@@ -1,12 +1,19 @@
import { error, fail } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { and, eq, isNull, ne, notInArray } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { updateProperty, softDeleteProperty } from '$lib/server/services/properties'; import { db } from '$lib/server/db/client';
import { redirect } from '@sveltejs/kit'; import { properties } from '$lib/server/db/schema/properties';
import type { Actions } from './$types'; import {
getDescendantIds,
softDeleteProperty,
updateProperty
} from '$lib/server/services/properties';
import type { Actions, PageServerLoad } from './$types';
const PatchSchema = z.object({ const PatchSchema = z.object({
name: z.string().trim().min(1).max(255), name: z.string().trim().min(1).max(255),
kind: z.string().trim().max(64).optional().or(z.literal('')), kind: z.string().trim().max(64).optional().or(z.literal('')),
parentId: z.string().uuid().optional().or(z.literal('')),
addressLine1: z.string().trim().max(255).optional().or(z.literal('')), addressLine1: z.string().trim().max(255).optional().or(z.literal('')),
addressLine2: z.string().trim().max(255).optional().or(z.literal('')), addressLine2: z.string().trim().max(255).optional().or(z.literal('')),
city: z.string().trim().max(128).optional().or(z.literal('')), city: z.string().trim().max(128).optional().or(z.literal('')),
@@ -18,6 +25,28 @@ const PatchSchema = z.object({
const e2n = (s: string | undefined) => (!s ? null : s); const e2n = (s: string | undefined) => (!s ? null : s);
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company');
// Eligible parents = all live properties in the company minus this one
// and its descendants. Excluding descendants is what blocks the user from
// creating a cycle through the picker; the service layer's assertNoCycle
// is the belt-and-braces guard.
const exclude = await getDescendantIds(locals.company.id, params.id);
const eligibleParents = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(
and(
eq(properties.companyId, locals.company.id),
isNull(properties.deletedAt),
ne(properties.id, params.id),
exclude.length > 0 ? notInArray(properties.id, exclude) : undefined
)
)
.orderBy(properties.name);
return { eligibleParents };
};
export const actions: Actions = { export const actions: Actions = {
save: async ({ request, locals, params }) => { save: async ({ request, locals, params }) => {
if (!locals.company) throw error(401); if (!locals.company) throw error(401);
@@ -28,9 +57,11 @@ export const actions: Actions = {
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' }); return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
} }
const v = parsed.data; const v = parsed.data;
try {
await updateProperty(locals.company.id, params.id, { await updateProperty(locals.company.id, params.id, {
name: v.name, name: v.name,
kind: e2n(v.kind), kind: e2n(v.kind),
parentId: v.parentId ? v.parentId : null,
addressLine1: e2n(v.addressLine1), addressLine1: e2n(v.addressLine1),
addressLine2: e2n(v.addressLine2), addressLine2: e2n(v.addressLine2),
city: e2n(v.city), city: e2n(v.city),
@@ -39,11 +70,18 @@ export const actions: Actions = {
countryCode: e2n(v.countryCode), countryCode: e2n(v.countryCode),
notes: e2n(v.notes) notes: e2n(v.notes)
}); });
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true }; return { ok: true };
}, },
delete: async ({ locals, params }) => { delete: async ({ locals, params }) => {
if (!locals.company) throw error(401); if (!locals.company) throw error(401);
try {
await softDeleteProperty(locals.company.id, params.id); await softDeleteProperty(locals.company.id, params.id);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
throw redirect(303, '/properties'); throw redirect(303, '/properties');
} }
}; };
@@ -34,6 +34,19 @@
<input id="kind" name="kind" value={p.kind ?? ''} <input id="kind" name="kind" value={p.kind ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" /> class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div> </div>
<div class="sm:col-span-2">
<label for="parentId" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Parent property</label>
<select id="parentId" name="parentId"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— Top level (no parent) —</option>
{#each data.eligibleParents as opt (opt.id)}
<option value={opt.id} selected={opt.id === p.parentId}>{opt.name}</option>
{/each}
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Sub-properties (e.g. apartments inside a building) inherit roll-up totals from their parent.
</p>
</div>
<div> <div>
<label for="countryCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country (ISO 2)</label> <label for="countryCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country (ISO 2)</label>
<input id="countryCode" name="countryCode" maxlength="2" value={p.countryCode ?? ''} <input id="countryCode" name="countryCode" maxlength="2" value={p.countryCode ?? ''}
@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { and, asc, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company');
const children = await db
.select({
id: properties.id,
name: properties.name,
kind: properties.kind,
city: properties.city,
countryCode: properties.countryCode
})
.from(properties)
.where(
and(
eq(properties.companyId, locals.company.id),
eq(properties.parentId, params.id),
isNull(properties.deletedAt)
)
)
.orderBy(asc(properties.name));
return { children };
};
@@ -0,0 +1,46 @@
<script lang="ts">
import type { PageData } from './$types';
import type { LayoutData } from '../$types';
let { data }: { data: PageData & LayoutData } = $props();
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Sub-properties</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Properties whose parent is <strong>{data.property.name}</strong>. Use these for apartments
inside a building, sub-buildings on a campus, and so on.
</p>
</div>
<a
href="/properties/new?parent={data.property.id}"
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
>
+ New sub-property
</a>
</div>
{#if data.children.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No sub-properties yet.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.children as c (c.id)}
<li>
<a href="/properties/{c.id}" class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">{c.name}</p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{[c.kind, c.city, c.countryCode].filter(Boolean).join(' · ') || '—'}
</p>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500"></span>
</a>
</li>
{/each}
</ul>
{/if}
</div>
@@ -1,11 +1,15 @@
import { fail, redirect, error } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import { createProperty } from '$lib/server/services/properties'; import { createProperty } from '$lib/server/services/properties';
import type { Actions } from './$types'; import type { Actions, PageServerLoad } from './$types';
const PropertySchema = z.object({ const PropertySchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(255), name: z.string().trim().min(1, 'Name is required').max(255),
kind: z.string().trim().max(64).optional().or(z.literal('')), kind: z.string().trim().max(64).optional().or(z.literal('')),
parentId: z.string().uuid().optional().or(z.literal('')),
addressLine1: z.string().trim().max(255).optional().or(z.literal('')), addressLine1: z.string().trim().max(255).optional().or(z.literal('')),
addressLine2: z.string().trim().max(255).optional().or(z.literal('')), addressLine2: z.string().trim().max(255).optional().or(z.literal('')),
city: z.string().trim().max(128).optional().or(z.literal('')), city: z.string().trim().max(128).optional().or(z.literal('')),
@@ -19,6 +23,17 @@ function emptyToNull(s: string | undefined): string | null {
return !s ? null : s; return !s ? null : s;
} }
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const eligibleParents = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(and(eq(properties.companyId, locals.company.id), isNull(properties.deletedAt)))
.orderBy(properties.name);
const preselectParentId = url.searchParams.get('parent') ?? '';
return { eligibleParents, preselectParentId };
};
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, locals }) => { default: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401, 'Not authenticated'); if (!locals.user || !locals.company) throw error(401, 'Not authenticated');
@@ -33,11 +48,13 @@ export const actions: Actions = {
}); });
} }
const v = parsed.data; const v = parsed.data;
try {
const { id } = await createProperty({ const { id } = await createProperty({
companyId: locals.company.id, companyId: locals.company.id,
createdBy: locals.user.id, createdBy: locals.user.id,
name: v.name, name: v.name,
kind: emptyToNull(v.kind), kind: emptyToNull(v.kind),
parentId: v.parentId ? v.parentId : null,
addressLine1: emptyToNull(v.addressLine1), addressLine1: emptyToNull(v.addressLine1),
addressLine2: emptyToNull(v.addressLine2), addressLine2: emptyToNull(v.addressLine2),
city: emptyToNull(v.city), city: emptyToNull(v.city),
@@ -47,5 +64,11 @@ export const actions: Actions = {
notes: emptyToNull(v.notes) notes: emptyToNull(v.notes)
}); });
throw redirect(303, `/properties/${id}`); throw redirect(303, `/properties/${id}`);
} catch (e) {
// SvelteKit redirects throw; let them bubble.
const { isRedirect, isHttpError } = await import('@sveltejs/kit');
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message, values: raw });
}
} }
}; };
+22 -2
View File
@@ -1,11 +1,15 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { ActionData } from './$types'; import type { ActionData, PageData } from './$types';
let { form }: { form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false); let submitting = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>); const v = $derived((form?.values ?? {}) as Record<string, string>);
const selectedParent = $derived(v.parentId ?? data.preselectParentId ?? '');
const parentName = $derived(
data.eligibleParents.find((p) => p.id === selectedParent)?.name ?? null
);
</script> </script>
<div class="mx-auto max-w-2xl space-y-6"> <div class="mx-auto max-w-2xl space-y-6">
@@ -13,6 +17,11 @@
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New property</h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New property</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
A property is a place where assets live (a warehouse, an office, a datacenter, a site). A property is a place where assets live (a warehouse, an office, a datacenter, a site).
{#if parentName}
<span class="ml-1 text-gray-700 dark:text-gray-300">
Creating as a sub-property under <strong>{parentName}</strong>.
</span>
{/if}
</p> </p>
</div> </div>
@@ -42,6 +51,17 @@
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" /> class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div> </div>
<div>
<label for="parentId" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Parent property</label>
<select id="parentId" name="parentId"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— Top level (no parent) —</option>
{#each data.eligibleParents as opt (opt.id)}
<option value={opt.id} selected={opt.id === selectedParent}>{opt.name}</option>
{/each}
</select>
</div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label for="addressLine1" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 1</label> <label for="addressLine1" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 1</label>