feat(properties): roll-up toggle on expenses/assets, new Maintenance + Todos tabs
Deploy to LXC / deploy (push) Successful in 15s
Validate / validate (push) Successful in 31s

Phase 4 — wires the descendant readers from Phase 2 into the UI.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:59:17 +07:00
parent 3106286629
commit c61be187e6
10 changed files with 368 additions and 16 deletions
+9 -2
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, isNull, or, sql } from 'drizzle-orm'; import { and, asc, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client'; import { db } from '$lib/server/db/client';
import { import {
assets, assets,
@@ -292,7 +292,10 @@ export async function appendAssetLog(
export interface AssetListOptions { export interface AssetListOptions {
companyId: string; companyId: string;
typeSlug?: string; typeSlug?: string;
/** Restrict to a single property. Mutually exclusive with `propertyIds`. */
propertyId?: string; propertyId?: string;
/** Restrict to assets at any of these properties (e.g. a property tree). */
propertyIds?: string[];
projectId?: string; projectId?: string;
roomId?: string; roomId?: string;
q?: string; q?: string;
@@ -302,7 +305,11 @@ export interface AssetListOptions {
export async function listAssets(opts: AssetListOptions) { export async function listAssets(opts: AssetListOptions) {
const where = [eq(assets.companyId, opts.companyId), isNull(assets.deletedAt)]; const where = [eq(assets.companyId, opts.companyId), isNull(assets.deletedAt)];
if (opts.propertyId) where.push(eq(assets.currentPropertyId, opts.propertyId)); if (opts.propertyIds && opts.propertyIds.length > 0) {
where.push(inArray(assets.currentPropertyId, opts.propertyIds));
} else if (opts.propertyId) {
where.push(eq(assets.currentPropertyId, opts.propertyId));
}
if (opts.projectId) where.push(eq(assets.currentProjectId, opts.projectId)); if (opts.projectId) where.push(eq(assets.currentProjectId, opts.projectId));
if (opts.roomId) where.push(eq(assets.currentRoomId, opts.roomId)); if (opts.roomId) where.push(eq(assets.currentRoomId, opts.roomId));
if (opts.typeSlug) { if (opts.typeSlug) {
@@ -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>