diff --git a/src/lib/server/services/assets.ts b/src/lib/server/services/assets.ts index 9a6ef65..ee5a832 100644 --- a/src/lib/server/services/assets.ts +++ b/src/lib/server/services/assets.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, isNull, or, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { db } from '$lib/server/db/client'; import { assets, @@ -292,7 +292,10 @@ export async function appendAssetLog( export interface AssetListOptions { companyId: string; typeSlug?: string; + /** Restrict to a single property. Mutually exclusive with `propertyIds`. */ propertyId?: string; + /** Restrict to assets at any of these properties (e.g. a property tree). */ + propertyIds?: string[]; projectId?: string; roomId?: string; q?: string; @@ -302,7 +305,11 @@ export interface AssetListOptions { export async function listAssets(opts: AssetListOptions) { const where = [eq(assets.companyId, opts.companyId), isNull(assets.deletedAt)]; - if (opts.propertyId) where.push(eq(assets.currentPropertyId, opts.propertyId)); + if (opts.propertyIds && opts.propertyIds.length > 0) { + where.push(inArray(assets.currentPropertyId, opts.propertyIds)); + } else if (opts.propertyId) { + where.push(eq(assets.currentPropertyId, opts.propertyId)); + } if (opts.projectId) where.push(eq(assets.currentProjectId, opts.projectId)); if (opts.roomId) where.push(eq(assets.currentRoomId, opts.roomId)); if (opts.typeSlug) { diff --git a/src/routes/(app)/properties/[id]/+layout.svelte b/src/routes/(app)/properties/[id]/+layout.svelte index 6f980b1..5a3b09e 100644 --- a/src/routes/(app)/properties/[id]/+layout.svelte +++ b/src/routes/(app)/properties/[id]/+layout.svelte @@ -15,6 +15,8 @@ { href: `/properties/${data.property.id}/assets`, label: 'Assets' }, { href: `/properties/${data.property.id}/accounts`, label: 'Accounts' }, { href: `/properties/${data.property.id}/expenses`, label: 'Expenses' }, + { href: `/properties/${data.property.id}/maintenance`, label: 'Maintenance' }, + { href: `/properties/${data.property.id}/todos`, label: 'Todos' }, { href: `/properties/${data.property.id}/documents`, label: 'Documents' } ]); diff --git a/src/routes/(app)/properties/[id]/assets/+page.server.ts b/src/routes/(app)/properties/[id]/assets/+page.server.ts index 6e3f999..d773bec 100644 --- a/src/routes/(app)/properties/[id]/assets/+page.server.ts +++ b/src/routes/(app)/properties/[id]/assets/+page.server.ts @@ -1,9 +1,14 @@ import { error } from '@sveltejs/kit'; import { listAssets } from '$lib/server/services/assets'; +import { getDescendantIds } from '$lib/server/services/properties'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ locals, params }) => { +export const load: PageServerLoad = async ({ locals, params, url }) => { if (!locals.company) throw error(401); - const rows = await listAssets({ companyId: locals.company.id, propertyId: params.id }); - return { assets: rows }; + const includeDescendants = url.searchParams.get('descendants') === '1'; + const propertyIds = includeDescendants + ? await getDescendantIds(locals.company.id, params.id) + : [params.id]; + const rows = await listAssets({ companyId: locals.company.id, propertyIds }); + return { assets: rows, includeDescendants, descendantCount: propertyIds.length }; }; diff --git a/src/routes/(app)/properties/[id]/assets/+page.svelte b/src/routes/(app)/properties/[id]/assets/+page.svelte index bad23bd..d55f371 100644 --- a/src/routes/(app)/properties/[id]/assets/+page.svelte +++ b/src/routes/(app)/properties/[id]/assets/+page.svelte @@ -1,6 +1,7 @@
+ {#if data.childCount > 0} +
+
+ {#if data.includeDescendants} + {data.property.name} + sub-properties + ({data.descendantCount} total) + {:else} + {data.property.name} + ({data.childCount} sub-properties hidden) + {/if} +
+
+ {#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt} + {@const active = (data.includeDescendants ? '1' : '') === opt.v} + + {opt.l} + + {/each} +
+
+ {/if} +
-

{data.assets.length} asset{data.assets.length === 1 ? '' : 's'} at this property.

+

{data.assets.length} asset{data.assets.length === 1 ? '' : 's'}{data.includeDescendants ? ' across the property tree' : ' at this property'}.

+ Add asset
diff --git a/src/routes/(app)/properties/[id]/expenses/+page.server.ts b/src/routes/(app)/properties/[id]/expenses/+page.server.ts index 2d950a9..96a51ec 100644 --- a/src/routes/(app)/properties/[id]/expenses/+page.server.ts +++ b/src/routes/(app)/properties/[id]/expenses/+page.server.ts @@ -8,13 +8,14 @@ import { companies } from '$lib/server/db/schema/tenancy'; import { createExpense, deleteExpense, - listExpensesForProperty, - monthlySeriesForProperty, - summaryForProperty, + listExpensesForProperties, + monthlySeriesForProperties, + summaryForProperties, updateExpense, type ChartRange, type ExpenseKind } from '$lib/server/services/expenses'; +import { getDescendantIds } from '$lib/server/services/properties'; import type { Actions, PageServerLoad } from './$types'; const KINDS = [ @@ -70,8 +71,17 @@ function parseRange(raw: string | null): ChartRange { export const load: PageServerLoad = async ({ locals, params, url }) => { const { company } = requireCompany(locals); const range = parseRange(url.searchParams.get('range')); + const includeDescendants = url.searchParams.get('descendants') === '1'; + + // Single-property reads still go through `[params.id]`; tree reads expand via + // the recursive CTE in services/properties. The query already validates the + // company scope on every descendant. + const propertyIds = includeDescendants + ? await getDescendantIds(company.id, params.id) + : [params.id]; + const [expenses, accounts, series, summary, [companyRow]] = await Promise.all([ - listExpensesForProperty(company.id, params.id), + listExpensesForProperties(propertyIds), db .select({ id: propertyAccounts.id, @@ -82,8 +92,8 @@ export const load: PageServerLoad = async ({ locals, params, url }) => { }) .from(propertyAccounts) .where(eq(propertyAccounts.propertyId, params.id)), - monthlySeriesForProperty(company.id, params.id, ['electricity', 'water'], range), - summaryForProperty(company.id, params.id, 365), + monthlySeriesForProperties(propertyIds, ['electricity', 'water'], range), + summaryForProperties(propertyIds, 365), db.select({ settings: companies.settings }).from(companies).where(eq(companies.id, company.id)).limit(1) ]); const defaultCurrency = parseSettings(companyRow?.settings ?? null).default_currency ?? 'USD'; @@ -93,7 +103,9 @@ export const load: PageServerLoad = async ({ locals, params, url }) => { chartSeries: series, chartRange: range, summary, - defaultCurrency + defaultCurrency, + includeDescendants, + descendantCount: propertyIds.length }; }; diff --git a/src/routes/(app)/properties/[id]/expenses/+page.svelte b/src/routes/(app)/properties/[id]/expenses/+page.svelte index 9df476b..a868220 100644 --- a/src/routes/(app)/properties/[id]/expenses/+page.svelte +++ b/src/routes/(app)/properties/[id]/expenses/+page.svelte @@ -3,8 +3,23 @@ import ExpenseChart from '$lib/components/ExpenseChart.svelte'; import { EXPENSE_KINDS, EXPENSE_KIND_LABEL, type ExpenseKind } from '$lib/expenses'; import type { PageData, ActionData } from './$types'; + import type { LayoutData } from '../$types'; - let { data, form }: { data: PageData; form: ActionData } = $props(); + let { data, form }: { data: PageData & LayoutData; form: ActionData } = $props(); + + function withParams(extra: Record): string { + const params = new URLSearchParams(); + const base = { + range: String(data.chartRange), + descendants: data.includeDescendants ? '1' : '' + } as Record; + const merged = { ...base, ...extra }; + for (const [k, v] of Object.entries(merged)) { + if (v) params.set(k, v); + } + const qs = params.toString(); + return qs ? `?${qs}` : '?'; + } let showForm = $state(false); let editingId = $state(null); @@ -39,6 +54,35 @@
+ {#if data.childCount > 0} +
+
+ {#if data.includeDescendants} + {data.property.name} + sub-properties + ({data.descendantCount} total) + {:else} + {data.property.name} + ({data.childCount} sub-properties hidden) + {/if} +
+
+ {#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt} + {@const active = (data.includeDescendants ? '1' : '') === opt.v} + + {opt.l} + + {/each} +
+
+ {/if} +
@@ -52,7 +96,7 @@ {@const active = String(data.chartRange) === String(opt.v)} + import type { PageData } from './$types'; + import type { LayoutData } from '../$types'; + + let { data }: { data: PageData & LayoutData } = $props(); + + function fmtDate(d: Date | string | null | undefined): string { + if (!d) return '—'; + const dt = typeof d === 'string' ? new Date(d) : d; + return Number.isNaN(dt.getTime()) ? '—' : dt.toISOString().slice(0, 10); + } + + const overdue = $derived( + data.schedules.filter((s) => s.active && s.nextDueAt && new Date(s.nextDueAt) < new Date()) + ); + const upcoming = $derived( + data.schedules.filter((s) => s.active && !overdue.includes(s)) + ); + + +
+ {#if data.childCount > 0} + + {/if} + +
+

+ Schedules +

+ {#if data.schedules.length === 0} +
+ No maintenance schedules on assets here. Set them up from each asset's page. +
+ {:else} +
+ + + + + + + + + + + + {#each [...overdue, ...upcoming, ...data.schedules.filter((s) => !s.active)] as s (s.id)} + {@const isOverdue = overdue.includes(s)} + + + + + + + + {/each} + +
ScheduleAssetIntervalNext dueStatus
{s.name} + {s.assetName} + every {s.intervalValue} {s.intervalUnit} + {s.kind === 'time' ? fmtDate(s.nextDueAt) : `${s.nextDueUsage ?? '—'} ${s.intervalUnit}`} + + {#if !s.active} + paused + {:else if isOverdue} + overdue + {:else} + active + {/if} +
+
+ {/if} +
+ +
+

+ Recent events +

+ {#if data.events.length === 0} +
+ No maintenance events recorded yet. +
+ {:else} +
    + {#each data.events as e (e.id)} +
  • +
    +
    + {e.scheduleName ?? 'Ad-hoc service'} · + {e.assetName} +
    + {#if e.notes} +

    {e.notes}

    + {/if} +
    +
    {fmtDate(e.performedAt)}
    +
  • + {/each} +
+ {/if} +
+
diff --git a/src/routes/(app)/properties/[id]/todos/+page.server.ts b/src/routes/(app)/properties/[id]/todos/+page.server.ts new file mode 100644 index 0000000..7ce2177 --- /dev/null +++ b/src/routes/(app)/properties/[id]/todos/+page.server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import { listInstancesForProperties } from '$lib/server/services/checklists'; +import { getDescendantIds } from '$lib/server/services/properties'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals, params, url }) => { + if (!locals.company) throw error(401); + const includeDescendants = url.searchParams.get('descendants') === '1'; + const propertyIds = includeDescendants + ? await getDescendantIds(locals.company.id, params.id) + : [params.id]; + const instances = await listInstancesForProperties(locals.company.id, propertyIds); + return { instances, includeDescendants, descendantCount: propertyIds.length }; +}; diff --git a/src/routes/(app)/properties/[id]/todos/+page.svelte b/src/routes/(app)/properties/[id]/todos/+page.svelte new file mode 100644 index 0000000..8555e8d --- /dev/null +++ b/src/routes/(app)/properties/[id]/todos/+page.svelte @@ -0,0 +1,86 @@ + + +
+ {#if data.childCount > 0} +
+
+ {#if data.includeDescendants} + {data.property.name} + sub-properties + ({data.descendantCount} total) + {:else} + {data.property.name} + ({data.childCount} sub-properties hidden) + {/if} +
+
+ {#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt} + {@const active = (data.includeDescendants ? '1' : '') === opt.v} + + {opt.l} + + {/each} +
+
+ {/if} + +
+

+ Open ({open.length}) +

+ {#if open.length === 0} +
+ No open checklists for this property. +
+ {:else} + + {/if} +
+ + {#if completed.length > 0} +
+

+ Completed ({completed.length}) +

+ +
+ {/if} +