Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c61be187e6 | |||
| 3106286629 | |||
| 3b34458a99 | |||
| 8117253841 |
@@ -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");
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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` })
|
||||||
|
|||||||
@@ -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">
|
||||||
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
{#if data.parent}
|
||||||
{data.property.kind ?? 'Property'}
|
<nav aria-label="Breadcrumb" class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
<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">
|
<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,22 +57,31 @@ 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;
|
||||||
await updateProperty(locals.company.id, params.id, {
|
try {
|
||||||
name: v.name,
|
await updateProperty(locals.company.id, params.id, {
|
||||||
kind: e2n(v.kind),
|
name: v.name,
|
||||||
addressLine1: e2n(v.addressLine1),
|
kind: e2n(v.kind),
|
||||||
addressLine2: e2n(v.addressLine2),
|
parentId: v.parentId ? v.parentId : null,
|
||||||
city: e2n(v.city),
|
addressLine1: e2n(v.addressLine1),
|
||||||
region: e2n(v.region),
|
addressLine2: e2n(v.addressLine2),
|
||||||
postalCode: e2n(v.postalCode),
|
city: e2n(v.city),
|
||||||
countryCode: e2n(v.countryCode),
|
region: e2n(v.region),
|
||||||
notes: e2n(v.notes)
|
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 };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
delete: async ({ locals, params }) => {
|
delete: async ({ locals, params }) => {
|
||||||
if (!locals.company) throw error(401);
|
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');
|
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,19 +48,27 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const v = parsed.data;
|
const v = parsed.data;
|
||||||
const { id } = await createProperty({
|
try {
|
||||||
companyId: locals.company.id,
|
const { id } = await createProperty({
|
||||||
createdBy: locals.user.id,
|
companyId: locals.company.id,
|
||||||
name: v.name,
|
createdBy: locals.user.id,
|
||||||
kind: emptyToNull(v.kind),
|
name: v.name,
|
||||||
addressLine1: emptyToNull(v.addressLine1),
|
kind: emptyToNull(v.kind),
|
||||||
addressLine2: emptyToNull(v.addressLine2),
|
parentId: v.parentId ? v.parentId : null,
|
||||||
city: emptyToNull(v.city),
|
addressLine1: emptyToNull(v.addressLine1),
|
||||||
region: emptyToNull(v.region),
|
addressLine2: emptyToNull(v.addressLine2),
|
||||||
postalCode: emptyToNull(v.postalCode),
|
city: emptyToNull(v.city),
|
||||||
countryCode: emptyToNull(v.countryCode),
|
region: emptyToNull(v.region),
|
||||||
notes: emptyToNull(v.notes)
|
postalCode: emptyToNull(v.postalCode),
|
||||||
});
|
countryCode: emptyToNull(v.countryCode),
|
||||||
throw redirect(303, `/properties/${id}`);
|
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">
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user