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:
@@ -1,4 +1,7 @@
|
||||
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 type { LayoutServerLoad } from './$types';
|
||||
|
||||
@@ -6,5 +9,40 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const property = await getProperty(locals.company.id, params.id);
|
||||
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([
|
||||
{ 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}/assets`, label: 'Assets' },
|
||||
{ href: `/properties/${data.property.id}/accounts`, label: 'Accounts' },
|
||||
@@ -18,9 +22,19 @@
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{data.property.kind ?? 'Property'}
|
||||
</div>
|
||||
{#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">
|
||||
{data.property.kind ?? 'Property'}
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{data.property.name}
|
||||
</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 { updateProperty, softDeleteProperty } from '$lib/server/services/properties';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { properties } from '$lib/server/db/schema/properties';
|
||||
import {
|
||||
getDescendantIds,
|
||||
softDeleteProperty,
|
||||
updateProperty
|
||||
} from '$lib/server/services/properties';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const PatchSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
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('')),
|
||||
addressLine2: z.string().trim().max(255).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);
|
||||
|
||||
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 = {
|
||||
save: async ({ request, locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
@@ -28,22 +57,31 @@ export const actions: Actions = {
|
||||
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
}
|
||||
const v = parsed.data;
|
||||
await updateProperty(locals.company.id, params.id, {
|
||||
name: v.name,
|
||||
kind: e2n(v.kind),
|
||||
addressLine1: e2n(v.addressLine1),
|
||||
addressLine2: e2n(v.addressLine2),
|
||||
city: e2n(v.city),
|
||||
region: e2n(v.region),
|
||||
postalCode: e2n(v.postalCode),
|
||||
countryCode: e2n(v.countryCode),
|
||||
notes: e2n(v.notes)
|
||||
});
|
||||
try {
|
||||
await updateProperty(locals.company.id, params.id, {
|
||||
name: v.name,
|
||||
kind: e2n(v.kind),
|
||||
parentId: v.parentId ? v.parentId : null,
|
||||
addressLine1: e2n(v.addressLine1),
|
||||
addressLine2: e2n(v.addressLine2),
|
||||
city: e2n(v.city),
|
||||
region: e2n(v.region),
|
||||
postalCode: e2n(v.postalCode),
|
||||
countryCode: e2n(v.countryCode),
|
||||
notes: e2n(v.notes)
|
||||
});
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
delete: async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
await softDeleteProperty(locals.company.id, params.id);
|
||||
try {
|
||||
await softDeleteProperty(locals.company.id, params.id);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
throw redirect(303, '/properties');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
<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" />
|
||||
</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>
|
||||
<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 ?? ''}
|
||||
|
||||
@@ -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 { db } from '$lib/server/db/client';
|
||||
import { properties } from '$lib/server/db/schema/properties';
|
||||
import { createProperty } from '$lib/server/services/properties';
|
||||
import type { Actions } from './$types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const PropertySchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(255),
|
||||
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('')),
|
||||
addressLine2: z.string().trim().max(255).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;
|
||||
}
|
||||
|
||||
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 = {
|
||||
default: async ({ request, locals }) => {
|
||||
if (!locals.user || !locals.company) throw error(401, 'Not authenticated');
|
||||
@@ -33,19 +48,27 @@ export const actions: Actions = {
|
||||
});
|
||||
}
|
||||
const v = parsed.data;
|
||||
const { id } = await createProperty({
|
||||
companyId: locals.company.id,
|
||||
createdBy: locals.user.id,
|
||||
name: v.name,
|
||||
kind: emptyToNull(v.kind),
|
||||
addressLine1: emptyToNull(v.addressLine1),
|
||||
addressLine2: emptyToNull(v.addressLine2),
|
||||
city: emptyToNull(v.city),
|
||||
region: emptyToNull(v.region),
|
||||
postalCode: emptyToNull(v.postalCode),
|
||||
countryCode: emptyToNull(v.countryCode),
|
||||
notes: emptyToNull(v.notes)
|
||||
});
|
||||
throw redirect(303, `/properties/${id}`);
|
||||
try {
|
||||
const { id } = await createProperty({
|
||||
companyId: locals.company.id,
|
||||
createdBy: locals.user.id,
|
||||
name: v.name,
|
||||
kind: emptyToNull(v.kind),
|
||||
parentId: v.parentId ? v.parentId : null,
|
||||
addressLine1: emptyToNull(v.addressLine1),
|
||||
addressLine2: emptyToNull(v.addressLine2),
|
||||
city: emptyToNull(v.city),
|
||||
region: emptyToNull(v.region),
|
||||
postalCode: emptyToNull(v.postalCode),
|
||||
countryCode: emptyToNull(v.countryCode),
|
||||
notes: emptyToNull(v.notes)
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
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);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
<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).
|
||||
{#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>
|
||||
</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" />
|
||||
</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="sm:col-span-2">
|
||||
<label for="addressLine1" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 1</label>
|
||||
|
||||
Reference in New Issue
Block a user