diff --git a/src/routes/(app)/properties/[id]/+layout.server.ts b/src/routes/(app)/properties/[id]/+layout.server.ts index ad5f9bd..7842305 100644 --- a/src/routes/(app)/properties/[id]/+layout.server.ts +++ b/src/routes/(app)/properties/[id]/+layout.server.ts @@ -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`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 + }; }; diff --git a/src/routes/(app)/properties/[id]/+layout.svelte b/src/routes/(app)/properties/[id]/+layout.svelte index 19b0780..6f980b1 100644 --- a/src/routes/(app)/properties/[id]/+layout.svelte +++ b/src/routes/(app)/properties/[id]/+layout.svelte @@ -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 @@
-
- {data.property.kind ?? 'Property'} -
+ {#if data.parent} + + {:else} +
+ {data.property.kind ?? 'Property'} +
+ {/if}

{data.property.name}

diff --git a/src/routes/(app)/properties/[id]/+page.server.ts b/src/routes/(app)/properties/[id]/+page.server.ts index cd39caa..fa86edb 100644 --- a/src/routes/(app)/properties/[id]/+page.server.ts +++ b/src/routes/(app)/properties/[id]/+page.server.ts @@ -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'); } }; diff --git a/src/routes/(app)/properties/[id]/+page.svelte b/src/routes/(app)/properties/[id]/+page.svelte index 80548c0..51055a0 100644 --- a/src/routes/(app)/properties/[id]/+page.svelte +++ b/src/routes/(app)/properties/[id]/+page.svelte @@ -34,6 +34,19 @@
+
+ + +

+ Sub-properties (e.g. apartments inside a building) inherit roll-up totals from their parent. +

+
{ + 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 }; +}; diff --git a/src/routes/(app)/properties/[id]/sub-properties/+page.svelte b/src/routes/(app)/properties/[id]/sub-properties/+page.svelte new file mode 100644 index 0000000..a94e55e --- /dev/null +++ b/src/routes/(app)/properties/[id]/sub-properties/+page.svelte @@ -0,0 +1,46 @@ + + +
+
+
+

Sub-properties

+

+ Properties whose parent is {data.property.name}. Use these for apartments + inside a building, sub-buildings on a campus, and so on. +

+
+ + + New sub-property + +
+ + {#if data.children.length === 0} +
+ No sub-properties yet. +
+ {:else} + + {/if} +
diff --git a/src/routes/(app)/properties/new/+page.server.ts b/src/routes/(app)/properties/new/+page.server.ts index 4187bc2..6ece561 100644 --- a/src/routes/(app)/properties/new/+page.server.ts +++ b/src/routes/(app)/properties/new/+page.server.ts @@ -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 }); + } } }; diff --git a/src/routes/(app)/properties/new/+page.svelte b/src/routes/(app)/properties/new/+page.svelte index 58b7ab1..c2c15ec 100644 --- a/src/routes/(app)/properties/new/+page.svelte +++ b/src/routes/(app)/properties/new/+page.svelte @@ -1,11 +1,15 @@
@@ -13,6 +17,11 @@

New property

A property is a place where assets live (a warehouse, an office, a datacenter, a site). + {#if parentName} + + Creating as a sub-property under {parentName}. + + {/if}

@@ -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" />
+
+ + +
+