feat(properties): roll-up toggle on expenses/assets, new Maintenance + Todos tabs
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>
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
{ 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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
Reference in New Issue
Block a user