Compare commits

...

4 Commits

Author SHA1 Message Date
grabowski c61be187e6 feat(properties): roll-up toggle on expenses/assets, new Maintenance + Todos tabs
Deploy to LXC / deploy (push) Successful in 15s
Validate / validate (push) Successful in 31s
Phase 4 — wires the descendant readers from Phase 2 into the UI.

- Expenses tab: ?descendants=1 query param toggles between
  "this property" and "include sub-properties". Affects the chart
  series, the 12-month summary, the recent-expense list, and the
  totals-by-kind chips.
- Assets tab: same toggle, expanded list across the property tree.
- New Maintenance tab on every property: schedules + recent events
  for assets at this property (or descendants), with overdue/active
  status badges. Scheduling itself stays at the asset level.
- New Todos tab: lists checklist instances scoped to this property
  (now possible thanks to the 'property' value added to
  checklist_scope in Phase 2). Open vs completed split.

Toggle only renders when childCount > 0 so leaf properties don't see
chrome they can't use. URL bookmarks without ?descendants stay on
the strict per-property view, preserving existing behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:59:17 +07:00
grabowski 3106286629 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>
2026-04-27 12:54:36 +07:00
grabowski 3b34458a99 feat(properties): tree-aware readers for expenses, maintenance, checklists
Phase 2. Each service grows a `*ForProperties` (plural) variant that
takes a resolved property id array; the existing single-property
functions now delegate to them with `[propertyId]`. The route layer
will compute the descendant set via getDescendantIds when the user
toggles "include sub-properties" — this commit only adds the readers
behind the scenes.

Also extends the checklist_scope enum with 'property' so checklists
can attach directly to a property and the rollup query has a
well-typed scope to filter on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:51:59 +07:00
grabowski 8117253841 feat(properties): add parent_id for sub-property hierarchy
Phase 1 of the parent/child rollup feature. Self-FK on properties
with ON DELETE RESTRICT, plus a CHECK that blocks self-reference at
the DB level. Service-layer helpers (getDescendantIds,
getAncestorIds, assertNoCycle) walk the tree via recursive CTEs and
guard against cycles and cross-company parents. softDeleteProperty
now refuses to delete a property with live children.

