Files
buildfor_life_ops/src/routes/(app)/assets/+page.svelte
T
grabowski b59904fdae Phases 1-5 + rooms/floors, accounts, custom types, users, notifications
Data model
- Properties, rooms (+optional floors), assets (typed custom fields + Zod
  runtime validator + move history), documents (polymorphic scope)
- Projects -> work packages -> tasks -> subtasks
- Decision events (scoped to project/property/asset/work_package)
- Checklist templates + instances, maintenance schedules (time + usage) with
  auto-materialized checklists on event recording
- Wiki (global + per-project) with revisions + tsvector FTS
- Property accounts (utility/meter numbers by kind)
- Notifications table + per-user channel prefs

Infra
- RBAC guards (requireCompany / requireAdmin)
- Storage abstraction: LocalDiskStorage (HMAC signed URLs) + S3Storage
  behind the same interface, switchable via STORAGE_BACKEND
- CSV export for assets / maintenance / decisions
- QR labels: /api/qr SVG endpoint + printable /assets/[id]/label
- Notifications: in-app + SMTP (own server via nodemailer) + Matrix
  (Client-Server API, per-company room) with opt-in per user
- Company switcher + auto-select first company on login

UI
- Topbar: bell with unread count, theme toggle, name, Sign Out (flat)
- Sidebar: main nav + dedicated Admin section (Asset types, Users, Company)
- Nested-route tabs on property / project / asset detail pages
- Admin UIs for users (invite, role, reset pw, deactivate) and company
  settings (default currency, Matrix room id)
- Custom asset type creation + field-def editor with immutable key/type
  guard and auto-deprecate when removing a field still referenced

Graph
- graphify-out/ committed: GRAPH_REPORT.md, graph.html, graph.json
2026-04-23 15:18:11 +07:00

75 lines
4.1 KiB
Svelte

<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Assets</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Everything you track — switches, ACs, filters, sensors, generators…
</p>
</div>
<a href="/assets/new"
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
+ New asset
</a>
</div>
<form method="get" class="flex flex-col gap-3 sm:flex-row">
<input
type="search"
name="q"
value={data.filterQ}
placeholder="Search by name, tag, or serial…"
class="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 sm:max-w-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
<select name="type" class="block 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-800 dark:text-gray-100">
<option value="">All types</option>
{#each data.types as t}
<option value={t.slug} selected={data.filterType === t.slug}>{t.name}</option>
{/each}
</select>
<button type="submit" class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">Filter</button>
<a href="/assets/export.csv?{new URLSearchParams({ q: data.filterQ, type: data.filterType }).toString()}"
class="self-center text-sm text-gray-600 hover:text-primary-600 dark:text-gray-400 dark:hover:text-primary-400">
Export CSV →
</a>
</form>
{#if data.assets.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-200">No assets match.</p>
<p class="mt-1">Adjust the filter above, or <a href="/assets/new" class="text-primary-600 hover:underline dark:text-primary-400">add a new asset</a>.</p>
</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">Name</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Type</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tag</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Serial</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.assets as a}
<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">
<a href="/assets/{a.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{a.name}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.assetTypeName}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.tag ?? '—'}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.serialNumber ?? '—'}</td>
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">{new Date(a.updatedAt).toLocaleDateString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>