No UI yet — readers and roll-up routes land in Phase 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:49:04 +07:00
29 changed files with 10098 additions and 64 deletions
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "properties" ADD COLUMN "parent_id" uuid;--> statement-breakpoint
ALTER TABLE "properties" ADD CONSTRAINT "properties_parent_id_properties_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."properties"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "properties_by_parent" ON "properties" USING btree ("company_id","parent_id");
+7
View File
@@ -0,0 +1,7 @@
-- A property cannot be its own parent. Deeper cycle prevention (e.g.
-- A→B→A) is enforced in the service layer because Postgres can't express
-- recursive CHECK constraints; the assertNoCycle helper in
-- src/lib/server/services/properties.ts walks ancestors before save.
ALTER TABLE "properties"
ADD CONSTRAINT "properties_parent_not_self"
CHECK (parent_id IS NULL OR parent_id <> id);
@@ -0,0 +1 @@
ALTER TYPE "public"."checklist_scope" ADD VALUE 'property';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -113,6 +113,27 @@
"when": 1776932900000, "when": 1776932900000,
"tag": "0015_expenses_updated_at_trigger", "tag": "0015_expenses_updated_at_trigger",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1777268853483,
"tag": "0016_property_parent_id",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1777268853484,
"tag": "0017_property_parent_check",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1777268985448,
"tag": "0018_checklist_scope_property",
"breakpoints": true
} }
] ]
} }
+2 -1
View File
@@ -33,7 +33,8 @@ export const checklistScopeEnum = pgEnum('checklist_scope', [
'task', 'task',
'subtask', 'subtask',
'maintenance_event', 'maintenance_event',
'ad_hoc' 'ad_hoc',
'property'
]); ]);
export const docScopeEnum = pgEnum('doc_scope', [ export const docScopeEnum = pgEnum('doc_scope', [
'project', 'project',
+10 -1
View File
@@ -1,3 +1,4 @@
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { pgTable, varchar, text, numeric, index } from 'drizzle-orm/pg-core'; import { pgTable, varchar, text, numeric, index } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy'; import { companies, users } from './tenancy';
import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared'; import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
@@ -9,6 +10,13 @@ export const properties = pgTable(
companyId: fk('company_id') companyId: fk('company_id')
.notNull() .notNull()
.references(() => companies.id, { onDelete: 'cascade' }), .references(() => companies.id, { onDelete: 'cascade' }),
// NULL = root. References another property in the same company. Use cases:
// a building owns its apartments, a campus owns its sub-buildings.
// Cross-company references and cycles are blocked at the service layer
// (see assertNoCycle / setParent in services/properties.ts).
parentId: fk('parent_id').references((): AnyPgColumn => properties.id, {
onDelete: 'restrict'
}),
name: varchar('name', { length: 255 }).notNull(), name: varchar('name', { length: 255 }).notNull(),
kind: varchar('kind', { length: 64 }), // warehouse, office, datacenter, ... kind: varchar('kind', { length: 64 }), // warehouse, office, datacenter, ...
addressLine1: varchar('address_line1', { length: 255 }), addressLine1: varchar('address_line1', { length: 255 }),
@@ -26,7 +34,8 @@ export const properties = pgTable(
deletedAt: deletedAt() deletedAt: deletedAt()
}, },
(t) => ({ (t) => ({
byCompany: index('properties_by_company').on(t.companyId) byCompany: index('properties_by_company').on(t.companyId),
byParent: index('properties_by_parent').on(t.companyId, t.parentId)
}) })
); );
+9 -2
View File
@@ -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 { db } from '$lib/server/db/client';
import { import {
assets, assets,
@@ -292,7 +292,10 @@ export async function appendAssetLog(
export interface AssetListOptions { export interface AssetListOptions {
companyId: string; companyId: string;
typeSlug?: string; typeSlug?: string;
/** Restrict to a single property. Mutually exclusive with `propertyIds`. */
propertyId?: string; propertyId?: string;
/** Restrict to assets at any of these properties (e.g. a property tree). */
propertyIds?: string[];
projectId?: string; projectId?: string;
roomId?: string; roomId?: string;
q?: string; q?: string;
@@ -302,7 +305,11 @@ export interface AssetListOptions {
export async function listAssets(opts: AssetListOptions) { export async function listAssets(opts: AssetListOptions) {
const where = [eq(assets.companyId, opts.companyId), isNull(assets.deletedAt)]; 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.projectId) where.push(eq(assets.currentProjectId, opts.projectId));
if (opts.roomId) where.push(eq(assets.currentRoomId, opts.roomId)); if (opts.roomId) where.push(eq(assets.currentRoomId, opts.roomId));
if (opts.typeSlug) { if (opts.typeSlug) {
+29 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm'; import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client'; import { db } from '$lib/server/db/client';
import { import {
checklistInstances, checklistInstances,
@@ -9,7 +9,12 @@ import {
type ChecklistTemplateItem type ChecklistTemplateItem
} from '$lib/server/db/schema/checklists'; } from '$lib/server/db/schema/checklists';
export type ChecklistScope = 'task' | 'subtask' | 'maintenance_event' | 'ad_hoc'; export type ChecklistScope =
| 'task'
| 'subtask'
| 'maintenance_event'
| 'ad_hoc'
| 'property';
// --- templates --------------------------------------------------------------- // --- templates ---------------------------------------------------------------
@@ -252,3 +257,25 @@ export async function listInstancesForScope(
) )
.orderBy(desc(checklistInstances.createdAt)); .orderBy(desc(checklistInstances.createdAt));
} }
/**
* Checklist instances scoped to any of the given property ids. Used by the
* property roll-up tab when "include sub-properties" is on.
*/
export async function listInstancesForProperties(
companyId: string,
propertyIds: string[]
) {
if (propertyIds.length === 0) return [];
return db
.select()
.from(checklistInstances)
.where(
and(
eq(checklistInstances.companyId, companyId),
eq(checklistInstances.scopeType, 'property'),
inArray(checklistInstances.scopeId, propertyIds)
)
)
.orderBy(desc(checklistInstances.createdAt));
}
+49 -5
View File
@@ -165,7 +165,21 @@ export async function listExpensesForProperty(
opts: { kinds?: ExpenseKind[]; limit?: number } = {} opts: { kinds?: ExpenseKind[]; limit?: number } = {}
): Promise<PropertyExpense[]> { ): Promise<PropertyExpense[]> {
await assertProperty(companyId, propertyId); await assertProperty(companyId, propertyId);
const where = [eq(propertyExpenses.propertyId, propertyId)]; return listExpensesForProperties([propertyId], opts);
}
/**
* Tree-aware list. Caller resolves descendants via getDescendantIds (in
* services/properties) and passes the full id array. No per-property auth
* check — caller is responsible for ensuring every id belongs to the active
* company. Empty array returns [].
*/
export async function listExpensesForProperties(
propertyIds: string[],
opts: { kinds?: ExpenseKind[]; limit?: number } = {}
): Promise<PropertyExpense[]> {
if (propertyIds.length === 0) return [];
const where = [inArray(propertyExpenses.propertyId, propertyIds)];
if (opts.kinds && opts.kinds.length > 0) { if (opts.kinds && opts.kinds.length > 0) {
where.push(inArray(propertyExpenses.kind, opts.kinds)); where.push(inArray(propertyExpenses.kind, opts.kinds));
} }
@@ -199,7 +213,18 @@ export async function monthlySeriesForProperty(
months: ChartRange = 12 months: ChartRange = 12
): Promise<MonthlySeriesPoint[]> { ): Promise<MonthlySeriesPoint[]> {
await assertProperty(companyId, propertyId); await assertProperty(companyId, propertyId);
if (kinds.length === 0) return []; return monthlySeriesForProperties([propertyId], kinds, months);
}
/**
* Tree-aware monthly series. Caller passes the resolved id array.
*/
export async function monthlySeriesForProperties(
propertyIds: string[],
kinds: ExpenseKind[],
months: ChartRange = 12
): Promise<MonthlySeriesPoint[]> {
if (propertyIds.length === 0 || kinds.length === 0) return [];
const now = new Date(); const now = new Date();
const thisMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); const thisMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
@@ -214,7 +239,7 @@ export async function monthlySeriesForProperty(
.from(propertyExpenses) .from(propertyExpenses)
.where( .where(
and( and(
eq(propertyExpenses.propertyId, propertyId), inArray(propertyExpenses.propertyId, propertyIds),
inArray(propertyExpenses.kind, kinds) inArray(propertyExpenses.kind, kinds)
) )
); );
@@ -241,7 +266,7 @@ export async function monthlySeriesForProperty(
.from(propertyExpenses) .from(propertyExpenses)
.where( .where(
and( and(
eq(propertyExpenses.propertyId, propertyId), inArray(propertyExpenses.propertyId, propertyIds),
inArray(propertyExpenses.kind, kinds), inArray(propertyExpenses.kind, kinds),
gte(propertyExpenses.incurredAt, start), gte(propertyExpenses.incurredAt, start),
lt(propertyExpenses.incurredAt, new Date(Date.UTC(thisMonthStart.getUTCFullYear(), thisMonthStart.getUTCMonth() + 1, 1))) lt(propertyExpenses.incurredAt, new Date(Date.UTC(thisMonthStart.getUTCFullYear(), thisMonthStart.getUTCMonth() + 1, 1)))
@@ -279,6 +304,25 @@ export async function summaryForProperty(
currency: string | null; currency: string | null;
}> { }> {
await assertProperty(companyId, propertyId); await assertProperty(companyId, propertyId);
return summaryForProperties([propertyId], days);
}
/**
* Tree-aware summary. Caller passes resolved id array. When children carry
* different currencies, `currency` is null — the UI groups by currency in
* that case rather than auto-converting.
*/
export async function summaryForProperties(
propertyIds: string[],
days = 365
): Promise<{
byKind: Partial<Record<ExpenseKind, number>>;
grandTotal: number;
currency: string | null;
}> {
if (propertyIds.length === 0) {
return { byKind: {}, grandTotal: 0, currency: null };
}
const since = new Date(Date.now() - days * 86_400_000); const since = new Date(Date.now() - days * 86_400_000);
const rows = await db const rows = await db
.select({ .select({
@@ -289,7 +333,7 @@ export async function summaryForProperty(
.from(propertyExpenses) .from(propertyExpenses)
.where( .where(
and( and(
eq(propertyExpenses.propertyId, propertyId), inArray(propertyExpenses.propertyId, propertyIds),
gte(propertyExpenses.incurredAt, since) gte(propertyExpenses.incurredAt, since)
) )
) )
+72 -1
View File
@@ -1,5 +1,5 @@
import { addDays, addHours, addMonths, addYears } from 'date-fns'; import { addDays, addHours, addMonths, addYears } from 'date-fns';
import { and, asc, desc, eq, isNotNull, lte, sql } from 'drizzle-orm'; import { and, asc, desc, eq, inArray, isNotNull, lte, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client'; import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets'; import { assets } from '$lib/server/db/schema/assets';
import { import {
@@ -349,6 +349,77 @@ export async function listDueAndOverdue(opts: OverdueOpts) {
.limit(opts.limit ?? 50); .limit(opts.limit ?? 50);
} }
// --- property-tree readers --------------------------------------------------
/**
* Maintenance schedules for assets currently located at any of the given
* properties. Caller passes a property id array (e.g. expanded via
* getDescendantIds).
*/
export async function listSchedulesForProperties(companyId: string, propertyIds: string[]) {
if (propertyIds.length === 0) return [];
return db
.select({
id: maintenanceSchedules.id,
name: maintenanceSchedules.name,
kind: maintenanceSchedules.kind,
active: maintenanceSchedules.active,
intervalValue: maintenanceSchedules.intervalValue,
intervalUnit: maintenanceSchedules.intervalUnit,
lastServicedAt: maintenanceSchedules.lastServicedAt,
nextDueAt: maintenanceSchedules.nextDueAt,
nextDueUsage: maintenanceSchedules.nextDueUsage,
assetId: maintenanceSchedules.assetId,
assetName: assets.name,
propertyId: assets.currentPropertyId
})
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.where(
and(
eq(assets.companyId, companyId),
inArray(assets.currentPropertyId, propertyIds)
)
)
.orderBy(desc(maintenanceSchedules.active), asc(maintenanceSchedules.nextDueAt));
}
/**
* Maintenance events for assets at any of the given properties. Recent first.
*/
export async function listEventsForProperties(
companyId: string,
propertyIds: string[],
limit = 200
) {
if (propertyIds.length === 0) return [];
return db
.select({
id: maintenanceEvents.id,
scheduleId: maintenanceEvents.scheduleId,
scheduleName: maintenanceSchedules.name,
performedAt: maintenanceEvents.performedAt,
performedBy: maintenanceEvents.performedBy,
notes: maintenanceEvents.notes,
usageReading: maintenanceEvents.usageReading,
checklistInstanceId: maintenanceEvents.checklistInstanceId,
assetId: maintenanceEvents.assetId,
assetName: assets.name,
propertyId: assets.currentPropertyId
})
.from(maintenanceEvents)
.innerJoin(assets, eq(assets.id, maintenanceEvents.assetId))
.leftJoin(maintenanceSchedules, eq(maintenanceSchedules.id, maintenanceEvents.scheduleId))
.where(
and(
eq(assets.companyId, companyId),
inArray(assets.currentPropertyId, propertyIds)
)
)
.orderBy(desc(maintenanceEvents.performedAt))
.limit(limit);
}
export async function countOverdueForCompany(companyId: string): Promise<number> { export async function countOverdueForCompany(companyId: string): Promise<number> {
const [row] = await db const [row] = await db
.select({ n: sql<number>`count(*)::int` }) .select({ n: sql<number>`count(*)::int` })
+114
View File
@@ -7,6 +7,7 @@ export interface PropertyCreateInput {
createdBy: string; createdBy: string;
name: string; name: string;
kind?: string | null; kind?: string | null;
parentId?: string | null;
addressLine1?: string | null; addressLine1?: string | null;
addressLine2?: string | null; addressLine2?: string | null;
city?: string | null; city?: string | null;
@@ -16,9 +17,28 @@ export interface PropertyCreateInput {
notes?: string | null; notes?: string | null;
} }
async function assertParentInCompany(companyId: string, parentId: string): Promise<void> {
const [row] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.id, parentId),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (!row) throw new Error('parent property not found in this company');
}
export async function createProperty(input: PropertyCreateInput): Promise<{ id: string }> { export async function createProperty(input: PropertyCreateInput): Promise<{ id: string }> {
if (input.parentId) {
await assertParentInCompany(input.companyId, input.parentId);
}
const values: NewProperty = { const values: NewProperty = {
companyId: input.companyId, companyId: input.companyId,
parentId: input.parentId ?? null,
name: input.name.trim(), name: input.name.trim(),
kind: input.kind ?? null, kind: input.kind ?? null,
addressLine1: input.addressLine1 ?? null, addressLine1: input.addressLine1 ?? null,
@@ -62,11 +82,17 @@ export async function updateProperty(
id: string, id: string,
patch: Partial<PropertyCreateInput> patch: Partial<PropertyCreateInput>
): Promise<void> { ): Promise<void> {
if (patch.parentId !== undefined && patch.parentId !== null) {
if (patch.parentId === id) throw new Error('a property cannot be its own parent');
await assertParentInCompany(companyId, patch.parentId);
await assertNoCycle(companyId, id, patch.parentId);
}
await db await db
.update(properties) .update(properties)
.set({ .set({
...(patch.name !== undefined && { name: patch.name.trim() }), ...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.kind !== undefined && { kind: patch.kind ?? null }), ...(patch.kind !== undefined && { kind: patch.kind ?? null }),
...(patch.parentId !== undefined && { parentId: patch.parentId ?? null }),
...(patch.addressLine1 !== undefined && { addressLine1: patch.addressLine1 ?? null }), ...(patch.addressLine1 !== undefined && { addressLine1: patch.addressLine1 ?? null }),
...(patch.addressLine2 !== undefined && { addressLine2: patch.addressLine2 ?? null }), ...(patch.addressLine2 !== undefined && { addressLine2: patch.addressLine2 ?? null }),
...(patch.city !== undefined && { city: patch.city ?? null }), ...(patch.city !== undefined && { city: patch.city ?? null }),
@@ -81,8 +107,96 @@ export async function updateProperty(
} }
export async function softDeleteProperty(companyId: string, id: string): Promise<void> { export async function softDeleteProperty(companyId: string, id: string): Promise<void> {
// Block delete when live children exist — orphaning to root would silently
// disconnect roll-up reports. Caller should detach children first if that's
// the intent.
const [child] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.parentId, id),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (child) throw new Error('detach or delete sub-properties first');
await db await db
.update(properties) .update(properties)
.set({ deletedAt: sql`now()` }) .set({ deletedAt: sql`now()` })
.where(and(eq(properties.id, id), eq(properties.companyId, companyId))); .where(and(eq(properties.id, id), eq(properties.companyId, companyId)));
} }
// --- Hierarchy helpers -------------------------------------------------------
/**
* Returns [propertyId, ...all descendants] using a recursive CTE. The result
* always includes the root id so callers can use it directly with
* `WHERE property_id = ANY($ids)` for roll-up queries.
*
* Bounded by `deleted_at IS NULL` and `company_id = $companyId` to avoid
* leaking cross-company rows even if a service caller goofs.
*/
export async function getDescendantIds(
companyId: string,
propertyId: string
): Promise<string[]> {
const rows = await db.execute<{ id: string }>(sql`
WITH RECURSIVE tree AS (
SELECT id FROM properties
WHERE id = ${propertyId}
AND company_id = ${companyId}
AND deleted_at IS NULL
UNION ALL
SELECT p.id FROM properties p
INNER JOIN tree t ON p.parent_id = t.id
WHERE p.company_id = ${companyId}
AND p.deleted_at IS NULL
)
SELECT id FROM tree
`);
return rows.rows.map((r) => r.id);
}
/**
* Walk parent_id from `propertyId` up to the root. Used by assertNoCycle
* to detect attempts at moving a property under one of its own descendants.
*/
export async function getAncestorIds(
companyId: string,
propertyId: string
): Promise<string[]> {
const rows = await db.execute<{ id: string }>(sql`
WITH RECURSIVE chain AS (
SELECT id, parent_id FROM properties
WHERE id = ${propertyId} AND company_id = ${companyId}
UNION ALL
SELECT p.id, p.parent_id FROM properties p
INNER JOIN chain c ON p.id = c.parent_id
WHERE p.company_id = ${companyId}
)
SELECT id FROM chain WHERE id <> ${propertyId}
`);
return rows.rows.map((r) => r.id);
}
/**
* Throws if assigning `candidateParentId` as the parent of `propertyId` would
* create a cycle (i.e. candidateParentId is propertyId itself or one of its
* descendants).
*/
export async function assertNoCycle(
companyId: string,
propertyId: string,
candidateParentId: string
): Promise<void> {
if (candidateParentId === propertyId) {
throw new Error('a property cannot be its own parent');
}
const descendants = await getDescendantIds(companyId, propertyId);
if (descendants.includes(candidateParentId)) {
throw new Error('cannot set parent: that property is already a descendant');
}
}
@@ -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,10 +7,16 @@
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' },
{ href: `/properties/${data.property.id}/expenses`, label: 'Expenses' }, { 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' } { href: `/properties/${data.property.id}/documents`, label: 'Documents' }
]); ]);
</script> </script>
@@ -18,9 +24,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 ?? ''}
@@ -1,9 +1,14 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { listAssets } from '$lib/server/services/assets'; import { listAssets } from '$lib/server/services/assets';
import { getDescendantIds } from '$lib/server/services/properties';
import type { PageServerLoad } from './$types'; 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); if (!locals.company) throw error(401);
const rows = await listAssets({ companyId: locals.company.id, propertyId: params.id }); const includeDescendants = url.searchParams.get('descendants') === '1';
return { assets: rows }; 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 };
}; };
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); import type { LayoutData } from '../$types';
let { data }: { data: PageData & LayoutData } = $props();
const propId = $derived(data.property.id); const propId = $derived(data.property.id);
// Group assets by their room (including an "Unassigned" bucket). // Group assets by their room (including an "Unassigned" bucket).
@@ -31,8 +32,37 @@
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={opt.v ? '?descendants=1' : '?'}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">{data.assets.length} asset{data.assets.length === 1 ? '' : 's'} at this property.</p> <p class="text-sm text-gray-500 dark:text-gray-400">{data.assets.length} asset{data.assets.length === 1 ? '' : 's'}{data.includeDescendants ? ' across the property tree' : ' at this property'}.</p>
<a href="/assets/new?property={propId}" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">+ Add asset</a> <a href="/assets/new?property={propId}" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">+ Add asset</a>
</div> </div>
@@ -8,13 +8,14 @@ import { companies } from '$lib/server/db/schema/tenancy';
import { import {
createExpense, createExpense,
deleteExpense, deleteExpense,
listExpensesForProperty, listExpensesForProperties,
monthlySeriesForProperty, monthlySeriesForProperties,
summaryForProperty, summaryForProperties,
updateExpense, updateExpense,
type ChartRange, type ChartRange,
type ExpenseKind type ExpenseKind
} from '$lib/server/services/expenses'; } from '$lib/server/services/expenses';
import { getDescendantIds } from '$lib/server/services/properties';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
const KINDS = [ const KINDS = [
@@ -70,8 +71,17 @@ function parseRange(raw: string | null): ChartRange {
export const load: PageServerLoad = async ({ locals, params, url }) => { export const load: PageServerLoad = async ({ locals, params, url }) => {
const { company } = requireCompany(locals); const { company } = requireCompany(locals);
const range = parseRange(url.searchParams.get('range')); 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([ const [expenses, accounts, series, summary, [companyRow]] = await Promise.all([
listExpensesForProperty(company.id, params.id), listExpensesForProperties(propertyIds),
db db
.select({ .select({
id: propertyAccounts.id, id: propertyAccounts.id,
@@ -82,8 +92,8 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
}) })
.from(propertyAccounts) .from(propertyAccounts)
.where(eq(propertyAccounts.propertyId, params.id)), .where(eq(propertyAccounts.propertyId, params.id)),
monthlySeriesForProperty(company.id, params.id, ['electricity', 'water'], range), monthlySeriesForProperties(propertyIds, ['electricity', 'water'], range),
summaryForProperty(company.id, params.id, 365), summaryForProperties(propertyIds, 365),
db.select({ settings: companies.settings }).from(companies).where(eq(companies.id, company.id)).limit(1) db.select({ settings: companies.settings }).from(companies).where(eq(companies.id, company.id)).limit(1)
]); ]);
const defaultCurrency = parseSettings(companyRow?.settings ?? null).default_currency ?? 'USD'; const defaultCurrency = parseSettings(companyRow?.settings ?? null).default_currency ?? 'USD';
@@ -93,7 +103,9 @@ export const load: PageServerLoad = async ({ locals, params, url }) => {
chartSeries: series, chartSeries: series,
chartRange: range, chartRange: range,
summary, summary,
defaultCurrency defaultCurrency,
includeDescendants,
descendantCount: propertyIds.length
}; };
}; };
@@ -3,8 +3,23 @@
import ExpenseChart from '$lib/components/ExpenseChart.svelte'; import ExpenseChart from '$lib/components/ExpenseChart.svelte';
import { EXPENSE_KINDS, EXPENSE_KIND_LABEL, type ExpenseKind } from '$lib/expenses'; import { EXPENSE_KINDS, EXPENSE_KIND_LABEL, type ExpenseKind } from '$lib/expenses';
import type { PageData, ActionData } from './$types'; 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, string | undefined>): string {
const params = new URLSearchParams();
const base = {
range: String(data.chartRange),
descendants: data.includeDescendants ? '1' : ''
} as Record<string, string>;
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 showForm = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
@@ -39,6 +54,35 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={withParams({ descendants: opt.v || undefined })}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<!-- Chart: electricity + water --> <!-- Chart: electricity + water -->
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"> <section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-3"> <div class="mb-3 flex flex-wrap items-baseline justify-between gap-3">
@@ -52,7 +96,7 @@
{@const active = String(data.chartRange) === String(opt.v)} {@const active = String(data.chartRange) === String(opt.v)}
<a <a
role="tab" role="tab"
href="?range={opt.v}" href={withParams({ range: String(opt.v) })}
aria-selected={active} aria-selected={active}
class="rounded px-2 py-1 transition {active class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100' ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
@@ -0,0 +1,25 @@
import { error } from '@sveltejs/kit';
import {
listEventsForProperties,
listSchedulesForProperties
} from '$lib/server/services/maintenance';
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 [schedules, events] = await Promise.all([
listSchedulesForProperties(locals.company.id, propertyIds),
listEventsForProperties(locals.company.id, propertyIds)
]);
return {
schedules,
events,
includeDescendants,
descendantCount: propertyIds.length
};
};
@@ -0,0 +1,127 @@
<script lang="ts">
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))
);
</script>
<div class="space-y-4">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={opt.v ? '?descendants=1' : '?'}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Schedules
</h2>
{#if data.schedules.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No maintenance schedules on assets here. Set them up from each asset's page.
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schedule</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Asset</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Interval</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Next due</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each [...overdue, ...upcoming, ...data.schedules.filter((s) => !s.active)] as s (s.id)}
{@const isOverdue = overdue.includes(s)}
<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">{s.name}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
<a href="/assets/{s.assetId}" class="hover:text-primary-600 dark:hover:text-primary-400">{s.assetName}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">every {s.intervalValue} {s.intervalUnit}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
{s.kind === 'time' ? fmtDate(s.nextDueAt) : `${s.nextDueUsage ?? '—'} ${s.intervalUnit}`}
</td>
<td class="px-4 py-2 text-xs">
{#if !s.active}
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600 dark:bg-gray-700 dark:text-gray-300">paused</span>
{:else if isOverdue}
<span class="rounded bg-red-100 px-1.5 py-0.5 font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300">overdue</span>
{:else}
<span class="rounded bg-emerald-100 px-1.5 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">active</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Recent events
</h2>
{#if data.events.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No maintenance events recorded 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.events as e (e.id)}
<li class="flex items-start justify-between gap-3 px-4 py-3 text-sm">
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">
{e.scheduleName ?? 'Ad-hoc service'} ·
<a href="/assets/{e.assetId}" class="text-primary-600 hover:underline dark:text-primary-400">{e.assetName}</a>
</div>
{#if e.notes}
<p class="mt-0.5 text-gray-500 dark:text-gray-400">{e.notes}</p>
{/if}
</div>
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">{fmtDate(e.performedAt)}</div>
</li>
{/each}
</ul>
{/if}
</section>
</div>
@@ -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>
@@ -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 };
};
@@ -0,0 +1,86 @@
<script lang="ts">
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 open = $derived(data.instances.filter((i) => !i.completedAt));
const completed = $derived(data.instances.filter((i) => i.completedAt));
</script>
<div class="space-y-4">
{#if data.childCount > 0}
<div class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<div class="text-gray-700 dark:text-gray-300">
{#if data.includeDescendants}
<span class="font-medium">{data.property.name}</span> + sub-properties
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.descendantCount} total)</span>
{:else}
<span class="font-medium">{data.property.name}</span>
<span class="ml-1 text-xs text-gray-500 dark:text-gray-400">({data.childCount} sub-properties hidden)</span>
{/if}
</div>
<div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: '', l: 'This property' }, { v: '1', l: 'Include sub-properties' }] as opt}
{@const active = (data.includeDescendants ? '1' : '') === opt.v}
<a
role="tab"
href={opt.v ? '?descendants=1' : '?'}
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
{/if}
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Open ({open.length})
</h2>
{#if open.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No open checklists for this property.
</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 open as i (i.id)}
<li class="px-4 py-3 text-sm">
<a href="/checklists/{i.id}" class="font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
{i.title ?? 'Checklist'}
</a>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">created {fmtDate(i.createdAt)}</div>
</li>
{/each}
</ul>
{/if}
</section>
{#if completed.length > 0}
<section>
<h2 class="mb-2 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Completed ({completed.length})
</h2>
<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 completed as i (i.id)}
<li class="px-4 py-3 text-sm">
<a href="/checklists/{i.id}" class="text-gray-700 hover:text-primary-600 dark:text-gray-300 dark:hover:text-primary-400">
{i.title ?? 'Checklist'}
</a>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">completed {fmtDate(i.completedAt)}</div>
</li>
{/each}
</ul>
</section>
{/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>