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
This commit is contained in:
2026-04-23 15:18:11 +07:00
parent ad155d6344
commit b59904fdae
387 changed files with 70371 additions and 82 deletions
+34
View File
@@ -0,0 +1,34 @@
// Safe to import from both server and browser code. Keep this module free of
// server-only dependencies (no DB, no env, no fs).
export type AccountKind =
| 'water'
| 'electricity'
| 'gas'
| 'internet'
| 'phone'
| 'cable'
| 'waste'
| 'other';
export const ACCOUNT_KINDS: readonly AccountKind[] = [
'electricity',
'water',
'gas',
'internet',
'phone',
'cable',
'waste',
'other'
] as const;
export const ACCOUNT_KIND_LABEL: Record<AccountKind, string> = {
water: 'Water',
electricity: 'Electricity',
gas: 'Gas',
internet: 'Internet',
phone: 'Phone',
cable: 'Cable TV',
waste: 'Waste',
other: 'Other'
};
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import type { AssetFieldDef } from '$lib/server/db/schema/assets';
interface Props {
defs: AssetFieldDef[];
values?: Record<string, unknown>;
// Field names get prefixed so they don't collide with native form fields.
// Server reads them via formData.get(`cf__${key}`).
prefix?: string;
}
let { defs, values = {}, prefix = 'cf__' }: Props = $props();
// Filter out deprecated fields entirely on form (still accepted on the server).
const visibleDefs = $derived(defs.filter((d) => d.deprecatedAt === null));
function asString(v: unknown): string {
if (v === null || v === undefined) return '';
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
function isChecked(d: AssetFieldDef, v: unknown, opt: string): boolean {
if (d.type !== 'multi_enum') return false;
if (!Array.isArray(v)) return false;
return v.includes(opt);
}
function inputType(d: AssetFieldDef): string {
switch (d.type) {
case 'int':
case 'float':
return 'number';
case 'date':
return 'date';
case 'url':
return 'url';
case 'email':
return 'email';
default:
return 'text';
}
}
</script>
{#if visibleDefs.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">This asset type has no custom fields.</p>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
{#each visibleDefs as d}
<div class="sm:col-span-2 sm:[&:has(input[type='number'])]:col-span-1 sm:[&:has(input[type='date'])]:col-span-1 sm:[&:has(input[type='checkbox'])]:col-span-1">
<label for="{prefix}{d.key}" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{d.label}
{#if d.required}<span class="text-red-500">*</span>{/if}
{#if d.unit}<span class="ml-1 text-xs text-gray-400">({d.unit})</span>{/if}
</label>
{#if d.type === 'textarea'}
<textarea id="{prefix}{d.key}" name="{prefix}{d.key}" rows="3" required={d.required}
placeholder={d.placeholder ?? ''}
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">{asString(values[d.key])}</textarea>
{:else if d.type === 'bool'}
<label class="mt-1 inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input id="{prefix}{d.key}" type="checkbox" name="{prefix}{d.key}" value="true"
checked={values[d.key] === true || values[d.key] === 'true'}
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
<span>{d.placeholder ?? d.label}</span>
</label>
{:else if d.type === 'enum'}
<select id="{prefix}{d.key}" name="{prefix}{d.key}" required={d.required}
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="">— select —</option>
{#each d.enumValues ?? [] as opt}
<option value={opt} selected={values[d.key] === opt}>{opt}</option>
{/each}
</select>
{:else if d.type === 'multi_enum'}
<div class="mt-1 flex flex-wrap gap-3 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900">
{#each d.enumValues ?? [] as opt}
<label class="inline-flex items-center gap-1 text-gray-700 dark:text-gray-300">
<input type="checkbox" name="{prefix}{d.key}" value={opt}
checked={isChecked(d, values[d.key], opt)}
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
<span>{opt}</span>
</label>
{/each}
</div>
{:else}
<input id="{prefix}{d.key}" name="{prefix}{d.key}" required={d.required}
type={inputType(d)} value={asString(values[d.key])}
placeholder={d.placeholder ?? ''}
step={d.type === 'float' ? 'any' : undefined}
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" />
{/if}
{#if d.helpText}
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{d.helpText}</p>
{/if}
</div>
{/each}
</div>
{/if}
+88 -49
View File
@@ -1,22 +1,22 @@
<script lang="ts">
import { page } from '$app/state';
import ThemeToggle from './ThemeToggle.svelte';
import type { SessionCompany, SessionUser } from '$lib/server/auth/types';
import { invalidateAll } from '$app/navigation';
import { enhance } from '$app/forms';
import type { SessionCompany } from '$lib/server/auth/types';
interface Props {
user: SessionUser;
company: SessionCompany | null;
companies: SessionCompany[];
open: boolean;
onclose: () => void;
}
let { user, company, companies, open, onclose }: Props = $props();
let { company, companies, open, onclose }: Props = $props();
interface NavItem {
href: string;
label: string;
icon: 'home' | 'briefcase' | 'building' | 'cube' | 'check' | 'book';
icon: 'home' | 'briefcase' | 'building' | 'cube' | 'check' | 'book' | 'cog' | 'wrench' | 'users';
}
const mainNav: NavItem[] = [
@@ -24,10 +24,17 @@
{ href: '/projects', label: 'Projects', icon: 'briefcase' },
{ href: '/properties', label: 'Properties', icon: 'building' },
{ href: '/assets', label: 'Assets', icon: 'cube' },
{ href: '/maintenance', label: 'Maintenance', icon: 'wrench' },
{ href: '/checklists', label: 'Checklists', icon: 'check' },
{ href: '/wiki', label: 'Wiki', icon: 'book' }
];
const adminNav: NavItem[] = [
{ href: '/admin/asset-types', label: 'Asset types', icon: 'cog' },
{ href: '/admin/users', label: 'Users', icon: 'users' },
{ href: '/admin/company', label: 'Company', icon: 'building' }
];
function isActive(href: string): boolean {
if (href === '/') return page.url.pathname === '/';
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
@@ -58,30 +65,56 @@
<!-- Nav -->
<nav class="flex-1 overflow-y-auto px-3 py-4 text-sm">
{#snippet navIcon(name: NavItem['icon'])}
{#if name === 'home'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>
{:else if name === 'briefcase'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.16 2.16 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>
{:else if name === 'building'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" /></svg>
{:else if name === 'cube'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
{:else if name === 'check'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
{:else if name === 'book'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>
{:else if name === 'cog'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
{:else if name === 'wrench'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" /></svg>
{:else if name === 'users'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>
{/if}
{/snippet}
<ul>
{#each mainNav as item}
<li>
<a
href={item.href}
onclick={onclose}
<a href={item.href} onclick={onclose}
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 font-medium {isActive(item.href)
? 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100'}"
>
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100'}">
<span aria-hidden="true" class="inline-flex h-4 w-4 items-center justify-center">
{#if item.icon === 'home'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>
{:else if item.icon === 'briefcase'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.16 2.16 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>
{:else if item.icon === 'building'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" /></svg>
{:else if item.icon === 'cube'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
{:else if item.icon === 'check'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>
{:else if item.icon === 'book'}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>
{/if}
{@render navIcon(item.icon)}
</span>
<span>{item.label}</span>
</a>
</li>
{/each}
</ul>
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Admin
</div>
<ul>
{#each adminNav as item}
<li>
<a href={item.href} onclick={onclose}
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 font-medium {isActive(item.href)
? 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100'}">
<span aria-hidden="true" class="inline-flex h-4 w-4 items-center justify-center">
{@render navIcon(item.icon)}
</span>
<span>{item.label}</span>
</a>
@@ -96,37 +129,43 @@
<ul>
{#each companies as c}
<li>
<div
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium {company?.id === c.id
? 'bg-gray-100 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
>
<span
class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-[10px] font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200"
{#if company?.id === c.id}
<div class="mb-1 flex items-center gap-2 rounded-md bg-gray-100 px-3 py-2 text-sm font-medium text-gray-900 dark:bg-gray-700 dark:text-gray-100">
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-[10px] font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{c.name.slice(0, 2).toUpperCase()}
</span>
<span class="flex-1 truncate">{c.name}</span>
<span class="text-xs text-gray-400 capitalize dark:text-gray-500">{c.role}</span>
</div>
{:else}
<form
method="post"
action="/switch-company"
use:enhance={() =>
async ({ update }) => {
await update();
await invalidateAll();
onclose();
}}
>
{c.name.slice(0, 2).toUpperCase()}
</span>
<span class="flex-1 truncate">{c.name}</span>
<span class="text-xs text-gray-400 capitalize dark:text-gray-500">{c.role}</span>
</div>
<input type="hidden" name="company_id" value={c.id} />
<input type="hidden" name="next" value={page.url.pathname + page.url.search} />
<button
type="submit"
class="mb-1 flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100"
>
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-[10px] font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{c.name.slice(0, 2).toUpperCase()}
</span>
<span class="flex-1 truncate">{c.name}</span>
<span class="text-xs text-gray-400 capitalize dark:text-gray-500">{c.role}</span>
</button>
</form>
{/if}
</li>
{/each}
</ul>
<!-- TODO: company switcher action is wired in Phase 1 (/switch-company form action). -->
{/if}
</nav>
<!-- Footer: user menu + theme toggle -->
<div class="flex h-14 items-center justify-between border-t border-gray-200 px-3 dark:border-gray-700">
<div class="flex min-w-0 items-center gap-2 text-sm">
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-gray-200 text-xs font-semibold text-gray-700 dark:bg-gray-600 dark:text-gray-200">
{user.displayName.slice(0, 1).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-gray-900 dark:text-gray-100">{user.displayName}</div>
<a href="/logout" data-sveltekit-preload-data="off" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Log out</a>
</div>
</div>
<ThemeToggle />
</div>
</aside>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import { page } from '$app/state';
interface Tab {
href: string;
label: string;
}
interface Props {
tabs: Tab[];
}
let { tabs }: Props = $props();
function isActive(href: string): boolean {
const path = page.url.pathname;
// Exact match for the "overview" tab (the page that owns the [id] root),
// startsWith for nested tabs.
const isLeafOfParent = tabs.every((t) => !path.startsWith(t.href + '/') || t.href === href);
if (isLeafOfParent && path === href) return true;
// Find the longest prefix match — the "winning" tab.
const match = tabs
.filter((t) => path === t.href || path.startsWith(t.href + '/'))
.sort((a, b) => b.href.length - a.href.length)[0];
return match?.href === href;
}
</script>
<nav class="flex gap-1 overflow-x-auto border-b border-gray-200 dark:border-gray-700">
{#each tabs as t}
<a
href={t.href}
class="-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium {isActive(t.href)
? 'border-primary-600 text-primary-700 dark:border-primary-500 dark:text-primary-300'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-200'}"
>
{t.label}
</a>
{/each}
</nav>
+29 -2
View File
@@ -1,13 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import ThemeToggle from './ThemeToggle.svelte';
import type { SessionUser } from '$lib/server/auth/types';
interface Props {
user: SessionUser;
unreadCount: number;
onmenu: () => void;
}
let { onmenu }: Props = $props();
let { user, unreadCount, onmenu }: Props = $props();
// Simple breadcrumbs derived from pathname segments.
// Individual routes can override with page data later.
let crumbs = $derived.by(() => {
const parts = page.url.pathname.split('/').filter(Boolean);
const out: { label: string; href: string }[] = [];
@@ -45,4 +48,28 @@
{/each}
{/if}
</nav>
<!-- Right-hand user block: bell · theme · name · sign out -->
<div class="flex shrink-0 items-center gap-4 text-sm">
<a href="/notifications" aria-label="Notifications"
class="relative text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" class="h-5 w-5"><path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" /></svg>
{#if unreadCount > 0}
<span class="absolute -right-1.5 -top-1 inline-flex min-w-[1rem] items-center justify-center rounded-full bg-red-600 px-1 text-[10px] font-semibold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
{/if}
</a>
<ThemeToggle />
<span class="hidden font-medium text-gray-900 sm:inline dark:text-gray-100">
{user.displayName.split(' ')[0]}
</span>
<a
href="/logout"
data-sveltekit-preload-data="off"
class="font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
>
Sign Out
</a>
</div>
</header>
+56
View File
@@ -0,0 +1,56 @@
// Client-safe constants for asset field types. Mirrors the pgEnum in
// src/lib/server/db/schema/_shared.ts — keep the two lists in sync.
export type FieldType =
| 'text'
| 'textarea'
| 'int'
| 'float'
| 'bool'
| 'date'
| 'ip'
| 'cidr'
| 'mac'
| 'enum'
| 'multi_enum'
| 'url'
| 'email'
| 'asset_ref';
export const FIELD_TYPES: readonly FieldType[] = [
'text',
'textarea',
'int',
'float',
'bool',
'date',
'ip',
'cidr',
'mac',
'enum',
'multi_enum',
'url',
'email',
'asset_ref'
] as const;
export const FIELD_TYPE_LABEL: Record<FieldType, string> = {
text: 'Text',
textarea: 'Text (multi-line)',
int: 'Integer',
float: 'Number',
bool: 'Boolean',
date: 'Date',
ip: 'IP address',
cidr: 'CIDR',
mac: 'MAC address',
enum: 'Single-choice (enum)',
multi_enum: 'Multi-choice (multi_enum)',
url: 'URL',
email: 'Email',
asset_ref: 'Asset reference (UUID)'
};
export function needsEnumValues(t: FieldType): boolean {
return t === 'enum' || t === 'multi_enum';
}
+19
View File
@@ -0,0 +1,19 @@
// Client-safe constants for notification kinds. Mirrors the pgEnum in
// src/lib/server/db/schema/_shared.ts.
export type NotificationKind =
| 'task_assigned'
| 'asset_log_added'
| 'asset_moved'
| 'decision_created'
| 'maintenance_event_recorded'
| 'generic';
export const NOTIFICATION_KIND_LABEL: Record<NotificationKind, string> = {
task_assigned: 'Task assigned',
asset_log_added: 'Asset log',
asset_moved: 'Asset moved',
decision_created: 'Decision',
maintenance_event_recorded: 'Maintenance',
generic: 'Notice'
};
+25
View File
@@ -0,0 +1,25 @@
// Client-safe constants for company roles. Mirrors the pgEnum in
// src/lib/server/db/schema/_shared.ts.
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer';
export const COMPANY_ROLES: readonly CompanyRole[] = [
'admin',
'manager',
'user',
'viewer'
] as const;
export const COMPANY_ROLE_LABEL: Record<CompanyRole, string> = {
admin: 'Admin',
manager: 'Manager',
user: 'User',
viewer: 'Viewer'
};
export const COMPANY_ROLE_DESCRIPTION: Record<CompanyRole, string> = {
admin: 'Full access, including user + company management',
manager: 'Can create and edit all project/property/asset data',
user: 'Can create and edit their own data',
viewer: 'Read-only access'
};
+32
View File
@@ -0,0 +1,32 @@
import { error } from '@sveltejs/kit';
import type { SessionCompany, SessionUser } from './types';
/**
* Throws 401/400 if the request isn't authenticated or has no active company.
* Use at the top of any +page.server.ts / action that needs tenant context.
*/
export function requireCompany(locals: App.Locals): {
user: SessionUser;
company: SessionCompany;
sessionId: string;
} {
if (!locals.user || !locals.sessionId) throw error(401, 'Not authenticated');
if (!locals.company) throw error(400, 'No active company');
return { user: locals.user, company: locals.company, sessionId: locals.sessionId };
}
/**
* Throws 403 unless the user is an admin of the active company.
* Layering: always call after requireCompany or with the same preconditions.
*/
export function requireAdmin(locals: App.Locals): {
user: SessionUser;
company: SessionCompany;
sessionId: string;
} {
const ctx = requireCompany(locals);
if (ctx.company.role !== 'admin') {
throw error(403, 'Admin access required');
}
return ctx;
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Minimal RFC 4180 CSV serializer. Fields containing comma, quote, or newline
* are quoted; inner quotes are doubled. Dates render as ISO strings.
*/
export function toCsv(rows: Array<Record<string, unknown>>, headers?: string[]): string {
if (rows.length === 0) {
return headers ? headers.join(',') + '\r\n' : '';
}
const cols = headers ?? Object.keys(rows[0]);
const out: string[] = [cols.map(escape).join(',')];
for (const row of rows) {
out.push(cols.map((k) => escape(toCell(row[k]))).join(','));
}
return out.join('\r\n') + '\r\n';
}
function toCell(v: unknown): string {
if (v === null || v === undefined) return '';
if (v instanceof Date) return v.toISOString();
if (Array.isArray(v)) return v.map((x) => toCell(x)).join('; ');
if (typeof v === 'object') return JSON.stringify(v);
return String(v);
}
function escape(s: string): string {
if (/[",\r\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
return s;
}
export function csvResponse(filename: string, body: string): Response {
return new Response(body, {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="${filename.replace(/"/g, '')}"`
}
});
}
+38
View File
@@ -0,0 +1,38 @@
import type { AssetFieldDef } from '$lib/server/db/schema/assets';
/**
* Convert an HTML form's cf__* keys into a typed JSONB blob suitable
* for `validateCustomFields()`. The validator does the actual coercion
* and stripping of unknowns, so this just gathers raw values per field def.
*/
export function gatherCustomFieldsFromForm(
form: FormData,
defs: AssetFieldDef[],
prefix = 'cf__'
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const d of defs) {
const name = `${prefix}${d.key}`;
switch (d.type) {
case 'multi_enum': {
const values = form.getAll(name).map((v) => String(v)).filter(Boolean);
if (values.length > 0) out[d.key] = values;
break;
}
case 'bool': {
const v = form.get(name);
out[d.key] = v === 'true' || v === 'on';
break;
}
default: {
const v = form.get(name);
if (v === null) break;
const s = typeof v === 'string' ? v.trim() : '';
if (s === '') break;
out[d.key] = s;
break;
}
}
}
return out;
}
+18
View File
@@ -59,6 +59,24 @@ export const auditActionEnum = pgEnum('audit_action', [
'login',
'logout'
]);
export const accountKindEnum = pgEnum('account_kind', [
'water',
'electricity',
'gas',
'internet',
'phone',
'cable',
'waste',
'other'
]);
export const notificationKindEnum = pgEnum('notification_kind', [
'task_assigned',
'asset_log_added',
'asset_moved',
'decision_created',
'maintenance_event_recorded',
'generic'
]);
export const pk = () => uuid('id').primaryKey().default(sql`gen_random_uuid()`);
export const fk = (name: string) => uuid(name);
+30
View File
@@ -0,0 +1,30 @@
import { pgTable, varchar, text, integer, index } from 'drizzle-orm/pg-core';
import { properties } from './properties';
import { accountKindEnum, pk, fk, createdAt, updatedAt } from './_shared';
// Utility / service accounts and meter numbers attached to a property.
// Examples: electricity billing account, water meter number, ISP subscription.
export const propertyAccounts = pgTable(
'property_accounts',
{
id: pk(),
propertyId: fk('property_id')
.notNull()
.references(() => properties.id, { onDelete: 'cascade' }),
kind: accountKindEnum('kind').notNull(),
provider: varchar('provider', { length: 128 }),
label: varchar('label', { length: 128 }), // friendly name, e.g. "Main building" vs "Subunit A"
accountNumber: varchar('account_number', { length: 128 }),
meterNumber: varchar('meter_number', { length: 128 }),
notes: text('notes'),
order: integer('order').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
byProperty: index('accounts_by_property').on(t.propertyId, t.kind, t.order)
})
);
export type PropertyAccount = typeof propertyAccounts.$inferSelect;
export type NewPropertyAccount = typeof propertyAccounts.$inferInsert;
+181
View File
@@ -0,0 +1,181 @@
import { sql } from 'drizzle-orm';
import {
pgTable,
varchar,
text,
integer,
boolean,
timestamp,
jsonb,
index,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { properties } from './properties';
import { projects } from './projects';
import { propertyRooms } from './rooms';
import {
containerKindEnum,
fieldTypeEnum,
pk,
fk,
createdAt,
updatedAt,
deletedAt,
slugCol
} from './_shared';
// asset_types: company-scoped catalog of "kinds of thing" (Switch, AC, Filter, ...).
// company_id NULL means a system-seeded type visible to every tenant.
export const assetTypes = pgTable(
'asset_types',
{
id: pk(),
companyId: fk('company_id').references(() => companies.id, { onDelete: 'cascade' }),
// Self-referential FK declared after the table via .references inline below.
parentId: fk('parent_id'),
name: varchar('name', { length: 128 }).notNull(),
slug: slugCol(),
icon: varchar('icon', { length: 64 }),
description: text('description'),
// Bumped by trigger whenever asset_field_defs for this type change. Used as
// the cache key for the runtime Zod validator.
schemaVersion: integer('schema_version').notNull().default(1),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
// Treat NULL company_id as "system" — Postgres NULL semantics already
// distinguish system vs company rows on this composite.
slugUq: uniqueIndex('asset_types_company_slug_uq').on(t.companyId, t.slug),
byParent: index('asset_types_by_parent').on(t.parentId)
})
);
export const assetFieldDefs = pgTable(
'asset_field_defs',
{
id: pk(),
assetTypeId: fk('asset_type_id')
.notNull()
.references(() => assetTypes.id, { onDelete: 'cascade' }),
// snake_case, immutable once any asset references it (renames are a
// 2-step JSONB migration).
key: varchar('key', { length: 64 }).notNull(),
label: varchar('label', { length: 128 }).notNull(),
type: fieldTypeEnum('type').notNull(),
required: boolean('required').notNull().default(false),
order: integer('order').notNull().default(0),
enumValues: text('enum_values').array(),
unit: varchar('unit', { length: 32 }),
placeholder: varchar('placeholder', { length: 255 }),
helpText: text('help_text'),
deprecatedAt: timestamp('deprecated_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
keyUq: uniqueIndex('asset_field_defs_type_key_uq').on(t.assetTypeId, t.key),
byType: index('asset_field_defs_by_type').on(t.assetTypeId, t.order)
})
);
export const assets = pgTable(
'assets',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
assetTypeId: fk('asset_type_id')
.notNull()
.references(() => assetTypes.id, { onDelete: 'restrict' }),
name: varchar('name', { length: 255 }).notNull(),
tag: varchar('tag', { length: 64 }), // asset tag / barcode
serialNumber: varchar('serial_number', { length: 128 }),
manufacturer: varchar('manufacturer', { length: 128 }),
model: varchar('model', { length: 128 }),
purchasedAt: timestamp('purchased_at', { withTimezone: true }),
// Denormalized current location — exactly one of (project, property) is non-null,
// enforced by a CHECK constraint added in the follow-up SQL migration.
currentContainerKind: containerKindEnum('current_container_kind').notNull(),
currentProjectId: fk('current_project_id').references(() => projects.id, {
onDelete: 'restrict'
}),
currentPropertyId: fk('current_property_id').references(() => properties.id, {
onDelete: 'restrict'
}),
// Optional refinement of property placement. CHECK constraint enforces
// current_room_id can only be set when container_kind='property'.
currentRoomId: fk('current_room_id').references(() => propertyRooms.id, {
onDelete: 'set null'
}),
customFields: jsonb('custom_fields').notNull().default(sql`'{}'::jsonb`),
// Maintained by trigger (see follow-up SQL migration); declared as text here so
// drizzle-kit can introspect, then ALTERed to tsvector.
searchTsv: text('search_tsv'),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byCompany: index('assets_by_company').on(t.companyId),
byType: index('assets_by_type').on(t.assetTypeId),
byProject: index('assets_by_project').on(t.currentProjectId),
byProperty: index('assets_by_property').on(t.currentPropertyId),
byRoom: index('assets_by_room').on(t.currentRoomId),
tagUq: uniqueIndex('assets_company_tag_uq').on(t.companyId, t.tag),
serialUq: uniqueIndex('assets_company_serial_uq').on(t.companyId, t.serialNumber)
// GIN on custom_fields and tsvector index added in follow-up SQL migration.
})
);
export const assetLocationHistory = pgTable(
'asset_location_history',
{
id: pk(),
assetId: fk('asset_id')
.notNull()
.references(() => assets.id, { onDelete: 'cascade' }),
fromKind: containerKindEnum('from_kind'),
fromProjectId: fk('from_project_id').references(() => projects.id, {
onDelete: 'set null'
}),
fromPropertyId: fk('from_property_id').references(() => properties.id, {
onDelete: 'set null'
}),
toKind: containerKindEnum('to_kind').notNull(),
toProjectId: fk('to_project_id').references(() => projects.id, { onDelete: 'set null' }),
toPropertyId: fk('to_property_id').references(() => properties.id, { onDelete: 'set null' }),
movedBy: fk('moved_by').references(() => users.id, { onDelete: 'set null' }),
movedAt: timestamp('moved_at', { withTimezone: true }).notNull().defaultNow(),
reason: text('reason')
},
(t) => ({
byAsset: index('alh_by_asset').on(t.assetId, t.movedAt)
})
);
export const assetLogs = pgTable(
'asset_logs',
{
id: pk(),
assetId: fk('asset_id')
.notNull()
.references(() => assets.id, { onDelete: 'cascade' }),
authorId: fk('author_id').references(() => users.id, { onDelete: 'set null' }),
body: text('body').notNull(),
createdAt: createdAt()
},
(t) => ({
byAsset: index('asset_logs_by_asset').on(t.assetId, t.createdAt)
})
);
export type AssetType = typeof assetTypes.$inferSelect;
export type AssetFieldDef = typeof assetFieldDefs.$inferSelect;
export type Asset = typeof assets.$inferSelect;
export type NewAsset = typeof assets.$inferInsert;
export type AssetLocationHistoryRow = typeof assetLocationHistory.$inferSelect;
export type AssetLog = typeof assetLogs.$inferSelect;
+97
View File
@@ -0,0 +1,97 @@
import {
pgTable,
varchar,
text,
integer,
boolean,
timestamp,
uuid,
index,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { checklistScopeEnum, pk, fk, createdAt, updatedAt } from './_shared';
// A reusable checklist (e.g. "Quarterly AC service", "Switch port audit").
export const checklistTemplates = pgTable(
'checklist_templates',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
byCompany: index('ct_by_company').on(t.companyId)
})
);
export const checklistTemplateItems = pgTable(
'checklist_template_items',
{
id: pk(),
templateId: fk('template_id')
.notNull()
.references(() => checklistTemplates.id, { onDelete: 'cascade' }),
text: varchar('text', { length: 500 }).notNull(),
order: integer('order').notNull().default(0),
required: boolean('required').notNull().default(false)
},
(t) => ({
byTemplate: index('cti_by_template').on(t.templateId, t.order)
})
);
// Polymorphic. scopeId meaning depends on scopeType (task | subtask |
// maintenance_event | ad_hoc). No FK by design — the service layer that
// creates an instance knows how to resolve the scope.
export const checklistInstances = pgTable(
'checklist_instances',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
templateId: fk('template_id').references(() => checklistTemplates.id, { onDelete: 'set null' }),
scopeType: checklistScopeEnum('scope_type').notNull(),
scopeId: uuid('scope_id'),
title: varchar('title', { length: 255 }),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
completedAt: timestamp('completed_at', { withTimezone: true })
},
(t) => ({
byScope: index('ci_by_scope').on(t.scopeType, t.scopeId),
byCompany: index('ci_by_company').on(t.companyId)
})
);
export const checklistItems = pgTable(
'checklist_items',
{
id: pk(),
instanceId: fk('instance_id')
.notNull()
.references(() => checklistInstances.id, { onDelete: 'cascade' }),
text: varchar('text', { length: 500 }).notNull(),
done: boolean('done').notNull().default(false),
doneAt: timestamp('done_at', { withTimezone: true }),
doneBy: fk('done_by').references(() => users.id, { onDelete: 'set null' }),
required: boolean('required').notNull().default(false),
order: integer('order').notNull().default(0),
note: text('note')
},
(t) => ({
byInstance: index('cit_by_instance').on(t.instanceId, t.order)
})
);
export type ChecklistTemplate = typeof checklistTemplates.$inferSelect;
export type ChecklistTemplateItem = typeof checklistTemplateItems.$inferSelect;
export type ChecklistInstance = typeof checklistInstances.$inferSelect;
export type ChecklistItem = typeof checklistItems.$inferSelect;
+38
View File
@@ -0,0 +1,38 @@
import { pgTable, varchar, text, numeric, timestamp, uuid, index } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { decisionScopeEnum, pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
// Structured decision log. scopeType controls what scope_id points at:
// project | property | asset | work_package
// No FK on scope_id by design — the service that writes/reads knows the scope.
export const decisionEvents = pgTable(
'decision_events',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
scopeType: decisionScopeEnum('scope_type').notNull(),
scopeId: uuid('scope_id').notNull(),
title: varchar('title', { length: 255 }).notNull(),
bodyMd: text('body_md').notNull(),
alternativesConsidered: text('alternatives_considered'),
costImpact: numeric('cost_impact', { precision: 18, scale: 4 }),
currency: varchar('currency', { length: 3 }),
approvedBy: fk('approved_by').references(() => users.id, { onDelete: 'set null' }),
decidedAt: timestamp('decided_at', { withTimezone: true }).notNull(),
decidedBy: fk('decided_by').references(() => users.id, { onDelete: 'set null' }),
tags: text('tags').array(),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byScope: index('de_by_scope').on(t.scopeType, t.scopeId, t.decidedAt),
byCompany: index('de_by_company').on(t.companyId),
byTags: index('de_tags_gin').using('gin', t.tags)
})
);
export type DecisionEvent = typeof decisionEvents.$inferSelect;
export type NewDecisionEvent = typeof decisionEvents.$inferInsert;
+35
View File
@@ -0,0 +1,35 @@
import { pgTable, varchar, bigint, uuid, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { docScopeEnum, pk, fk } from './_shared';
// Polymorphic. scope_id semantics depend on scope_type (project | property | asset
// | work_package | decision_event). No FK by design — enforced in the service
// layer that knows how to resolve each scope.
export const documents = pgTable(
'documents',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
scopeType: docScopeEnum('scope_type').notNull(),
scopeId: uuid('scope_id').notNull(),
filename: varchar('filename', { length: 512 }).notNull(),
mimeType: varchar('mime_type', { length: 128 }).notNull(),
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
sha256: varchar('sha256', { length: 64 }).notNull(),
// OPAQUE — interpreted only by the storage adapter. Never a filesystem path.
storageKey: varchar('storage_key', { length: 512 }).notNull(),
uploadedBy: fk('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).notNull().defaultNow()
},
(t) => ({
byScope: index('docs_by_scope').on(t.scopeType, t.scopeId),
byCompany: index('docs_by_company').on(t.companyId),
byHash: index('docs_by_hash').on(t.sha256),
storageKeyUq: uniqueIndex('docs_storage_key_uq').on(t.storageKey)
})
);
export type DocumentRow = typeof documents.$inferSelect;
export type NewDocumentRow = typeof documents.$inferInsert;
+12 -3
View File
@@ -1,5 +1,14 @@
export * from './_shared';
export * from './tenancy';
// Additional schema modules (projects, properties, assets, maintenance,
// checklists, decisions, documents, wiki, audit) are added in phases as
// laid out in project_buildfor_life_ops.md.
export * from './properties';
export * from './rooms';
export * from './accounts';
export * from './assets';
export * from './documents';
export * from './checklists';
export * from './maintenance';
export * from './projects';
export * from './decisions';
export * from './wiki';
export * from './notifications';
// audit ships in a later phase.
+86
View File
@@ -0,0 +1,86 @@
import { pgTable, varchar, text, integer, numeric, timestamp, boolean, index } from 'drizzle-orm/pg-core';
import { users } from './tenancy';
import { assets } from './assets';
import { checklistTemplates, checklistInstances } from './checklists';
import { scheduleKindEnum, intervalUnitEnum, pk, fk, createdAt, updatedAt } from './_shared';
export const maintenanceSchedules = pgTable(
'maintenance_schedules',
{
id: pk(),
assetId: fk('asset_id')
.notNull()
.references(() => assets.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
kind: scheduleKindEnum('kind').notNull(),
intervalValue: integer('interval_value').notNull(),
intervalUnit: intervalUnitEnum('interval_unit').notNull(),
lastServicedAt: timestamp('last_serviced_at', { withTimezone: true }),
// kind=time: when the next service is due
nextDueAt: timestamp('next_due_at', { withTimezone: true }),
// kind=usage: usage reading at which the next service is due
nextDueUsage: numeric('next_due_usage', { precision: 18, scale: 4 }),
checklistTemplateId: fk('checklist_template_id').references(() => checklistTemplates.id, {
onDelete: 'set null'
}),
active: boolean('active').notNull().default(true),
notes: text('notes'),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
byAsset: index('ms_by_asset').on(t.assetId),
byNextDue: index('ms_by_next_due').on(t.nextDueAt)
// partial index `ms_next_due_active` is added in the follow-up SQL migration
})
);
export const usageReadings = pgTable(
'usage_readings',
{
id: pk(),
assetId: fk('asset_id')
.notNull()
.references(() => assets.id, { onDelete: 'cascade' }),
reading: numeric('reading', { precision: 18, scale: 4 }).notNull(),
unit: intervalUnitEnum('unit').notNull(),
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull().defaultNow(),
recordedBy: fk('recorded_by').references(() => users.id, { onDelete: 'set null' }),
notes: text('notes')
},
(t) => ({
byAsset: index('ur_by_asset_time').on(t.assetId, t.recordedAt)
})
);
export const maintenanceEvents = pgTable(
'maintenance_events',
{
id: pk(),
assetId: fk('asset_id')
.notNull()
.references(() => assets.id, { onDelete: 'cascade' }),
scheduleId: fk('schedule_id').references(() => maintenanceSchedules.id, {
onDelete: 'set null'
}),
performedAt: timestamp('performed_at', { withTimezone: true }).notNull(),
performedBy: fk('performed_by').references(() => users.id, { onDelete: 'set null' }),
notes: text('notes'),
// usage reading at the time of service (for kind=usage schedules)
usageReading: numeric('usage_reading', { precision: 18, scale: 4 }),
checklistInstanceId: fk('checklist_instance_id').references(() => checklistInstances.id, {
onDelete: 'set null'
}),
createdAt: createdAt()
},
(t) => ({
byAsset: index('me_by_asset_time').on(t.assetId, t.performedAt),
bySchedule: index('me_by_schedule').on(t.scheduleId)
})
);
export type MaintenanceSchedule = typeof maintenanceSchedules.$inferSelect;
export type NewMaintenanceSchedule = typeof maintenanceSchedules.$inferInsert;
export type UsageReading = typeof usageReadings.$inferSelect;
export type MaintenanceEvent = typeof maintenanceEvents.$inferSelect;
+31
View File
@@ -0,0 +1,31 @@
import { pgTable, varchar, text, timestamp, index } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { notificationKindEnum, pk, fk, createdAt } from './_shared';
export const notifications = pgTable(
'notifications',
{
id: pk(),
userId: fk('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
kind: notificationKindEnum('kind').notNull(),
title: varchar('title', { length: 255 }).notNull(),
body: text('body').notNull(),
// Absolute or relative URL back into the app (e.g. /assets/<id>).
link: varchar('link', { length: 1024 }),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: createdAt()
},
(t) => ({
// Unread-list query: user + read_at IS NULL + ordered by created_at desc.
byUserUnread: index('notifications_by_user_unread').on(t.userId, t.readAt, t.createdAt),
byUserCompany: index('notifications_by_user_company').on(t.userId, t.companyId, t.createdAt)
})
);
export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert;
+106
View File
@@ -0,0 +1,106 @@
import {
pgTable,
varchar,
text,
integer,
boolean,
timestamp,
index,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { taskStatusEnum, pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
export const projects = pgTable(
'projects',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
code: varchar('code', { length: 64 }),
description: text('description'),
// Free-form for v1: active | on_hold | done | cancelled. Promote to enum later.
status: varchar('status', { length: 32 }).notNull().default('active'),
startDate: timestamp('start_date', { withTimezone: true }),
endDate: timestamp('end_date', { withTimezone: true }),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byCompany: index('projects_by_company').on(t.companyId),
codeUq: uniqueIndex('projects_company_code_uq').on(t.companyId, t.code)
})
);
export const workPackages = pgTable(
'work_packages',
{
id: pk(),
projectId: fk('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
order: integer('order').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byProject: index('work_packages_by_project').on(t.projectId)
})
);
export const tasks = pgTable(
'tasks',
{
id: pk(),
workPackageId: fk('work_package_id')
.notNull()
.references(() => workPackages.id, { onDelete: 'cascade' }),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
status: taskStatusEnum('status').notNull().default('todo'),
assigneeId: fk('assignee_id').references(() => users.id, { onDelete: 'set null' }),
dueAt: timestamp('due_at', { withTimezone: true }),
order: integer('order').notNull().default(0),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byWp: index('tasks_by_wp').on(t.workPackageId),
byAssignee: index('tasks_by_assignee').on(t.assigneeId),
byStatusDue: index('tasks_status_due').on(t.status, t.dueAt)
})
);
export const subtasks = pgTable(
'subtasks',
{
id: pk(),
taskId: fk('task_id')
.notNull()
.references(() => tasks.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 500 }).notNull(),
done: boolean('done').notNull().default(false),
order: integer('order').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
byTask: index('subtasks_by_task').on(t.taskId, t.order)
})
);
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type WorkPackage = typeof workPackages.$inferSelect;
export type Task = typeof tasks.$inferSelect;
export type Subtask = typeof subtasks.$inferSelect;
+34
View File
@@ -0,0 +1,34 @@
import { pgTable, varchar, text, numeric, index } from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
export const properties = pgTable(
'properties',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 255 }).notNull(),
kind: varchar('kind', { length: 64 }), // warehouse, office, datacenter, ...
addressLine1: varchar('address_line1', { length: 255 }),
addressLine2: varchar('address_line2', { length: 255 }),
city: varchar('city', { length: 128 }),
region: varchar('region', { length: 128 }),
postalCode: varchar('postal_code', { length: 32 }),
countryCode: varchar('country_code', { length: 2 }),
lat: numeric('lat', { precision: 9, scale: 6 }),
lng: numeric('lng', { precision: 9, scale: 6 }),
notes: text('notes'),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byCompany: index('properties_by_company').on(t.companyId)
})
);
export type Property = typeof properties.$inferSelect;
export type NewProperty = typeof properties.$inferInsert;
+59
View File
@@ -0,0 +1,59 @@
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { pgTable, varchar, text, integer, index, uniqueIndex } from 'drizzle-orm/pg-core';
import { properties } from './properties';
import { pk, fk, createdAt, updatedAt, deletedAt } from './_shared';
// Floors are first-class so assets on the same floor share a reference point.
// `label` is the short identifier (B1, 1, 2, M, roof); `name` is optional
// descriptive text (e.g. "Basement parking").
export const propertyFloors = pgTable(
'property_floors',
{
id: pk(),
propertyId: fk('property_id')
.notNull()
.references(() => properties.id, { onDelete: 'cascade' }),
label: varchar('label', { length: 32 }).notNull(),
name: varchar('name', { length: 255 }),
order: integer('order').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt()
},
(t) => ({
byProperty: index('floors_by_property').on(t.propertyId, t.order),
labelUq: uniqueIndex('floors_property_label_uq').on(t.propertyId, t.label)
})
);
// A room / zone inside a property, optionally linked to a floor.
// floor_id belonging to the same property is enforced in the service layer
// (a CHECK against a cross-table column would need a trigger).
export const propertyRooms = pgTable(
'property_rooms',
{
id: pk(),
propertyId: fk('property_id')
.notNull()
.references(() => properties.id, { onDelete: 'cascade' }),
floorId: fk('floor_id').references((): AnyPgColumn => propertyFloors.id, {
onDelete: 'set null'
}),
name: varchar('name', { length: 255 }).notNull(),
notes: text('notes'),
order: integer('order').notNull().default(0),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byProperty: index('rooms_by_property').on(t.propertyId, t.order),
byFloor: index('rooms_by_floor').on(t.floorId),
// Same name on the same floor (or both null-floor) of a property is duplication.
uniquePerFloor: uniqueIndex('rooms_floor_name_uq').on(t.propertyId, t.floorId, t.name)
})
);
export type PropertyFloor = typeof propertyFloors.$inferSelect;
export type NewPropertyFloor = typeof propertyFloors.$inferInsert;
export type PropertyRoom = typeof propertyRooms.$inferSelect;
export type NewPropertyRoom = typeof propertyRooms.$inferInsert;
+6
View File
@@ -42,6 +42,12 @@ export const users = pgTable(
oidcIssuer: varchar('oidc_issuer', { length: 255 }),
isActive: boolean('is_active').notNull().default(true),
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
// Notification channel preferences. In-app is always on.
emailNotifications: boolean('email_notifications').notNull().default(true),
matrixNotifications: boolean('matrix_notifications').notNull().default(false),
// Full Matrix user id incl. homeserver, e.g. @alice:matrix.org. Used to
// mention a user in the company Matrix room.
matrixUserId: varchar('matrix_user_id', { length: 255 }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
+64
View File
@@ -0,0 +1,64 @@
import {
pgTable,
varchar,
text,
integer,
uuid,
index,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { companies, users } from './tenancy';
import { wikiScopeEnum, pk, fk, createdAt, updatedAt, deletedAt, slugCol } from './_shared';
// One wiki page per (company, scope, slug). scope_id is null for global pages.
// Unique-with-NULLS-NOT-DISTINCT on (company_id, scope_type, scope_id, slug)
// is added in the follow-up SQL migration (Drizzle can't express it).
export const wikiPages = pgTable(
'wiki_pages',
{
id: pk(),
companyId: fk('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
scopeType: wikiScopeEnum('scope_type').notNull(),
scopeId: uuid('scope_id'), // null for global
slug: slugCol(),
title: varchar('title', { length: 255 }).notNull(),
// Pointer to the latest wiki_revisions row. Set after first revision is written.
currentRevisionId: fk('current_revision_id'),
createdBy: fk('created_by').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
deletedAt: deletedAt()
},
(t) => ({
byScope: index('wiki_by_scope').on(t.scopeType, t.scopeId)
// (company_id, scope_type, scope_id, slug) unique with NULLS NOT DISTINCT
// is created in the follow-up SQL migration.
})
);
export const wikiRevisions = pgTable(
'wiki_revisions',
{
id: pk(),
pageId: fk('page_id')
.notNull()
.references(() => wikiPages.id, { onDelete: 'cascade' }),
revision: integer('revision').notNull(),
title: varchar('title', { length: 255 }).notNull(),
bodyMd: text('body_md').notNull(),
// Maintained by trigger (declared as text, ALTERed to tsvector in follow-up).
bodyTsv: text('body_tsv'),
editedBy: fk('edited_by').references(() => users.id, { onDelete: 'set null' }),
editedAt: createdAt(),
comment: varchar('comment', { length: 500 })
},
(t) => ({
pageRevUq: uniqueIndex('wiki_rev_page_rev_uq').on(t.pageId, t.revision),
byPage: index('wiki_rev_by_page').on(t.pageId, t.revision)
})
);
export type WikiPage = typeof wikiPages.$inferSelect;
export type WikiRevision = typeof wikiRevisions.$inferSelect;
+29 -1
View File
@@ -10,6 +10,18 @@ const EnvSchema = z.object({
STORAGE_LOCAL_ROOT: z.string().default('./storage'),
STORAGE_SIGNING_SECRET: z.string().min(32, 'STORAGE_SIGNING_SECRET must be at least 32 characters'),
// S3 config (only required when STORAGE_BACKEND=s3). MinIO / self-hosted
// S3-compatibles: set S3_ENDPOINT and leave access keys in env.
S3_BUCKET: z.string().optional(),
S3_REGION: z.string().default('us-east-1'),
S3_ENDPOINT: z.string().url().optional().or(z.literal('')),
S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_FORCE_PATH_STYLE: z
.string()
.transform((v) => v === 'true')
.default('false'),
OIDC_ENABLED: z
.string()
.transform((v) => v === 'true')
@@ -17,7 +29,23 @@ const EnvSchema = z.object({
OIDC_ISSUER: z.string().url().optional().or(z.literal('')),
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_REDIRECT_URI: z.string().url().optional().or(z.literal(''))
OIDC_REDIRECT_URI: z.string().url().optional().or(z.literal('')),
// SMTP — if HOST/PORT/FROM is unset, email delivery is silently disabled.
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().optional(),
SMTP_SECURE: z
.string()
.transform((v) => v === 'true')
.default('false'),
// Matrix — if HOMESERVER or ACCESS_TOKEN is unset, Matrix delivery is disabled.
// Per-company room id comes from companies.settings.matrix_room_id.
MATRIX_HOMESERVER: z.string().url().optional().or(z.literal('')),
MATRIX_ACCESS_TOKEN: z.string().optional()
});
const parsed = EnvSchema.safeParse(process.env);
+31
View File
@@ -0,0 +1,31 @@
import { Marked } from 'marked';
// Inline HTML in markdown is treated as literal text — no raw HTML passes through.
// This is the cheap-and-cheerful sanitization story for an internal tool with
// trusted authors. Swap to DOMPurify+jsdom if/when the wiki opens to outsiders.
const marked = new Marked({ gfm: true, breaks: true });
marked.use({
renderer: {
// Render any inline/block HTML token as escaped text.
html(token) {
const raw = typeof token === 'string' ? token : (token as { text?: string; raw?: string }).text ?? (token as { raw?: string }).raw ?? '';
return escapeHtml(raw);
}
}
});
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function renderMarkdown(md: string): string {
if (!md) return '';
const html = marked.parse(md, { async: false }) as string;
// Belt-and-braces: drop any javascript: hrefs that survived.
return html.replace(/\s(href|src)\s*=\s*"javascript:[^"]*"/gi, ' $1="#"');
}
+48
View File
@@ -0,0 +1,48 @@
import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '$lib/server/env';
let cached: Transporter | null = null;
export function isEmailConfigured(): boolean {
return Boolean(env.SMTP_HOST && env.SMTP_PORT && env.SMTP_FROM);
}
function getTransport(): Transporter {
if (cached) return cached;
if (!isEmailConfigured()) {
throw new Error('SMTP is not configured');
}
cached = nodemailer.createTransport({
host: env.SMTP_HOST,
port: Number.parseInt(env.SMTP_PORT ?? '587', 10),
secure: env.SMTP_SECURE, // true for 465, false for 587/STARTTLS
auth: env.SMTP_USER && env.SMTP_PASS ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined
});
return cached;
}
export interface EmailInput {
to: string;
subject: string;
text: string;
html?: string;
}
/**
* Send an email via the configured SMTP. Silently no-ops if SMTP env is missing,
* logs errors instead of throwing so a dead MTA can't block a user action.
*/
export async function sendEmail(input: EmailInput): Promise<void> {
if (!isEmailConfigured()) return;
try {
await getTransport().sendMail({
from: env.SMTP_FROM,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html
});
} catch (err) {
console.warn('[email] send failed', { to: input.to, subject: input.subject, err: (err as Error).message });
}
}
+71
View File
@@ -0,0 +1,71 @@
import { env } from '$lib/server/env';
export function isMatrixConfigured(): boolean {
return Boolean(env.MATRIX_HOMESERVER && env.MATRIX_ACCESS_TOKEN);
}
/**
* Render a plain-text body + an HTML body where each mention is a matrix.to link.
* Matrix clients render the formatted_body when format=org.matrix.custom.html.
*/
function buildBodies(text: string, mentions: string[]): { text: string; html: string } {
const mentionPrefix = mentions.length > 0 ? mentions.join(' ') + ' · ' : '';
const plain = mentionPrefix + text;
const escapeHtml = (s: string) =>
s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const htmlMentions = mentions
.map((m) => `<a href="https://matrix.to/#/${encodeURIComponent(m)}">${escapeHtml(m)}</a>`)
.join(' ');
const html = (htmlMentions ? htmlMentions + ' · ' : '') + escapeHtml(text);
return { text: plain, html };
}
export interface MatrixSendInput {
roomId: string;
text: string;
mentions?: string[]; // Matrix user ids like @alice:server
}
/**
* Post a message to a Matrix room using a bot access token. Silently no-ops if
* Matrix is not configured; logs + swallows API errors so a dead homeserver
* doesn't block user actions.
*/
export async function sendMatrixMessage(input: MatrixSendInput): Promise<void> {
if (!isMatrixConfigured()) return;
if (!input.roomId) return;
const { text, html } = buildBodies(input.text, input.mentions ?? []);
const txnId = `b4l-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const url = `${env.MATRIX_HOMESERVER!.replace(/\/$/, '')}/_matrix/client/v3/rooms/${encodeURIComponent(input.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`;
const body: Record<string, unknown> = {
msgtype: 'm.text',
body: text,
format: 'org.matrix.custom.html',
formatted_body: html
};
if (input.mentions && input.mentions.length > 0) {
// Intentional mentions tell clients to push-notify these users.
body['m.mentions'] = { user_ids: input.mentions };
}
try {
const res = await fetch(url, {
method: 'PUT',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.MATRIX_ACCESS_TOKEN}`
},
body: JSON.stringify(body)
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
console.warn('[matrix] send failed', { status: res.status, detail: detail.slice(0, 500) });
}
} catch (err) {
console.warn('[matrix] fetch error', { err: (err as Error).message });
}
}
+126
View File
@@ -0,0 +1,126 @@
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import {
propertyAccounts,
type NewPropertyAccount,
type PropertyAccount
} from '$lib/server/db/schema/accounts';
import type { AccountKind } from '$lib/accounts';
export type { AccountKind };
async function assertProperty(companyId: string, propertyId: string): Promise<void> {
const [p] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.id, propertyId),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (!p) throw new Error('property not found');
}
export async function listAccounts(
companyId: string,
propertyId: string
): Promise<PropertyAccount[]> {
await assertProperty(companyId, propertyId);
return db
.select()
.from(propertyAccounts)
.where(eq(propertyAccounts.propertyId, propertyId))
.orderBy(asc(propertyAccounts.kind), asc(propertyAccounts.order), asc(propertyAccounts.label));
}
export async function createAccount(input: {
companyId: string;
propertyId: string;
kind: AccountKind;
provider?: string | null;
label?: string | null;
accountNumber?: string | null;
meterNumber?: string | null;
notes?: string | null;
}): Promise<{ id: string }> {
await assertProperty(input.companyId, input.propertyId);
const hasSomething =
!!input.accountNumber?.trim() || !!input.meterNumber?.trim() || !!input.provider?.trim();
if (!hasSomething) {
throw new Error('Provide at least a provider, account number, or meter number.');
}
const [{ next }] = await db
.select({ next: sql<number>`coalesce(max(${propertyAccounts.order}), -1) + 1` })
.from(propertyAccounts)
.where(
and(
eq(propertyAccounts.propertyId, input.propertyId),
eq(propertyAccounts.kind, input.kind)
)
);
const values: NewPropertyAccount = {
propertyId: input.propertyId,
kind: input.kind,
provider: input.provider?.trim() || null,
label: input.label?.trim() || null,
accountNumber: input.accountNumber?.trim() || null,
meterNumber: input.meterNumber?.trim() || null,
notes: input.notes?.trim() || null,
order: next ?? 0
};
const [row] = await db
.insert(propertyAccounts)
.values(values)
.returning({ id: propertyAccounts.id });
return row;
}
export async function updateAccount(
companyId: string,
id: string,
patch: {
kind?: AccountKind;
provider?: string | null;
label?: string | null;
accountNumber?: string | null;
meterNumber?: string | null;
notes?: string | null;
}
): Promise<void> {
// Tenant guard via join.
const [row] = await db
.select({ id: propertyAccounts.id })
.from(propertyAccounts)
.innerJoin(properties, eq(properties.id, propertyAccounts.propertyId))
.where(and(eq(propertyAccounts.id, id), eq(properties.companyId, companyId)))
.limit(1);
if (!row) throw new Error('account not found');
await db
.update(propertyAccounts)
.set({
...(patch.kind !== undefined && { kind: patch.kind }),
...(patch.provider !== undefined && { provider: patch.provider?.trim() || null }),
...(patch.label !== undefined && { label: patch.label?.trim() || null }),
...(patch.accountNumber !== undefined && {
accountNumber: patch.accountNumber?.trim() || null
}),
...(patch.meterNumber !== undefined && { meterNumber: patch.meterNumber?.trim() || null }),
...(patch.notes !== undefined && { notes: patch.notes?.trim() || null })
})
.where(eq(propertyAccounts.id, id));
}
export async function deleteAccount(companyId: string, id: string): Promise<void> {
const [row] = await db
.select({ id: propertyAccounts.id })
.from(propertyAccounts)
.innerJoin(properties, eq(properties.id, propertyAccounts.propertyId))
.where(and(eq(propertyAccounts.id, id), eq(properties.companyId, companyId)))
.limit(1);
if (!row) return;
await db.delete(propertyAccounts).where(eq(propertyAccounts.id, id));
}
+269
View File
@@ -0,0 +1,269 @@
import { and, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets, assetFieldDefs, assetTypes } from '$lib/server/db/schema/assets';
export type FieldType =
| 'text'
| 'textarea'
| 'int'
| 'float'
| 'bool'
| 'date'
| 'ip'
| 'cidr'
| 'mac'
| 'enum'
| 'multi_enum'
| 'url'
| 'email'
| 'asset_ref';
export function slugifyTypeSlug(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
}
export function normalizeFieldKey(s: string): string {
// snake_case, starts with a letter or underscore, ASCII only.
const cleaned = s
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
return cleaned.replace(/^[0-9]/, (d) => `_${d}`);
}
/** Confirm an asset type is editable by this company: system types (company_id IS NULL) are not. */
async function loadEditableType(
companyId: string,
assetTypeId: string
): Promise<typeof assetTypes.$inferSelect | null> {
const [row] = await db
.select()
.from(assetTypes)
.where(and(eq(assetTypes.id, assetTypeId), eq(assetTypes.companyId, companyId)))
.limit(1);
return row ?? null;
}
// --- types ------------------------------------------------------------------
export async function createCompanyAssetType(input: {
companyId: string;
name: string;
slug?: string | null;
icon?: string | null;
description?: string | null;
}): Promise<{ id: string }> {
const name = input.name.trim();
if (!name) throw new Error('name is required');
const slug = slugifyTypeSlug(input.slug?.trim() || name);
if (!slug) throw new Error('slug is empty after normalization');
const [row] = await db
.insert(assetTypes)
.values({
companyId: input.companyId,
name,
slug,
icon: input.icon?.trim() || null,
description: input.description?.trim() || null
})
.returning({ id: assetTypes.id });
return row;
}
export async function updateCompanyAssetType(
companyId: string,
assetTypeId: string,
patch: { name?: string; icon?: string | null; description?: string | null }
): Promise<void> {
const existing = await loadEditableType(companyId, assetTypeId);
if (!existing) throw new Error('asset type not found (or it is a system type)');
await db
.update(assetTypes)
.set({
...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.icon !== undefined && { icon: patch.icon?.trim() || null }),
...(patch.description !== undefined && { description: patch.description?.trim() || null })
})
.where(eq(assetTypes.id, assetTypeId));
}
export async function deleteCompanyAssetType(
companyId: string,
assetTypeId: string
): Promise<void> {
const existing = await loadEditableType(companyId, assetTypeId);
if (!existing) throw new Error('asset type not found (or it is a system type)');
// Guard: block if any asset still references this type.
const [{ n }] = await db
.select({ n: sql<number>`count(*)::int` })
.from(assets)
.where(
and(
eq(assets.assetTypeId, assetTypeId),
eq(assets.companyId, companyId),
isNull(assets.deletedAt)
)
);
if (n > 0) {
throw new Error(`Cannot delete: ${n} asset${n === 1 ? '' : 's'} of this type still exist.`);
}
await db.delete(assetTypes).where(eq(assetTypes.id, assetTypeId));
}
// --- field defs -------------------------------------------------------------
export interface FieldDefInput {
key: string;
label: string;
type: FieldType;
required?: boolean;
enumValues?: string[] | null;
unit?: string | null;
placeholder?: string | null;
helpText?: string | null;
}
export async function addFieldDef(
companyId: string,
assetTypeId: string,
input: FieldDefInput
): Promise<{ id: string }> {
const existing = await loadEditableType(companyId, assetTypeId);
if (!existing) throw new Error('asset type not found (or it is a system type)');
const key = normalizeFieldKey(input.key.trim() || input.label);
if (!key) throw new Error('field key is empty after normalization');
if (input.type === 'enum' || input.type === 'multi_enum') {
if (!input.enumValues || input.enumValues.length === 0) {
throw new Error('enum and multi_enum require at least one value');
}
}
const [{ next }] = await db
.select({ next: sql<number>`coalesce(max(${assetFieldDefs.order}), -1) + 1` })
.from(assetFieldDefs)
.where(eq(assetFieldDefs.assetTypeId, assetTypeId));
const [row] = await db
.insert(assetFieldDefs)
.values({
assetTypeId,
key,
label: input.label.trim(),
type: input.type,
required: input.required ?? false,
order: next ?? 0,
enumValues: input.enumValues && input.enumValues.length > 0 ? input.enumValues : null,
unit: input.unit?.trim() || null,
placeholder: input.placeholder?.trim() || null,
helpText: input.helpText?.trim() || null
})
.returning({ id: assetFieldDefs.id });
return row;
}
/**
* Edit a field def. `key` and `type` are intentionally NOT editable here —
* changing them against existing JSONB data would corrupt it. Renames go
* through a separate 2-step JSONB migration script.
*/
export async function updateFieldDef(
companyId: string,
fieldDefId: string,
patch: {
label?: string;
required?: boolean;
order?: number;
enumValues?: string[] | null;
unit?: string | null;
placeholder?: string | null;
helpText?: string | null;
deprecatedAt?: Date | null;
}
): Promise<void> {
// Tenant + editability guard: the parent type must belong to this company.
const [row] = await db
.select({ id: assetFieldDefs.id, type: assetFieldDefs.type })
.from(assetFieldDefs)
.innerJoin(assetTypes, eq(assetTypes.id, assetFieldDefs.assetTypeId))
.where(and(eq(assetFieldDefs.id, fieldDefId), eq(assetTypes.companyId, companyId)))
.limit(1);
if (!row) throw new Error('field def not found (or it belongs to a system type)');
if ((row.type === 'enum' || row.type === 'multi_enum') && patch.enumValues !== undefined) {
if (patch.enumValues === null || patch.enumValues.length === 0) {
throw new Error('enum and multi_enum require at least one value');
}
}
await db
.update(assetFieldDefs)
.set({
...(patch.label !== undefined && { label: patch.label.trim() }),
...(patch.required !== undefined && { required: patch.required }),
...(patch.order !== undefined && { order: patch.order }),
...(patch.enumValues !== undefined && { enumValues: patch.enumValues }),
...(patch.unit !== undefined && { unit: patch.unit?.trim() || null }),
...(patch.placeholder !== undefined && { placeholder: patch.placeholder?.trim() || null }),
...(patch.helpText !== undefined && { helpText: patch.helpText?.trim() || null }),
...(patch.deprecatedAt !== undefined && { deprecatedAt: patch.deprecatedAt })
})
.where(eq(assetFieldDefs.id, fieldDefId));
}
/**
* Remove a field def. If any asset still has a value under this key, we
* soft-deprecate instead of hard-delete so existing data stays valid.
* Pass `force: true` to hard-delete (caller is responsible for cleanup).
*/
export async function removeFieldDef(
companyId: string,
fieldDefId: string,
opts: { force?: boolean } = {}
): Promise<{ hardDeleted: boolean }> {
const [row] = await db
.select({
id: assetFieldDefs.id,
key: assetFieldDefs.key,
assetTypeId: assetFieldDefs.assetTypeId
})
.from(assetFieldDefs)
.innerJoin(assetTypes, eq(assetTypes.id, assetFieldDefs.assetTypeId))
.where(and(eq(assetFieldDefs.id, fieldDefId), eq(assetTypes.companyId, companyId)))
.limit(1);
if (!row) throw new Error('field def not found (or it belongs to a system type)');
// Count assets of this type that have data under this key.
const [{ n }] = await db
.select({ n: sql<number>`count(*)::int` })
.from(assets)
.where(
and(
eq(assets.assetTypeId, row.assetTypeId),
sql`${assets.customFields} ? ${row.key}`,
isNull(assets.deletedAt)
)
);
if (n > 0 && !opts.force) {
await db
.update(assetFieldDefs)
.set({ deprecatedAt: new Date() })
.where(eq(assetFieldDefs.id, fieldDefId));
return { hardDeleted: false };
}
await db.delete(assetFieldDefs).where(eq(assetFieldDefs.id, fieldDefId));
return { hardDeleted: true };
}
+349
View File
@@ -0,0 +1,349 @@
import { and, asc, desc, eq, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import {
assets,
assetFieldDefs,
assetLocationHistory,
assetLogs,
assetTypes,
type AssetFieldDef
} from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import { projects } from '$lib/server/db/schema/projects';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { assertRoomInProperty } from './rooms';
import { getCachedCustomFieldsSchema } from '$lib/server/validation/custom-fields';
export type ContainerKind = 'project' | 'property';
export interface AssetCreateInput {
companyId: string;
assetTypeId: string;
name: string;
tag?: string | null;
serialNumber?: string | null;
manufacturer?: string | null;
model?: string | null;
purchasedAt?: Date | null;
containerKind: ContainerKind;
containerId: string; // project or property id
roomId?: string | null; // only valid when containerKind='property'
customFields: Record<string, unknown>;
createdBy: string;
}
export interface AssetUpdateInput {
name?: string;
tag?: string | null;
serialNumber?: string | null;
manufacturer?: string | null;
model?: string | null;
purchasedAt?: Date | null;
customFields?: Record<string, unknown>;
// Only meaningful when the asset is at a property. Pass null to clear the
// room assignment; omit the field to leave it unchanged.
roomId?: string | null;
}
export interface AssetMoveInput {
toKind: ContainerKind;
toId: string;
movedBy: string;
reason?: string | null;
// Optional room assignment when toKind='property'. Ignored for project moves.
toRoomId?: string | null;
}
/** Fetch all field defs for an asset type, plus the type row itself. */
export async function loadTypeWithFields(assetTypeId: string): Promise<{
type: typeof assetTypes.$inferSelect;
fields: AssetFieldDef[];
} | null> {
const [type] = await db.select().from(assetTypes).where(eq(assetTypes.id, assetTypeId)).limit(1);
if (!type) return null;
const fields = await db
.select()
.from(assetFieldDefs)
.where(eq(assetFieldDefs.assetTypeId, assetTypeId))
.orderBy(asc(assetFieldDefs.order));
return { type, fields };
}
/** Validate the JSONB custom_fields blob against the type's field defs. */
export async function validateCustomFields(
assetTypeId: string,
input: Record<string, unknown>
): Promise<Record<string, unknown>> {
const tf = await loadTypeWithFields(assetTypeId);
if (!tf) throw new Error('asset type not found');
const schema = getCachedCustomFieldsSchema(assetTypeId, tf.type.schemaVersion, tf.fields);
return schema.parse(input) as Record<string, unknown>;
}
/** Container existence + tenant guard. Returns true if the container belongs to companyId. */
async function assertContainer(
companyId: string,
kind: ContainerKind,
id: string
): Promise<void> {
if (kind === 'property') {
const [row] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.id, id),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (!row) throw new Error('property not found in this company');
return;
}
const [row] = await db
.select({ id: projects.id })
.from(projects)
.where(
and(eq(projects.id, id), eq(projects.companyId, companyId), isNull(projects.deletedAt))
)
.limit(1);
if (!row) throw new Error('project not found in this company');
}
export async function createAsset(input: AssetCreateInput): Promise<{ id: string }> {
const cleanFields = await validateCustomFields(input.assetTypeId, input.customFields);
await assertContainer(input.companyId, input.containerKind, input.containerId);
let roomId: string | null = null;
if (input.roomId) {
if (input.containerKind !== 'property') {
throw new Error('room can only be set when the asset is at a property');
}
await assertRoomInProperty(input.companyId, input.roomId, input.containerId);
roomId = input.roomId;
}
return db.transaction(async (tx) => {
const [created] = await tx
.insert(assets)
.values({
companyId: input.companyId,
assetTypeId: input.assetTypeId,
name: input.name,
tag: input.tag ?? null,
serialNumber: input.serialNumber ?? null,
manufacturer: input.manufacturer ?? null,
model: input.model ?? null,
purchasedAt: input.purchasedAt ?? null,
currentContainerKind: input.containerKind,
currentProjectId: input.containerKind === 'project' ? input.containerId : null,
currentPropertyId: input.containerKind === 'property' ? input.containerId : null,
currentRoomId: roomId,
customFields: cleanFields,
createdBy: input.createdBy
})
.returning({ id: assets.id });
// Initial history row so the timeline starts at "first placed here".
await tx.insert(assetLocationHistory).values({
assetId: created.id,
fromKind: null,
fromProjectId: null,
fromPropertyId: null,
toKind: input.containerKind,
toProjectId: input.containerKind === 'project' ? input.containerId : null,
toPropertyId: input.containerKind === 'property' ? input.containerId : null,
movedBy: input.createdBy,
reason: 'initial placement'
});
return { id: created.id };
});
}
export async function updateAsset(
companyId: string,
id: string,
patch: AssetUpdateInput
): Promise<void> {
const [existing] = await db
.select()
.from(assets)
.where(and(eq(assets.id, id), eq(assets.companyId, companyId), isNull(assets.deletedAt)))
.limit(1);
if (!existing) throw new Error('asset not found');
let cleanFields: Record<string, unknown> | undefined;
if (patch.customFields) {
cleanFields = await validateCustomFields(existing.assetTypeId, patch.customFields);
}
// Room patch: null clears, uuid validates against the current property.
let roomSet: { currentRoomId: string | null } | undefined;
if (patch.roomId !== undefined) {
if (patch.roomId === null) {
roomSet = { currentRoomId: null };
} else {
if (existing.currentContainerKind !== 'property' || !existing.currentPropertyId) {
throw new Error('room can only be set when the asset is at a property');
}
await assertRoomInProperty(companyId, patch.roomId, existing.currentPropertyId);
roomSet = { currentRoomId: patch.roomId };
}
}
await db
.update(assets)
.set({
...(patch.name !== undefined && { name: patch.name }),
...(patch.tag !== undefined && { tag: patch.tag }),
...(patch.serialNumber !== undefined && { serialNumber: patch.serialNumber }),
...(patch.manufacturer !== undefined && { manufacturer: patch.manufacturer }),
...(patch.model !== undefined && { model: patch.model }),
...(patch.purchasedAt !== undefined && { purchasedAt: patch.purchasedAt }),
...(cleanFields !== undefined && { customFields: cleanFields }),
...(roomSet ?? {})
})
.where(eq(assets.id, id));
}
export async function moveAsset(
companyId: string,
assetId: string,
move: AssetMoveInput
): Promise<void> {
const [a] = await db
.select()
.from(assets)
.where(and(eq(assets.id, assetId), eq(assets.companyId, companyId), isNull(assets.deletedAt)))
.limit(1);
if (!a) throw new Error('asset not found');
await assertContainer(companyId, move.toKind, move.toId);
let toRoomId: string | null = null;
if (move.toRoomId) {
if (move.toKind !== 'property') {
throw new Error('room can only be set when moving to a property');
}
await assertRoomInProperty(companyId, move.toRoomId, move.toId);
toRoomId = move.toRoomId;
}
const sameLocation =
a.currentContainerKind === move.toKind &&
((move.toKind === 'property' && a.currentPropertyId === move.toId) ||
(move.toKind === 'project' && a.currentProjectId === move.toId)) &&
(a.currentRoomId ?? null) === toRoomId;
if (sameLocation) return;
await db.transaction(async (tx) => {
await tx.insert(assetLocationHistory).values({
assetId,
fromKind: a.currentContainerKind,
fromProjectId: a.currentProjectId,
fromPropertyId: a.currentPropertyId,
toKind: move.toKind,
toProjectId: move.toKind === 'project' ? move.toId : null,
toPropertyId: move.toKind === 'property' ? move.toId : null,
movedBy: move.movedBy,
reason: move.reason ?? null
});
await tx
.update(assets)
.set({
currentContainerKind: move.toKind,
currentProjectId: move.toKind === 'project' ? move.toId : null,
currentPropertyId: move.toKind === 'property' ? move.toId : null,
// Moving away from a property clears the room; moving between
// properties without a new room also clears it.
currentRoomId: move.toKind === 'property' ? toRoomId : null
})
.where(eq(assets.id, assetId));
});
}
export async function softDeleteAsset(companyId: string, assetId: string): Promise<void> {
await db
.update(assets)
.set({ deletedAt: sql`now()` })
.where(and(eq(assets.id, assetId), eq(assets.companyId, companyId)));
}
export async function appendAssetLog(
companyId: string,
assetId: string,
authorId: string,
body: string
): Promise<void> {
const [a] = await db
.select({ id: assets.id })
.from(assets)
.where(and(eq(assets.id, assetId), eq(assets.companyId, companyId)))
.limit(1);
if (!a) throw new Error('asset not found');
if (!body.trim()) return;
await db.insert(assetLogs).values({ assetId, authorId, body: body.trim() });
}
/** Listing helper for /assets and /properties/[id]. */
export interface AssetListOptions {
companyId: string;
typeSlug?: string;
propertyId?: string;
projectId?: string;
roomId?: string;
q?: string;
limit?: number;
offset?: number;
}
export async function listAssets(opts: AssetListOptions) {
const where = [eq(assets.companyId, opts.companyId), isNull(assets.deletedAt)];
if (opts.propertyId) where.push(eq(assets.currentPropertyId, opts.propertyId));
if (opts.projectId) where.push(eq(assets.currentProjectId, opts.projectId));
if (opts.roomId) where.push(eq(assets.currentRoomId, opts.roomId));
if (opts.typeSlug) {
where.push(
sql`exists (select 1 from ${assetTypes} t where t.id = ${assets.assetTypeId} and t.slug = ${opts.typeSlug})`
);
}
if (opts.q && opts.q.trim()) {
const q = `%${opts.q.trim()}%`;
where.push(
or(
sql`${assets.name} ilike ${q}`,
sql`${assets.tag} ilike ${q}`,
sql`${assets.serialNumber} ilike ${q}`
)!
);
}
return db
.select({
id: assets.id,
name: assets.name,
tag: assets.tag,
serialNumber: assets.serialNumber,
manufacturer: assets.manufacturer,
model: assets.model,
currentContainerKind: assets.currentContainerKind,
currentPropertyId: assets.currentPropertyId,
currentRoomId: assets.currentRoomId,
roomName: propertyRooms.name,
floorLabel: propertyFloors.label,
assetTypeId: assets.assetTypeId,
assetTypeSlug: assetTypes.slug,
assetTypeName: assetTypes.name,
updatedAt: assets.updatedAt
})
.from(assets)
.innerJoin(assetTypes, eq(assetTypes.id, assets.assetTypeId))
.leftJoin(propertyRooms, eq(propertyRooms.id, assets.currentRoomId))
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.where(and(...where))
.orderBy(desc(assets.updatedAt))
.limit(opts.limit ?? 100)
.offset(opts.offset ?? 0);
}
+254
View File
@@ -0,0 +1,254 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import {
checklistInstances,
checklistItems,
checklistTemplateItems,
checklistTemplates,
type ChecklistTemplate,
type ChecklistTemplateItem
} from '$lib/server/db/schema/checklists';
export type ChecklistScope = 'task' | 'subtask' | 'maintenance_event' | 'ad_hoc';
// --- templates ---------------------------------------------------------------
export async function listTemplates(companyId: string): Promise<
(ChecklistTemplate & { itemCount: number })[]
> {
return db
.select({
id: checklistTemplates.id,
companyId: checklistTemplates.companyId,
name: checklistTemplates.name,
description: checklistTemplates.description,
createdBy: checklistTemplates.createdBy,
createdAt: checklistTemplates.createdAt,
updatedAt: checklistTemplates.updatedAt,
itemCount: sql<number>`(
select count(*)::int from ${checklistTemplateItems}
where ${checklistTemplateItems.templateId} = ${checklistTemplates.id}
)`
})
.from(checklistTemplates)
.where(eq(checklistTemplates.companyId, companyId))
.orderBy(asc(checklistTemplates.name));
}
export async function getTemplate(
companyId: string,
id: string
): Promise<{ template: ChecklistTemplate; items: ChecklistTemplateItem[] } | null> {
const [t] = await db
.select()
.from(checklistTemplates)
.where(and(eq(checklistTemplates.id, id), eq(checklistTemplates.companyId, companyId)))
.limit(1);
if (!t) return null;
const items = await db
.select()
.from(checklistTemplateItems)
.where(eq(checklistTemplateItems.templateId, id))
.orderBy(asc(checklistTemplateItems.order));
return { template: t, items };
}
export async function createTemplate(input: {
companyId: string;
createdBy: string;
name: string;
description?: string | null;
}): Promise<{ id: string }> {
const [row] = await db
.insert(checklistTemplates)
.values({
companyId: input.companyId,
createdBy: input.createdBy,
name: input.name.trim(),
description: input.description ?? null
})
.returning({ id: checklistTemplates.id });
return row;
}
export async function updateTemplate(
companyId: string,
id: string,
patch: { name?: string; description?: string | null }
): Promise<void> {
await db
.update(checklistTemplates)
.set({
...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.description !== undefined && { description: patch.description })
})
.where(and(eq(checklistTemplates.id, id), eq(checklistTemplates.companyId, companyId)));
}
export async function deleteTemplate(companyId: string, id: string): Promise<void> {
await db
.delete(checklistTemplates)
.where(and(eq(checklistTemplates.id, id), eq(checklistTemplates.companyId, companyId)));
}
export async function addTemplateItem(
companyId: string,
templateId: string,
text: string,
required = false
): Promise<void> {
const tpl = await getTemplate(companyId, templateId);
if (!tpl) throw new Error('template not found');
const order = tpl.items.length;
await db.insert(checklistTemplateItems).values({
templateId,
text: text.trim(),
required,
order
});
}
export async function removeTemplateItem(
companyId: string,
templateId: string,
itemId: string
): Promise<void> {
const tpl = await getTemplate(companyId, templateId);
if (!tpl) throw new Error('template not found');
await db.delete(checklistTemplateItems).where(eq(checklistTemplateItems.id, itemId));
}
// --- instances ---------------------------------------------------------------
export async function instantiateChecklist(input: {
companyId: string;
createdBy: string;
scopeType: ChecklistScope;
scopeId: string | null;
templateId?: string | null;
title?: string | null;
}): Promise<{ id: string }> {
return db.transaction(async (tx) => {
let title = input.title ?? null;
let templateItems: ChecklistTemplateItem[] = [];
if (input.templateId) {
const [t] = await tx
.select()
.from(checklistTemplates)
.where(
and(
eq(checklistTemplates.id, input.templateId),
eq(checklistTemplates.companyId, input.companyId)
)
)
.limit(1);
if (!t) throw new Error('template not found');
if (!title) title = t.name;
templateItems = await tx
.select()
.from(checklistTemplateItems)
.where(eq(checklistTemplateItems.templateId, t.id))
.orderBy(asc(checklistTemplateItems.order));
}
const [inst] = await tx
.insert(checklistInstances)
.values({
companyId: input.companyId,
templateId: input.templateId ?? null,
scopeType: input.scopeType,
scopeId: input.scopeId,
title,
createdBy: input.createdBy
})
.returning({ id: checklistInstances.id });
if (templateItems.length > 0) {
await tx.insert(checklistItems).values(
templateItems.map((ti) => ({
instanceId: inst.id,
text: ti.text,
required: ti.required,
order: ti.order
}))
);
}
return { id: inst.id };
});
}
export async function getInstance(
companyId: string,
id: string
): Promise<{
instance: typeof checklistInstances.$inferSelect;
items: (typeof checklistItems.$inferSelect)[];
} | null> {
const [inst] = await db
.select()
.from(checklistInstances)
.where(and(eq(checklistInstances.id, id), eq(checklistInstances.companyId, companyId)))
.limit(1);
if (!inst) return null;
const items = await db
.select()
.from(checklistItems)
.where(eq(checklistItems.instanceId, id))
.orderBy(asc(checklistItems.order));
return { instance: inst, items };
}
export async function setItemDone(
companyId: string,
instanceId: string,
itemId: string,
done: boolean,
doneByUserId: string,
note?: string | null
): Promise<void> {
const inst = await getInstance(companyId, instanceId);
if (!inst) throw new Error('instance not found');
await db
.update(checklistItems)
.set({
done,
doneAt: done ? new Date() : null,
doneBy: done ? doneByUserId : null,
...(note !== undefined && { note })
})
.where(and(eq(checklistItems.id, itemId), eq(checklistItems.instanceId, instanceId)));
}
export async function completeInstance(
companyId: string,
instanceId: string
): Promise<void> {
await db
.update(checklistInstances)
.set({ completedAt: sql`now()` })
.where(
and(
eq(checklistInstances.id, instanceId),
eq(checklistInstances.companyId, companyId),
isNull(checklistInstances.completedAt)
)
);
}
export async function listInstancesForScope(
companyId: string,
scopeType: ChecklistScope,
scopeId: string
) {
return db
.select()
.from(checklistInstances)
.where(
and(
eq(checklistInstances.companyId, companyId),
eq(checklistInstances.scopeType, scopeType),
eq(checklistInstances.scopeId, scopeId)
)
)
.orderBy(desc(checklistInstances.createdAt));
}
+62
View File
@@ -0,0 +1,62 @@
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { companies, companyUsers } from '$lib/server/db/schema/tenancy';
function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 128);
}
export async function getCompany(id: string) {
const [row] = await db
.select()
.from(companies)
.where(and(eq(companies.id, id), isNull(companies.deletedAt)))
.limit(1);
return row ?? null;
}
export async function updateCompany(
id: string,
patch: { name?: string; slug?: string; settings?: string | null }
): Promise<void> {
const update: Record<string, unknown> = {};
if (patch.name !== undefined) update.name = patch.name.trim();
if (patch.slug !== undefined) {
const clean = slugify(patch.slug);
if (!clean) throw new Error('slug is empty after normalization');
update.slug = clean;
}
if (patch.settings !== undefined) update.settings = patch.settings;
await db.update(companies).set(update).where(eq(companies.id, id));
}
export async function createCompanyWithAdmin(input: {
name: string;
slug?: string | null;
settings?: string | null;
creatorUserId: string;
}): Promise<{ id: string }> {
const name = input.name.trim();
if (!name) throw new Error('name is required');
const slug = slugify(input.slug?.trim() || name);
if (!slug) throw new Error('slug is empty after normalization');
return db.transaction(async (tx) => {
const [row] = await tx
.insert(companies)
.values({ name, slug, settings: input.settings ?? null })
.returning({ id: companies.id });
await tx.insert(companyUsers).values({
companyId: row.id,
userId: input.creatorUserId,
role: 'admin'
});
return row;
});
}
+125
View File
@@ -0,0 +1,125 @@
import { and, desc, eq, inArray, isNull, ne } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { decisionEvents, type NewDecisionEvent } from '$lib/server/db/schema/decisions';
import { companyUsers, users } from '$lib/server/db/schema/tenancy';
import { notify } from './notifications';
export type DecisionScope = 'project' | 'property' | 'asset' | 'work_package';
export interface DecisionCreateInput {
companyId: string;
decidedBy: string;
scopeType: DecisionScope;
scopeId: string;
title: string;
bodyMd: string;
alternativesConsidered?: string | null;
costImpact?: number | null;
currency?: string | null;
approvedBy?: string | null;
decidedAt: Date;
tags?: string[] | null;
}
export async function createDecision(
input: DecisionCreateInput
): Promise<{ id: string }> {
const values: NewDecisionEvent = {
companyId: input.companyId,
scopeType: input.scopeType,
scopeId: input.scopeId,
title: input.title.trim(),
bodyMd: input.bodyMd,
alternativesConsidered: input.alternativesConsidered ?? null,
costImpact: input.costImpact != null ? String(input.costImpact) : null,
currency: input.currency ? input.currency.toUpperCase() : null,
approvedBy: input.approvedBy ?? null,
decidedAt: input.decidedAt,
decidedBy: input.decidedBy,
tags: input.tags && input.tags.length > 0 ? input.tags : null
};
const [row] = await db
.insert(decisionEvents)
.values(values)
.returning({ id: decisionEvents.id });
// Notify all admins + managers in the company except the decider themselves.
const recipients = await db
.select({ userId: companyUsers.userId })
.from(companyUsers)
.innerJoin(users, eq(users.id, companyUsers.userId))
.where(
and(
eq(companyUsers.companyId, input.companyId),
inArray(companyUsers.role, ['admin', 'manager']),
eq(users.isActive, true),
ne(companyUsers.userId, input.decidedBy)
)
);
if (recipients.length > 0) {
const link = decisionScopeLink(input.scopeType, input.scopeId);
void notify({
companyId: input.companyId,
userIds: recipients.map((r) => r.userId),
kind: 'decision_created',
title: `Decision logged: ${input.title.trim()}`,
body: input.bodyMd.slice(0, 500),
link
});
}
return row;
}
function decisionScopeLink(scopeType: DecisionScope, scopeId: string): string | null {
switch (scopeType) {
case 'project':
return `/projects/${scopeId}/decisions`;
case 'property':
return `/properties/${scopeId}`;
case 'asset':
return `/assets/${scopeId}`;
case 'work_package':
// work_packages don't have their own decisions page yet — link to the project.
return null;
}
}
export async function listDecisionsForScope(
companyId: string,
scopeType: DecisionScope,
scopeId: string
) {
return db
.select({
id: decisionEvents.id,
title: decisionEvents.title,
bodyMd: decisionEvents.bodyMd,
alternativesConsidered: decisionEvents.alternativesConsidered,
costImpact: decisionEvents.costImpact,
currency: decisionEvents.currency,
tags: decisionEvents.tags,
decidedAt: decisionEvents.decidedAt,
decidedByName: users.displayName,
approvedById: decisionEvents.approvedBy,
createdAt: decisionEvents.createdAt
})
.from(decisionEvents)
.leftJoin(users, eq(users.id, decisionEvents.decidedBy))
.where(
and(
eq(decisionEvents.companyId, companyId),
eq(decisionEvents.scopeType, scopeType),
eq(decisionEvents.scopeId, scopeId),
isNull(decisionEvents.deletedAt)
)
)
.orderBy(desc(decisionEvents.decidedAt));
}
export async function softDeleteDecision(companyId: string, id: string): Promise<void> {
await db
.update(decisionEvents)
.set({ deletedAt: new Date() })
.where(and(eq(decisionEvents.id, id), eq(decisionEvents.companyId, companyId)));
}
+123
View File
@@ -0,0 +1,123 @@
import { and, desc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { documents } from '$lib/server/db/schema/documents';
import { properties } from '$lib/server/db/schema/properties';
import { assets } from '$lib/server/db/schema/assets';
import { getStorage } from '$lib/server/storage';
export type DocScope = 'project' | 'property' | 'asset' | 'work_package' | 'decision_event';
export interface UploadInput {
companyId: string;
uploadedBy: string;
scopeType: DocScope;
scopeId: string;
filename: string;
mimeType: string;
body: Buffer;
}
async function assertScope(
companyId: string,
scopeType: DocScope,
scopeId: string
): Promise<void> {
if (scopeType === 'property') {
const [p] = await db
.select({ id: properties.id })
.from(properties)
.where(and(eq(properties.id, scopeId), eq(properties.companyId, companyId)))
.limit(1);
if (!p) throw new Error('property not found in this company');
return;
}
if (scopeType === 'asset') {
const [a] = await db
.select({ id: assets.id })
.from(assets)
.where(and(eq(assets.id, scopeId), eq(assets.companyId, companyId)))
.limit(1);
if (!a) throw new Error('asset not found in this company');
return;
}
// project / work_package / decision_event are added in later phases
throw new Error(`scope type '${scopeType}' is not available yet`);
}
export async function uploadDocument(input: UploadInput): Promise<{ id: string }> {
await assertScope(input.companyId, input.scopeType, input.scopeId);
const storage = getStorage();
const key = storage.generateKey(input.filename);
const put = await storage.put({
key,
body: input.body,
contentType: input.mimeType
});
const [row] = await db
.insert(documents)
.values({
companyId: input.companyId,
scopeType: input.scopeType,
scopeId: input.scopeId,
filename: input.filename,
mimeType: input.mimeType,
sizeBytes: put.sizeBytes,
sha256: put.sha256,
storageKey: put.key,
uploadedBy: input.uploadedBy
})
.returning({ id: documents.id });
return row;
}
export async function listDocumentsForScope(
companyId: string,
scopeType: DocScope,
scopeId: string
) {
return db
.select()
.from(documents)
.where(
and(
eq(documents.companyId, companyId),
eq(documents.scopeType, scopeType),
eq(documents.scopeId, scopeId)
)
)
.orderBy(desc(documents.uploadedAt));
}
export async function getDocument(companyId: string, id: string) {
const [row] = await db
.select()
.from(documents)
.where(and(eq(documents.id, id), eq(documents.companyId, companyId)))
.limit(1);
return row ?? null;
}
export async function deleteDocument(companyId: string, id: string): Promise<void> {
const doc = await getDocument(companyId, id);
if (!doc) return;
const storage = getStorage();
await storage.delete(doc.storageKey).catch((err) => {
// Storage delete failure should not block DB cleanup; surface as warning.
console.warn('storage.delete failed', { key: doc.storageKey, err });
});
await db.delete(documents).where(eq(documents.id, id));
}
export async function signedUrlForDocument(
doc: { storageKey: string; mimeType: string; filename: string },
disposition: 'inline' | 'attachment' = 'inline'
): Promise<string> {
const storage = getStorage();
return storage.getSignedUrl(doc.storageKey, {
expiresInSeconds: 300,
disposition,
filename: doc.filename
});
}
+367
View File
@@ -0,0 +1,367 @@
import { addDays, addHours, addMonths, addYears } from 'date-fns';
import { and, asc, desc, eq, isNotNull, lte, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import {
maintenanceEvents,
maintenanceSchedules,
usageReadings,
type NewMaintenanceSchedule
} from '$lib/server/db/schema/maintenance';
import { instantiateChecklist } from './checklists';
export type ScheduleKind = 'time' | 'usage';
export type IntervalUnit = 'days' | 'months' | 'years' | 'hours' | 'cycles' | 'km';
const TIME_UNITS = new Set<IntervalUnit>(['days', 'months', 'years', 'hours']);
const USAGE_UNITS = new Set<IntervalUnit>(['hours', 'cycles', 'km']);
function addInterval(from: Date, value: number, unit: IntervalUnit): Date {
switch (unit) {
case 'days':
return addDays(from, value);
case 'months':
return addMonths(from, value);
case 'years':
return addYears(from, value);
case 'hours':
return addHours(from, value);
default:
throw new Error(`unit ${unit} is not a time unit`);
}
}
async function assertAsset(companyId: string, assetId: string): Promise<void> {
const [a] = await db
.select({ id: assets.id })
.from(assets)
.where(and(eq(assets.id, assetId), eq(assets.companyId, companyId)))
.limit(1);
if (!a) throw new Error('asset not found');
}
// --- schedules ---------------------------------------------------------------
export interface ScheduleCreateInput {
companyId: string;
createdBy: string;
assetId: string;
name: string;
kind: ScheduleKind;
intervalValue: number;
intervalUnit: IntervalUnit;
startFrom?: Date | null; // for time: anchor for nextDueAt
startUsage?: number | null; // for usage: current reading anchor
checklistTemplateId?: string | null;
notes?: string | null;
}
export async function createSchedule(input: ScheduleCreateInput): Promise<{ id: string }> {
await assertAsset(input.companyId, input.assetId);
if (input.kind === 'time' && !TIME_UNITS.has(input.intervalUnit)) {
throw new Error(`Time-based schedule needs a time interval unit, got ${input.intervalUnit}`);
}
if (input.kind === 'usage' && !USAGE_UNITS.has(input.intervalUnit)) {
throw new Error(`Usage-based schedule needs a usage interval unit, got ${input.intervalUnit}`);
}
const values: NewMaintenanceSchedule = {
assetId: input.assetId,
name: input.name.trim(),
kind: input.kind,
intervalValue: input.intervalValue,
intervalUnit: input.intervalUnit,
lastServicedAt: null,
nextDueAt: null,
nextDueUsage: null,
checklistTemplateId: input.checklistTemplateId ?? null,
active: true,
notes: input.notes ?? null,
createdBy: input.createdBy
};
if (input.kind === 'time') {
const anchor = input.startFrom ?? new Date();
values.nextDueAt = addInterval(anchor, input.intervalValue, input.intervalUnit);
} else {
const anchor = input.startUsage ?? 0;
values.nextDueUsage = String(anchor + input.intervalValue);
}
const [row] = await db
.insert(maintenanceSchedules)
.values(values)
.returning({ id: maintenanceSchedules.id });
return row;
}
export async function listSchedulesForAsset(companyId: string, assetId: string) {
await assertAsset(companyId, assetId);
return db
.select()
.from(maintenanceSchedules)
.where(eq(maintenanceSchedules.assetId, assetId))
.orderBy(desc(maintenanceSchedules.active), asc(maintenanceSchedules.nextDueAt));
}
export async function getSchedule(companyId: string, id: string) {
const [row] = await db
.select({
schedule: maintenanceSchedules,
assetId: maintenanceSchedules.assetId
})
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.where(and(eq(maintenanceSchedules.id, id), eq(assets.companyId, companyId)))
.limit(1);
return row ? row.schedule : null;
}
export async function setScheduleActive(
companyId: string,
scheduleId: string,
active: boolean
): Promise<void> {
const sched = await getSchedule(companyId, scheduleId);
if (!sched) throw new Error('schedule not found');
await db
.update(maintenanceSchedules)
.set({ active })
.where(eq(maintenanceSchedules.id, scheduleId));
}
export async function deleteSchedule(companyId: string, scheduleId: string): Promise<void> {
const sched = await getSchedule(companyId, scheduleId);
if (!sched) throw new Error('schedule not found');
await db.delete(maintenanceSchedules).where(eq(maintenanceSchedules.id, scheduleId));
}
// --- usage readings ----------------------------------------------------------
export async function recordUsageReading(input: {
companyId: string;
recordedBy: string;
assetId: string;
reading: number;
unit: IntervalUnit;
notes?: string | null;
}): Promise<{ id: string }> {
await assertAsset(input.companyId, input.assetId);
if (!USAGE_UNITS.has(input.unit)) {
throw new Error(`Usage reading must use a usage unit, got ${input.unit}`);
}
const [row] = await db
.insert(usageReadings)
.values({
assetId: input.assetId,
reading: String(input.reading),
unit: input.unit,
recordedBy: input.recordedBy,
notes: input.notes ?? null
})
.returning({ id: usageReadings.id });
return row;
}
export async function latestUsageReading(
assetId: string,
unit: IntervalUnit
): Promise<number | null> {
const [row] = await db
.select({ reading: usageReadings.reading })
.from(usageReadings)
.where(and(eq(usageReadings.assetId, assetId), eq(usageReadings.unit, unit)))
.orderBy(desc(usageReadings.recordedAt))
.limit(1);
if (!row) return null;
const n = Number(row.reading);
return Number.isFinite(n) ? n : null;
}
// --- events ------------------------------------------------------------------
export interface EventRecordInput {
companyId: string;
performedBy: string;
scheduleId: string;
performedAt?: Date;
notes?: string | null;
usageReading?: number | null; // required for kind=usage
instantiateChecklist?: boolean; // default true if schedule has a checklist_template_id
}
/**
* Record a maintenance event:
* 1. inserts maintenance_events row
* 2. (optional) instantiates the schedule's checklist template
* 3. advances the schedule's last_serviced_at + next_due_at / next_due_usage
* 4. (kind=usage) writes a usage_readings row from the supplied usage_reading
*/
export async function recordMaintenanceEvent(input: EventRecordInput): Promise<{
eventId: string;
checklistInstanceId: string | null;
}> {
const sched = await getSchedule(input.companyId, input.scheduleId);
if (!sched) throw new Error('schedule not found');
if (!sched.active) throw new Error('schedule is inactive');
const performedAt = input.performedAt ?? new Date();
const wantChecklist =
input.instantiateChecklist ?? sched.checklistTemplateId !== null;
if (sched.kind === 'usage') {
if (input.usageReading === null || input.usageReading === undefined) {
throw new Error('usage_reading is required when completing a usage-based schedule');
}
}
return db.transaction(async (tx) => {
// 1. checklist instance (if any)
let checklistInstanceId: string | null = null;
if (wantChecklist && sched.checklistTemplateId) {
const inst = await instantiateChecklist({
companyId: input.companyId,
createdBy: input.performedBy,
scopeType: 'maintenance_event',
scopeId: null, // filled in below after we have the event id
templateId: sched.checklistTemplateId,
title: sched.name
});
checklistInstanceId = inst.id;
}
// 2. event row
const [evt] = await tx
.insert(maintenanceEvents)
.values({
assetId: sched.assetId,
scheduleId: sched.id,
performedAt,
performedBy: input.performedBy,
notes: input.notes ?? null,
usageReading: input.usageReading != null ? String(input.usageReading) : null,
checklistInstanceId
})
.returning({ id: maintenanceEvents.id });
// 3. wire the checklist's scope_id to the event
if (checklistInstanceId) {
await tx.execute(
sql`update checklist_instances set scope_id = ${evt.id} where id = ${checklistInstanceId}`
);
}
// 4. usage_readings row for usage schedules
if (sched.kind === 'usage' && input.usageReading != null) {
await tx.insert(usageReadings).values({
assetId: sched.assetId,
reading: String(input.usageReading),
unit: sched.intervalUnit,
recordedBy: input.performedBy,
notes: 'auto-recorded from maintenance event'
});
}
// 5. advance the schedule
const update: Partial<typeof maintenanceSchedules.$inferInsert> = {
lastServicedAt: performedAt
};
if (sched.kind === 'time') {
update.nextDueAt = addInterval(performedAt, sched.intervalValue, sched.intervalUnit);
} else {
const next = (input.usageReading ?? 0) + sched.intervalValue;
update.nextDueUsage = String(next);
}
await tx
.update(maintenanceSchedules)
.set(update)
.where(eq(maintenanceSchedules.id, sched.id));
return { eventId: evt.id, checklistInstanceId };
});
}
export async function listEventsForAsset(companyId: string, assetId: string) {
await assertAsset(companyId, assetId);
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
})
.from(maintenanceEvents)
.leftJoin(maintenanceSchedules, eq(maintenanceSchedules.id, maintenanceEvents.scheduleId))
.where(eq(maintenanceEvents.assetId, assetId))
.orderBy(desc(maintenanceEvents.performedAt))
.limit(200);
}
export async function listUsageReadingsForAsset(companyId: string, assetId: string) {
await assertAsset(companyId, assetId);
return db
.select()
.from(usageReadings)
.where(eq(usageReadings.assetId, assetId))
.orderBy(desc(usageReadings.recordedAt))
.limit(200);
}
// --- dashboard / overview ----------------------------------------------------
export interface OverdueOpts {
companyId: string;
limit?: number;
upcomingDays?: number; // include schedules due within this window
}
export async function listDueAndOverdue(opts: OverdueOpts) {
const cutoff = new Date(
Date.now() + (opts.upcomingDays ?? 30) * 24 * 60 * 60 * 1000
);
return db
.select({
scheduleId: maintenanceSchedules.id,
scheduleName: maintenanceSchedules.name,
assetId: maintenanceSchedules.assetId,
assetName: assets.name,
nextDueAt: maintenanceSchedules.nextDueAt,
intervalValue: maintenanceSchedules.intervalValue,
intervalUnit: maintenanceSchedules.intervalUnit
})
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.where(
and(
eq(assets.companyId, opts.companyId),
eq(maintenanceSchedules.active, true),
eq(maintenanceSchedules.kind, 'time'),
isNotNull(maintenanceSchedules.nextDueAt),
lte(maintenanceSchedules.nextDueAt, cutoff)
)
)
.orderBy(asc(maintenanceSchedules.nextDueAt))
.limit(opts.limit ?? 50);
}
export async function countOverdueForCompany(companyId: string): Promise<number> {
const [row] = await db
.select({ n: sql<number>`count(*)::int` })
.from(maintenanceSchedules)
.innerJoin(assets, eq(assets.id, maintenanceSchedules.assetId))
.where(
and(
eq(assets.companyId, companyId),
eq(maintenanceSchedules.active, true),
eq(maintenanceSchedules.kind, 'time'),
isNotNull(maintenanceSchedules.nextDueAt),
lte(maintenanceSchedules.nextDueAt, sql`now()`)
)
);
return row?.n ?? 0;
}
+247
View File
@@ -0,0 +1,247 @@
import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { env } from '$lib/server/env';
import { db } from '$lib/server/db/client';
import { companies, users } from '$lib/server/db/schema/tenancy';
import { notifications, type Notification } from '$lib/server/db/schema/notifications';
import { isEmailConfigured, sendEmail } from '$lib/server/notifications/email';
import { isMatrixConfigured, sendMatrixMessage } from '$lib/server/notifications/matrix';
export type NotificationKind =
| 'task_assigned'
| 'asset_log_added'
| 'asset_moved'
| 'decision_created'
| 'maintenance_event_recorded'
| 'generic';
export interface NotifyInput {
companyId: string;
userIds: string[];
kind: NotificationKind;
title: string;
body: string;
/** Absolute or relative URL back into the app. */
link?: string | null;
}
interface CompanySettings {
matrix_room_id?: string;
}
function parseSettings(raw: string | null | undefined): CompanySettings {
if (!raw) return {};
try {
return JSON.parse(raw) as CompanySettings;
} catch {
return {};
}
}
function absoluteLink(link: string | null | undefined): string | null {
if (!link) return null;
if (/^https?:\/\//i.test(link)) return link;
return `${env.PUBLIC_BASE_URL.replace(/\/$/, '')}${link.startsWith('/') ? '' : '/'}${link}`;
}
/**
* Fire a notification to a set of users. Always inserts DB rows (in-app
* channel); fans out to email + Matrix asynchronously for users who've opted
* in. External channel failures are logged, never thrown.
*/
export async function notify(input: NotifyInput): Promise<void> {
if (input.userIds.length === 0) return;
const rows = input.userIds.map((userId) => ({
userId,
companyId: input.companyId,
kind: input.kind,
title: input.title,
body: input.body,
link: input.link ?? null
}));
await db.insert(notifications).values(rows);
// Fan-out external delivery: never block caller, never throw.
void fanOutExternal(input).catch((err) => {
console.warn('[notify] fan-out error', { err: (err as Error).message });
});
}
async function fanOutExternal(input: NotifyInput): Promise<void> {
if (!isEmailConfigured() && !isMatrixConfigured()) return;
const recipients = await db
.select({
id: users.id,
email: users.email,
displayName: users.displayName,
emailNotifications: users.emailNotifications,
matrixNotifications: users.matrixNotifications,
matrixUserId: users.matrixUserId
})
.from(users)
.where(and(inArray(users.id, input.userIds), eq(users.isActive, true)));
const link = absoluteLink(input.link);
const linkSuffix = link ? `\n\n${link}` : '';
// --- email: one message per recipient who opted in ----------------------
if (isEmailConfigured()) {
await Promise.all(
recipients
.filter((r) => r.emailNotifications && r.email)
.map((r) =>
sendEmail({
to: r.email,
subject: input.title,
text: `${input.body}${linkSuffix}`,
html: renderEmailHtml(input.title, input.body, link)
})
)
);
}
// --- matrix: single message per company room, mentioning opt-in users ---
if (isMatrixConfigured()) {
const [company] = await db
.select({ settings: companies.settings })
.from(companies)
.where(eq(companies.id, input.companyId))
.limit(1);
const roomId = parseSettings(company?.settings).matrix_room_id;
if (roomId) {
const mentions = recipients
.filter((r) => r.matrixNotifications && r.matrixUserId)
.map((r) => r.matrixUserId!);
await sendMatrixMessage({
roomId,
text: `${input.title}${input.body}${linkSuffix}`,
mentions
});
}
}
}
function renderEmailHtml(title: string, body: string, link: string | null): string {
const esc = (s: string) =>
s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return [
`<div style="font-family:system-ui,Segoe UI,sans-serif;max-width:560px;margin:0 auto">`,
`<h2 style="color:#111;margin:0 0 8px">${esc(title)}</h2>`,
`<p style="color:#333;white-space:pre-wrap">${esc(body)}</p>`,
link
? `<p><a href="${esc(link)}" style="background:#2563eb;color:white;padding:8px 14px;border-radius:6px;text-decoration:none;display:inline-block">Open in buildfor_life ops</a></p>`
: '',
`</div>`
].join('\n');
}
// --- reads used by the bell + list + settings -------------------------------
export async function unreadCountForUser(
userId: string,
companyId: string
): Promise<number> {
const [row] = await db
.select({ n: count() })
.from(notifications)
.where(
and(
eq(notifications.userId, userId),
eq(notifications.companyId, companyId),
isNull(notifications.readAt)
)
);
return Number(row?.n ?? 0);
}
export async function listForUser(
userId: string,
companyId: string,
limit = 100
): Promise<Notification[]> {
return db
.select()
.from(notifications)
.where(
and(eq(notifications.userId, userId), eq(notifications.companyId, companyId))
)
.orderBy(desc(notifications.createdAt))
.limit(limit);
}
export async function markRead(userId: string, ids: string[]): Promise<void> {
if (ids.length === 0) return;
await db
.update(notifications)
.set({ readAt: sql`now()` })
.where(
and(
eq(notifications.userId, userId),
inArray(notifications.id, ids),
isNull(notifications.readAt)
)
);
}
export async function markAllRead(userId: string, companyId: string): Promise<void> {
await db
.update(notifications)
.set({ readAt: sql`now()` })
.where(
and(
eq(notifications.userId, userId),
eq(notifications.companyId, companyId),
isNull(notifications.readAt)
)
);
}
// --- per-user preferences ---------------------------------------------------
export interface NotificationPrefsPatch {
emailNotifications?: boolean;
matrixNotifications?: boolean;
matrixUserId?: string | null;
}
export async function updateUserPrefs(
userId: string,
patch: NotificationPrefsPatch
): Promise<void> {
const update: Record<string, unknown> = {};
if (patch.emailNotifications !== undefined) update.emailNotifications = patch.emailNotifications;
if (patch.matrixNotifications !== undefined) update.matrixNotifications = patch.matrixNotifications;
if (patch.matrixUserId !== undefined) {
const trimmed = patch.matrixUserId?.trim() || null;
if (trimmed && !/^@[^:\s]+:[^:\s]+$/.test(trimmed)) {
throw new Error('Matrix user id must look like @name:server');
}
update.matrixUserId = trimmed;
}
if (Object.keys(update).length === 0) return;
await db.update(users).set(update).where(eq(users.id, userId));
}
export async function getUserPrefs(userId: string): Promise<{
emailNotifications: boolean;
matrixNotifications: boolean;
matrixUserId: string | null;
email: string;
}> {
const [row] = await db
.select({
emailNotifications: users.emailNotifications,
matrixNotifications: users.matrixNotifications,
matrixUserId: users.matrixUserId,
email: users.email
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!row) throw new Error('user not found');
return row;
}
+73
View File
@@ -0,0 +1,73 @@
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { projects, type NewProject } from '$lib/server/db/schema/projects';
export interface ProjectCreateInput {
companyId: string;
createdBy: string;
name: string;
code?: string | null;
description?: string | null;
status?: string;
startDate?: Date | null;
endDate?: Date | null;
}
export async function createProject(input: ProjectCreateInput): Promise<{ id: string }> {
const values: NewProject = {
companyId: input.companyId,
name: input.name.trim(),
code: input.code ?? null,
description: input.description ?? null,
status: input.status ?? 'active',
startDate: input.startDate ?? null,
endDate: input.endDate ?? null,
createdBy: input.createdBy
};
const [row] = await db.insert(projects).values(values).returning({ id: projects.id });
return row;
}
export async function listProjects(companyId: string) {
return db
.select()
.from(projects)
.where(and(eq(projects.companyId, companyId), isNull(projects.deletedAt)))
.orderBy(desc(projects.updatedAt));
}
export async function getProject(companyId: string, id: string) {
const [row] = await db
.select()
.from(projects)
.where(
and(eq(projects.id, id), eq(projects.companyId, companyId), isNull(projects.deletedAt))
)
.limit(1);
return row ?? null;
}
export async function updateProject(
companyId: string,
id: string,
patch: Partial<ProjectCreateInput>
): Promise<void> {
await db
.update(projects)
.set({
...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.code !== undefined && { code: patch.code ?? null }),
...(patch.description !== undefined && { description: patch.description ?? null }),
...(patch.status !== undefined && { status: patch.status }),
...(patch.startDate !== undefined && { startDate: patch.startDate ?? null }),
...(patch.endDate !== undefined && { endDate: patch.endDate ?? null })
})
.where(and(eq(projects.id, id), eq(projects.companyId, companyId)));
}
export async function softDeleteProject(companyId: string, id: string): Promise<void> {
await db
.update(projects)
.set({ deletedAt: sql`now()` })
.where(and(eq(projects.id, id), eq(projects.companyId, companyId)));
}
+88
View File
@@ -0,0 +1,88 @@
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties, type NewProperty } from '$lib/server/db/schema/properties';
export interface PropertyCreateInput {
companyId: string;
createdBy: string;
name: string;
kind?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
region?: string | null;
postalCode?: string | null;
countryCode?: string | null;
notes?: string | null;
}
export async function createProperty(input: PropertyCreateInput): Promise<{ id: string }> {
const values: NewProperty = {
companyId: input.companyId,
name: input.name.trim(),
kind: input.kind ?? null,
addressLine1: input.addressLine1 ?? null,
addressLine2: input.addressLine2 ?? null,
city: input.city ?? null,
region: input.region ?? null,
postalCode: input.postalCode ?? null,
countryCode: input.countryCode ? input.countryCode.toUpperCase() : null,
notes: input.notes ?? null,
createdBy: input.createdBy
};
const [row] = await db.insert(properties).values(values).returning({ id: properties.id });
return row;
}
export async function listProperties(companyId: string) {
return db
.select()
.from(properties)
.where(and(eq(properties.companyId, companyId), isNull(properties.deletedAt)))
.orderBy(desc(properties.updatedAt));
}
export async function getProperty(companyId: string, id: string) {
const [row] = await db
.select()
.from(properties)
.where(
and(
eq(properties.id, id),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
return row ?? null;
}
export async function updateProperty(
companyId: string,
id: string,
patch: Partial<PropertyCreateInput>
): Promise<void> {
await db
.update(properties)
.set({
...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.kind !== undefined && { kind: patch.kind ?? null }),
...(patch.addressLine1 !== undefined && { addressLine1: patch.addressLine1 ?? null }),
...(patch.addressLine2 !== undefined && { addressLine2: patch.addressLine2 ?? null }),
...(patch.city !== undefined && { city: patch.city ?? null }),
...(patch.region !== undefined && { region: patch.region ?? null }),
...(patch.postalCode !== undefined && { postalCode: patch.postalCode ?? null }),
...(patch.countryCode !== undefined && {
countryCode: patch.countryCode ? patch.countryCode.toUpperCase() : null
}),
...(patch.notes !== undefined && { notes: patch.notes ?? null })
})
.where(and(eq(properties.id, id), eq(properties.companyId, companyId)));
}
export async function softDeleteProperty(companyId: string, id: string): Promise<void> {
await db
.update(properties)
.set({ deletedAt: sql`now()` })
.where(and(eq(properties.id, id), eq(properties.companyId, companyId)));
}
+258
View File
@@ -0,0 +1,258 @@
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import {
propertyFloors,
propertyRooms,
type PropertyFloor,
type PropertyRoom
} from '$lib/server/db/schema/rooms';
import { assets } from '$lib/server/db/schema/assets';
async function assertProperty(companyId: string, propertyId: string): Promise<void> {
const [row] = await db
.select({ id: properties.id })
.from(properties)
.where(
and(
eq(properties.id, propertyId),
eq(properties.companyId, companyId),
isNull(properties.deletedAt)
)
)
.limit(1);
if (!row) throw new Error('property not found');
}
// --- floors -----------------------------------------------------------------
export async function listFloors(companyId: string, propertyId: string): Promise<PropertyFloor[]> {
await assertProperty(companyId, propertyId);
return db
.select()
.from(propertyFloors)
.where(eq(propertyFloors.propertyId, propertyId))
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label));
}
export async function createFloor(input: {
companyId: string;
propertyId: string;
label: string;
name?: string | null;
}): Promise<{ id: string }> {
await assertProperty(input.companyId, input.propertyId);
const label = input.label.trim();
if (!label) throw new Error('floor label is required');
const [{ next }] = await db
.select({ next: sql<number>`coalesce(max(${propertyFloors.order}), -1) + 1` })
.from(propertyFloors)
.where(eq(propertyFloors.propertyId, input.propertyId));
const [row] = await db
.insert(propertyFloors)
.values({
propertyId: input.propertyId,
label,
name: input.name?.trim() || null,
order: next ?? 0
})
.returning({ id: propertyFloors.id });
return row;
}
export async function updateFloor(
companyId: string,
floorId: string,
patch: { label?: string; name?: string | null }
): Promise<void> {
// Tenant guard via join.
const [f] = await db
.select({ id: propertyFloors.id })
.from(propertyFloors)
.innerJoin(properties, eq(properties.id, propertyFloors.propertyId))
.where(and(eq(propertyFloors.id, floorId), eq(properties.companyId, companyId)))
.limit(1);
if (!f) throw new Error('floor not found');
await db
.update(propertyFloors)
.set({
...(patch.label !== undefined && { label: patch.label.trim() }),
...(patch.name !== undefined && { name: patch.name?.trim() || null })
})
.where(eq(propertyFloors.id, floorId));
}
export async function deleteFloor(companyId: string, floorId: string): Promise<void> {
// ON DELETE SET NULL on rooms.floor_id detaches rooms rather than nuking them.
const [f] = await db
.select({ id: propertyFloors.id })
.from(propertyFloors)
.innerJoin(properties, eq(properties.id, propertyFloors.propertyId))
.where(and(eq(propertyFloors.id, floorId), eq(properties.companyId, companyId)))
.limit(1);
if (!f) throw new Error('floor not found');
await db.delete(propertyFloors).where(eq(propertyFloors.id, floorId));
}
// --- rooms ------------------------------------------------------------------
export async function listRoomsWithCounts(companyId: string, propertyId: string) {
await assertProperty(companyId, propertyId);
return db
.select({
id: propertyRooms.id,
propertyId: propertyRooms.propertyId,
floorId: propertyRooms.floorId,
floorLabel: propertyFloors.label,
floorName: propertyFloors.name,
name: propertyRooms.name,
notes: propertyRooms.notes,
order: propertyRooms.order,
updatedAt: propertyRooms.updatedAt,
assetCount: sql<number>`(
select count(*)::int from ${assets}
where ${assets.currentRoomId} = ${propertyRooms.id}
and ${assets.deletedAt} is null
)`
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.where(and(eq(propertyRooms.propertyId, propertyId), isNull(propertyRooms.deletedAt)))
.orderBy(
asc(propertyFloors.order),
asc(propertyFloors.label),
asc(propertyRooms.order),
asc(propertyRooms.name)
);
}
export async function getRoom(companyId: string, roomId: string): Promise<PropertyRoom | null> {
const [row] = await db
.select({ room: propertyRooms })
.from(propertyRooms)
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
.where(
and(
eq(propertyRooms.id, roomId),
eq(properties.companyId, companyId),
isNull(propertyRooms.deletedAt)
)
)
.limit(1);
return row?.room ?? null;
}
export async function createRoom(input: {
companyId: string;
propertyId: string;
floorId?: string | null;
name: string;
notes?: string | null;
}): Promise<{ id: string }> {
await assertProperty(input.companyId, input.propertyId);
const name = input.name.trim();
if (!name) throw new Error('room name is required');
if (input.floorId) {
const [f] = await db
.select({ id: propertyFloors.id })
.from(propertyFloors)
.where(
and(
eq(propertyFloors.id, input.floorId),
eq(propertyFloors.propertyId, input.propertyId)
)
)
.limit(1);
if (!f) throw new Error('floor does not belong to this property');
}
const [{ next }] = await db
.select({ next: sql<number>`coalesce(max(${propertyRooms.order}), -1) + 1` })
.from(propertyRooms)
.where(eq(propertyRooms.propertyId, input.propertyId));
const [row] = await db
.insert(propertyRooms)
.values({
propertyId: input.propertyId,
floorId: input.floorId ?? null,
name,
notes: input.notes?.trim() || null,
order: next ?? 0
})
.returning({ id: propertyRooms.id });
return row;
}
export async function updateRoom(
companyId: string,
roomId: string,
patch: { name?: string; notes?: string | null; floorId?: string | null }
): Promise<void> {
const room = await getRoom(companyId, roomId);
if (!room) throw new Error('room not found');
if (patch.floorId !== undefined && patch.floorId !== null) {
const [f] = await db
.select({ id: propertyFloors.id })
.from(propertyFloors)
.where(
and(eq(propertyFloors.id, patch.floorId), eq(propertyFloors.propertyId, room.propertyId))
)
.limit(1);
if (!f) throw new Error('floor does not belong to this property');
}
await db
.update(propertyRooms)
.set({
...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.notes !== undefined && { notes: patch.notes?.trim() || null }),
...(patch.floorId !== undefined && { floorId: patch.floorId })
})
.where(eq(propertyRooms.id, roomId));
}
export async function softDeleteRoom(companyId: string, roomId: string): Promise<void> {
const room = await getRoom(companyId, roomId);
if (!room) return;
// Detach any assets first, then soft-delete. The FK is ON DELETE SET NULL, so
// technically we could just hard-delete; doing soft + explicit detach keeps
// asset_location_history consistent (no "room vanished" mystery).
await db.transaction(async (tx) => {
await tx
.update(assets)
.set({ currentRoomId: null })
.where(eq(assets.currentRoomId, roomId));
await tx
.update(propertyRooms)
.set({ deletedAt: new Date() })
.where(eq(propertyRooms.id, roomId));
});
}
/**
* Validate that a room belongs to a given property. Used by the assets service
* before accepting a room_id on create / update / move.
*/
export async function assertRoomInProperty(
companyId: string,
roomId: string,
propertyId: string
): Promise<void> {
const [r] = await db
.select({ id: propertyRooms.id })
.from(propertyRooms)
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
.where(
and(
eq(propertyRooms.id, roomId),
eq(propertyRooms.propertyId, propertyId),
eq(properties.companyId, companyId),
isNull(propertyRooms.deletedAt)
)
)
.limit(1);
if (!r) throw new Error('room does not belong to this property');
}
+167
View File
@@ -0,0 +1,167 @@
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { projects, subtasks, tasks, workPackages } from '$lib/server/db/schema/projects';
import { notify } from './notifications';
export type TaskStatus = 'todo' | 'doing' | 'done' | 'blocked';
async function assertWorkPackage(companyId: string, wpId: string): Promise<void> {
const [row] = await db
.select({ id: workPackages.id })
.from(workPackages)
.innerJoin(projects, eq(projects.id, workPackages.projectId))
.where(
and(
eq(workPackages.id, wpId),
eq(projects.companyId, companyId),
isNull(workPackages.deletedAt)
)
)
.limit(1);
if (!row) throw new Error('work package not found');
}
export async function listTasksForWorkPackage(companyId: string, wpId: string) {
await assertWorkPackage(companyId, wpId);
return db
.select()
.from(tasks)
.where(and(eq(tasks.workPackageId, wpId), isNull(tasks.deletedAt)))
.orderBy(asc(tasks.order), asc(tasks.createdAt));
}
export async function createTask(input: {
companyId: string;
createdBy: string;
workPackageId: string;
title: string;
description?: string | null;
assigneeId?: string | null;
dueAt?: Date | null;
}): Promise<{ id: string }> {
await assertWorkPackage(input.companyId, input.workPackageId);
const [{ next }] = await db
.select({ next: sql<number>`coalesce(max(${tasks.order}), -1) + 1` })
.from(tasks)
.where(eq(tasks.workPackageId, input.workPackageId));
const [row] = await db
.insert(tasks)
.values({
workPackageId: input.workPackageId,
title: input.title.trim(),
description: input.description ?? null,
assigneeId: input.assigneeId ?? null,
dueAt: input.dueAt ?? null,
order: next ?? 0,
createdBy: input.createdBy
})
.returning({ id: tasks.id });
return row;
}
export async function getTaskWithSubtasks(companyId: string, id: string) {
const [t] = await db
.select({ task: tasks, projectId: workPackages.projectId, wpName: workPackages.name })
.from(tasks)
.innerJoin(workPackages, eq(workPackages.id, tasks.workPackageId))
.innerJoin(projects, eq(projects.id, workPackages.projectId))
.where(and(eq(tasks.id, id), eq(projects.companyId, companyId), isNull(tasks.deletedAt)))
.limit(1);
if (!t) return null;
const subs = await db
.select()
.from(subtasks)
.where(eq(subtasks.taskId, id))
.orderBy(asc(subtasks.order), asc(subtasks.createdAt));
return { task: t.task, projectId: t.projectId, workPackageName: t.wpName, subtasks: subs };
}
export async function updateTask(
companyId: string,
id: string,
patch: {
title?: string;
description?: string | null;
status?: TaskStatus;
assigneeId?: string | null;
dueAt?: Date | null;
}
): Promise<void> {
const t = await getTaskWithSubtasks(companyId, id);
if (!t) throw new Error('task not found');
const update: Partial<typeof tasks.$inferInsert> = {};
if (patch.title !== undefined) update.title = patch.title.trim();
if (patch.description !== undefined) update.description = patch.description;
if (patch.assigneeId !== undefined) update.assigneeId = patch.assigneeId;
if (patch.dueAt !== undefined) update.dueAt = patch.dueAt;
if (patch.status !== undefined) {
update.status = patch.status;
if (patch.status === 'done' && !t.task.completedAt) update.completedAt = new Date();
if (patch.status !== 'done' && t.task.completedAt) update.completedAt = null;
}
await db.update(tasks).set(update).where(eq(tasks.id, id));
// Fire task_assigned when assignee actually changes to a real user.
const newAssignee = patch.assigneeId ?? null;
if (
patch.assigneeId !== undefined &&
newAssignee &&
newAssignee !== t.task.assigneeId
) {
const title = patch.title ?? t.task.title;
void notify({
companyId,
userIds: [newAssignee],
kind: 'task_assigned',
title: `Task assigned: ${title}`,
body: t.task.description?.slice(0, 500) ?? 'You were assigned this task.',
link: `/projects/${t.projectId}/work/${t.task.workPackageId}/${id}`
});
}
}
export async function softDeleteTask(companyId: string, id: string): Promise<void> {
const t = await getTaskWithSubtasks(companyId, id);
if (!t) throw new Error('task not found');
await db
.update(tasks)
.set({ deletedAt: sql`now()` })
.where(eq(tasks.id, id));
}
// --- subtasks ---------------------------------------------------------------
export async function addSubtask(companyId: string, taskId: string, name: string): Promise<void> {
const t = await getTaskWithSubtasks(companyId, taskId);
if (!t) throw new Error('task not found');
const order = t.subtasks.length;
await db.insert(subtasks).values({ taskId, name: name.trim(), order });
}
export async function toggleSubtask(
companyId: string,
taskId: string,
subtaskId: string,
done: boolean
): Promise<void> {
const t = await getTaskWithSubtasks(companyId, taskId);
if (!t) throw new Error('task not found');
await db
.update(subtasks)
.set({ done })
.where(and(eq(subtasks.id, subtaskId), eq(subtasks.taskId, taskId)));
}
export async function removeSubtask(
companyId: string,
taskId: string,
subtaskId: string
): Promise<void> {
const t = await getTaskWithSubtasks(companyId, taskId);
if (!t) throw new Error('task not found');
await db
.delete(subtasks)
.where(and(eq(subtasks.id, subtaskId), eq(subtasks.taskId, taskId)));
}
+228
View File
@@ -0,0 +1,228 @@
import { and, desc, eq, ne, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { companyUsers, users } from '$lib/server/db/schema/tenancy';
import { hashPassword } from '$lib/server/auth/password';
import { normalizeEmail } from '$lib/utils/email';
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer';
export interface CompanyUserRow {
userId: string;
email: string;
displayName: string;
isActive: boolean;
lastLoginAt: Date | null;
role: CompanyRole;
membershipId: string;
joinedAt: Date;
}
export async function listCompanyUsers(companyId: string): Promise<CompanyUserRow[]> {
const rows = await db
.select({
userId: users.id,
email: users.email,
displayName: users.displayName,
isActive: users.isActive,
lastLoginAt: users.lastLoginAt,
role: companyUsers.role,
membershipId: companyUsers.id,
joinedAt: companyUsers.joinedAt
})
.from(companyUsers)
.innerJoin(users, eq(users.id, companyUsers.userId))
.where(eq(companyUsers.companyId, companyId))
.orderBy(desc(users.isActive), desc(companyUsers.joinedAt));
return rows as CompanyUserRow[];
}
async function countAdmins(companyId: string): Promise<number> {
const [{ n }] = await db
.select({ n: sql<number>`count(*)::int` })
.from(companyUsers)
.innerJoin(users, eq(users.id, companyUsers.userId))
.where(
and(
eq(companyUsers.companyId, companyId),
eq(companyUsers.role, 'admin'),
eq(users.isActive, true)
)
);
return n;
}
export interface CreateUserInput {
companyId: string;
email: string;
displayName: string;
password: string;
role: CompanyRole;
}
/**
* Create a new user and add them to this company at the given role.
* If a user with this normalized email already exists we reuse them and
* just add the membership (common invite flow).
*/
export async function createUserAndAddToCompany(
input: CreateUserInput
): Promise<{ userId: string; created: boolean }> {
const normalized = normalizeEmail(input.email);
const displayName = input.displayName.trim();
if (!displayName) throw new Error('display name is required');
if (input.password.length < 8) {
throw new Error('password must be at least 8 characters');
}
return db.transaction(async (tx) => {
let userId: string;
let created = false;
const [existing] = await tx
.select()
.from(users)
.where(eq(users.emailNormalized, normalized))
.limit(1);
if (existing) {
userId = existing.id;
} else {
const hash = await hashPassword(input.password);
const [row] = await tx
.insert(users)
.values({
email: input.email.trim(),
emailNormalized: normalized,
displayName,
passwordHash: hash
})
.returning({ id: users.id });
userId = row.id;
created = true;
}
const [membership] = await tx
.select({ id: companyUsers.id })
.from(companyUsers)
.where(
and(eq(companyUsers.companyId, input.companyId), eq(companyUsers.userId, userId))
)
.limit(1);
if (membership) throw new Error('user is already a member of this company');
await tx
.insert(companyUsers)
.values({ companyId: input.companyId, userId, role: input.role });
return { userId, created };
});
}
export async function updateDisplayName(
companyId: string,
userId: string,
displayName: string
): Promise<void> {
await assertMembership(companyId, userId);
const clean = displayName.trim();
if (!clean) throw new Error('display name is required');
await db.update(users).set({ displayName: clean }).where(eq(users.id, userId));
}
export async function setUserRoleInCompany(
companyId: string,
userId: string,
role: CompanyRole
): Promise<void> {
await assertMembership(companyId, userId);
const [current] = await db
.select({ role: companyUsers.role })
.from(companyUsers)
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
.limit(1);
if (!current) throw new Error('membership not found');
if (current.role === 'admin' && role !== 'admin') {
const admins = await countAdmins(companyId);
if (admins <= 1) {
throw new Error('Cannot demote the last admin of this company.');
}
}
await db
.update(companyUsers)
.set({ role })
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)));
}
export async function removeUserFromCompany(
companyId: string,
userId: string
): Promise<void> {
await assertMembership(companyId, userId);
const [current] = await db
.select({ role: companyUsers.role })
.from(companyUsers)
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
.limit(1);
if (!current) return;
if (current.role === 'admin') {
const admins = await countAdmins(companyId);
if (admins <= 1) {
throw new Error('Cannot remove the last admin of this company.');
}
}
await db
.delete(companyUsers)
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)));
}
export async function setUserActive(
companyId: string,
userId: string,
active: boolean
): Promise<void> {
await assertMembership(companyId, userId);
// Don't let the last admin deactivate themselves.
if (!active) {
const [m] = await db
.select({ role: companyUsers.role })
.from(companyUsers)
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
.limit(1);
if (m?.role === 'admin') {
const admins = await countAdmins(companyId);
if (admins <= 1) {
throw new Error('Cannot deactivate the last admin of this company.');
}
}
}
await db.update(users).set({ isActive: active }).where(eq(users.id, userId));
}
export async function resetUserPassword(
companyId: string,
userId: string,
newPassword: string
): Promise<void> {
await assertMembership(companyId, userId);
if (newPassword.length < 8) {
throw new Error('password must be at least 8 characters');
}
const hash = await hashPassword(newPassword);
await db.update(users).set({ passwordHash: hash }).where(eq(users.id, userId));
}
async function assertMembership(companyId: string, userId: string): Promise<void> {
const [row] = await db
.select({ id: companyUsers.id })
.from(companyUsers)
.where(and(eq(companyUsers.companyId, companyId), eq(companyUsers.userId, userId)))
.limit(1);
if (!row) throw new Error('that user is not in this company');
}
// Exported for the /admin/users page to prevent deactivating yourself while
// doing it from the UI (backend already enforces; this is UX helper).
export function isSelf(targetUserId: string, selfUserId: string): boolean {
return targetUserId === selfUserId;
}
// ne import kept in case we want future checks like "admins other than self"; silence unused.
void ne;
+257
View File
@@ -0,0 +1,257 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { users } from '$lib/server/db/schema/tenancy';
import { wikiPages, wikiRevisions, type WikiPage, type WikiRevision } from '$lib/server/db/schema/wiki';
export type WikiScope = 'global' | 'project' | 'property';
export function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 128);
}
// scope_id is null for global pages — use IS NULL instead of equality.
function scopeIdMatches(col: typeof wikiPages.scopeId, scopeId: string | null) {
return scopeId === null ? isNull(col) : eq(col, scopeId);
}
export async function listPagesForScope(
companyId: string,
scopeType: WikiScope,
scopeId: string | null
) {
return db
.select({
id: wikiPages.id,
slug: wikiPages.slug,
title: wikiPages.title,
updatedAt: wikiPages.updatedAt
})
.from(wikiPages)
.where(
and(
eq(wikiPages.companyId, companyId),
eq(wikiPages.scopeType, scopeType),
scopeIdMatches(wikiPages.scopeId, scopeId),
isNull(wikiPages.deletedAt)
)
)
.orderBy(asc(wikiPages.title));
}
export async function getPageWithCurrentRevision(
companyId: string,
scopeType: WikiScope,
scopeId: string | null,
slug: string
): Promise<{ page: WikiPage; revision: WikiRevision; editedByName: string | null } | null> {
const [page] = await db
.select()
.from(wikiPages)
.where(
and(
eq(wikiPages.companyId, companyId),
eq(wikiPages.scopeType, scopeType),
scopeIdMatches(wikiPages.scopeId, scopeId),
eq(wikiPages.slug, slug),
isNull(wikiPages.deletedAt)
)
)
.limit(1);
if (!page || !page.currentRevisionId) return null;
const [row] = await db
.select({
revision: wikiRevisions,
editedByName: users.displayName
})
.from(wikiRevisions)
.leftJoin(users, eq(users.id, wikiRevisions.editedBy))
.where(eq(wikiRevisions.id, page.currentRevisionId))
.limit(1);
if (!row) return null;
return { page, revision: row.revision, editedByName: row.editedByName };
}
export async function listRevisions(companyId: string, pageId: string) {
const [page] = await db
.select({ id: wikiPages.id })
.from(wikiPages)
.where(and(eq(wikiPages.id, pageId), eq(wikiPages.companyId, companyId)))
.limit(1);
if (!page) return [];
return db
.select({
id: wikiRevisions.id,
revision: wikiRevisions.revision,
title: wikiRevisions.title,
comment: wikiRevisions.comment,
editedAt: wikiRevisions.editedAt,
editedByName: users.displayName
})
.from(wikiRevisions)
.leftJoin(users, eq(users.id, wikiRevisions.editedBy))
.where(eq(wikiRevisions.pageId, pageId))
.orderBy(desc(wikiRevisions.revision));
}
export async function getRevision(companyId: string, pageId: string, revision: number) {
const [page] = await db
.select({ id: wikiPages.id })
.from(wikiPages)
.where(and(eq(wikiPages.id, pageId), eq(wikiPages.companyId, companyId)))
.limit(1);
if (!page) return null;
const [row] = await db
.select({
revision: wikiRevisions,
editedByName: users.displayName
})
.from(wikiRevisions)
.leftJoin(users, eq(users.id, wikiRevisions.editedBy))
.where(and(eq(wikiRevisions.pageId, pageId), eq(wikiRevisions.revision, revision)))
.limit(1);
return row ? { revision: row.revision, editedByName: row.editedByName } : null;
}
/**
* Create a new page or write a new revision of an existing one. Returns the
* page id and the new revision number.
*/
export async function upsertPage(input: {
companyId: string;
editedBy: string;
scopeType: WikiScope;
scopeId: string | null;
slug: string;
title: string;
bodyMd: string;
comment?: string | null;
}): Promise<{ id: string; revision: number; created: boolean }> {
const slug = slugify(input.slug);
if (!slug) throw new Error('slug is empty after normalization');
return db.transaction(async (tx) => {
// Look up existing page (slug uniqueness enforced by NULLS NOT DISTINCT idx).
const [existing] = await tx
.select()
.from(wikiPages)
.where(
and(
eq(wikiPages.companyId, input.companyId),
eq(wikiPages.scopeType, input.scopeType),
scopeIdMatches(wikiPages.scopeId, input.scopeId),
eq(wikiPages.slug, slug)
)
)
.limit(1);
let pageId: string;
let created = false;
if (existing) {
pageId = existing.id;
if (existing.deletedAt !== null) {
// Resurrect a soft-deleted page rather than erroring.
await tx
.update(wikiPages)
.set({ deletedAt: null, title: input.title.trim() })
.where(eq(wikiPages.id, pageId));
} else if (existing.title !== input.title.trim()) {
await tx
.update(wikiPages)
.set({ title: input.title.trim() })
.where(eq(wikiPages.id, pageId));
}
} else {
const [row] = await tx
.insert(wikiPages)
.values({
companyId: input.companyId,
scopeType: input.scopeType,
scopeId: input.scopeId,
slug,
title: input.title.trim(),
createdBy: input.editedBy
})
.returning({ id: wikiPages.id });
pageId = row.id;
created = true;
}
// Compute next revision number.
const [{ nextRev }] = await tx
.select({
nextRev: sql<number>`coalesce(max(${wikiRevisions.revision}), 0) + 1`
})
.from(wikiRevisions)
.where(eq(wikiRevisions.pageId, pageId));
const [rev] = await tx
.insert(wikiRevisions)
.values({
pageId,
revision: nextRev,
title: input.title.trim(),
bodyMd: input.bodyMd,
editedBy: input.editedBy,
comment: input.comment ?? null
})
.returning({ id: wikiRevisions.id });
await tx
.update(wikiPages)
.set({ currentRevisionId: rev.id })
.where(eq(wikiPages.id, pageId));
return { id: pageId, revision: nextRev, created };
});
}
export async function softDeletePage(companyId: string, pageId: string): Promise<void> {
await db
.update(wikiPages)
.set({ deletedAt: new Date() })
.where(and(eq(wikiPages.id, pageId), eq(wikiPages.companyId, companyId)));
}
/**
* Full-text search across the **current** revision of pages within a scope.
* Limited to one scope at a time so global vs. project search stay separate.
*/
export async function searchPages(input: {
companyId: string;
scopeType: WikiScope;
scopeId: string | null;
q: string;
limit?: number;
}) {
const q = input.q.trim();
if (!q) return [];
// Use plainto_tsquery for cheap, forgiving matching. websearch_to_tsquery
// would be nicer but is overkill for an internal tool.
const results = await db
.select({
id: wikiPages.id,
slug: wikiPages.slug,
title: wikiPages.title,
updatedAt: wikiPages.updatedAt
})
.from(wikiPages)
.innerJoin(wikiRevisions, eq(wikiRevisions.id, wikiPages.currentRevisionId))
.where(
and(
eq(wikiPages.companyId, input.companyId),
eq(wikiPages.scopeType, input.scopeType),
scopeIdMatches(wikiPages.scopeId, input.scopeId),
isNull(wikiPages.deletedAt),
sql`${wikiRevisions.bodyTsv} @@ plainto_tsquery('simple', ${q})`
)
)
.orderBy(desc(wikiPages.updatedAt))
.limit(input.limit ?? 50);
return results;
}
+111
View File
@@ -0,0 +1,111 @@
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { projects, workPackages, tasks } from '$lib/server/db/schema/projects';
async function assertProject(companyId: string, projectId: string): Promise<void> {
const [p] = await db
.select({ id: projects.id })
.from(projects)
.where(
and(
eq(projects.id, projectId),
eq(projects.companyId, companyId),
isNull(projects.deletedAt)
)
)
.limit(1);
if (!p) throw new Error('project not found');
}
export async function listWorkPackagesForProject(companyId: string, projectId: string) {
await assertProject(companyId, projectId);
return db
.select({
id: workPackages.id,
name: workPackages.name,
description: workPackages.description,
order: workPackages.order,
createdAt: workPackages.createdAt,
updatedAt: workPackages.updatedAt,
openTasks: sql<number>`(
select count(*)::int from ${tasks}
where ${tasks.workPackageId} = ${workPackages.id}
and ${tasks.deletedAt} is null
and ${tasks.status} <> 'done'
)`,
totalTasks: sql<number>`(
select count(*)::int from ${tasks}
where ${tasks.workPackageId} = ${workPackages.id}
and ${tasks.deletedAt} is null
)`
})
.from(workPackages)
.where(and(eq(workPackages.projectId, projectId), isNull(workPackages.deletedAt)))
.orderBy(asc(workPackages.order), asc(workPackages.createdAt));
}
export async function createWorkPackage(input: {
companyId: string;
projectId: string;
name: string;
description?: string | null;
}): Promise<{ id: string }> {
await assertProject(input.companyId, input.projectId);
const [{ next }] = await db
.select({
next: sql<number>`coalesce(max(${workPackages.order}), -1) + 1`
})
.from(workPackages)
.where(eq(workPackages.projectId, input.projectId));
const [row] = await db
.insert(workPackages)
.values({
projectId: input.projectId,
name: input.name.trim(),
description: input.description ?? null,
order: next ?? 0
})
.returning({ id: workPackages.id });
return row;
}
export async function getWorkPackage(companyId: string, id: string) {
const [row] = await db
.select({ wp: workPackages, projectId: workPackages.projectId })
.from(workPackages)
.innerJoin(projects, eq(projects.id, workPackages.projectId))
.where(
and(
eq(workPackages.id, id),
eq(projects.companyId, companyId),
isNull(workPackages.deletedAt)
)
)
.limit(1);
return row ? { ...row.wp } : null;
}
export async function updateWorkPackage(
companyId: string,
id: string,
patch: { name?: string; description?: string | null }
): Promise<void> {
const wp = await getWorkPackage(companyId, id);
if (!wp) throw new Error('work package not found');
await db
.update(workPackages)
.set({
...(patch.name !== undefined && { name: patch.name.trim() }),
...(patch.description !== undefined && { description: patch.description })
})
.where(eq(workPackages.id, id));
}
export async function softDeleteWorkPackage(companyId: string, id: string): Promise<void> {
const wp = await getWorkPackage(companyId, id);
if (!wp) throw new Error('work package not found');
await db
.update(workPackages)
.set({ deletedAt: sql`now()` })
.where(eq(workPackages.id, id));
}
+14 -1
View File
@@ -1,5 +1,6 @@
import { env } from '../env';
import { LocalDiskStorage } from './local';
import { S3Storage } from './s3';
import type { StorageAdapter } from './types';
let instance: StorageAdapter | null = null;
@@ -12,8 +13,20 @@ export function getStorage(): StorageAdapter {
env.STORAGE_SIGNING_SECRET,
env.PUBLIC_BASE_URL
);
} else if (env.STORAGE_BACKEND === 's3') {
if (!env.S3_BUCKET) {
throw new Error('STORAGE_BACKEND=s3 requires S3_BUCKET to be set');
}
instance = new S3Storage({
bucket: env.S3_BUCKET,
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT || undefined,
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
forcePathStyle: env.S3_FORCE_PATH_STYLE
});
} else {
throw new Error(`Storage backend '${env.STORAGE_BACKEND}' is not wired yet`);
throw new Error(`Storage backend '${env.STORAGE_BACKEND}' is not wired`);
}
return instance;
}
+121
View File
@@ -0,0 +1,121 @@
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
type S3ClientConfig
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { createHash } from 'node:crypto';
import type { Readable } from 'node:stream';
import {
generateStorageKey,
type GetObjectResult,
type PutObjectInput,
type PutObjectResult,
type SignedUrlOptions,
type StorageAdapter
} from './types';
export interface S3StorageConfig {
bucket: string;
region: string;
endpoint?: string; // for MinIO / self-hosted S3
accessKeyId?: string;
secretAccessKey?: string;
forcePathStyle?: boolean; // MinIO needs this
}
export class S3Storage implements StorageAdapter {
private readonly client: S3Client;
private readonly bucket: string;
constructor(cfg: S3StorageConfig) {
const s3Config: S3ClientConfig = {
region: cfg.region,
endpoint: cfg.endpoint,
forcePathStyle: cfg.forcePathStyle ?? Boolean(cfg.endpoint)
};
if (cfg.accessKeyId && cfg.secretAccessKey) {
s3Config.credentials = {
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey
};
}
this.client = new S3Client(s3Config);
this.bucket = cfg.bucket;
}
generateKey(filename: string): string {
return generateStorageKey(filename);
}
async put(input: PutObjectInput): Promise<PutObjectResult> {
// Coerce stream to Buffer so we can (a) hash without duplicating a stream
// and (b) know the content length for S3's required Content-Length header.
const buf = Buffer.isBuffer(input.body) ? input.body : await streamToBuffer(input.body);
const sha256 = createHash('sha256').update(buf).digest('hex');
if (input.sha256 && input.sha256 !== sha256) {
throw new Error('sha256 mismatch on upload');
}
await this.client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: input.key,
Body: buf,
ContentType: input.contentType,
ContentLength: buf.length,
Metadata: input.metadata
})
);
return { key: input.key, sha256, sizeBytes: buf.length };
}
async get(key: string): Promise<GetObjectResult> {
const out = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: key })
);
if (!out.Body) throw new Error(`empty body for key ${key}`);
return {
stream: out.Body as Readable,
contentType: out.ContentType ?? 'application/octet-stream',
sizeBytes: out.ContentLength ?? 0
};
}
async head(key: string) {
const out = await this.client.send(
new HeadObjectCommand({ Bucket: this.bucket, Key: key })
);
return {
sizeBytes: out.ContentLength ?? 0,
contentType: out.ContentType ?? 'application/octet-stream'
};
}
async delete(key: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
}
async getSignedUrl(key: string, opts: SignedUrlOptions): Promise<string> {
const cmd = new GetObjectCommand({
Bucket: this.bucket,
Key: key,
ResponseContentDisposition: opts.filename
? `${opts.disposition ?? 'inline'}; filename="${opts.filename.replace(/"/g, '')}"`
: undefined
});
return getSignedUrl(this.client, cmd, { expiresIn: opts.expiresInSeconds });
}
}
async function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as ArrayBuffer));
}
return Buffer.concat(chunks);
}
+113
View File
@@ -0,0 +1,113 @@
import { z } from 'zod';
import type { AssetFieldDef } from '$lib/server/db/schema/assets';
// --- per-type validators -----------------------------------------------------
const ipV4orV6 = z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}|[0-9a-fA-F:]+)$/,
'Invalid IP address'
);
const cidr = z
.string()
.regex(
/^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}|[0-9a-fA-F:]+)\/\d{1,3}$/,
'Invalid CIDR'
);
const mac = z
.string()
.regex(/^(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/, 'Invalid MAC address');
function validatorFor(def: AssetFieldDef): z.ZodTypeAny {
switch (def.type) {
case 'text':
case 'textarea':
return z.string().max(10_000);
case 'int':
return z.coerce.number().int();
case 'float':
return z.coerce.number();
case 'bool':
return z.coerce.boolean();
case 'date':
return z.string().min(1, 'Required'); // ISO date or datetime; storage layer normalizes
case 'ip':
return ipV4orV6;
case 'cidr':
return cidr;
case 'mac':
return mac;
case 'url':
return z.string().url();
case 'email':
return z.string().email();
case 'enum': {
const values = (def.enumValues ?? []) as string[];
if (values.length === 0) return z.string();
return z.enum(values as [string, ...string[]]);
}
case 'multi_enum': {
const values = (def.enumValues ?? []) as string[];
if (values.length === 0) return z.array(z.string()).max(64);
return z.array(z.enum(values as [string, ...string[]])).max(64);
}
case 'asset_ref':
return z.string().uuid();
}
}
/**
* Build a Zod schema from a list of field defs. Required fields are required,
* optional fields accept null/undefined. Unknown keys are stripped (via
* `.passthrough()` then post-filter); flip to `.strict()` if we want hard
* rejection — see the open question in the architect spec.
*
* Deprecated defs are accepted but never required.
*/
export function buildCustomFieldsSchema(defs: AssetFieldDef[]) {
const shape: Record<string, z.ZodTypeAny> = {};
for (const d of defs) {
let v = validatorFor(d);
const isRequired = d.required && d.deprecatedAt === null;
if (!isRequired) v = v.optional().nullable();
shape[d.key] = v;
}
const allowedKeys = new Set(defs.map((d) => d.key));
const obj = z.object(shape).passthrough();
return obj.transform((data) => {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(data)) {
if (!allowedKeys.has(k)) continue; // drop unknowns silently
if (v === null || v === undefined || v === '') continue;
out[k] = v;
}
return out;
});
}
// --- cache, keyed on (asset_type_id, schema_version) -------------------------
interface CacheEntry {
schemaVersion: number;
schema: z.ZodTypeAny;
}
const cache = new Map<string, CacheEntry>();
export function getCachedCustomFieldsSchema(
assetTypeId: string,
schemaVersion: number,
defs: AssetFieldDef[]
): z.ZodTypeAny {
const hit = cache.get(assetTypeId);
if (hit && hit.schemaVersion === schemaVersion) return hit.schema;
const schema = buildCustomFieldsSchema(defs);
cache.set(assetTypeId, { schemaVersion, schema });
return schema;
}
export function clearCustomFieldsCache(): void {
cache.clear();
}
+17 -3
View File
@@ -2,6 +2,8 @@ import { redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { companies, companyUsers } from '$lib/server/db/schema/tenancy';
import { setActiveCompany } from '$lib/server/auth/session';
import { unreadCountForUser } from '$lib/server/services/notifications';
import type { LayoutServerLoad } from './$types';
import type { SessionCompany } from '$lib/server/auth/types';
@@ -11,7 +13,6 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
throw redirect(303, `/login?next=${encodeURIComponent(target)}`);
}
// Load the user's companies so the sidebar switcher can render.
const rows = await db
.select({
id: companies.id,
@@ -30,9 +31,22 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
role: r.role
}));
// If the session has no active company but the user belongs to at least one,
// auto-select the first one. Saves a click and matches the budget app's UX.
let active = locals.company;
if (!active && userCompanies.length > 0 && locals.sessionId) {
const first = userCompanies[0];
await setActiveCompany(locals.sessionId, first.id);
active = first;
locals.company = first;
}
const unreadCount = active ? await unreadCountForUser(locals.user.id, active.id) : 0;
return {
user: locals.user,
company: locals.company,
companies: userCompanies
company: active,
companies: userCompanies,
unreadCount
};
};
+1 -2
View File
@@ -10,7 +10,6 @@
<div class="flex min-h-screen">
<Sidebar
user={data.user}
company={data.company}
companies={data.companies}
open={sidebarOpen}
@@ -18,7 +17,7 @@
/>
<div class="flex min-w-0 flex-1 flex-col">
<TopBar onmenu={() => (sidebarOpen = true)} />
<TopBar user={data.user} unreadCount={data.unreadCount} onmenu={() => (sidebarOpen = true)} />
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-4 py-6 lg:px-6">
{@render children()}
+13
View File
@@ -0,0 +1,13 @@
import { countOverdueForCompany, listDueAndOverdue } from '$lib/server/services/maintenance';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) {
return { overdueCount: 0, upcomingSoon: [] as Awaited<ReturnType<typeof listDueAndOverdue>> };
}
const [overdueCount, upcomingSoon] = await Promise.all([
countOverdueForCompany(locals.company.id),
listDueAndOverdue({ companyId: locals.company.id, limit: 5, upcomingDays: 14 })
]);
return { overdueCount, upcomingSoon };
};
+39 -6
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function dayDelta(d: Date | string): number {
return Math.round((new Date(d).getTime() - Date.now()) / 86400000);
}
</script>
<div class="space-y-6">
@@ -18,13 +22,19 @@
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<a href="/maintenance" class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-primary-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-primary-700">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Overdue maintenance
</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100"></p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once maintenance schedules exist.</p>
</div>
<p class="mt-2 text-3xl font-bold {data.overdueCount > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'}">{data.overdueCount}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{#if data.overdueCount === 0}
All time-based schedules are on track.
{:else}
Time-based schedules past their next-due date.
{/if}
</p>
</a>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
My open tasks
@@ -41,8 +51,31 @@
</div>
</div>
{#if data.upcomingSoon.length > 0}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700/50 dark:bg-amber-900/20">
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">Due in the next 14 days</p>
<ul class="space-y-1 text-sm">
{#each data.upcomingSoon as r}
{@const d = dayDelta(r.nextDueAt!)}
<li class="flex items-center justify-between gap-3">
<a href="/assets/{r.assetId}/maintenance" class="truncate text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
{r.assetName}<span class="text-gray-500 dark:text-gray-400">{r.scheduleName}</span>
</a>
<span class="shrink-0 text-xs {d < 0 ? 'text-red-600 dark:text-red-400 font-medium' : 'text-amber-700 dark:text-amber-400'}">
{d < 0 ? `${-d}d overdue` : `in ${d}d`}
</span>
</li>
{/each}
</ul>
<a href="/maintenance" class="mt-2 inline-block text-xs text-amber-800 hover:underline dark:text-amber-300">View all →</a>
</div>
{/if}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 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">Phase 0 complete.</p>
<p class="mt-1">Auth, layout shell, storage interface, and the tenancy schema are wired. The remaining schema modules (projects, properties, assets, maintenance, checklists, decisions, documents, wiki, audit) land in Phase 1.</p>
<p class="font-medium text-gray-700 dark:text-gray-200">Phase 5 (partial) shipped.</p>
<p class="mt-1">
Printable QR labels per asset, S3 storage backend (switch via <code class="text-[11px]">STORAGE_BACKEND=s3</code>),
and CSV exports for assets / maintenance / project decisions are live. Notifications and cross-app APIs land in a later session.
</p>
</div>
</div>
@@ -0,0 +1,28 @@
import { error } from '@sveltejs/kit';
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetTypes, assetFieldDefs } from '$lib/server/db/schema/assets';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(401);
const types = await db
.select({
id: assetTypes.id,
name: assetTypes.name,
slug: assetTypes.slug,
icon: assetTypes.icon,
description: assetTypes.description,
companyId: assetTypes.companyId,
schemaVersion: assetTypes.schemaVersion,
fieldCount: sql<number>`(
select count(*)::int from ${assetFieldDefs}
where ${assetFieldDefs.assetTypeId} = ${assetTypes.id}
and ${assetFieldDefs.deprecatedAt} is null
)`
})
.from(assetTypes)
.where(or(isNull(assetTypes.companyId), eq(assetTypes.companyId, locals.company.id))!)
.orderBy(asc(assetTypes.name));
return { types };
};
@@ -0,0 +1,53 @@
<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">Asset types</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Catalog of "kinds of thing" you can track. System types are shared across every company; company types are yours to add and edit.
</p>
</div>
<a href="/admin/asset-types/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 type
</a>
</div>
<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">Slug</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Scope</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Fields</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schema v</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.types as t}
<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="/admin/asset-types/{t.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{t.name}</a>
{#if t.description}<div class="text-xs text-gray-500 dark:text-gray-400">{t.description}</div>{/if}
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{t.slug}</td>
<td class="px-4 py-2 text-xs">
{#if t.companyId === null}
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">system</span>
{:else}
<span class="rounded-full bg-primary-100 px-2 py-0.5 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">company</span>
{/if}
</td>
<td class="px-4 py-2 text-right text-sm text-gray-700 dark:text-gray-300">{t.fieldCount}</td>
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">v{t.schemaVersion}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,192 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { loadTypeWithFields } from '$lib/server/services/assets';
import {
addFieldDef,
deleteCompanyAssetType,
removeFieldDef,
updateCompanyAssetType,
updateFieldDef,
type FieldType
} from '$lib/server/services/asset-types';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const tf = await loadTypeWithFields(params.id);
if (!tf) throw error(404, 'Asset type not found');
// Tenant guard: company-scoped types must belong to the active company.
if (tf.type.companyId !== null && tf.type.companyId !== locals.company.id) {
throw error(404, 'Asset type not found');
}
const editable = tf.type.companyId === locals.company.id;
return { type: tf.type, fields: tf.fields, editable };
};
const FIELD_TYPES = [
'text',
'textarea',
'int',
'float',
'bool',
'date',
'ip',
'cidr',
'mac',
'enum',
'multi_enum',
'url',
'email',
'asset_ref'
] as const;
const MetaSchema = z.object({
name: z.string().trim().min(1).max(128),
icon: z.string().trim().max(64).optional().or(z.literal('')),
description: z.string().trim().max(2000).optional().or(z.literal(''))
});
const FieldSchema = z.object({
key: z.string().trim().max(64).optional().or(z.literal('')),
label: z.string().trim().min(1).max(128),
type: z.enum(FIELD_TYPES),
required: z.string().optional(),
enum_values: z.string().trim().max(2000).optional().or(z.literal('')),
unit: z.string().trim().max(32).optional().or(z.literal('')),
placeholder: z.string().trim().max(255).optional().or(z.literal('')),
help_text: z.string().trim().max(2000).optional().or(z.literal(''))
});
const FieldPatchSchema = z.object({
label: z.string().trim().min(1).max(128),
required: z.string().optional(),
enum_values: z.string().trim().max(2000).optional().or(z.literal('')),
unit: z.string().trim().max(32).optional().or(z.literal('')),
placeholder: z.string().trim().max(255).optional().or(z.literal('')),
help_text: z.string().trim().max(2000).optional().or(z.literal(''))
});
function parseEnumValues(raw: string | undefined): string[] | null {
if (!raw) return null;
const parts = raw
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
return parts.length > 0 ? parts : null;
}
export const actions: Actions = {
saveMeta: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = MetaSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
try {
await updateCompanyAssetType(locals.company.id, params.id, {
name: parsed.data.name,
icon: parsed.data.icon || null,
description: parsed.data.description || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
deleteType: async ({ locals, params }) => {
if (!locals.company) throw error(401);
try {
await deleteCompanyAssetType(locals.company.id, params.id);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
throw redirect(303, '/admin/asset-types');
},
addField: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = FieldSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
try {
await addFieldDef(locals.company.id, params.id, {
key: v.key || v.label,
label: v.label,
type: v.type as FieldType,
required: v.required === 'true',
enumValues: parseEnumValues(v.enum_values),
unit: v.unit || null,
placeholder: v.placeholder || null,
helpText: v.help_text || null
});
} catch (e) {
const msg = (e as Error).message;
if (msg.includes('asset_field_defs_type_key_uq')) {
return fail(400, { error: 'A field with that key already exists on this type.' });
}
return fail(400, { error: msg });
}
return { ok: true };
},
updateField: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const fieldId = String(form.get('field_id') ?? '');
if (!fieldId) return fail(400, { error: 'Missing field_id' });
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = FieldPatchSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
const enumVals = parseEnumValues(v.enum_values);
try {
await updateFieldDef(locals.company.id, fieldId, {
label: v.label,
required: v.required === 'true',
enumValues: raw.enum_values !== undefined ? enumVals : undefined,
unit: v.unit || null,
placeholder: v.placeholder || null,
helpText: v.help_text || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
removeField: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const fieldId = String(form.get('field_id') ?? '');
const force = form.get('force') === 'true';
if (!fieldId) return fail(400, { error: 'Missing field_id' });
try {
const res = await removeFieldDef(locals.company.id, fieldId, { force });
return {
ok: true,
deprecated: !res.hardDeleted
};
} catch (e) {
return fail(400, { error: (e as Error).message });
}
},
restoreField: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const fieldId = String(form.get('field_id') ?? '');
if (!fieldId) return fail(400, { error: 'Missing field_id' });
try {
await updateFieldDef(locals.company.id, fieldId, {
deprecatedAt: null
} as never);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};
@@ -0,0 +1,291 @@
<script lang="ts">
import { enhance } from '$app/forms';
import {
FIELD_TYPES,
FIELD_TYPE_LABEL,
needsEnumValues,
type FieldType
} from '$lib/field-types';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let editingMeta = $state(false);
let adding = $state(false);
let editingFieldId = $state<string | null>(null);
let confirmingDelete = $state(false);
// Controls conditional rendering of the "values" textarea in the new-field form.
let newFieldType = $state<FieldType>('text');
</script>
<div class="space-y-6">
<div>
<a href="/admin/asset-types" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all asset types</a>
<div class="mt-1 flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{data.type.name}</h1>
<div class="mt-1 flex flex-wrap gap-x-3 text-sm text-gray-500 dark:text-gray-400">
<span>slug <code class="font-mono text-xs">{data.type.slug}</code></span>
<span>schema v{data.type.schemaVersion}</span>
<span>
{#if data.editable}
<span class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">company type</span>
{:else}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">system type · read-only</span>
{/if}
</span>
</div>
{#if data.type.description}
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{data.type.description}</p>
{/if}
</div>
{#if data.editable && !editingMeta}
<button type="button" onclick={() => (editingMeta = true)}
class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Edit
</button>
{/if}
</div>
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
{#if editingMeta}
<form method="post" action="?/saveMeta"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingMeta = false;
}}
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</span>
<input name="name" required value={data.type.name}
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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Icon</span>
<input name="icon" value={data.type.icon ?? ''}
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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</span>
<textarea name="description" rows="3"
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">{data.type.description ?? ''}</textarea>
</label>
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (editingMeta = false)} class="text-sm text-gray-600 dark:text-gray-400">Cancel</button>
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Save</button>
</div>
</form>
{/if}
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Fields</h2>
{#if data.editable}
<button type="button" onclick={() => (adding = !adding)}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{adding ? 'Cancel' : '+ Add field'}
</button>
{/if}
</div>
{#if adding}
<form method="post" action="?/addField"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') {
adding = false;
newFieldType = 'text';
}
}}
class="mb-3 grid gap-3 rounded-lg border border-gray-200 bg-white p-4 sm:grid-cols-2 dark:border-gray-700 dark:bg-gray-800">
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Label <span class="text-red-500">*</span></span>
<input name="label" required placeholder="e.g. Max pressure"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Key</span>
<input name="key" placeholder="leave empty to derive from label"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-mono 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" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Type <span class="text-red-500">*</span></span>
<select name="type" required bind:value={newFieldType}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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">
{#each FIELD_TYPES as t}<option value={t}>{FIELD_TYPE_LABEL[t]}</option>{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
<input name="unit" placeholder="e.g. kW, °C, mm"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
{#if needsEnumValues(newFieldType)}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Values (comma or newline separated) <span class="text-red-500">*</span></span>
<textarea name="enum_values" rows="2" required placeholder="small, medium, large"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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"></textarea>
</label>
{/if}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Placeholder</span>
<input name="placeholder"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Help text</span>
<input name="help_text"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 sm:col-span-2">
<input type="checkbox" name="required" value="true"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
Required on new assets
</label>
<div class="sm:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Add field</button>
</div>
</form>
{/if}
{#if data.fields.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No fields defined.</p>
{: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">Key</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Label</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">Options</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Required</th>
{#if data.editable}
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">&nbsp;</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.fields as f}
<tr class={f.deprecatedAt ? 'opacity-50' : ''}>
{#if editingFieldId === f.id}
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{f.key}</td>
<td colspan={data.editable ? 5 : 4} class="px-4 py-2">
<form method="post" action="?/updateField"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingFieldId = null;
}}
class="grid gap-2 sm:grid-cols-2">
<input type="hidden" name="field_id" value={f.id} />
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Label</span>
<input name="label" required value={f.label}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
<input name="unit" value={f.unit ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Placeholder</span>
<input name="placeholder" value={f.placeholder ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
{#if needsEnumValues(f.type as FieldType)}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Values</span>
<textarea name="enum_values" rows="2" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{(f.enumValues ?? []).join(', ')}</textarea>
</label>
{/if}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Help text</span>
<input name="help_text" value={f.helpText ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 sm:col-span-2 dark:text-gray-300">
<input type="checkbox" name="required" value="true" checked={f.required}
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
Required
</label>
<div class="sm:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editingFieldId = null)} class="text-xs text-gray-500">Cancel</button>
<button type="submit" class="rounded-md bg-primary-600 px-2 py-1 text-xs font-medium text-white hover:bg-primary-700">Save</button>
</div>
</form>
</td>
{:else}
<td class="px-4 py-2 text-xs font-mono text-gray-700 dark:text-gray-300">{f.key}</td>
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">
{f.label}
{#if f.unit}<span class="text-xs text-gray-400">({f.unit})</span>{/if}
{#if f.deprecatedAt}<span class="ml-1 rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">deprecated</span>{/if}
</td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">{FIELD_TYPE_LABEL[f.type as FieldType] ?? f.type}</td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
{#if f.enumValues && f.enumValues.length > 0}
{f.enumValues.join(', ')}
{:else}
{/if}
</td>
<td class="px-4 py-2 text-right text-xs">
{#if f.required}
<span class="rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">required</span>
{:else}
<span class="text-gray-400 dark:text-gray-500">optional</span>
{/if}
</td>
{#if data.editable}
<td class="px-4 py-2 text-right">
<div class="flex justify-end gap-2 text-xs">
<button type="button" onclick={() => (editingFieldId = f.id)} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">edit</button>
{#if f.deprecatedAt}
<form method="post" action="?/restoreField" use:enhance class="inline">
<input type="hidden" name="field_id" value={f.id} />
<button type="submit" class="text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400">restore</button>
</form>
{:else}
<form method="post" action="?/removeField" use:enhance class="inline">
<input type="hidden" name="field_id" value={f.id} />
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
</form>
{/if}
</div>
</td>
{/if}
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{#if data.editable}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Field <code class="font-mono">key</code> and <code class="font-mono">type</code> are immutable after creation — changing them against existing JSONB data would corrupt it. Remove + re-add (and optionally script a JSONB migration) if you need to change a field's shape.
</p>
{/if}
{/if}
</div>
{#if data.editable}
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
{confirmingDelete ? 'Cancel delete' : 'Delete this type…'}
</button>
{#if confirmingDelete}
<form method="post" action="?/deleteType" use:enhance
class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
<p>Hard-delete this asset type and all of its field defs. Only works if no assets of this type exist — move or soft-delete them first.</p>
<div class="mt-2 flex justify-end gap-2">
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete type</button>
</div>
</form>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,41 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { createCompanyAssetType } from '$lib/server/services/asset-types';
import type { Actions } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(128),
slug: z.string().trim().max(64).optional().or(z.literal('')),
icon: z.string().trim().max(64).optional().or(z.literal('')),
description: z.string().trim().max(2000).optional().or(z.literal(''))
});
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const v = parsed.data;
try {
const { id } = await createCompanyAssetType({
companyId: locals.company.id,
name: v.name,
slug: v.slug || null,
icon: v.icon || null,
description: v.description || null
});
throw redirect(303, `/admin/asset-types/${id}`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
const msg = (e as Error).message ?? 'create failed';
if (msg.includes('asset_types_company_slug_uq')) {
return fail(400, { error: 'A type with that slug already exists in this company.', values: raw });
}
return fail(400, { error: msg, values: raw });
}
}
};
@@ -0,0 +1,59 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-xl space-y-6">
<div>
<a href="/admin/asset-types" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all asset types</a>
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">New asset type</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Custom company-scoped type. After creating it you can add typed fields
(IP, enum, int, etc.) on the next screen.
</p>
</div>
<form method="post"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></span>
<input name="name" required value={v.name ?? ''} placeholder="e.g. Security Camera"
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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
<input name="slug" value={v.slug ?? ''} placeholder="leave empty to derive from name"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono 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" />
<p class="mt-1 text-xs text-gray-400">Lowercase snake_case. Used internally; must be unique within your company.</p>
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Icon</span>
<input name="icon" value={v.icon ?? ''} placeholder="optional icon name or emoji"
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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</span>
<textarea name="description" rows="3"
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">{v.description ?? ''}</textarea>
</label>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/admin/asset-types" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Creating…' : 'Create type'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,76 @@
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
import { requireAdmin, requireCompany } from '$lib/server/auth/guards';
import { getCompany, updateCompany } from '$lib/server/services/companies';
import type { Actions, PageServerLoad } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(255),
slug: z.string().trim().min(1).max(128),
default_currency: z.string().trim().length(3).optional().or(z.literal('')),
matrix_room_id: z.string().trim().max(255).optional().or(z.literal(''))
});
interface CompanySettings {
default_currency?: string | null;
matrix_room_id?: string | null;
}
function parseSettings(raw: string | null | undefined): CompanySettings {
if (!raw) return {};
try {
return JSON.parse(raw) as CompanySettings;
} catch {
return {};
}
}
export const load: PageServerLoad = async ({ locals }) => {
const { company } = requireCompany(locals);
const full = await getCompany(company.id);
if (!full) throw new Error('active company row missing');
return {
fullCompany: full,
settings: parseSettings(full.settings),
isAdmin: company.role === 'admin'
};
};
export const actions: Actions = {
save: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
const existing = await getCompany(company.id);
if (!existing) return fail(404, { error: 'Company not found' });
const settings = parseSettings(existing.settings);
if (v.default_currency) settings.default_currency = v.default_currency.toUpperCase();
else delete settings.default_currency;
if (v.matrix_room_id) {
const trimmed = v.matrix_room_id.trim();
if (!/^![^:\s]+:[^:\s]+$/.test(trimmed)) {
return fail(400, { error: 'Matrix room id must look like !roomid:server' });
}
settings.matrix_room_id = trimmed;
} else {
delete settings.matrix_room_id;
}
try {
await updateCompany(company.id, {
name: v.name,
slug: v.slug,
settings: Object.keys(settings).length > 0 ? JSON.stringify(settings) : null
});
} catch (e) {
const msg = (e as Error).message;
if (msg.includes('companies_slug_unique')) {
return fail(400, { error: 'A company with that slug already exists.' });
}
return fail(400, { error: msg });
}
return { ok: true };
}
};
@@ -0,0 +1,64 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let saving = $state(false);
const c = $derived(data.fullCompany);
</script>
<div class="mx-auto max-w-2xl 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">Company settings</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{data.isAdmin ? 'Edit the active company.' : 'Read-only — only admins can change these.'}
</p>
</div>
<a href="/admin/company/new" class="text-sm text-primary-600 hover:underline dark:text-primary-400">+ Create new company</a>
</div>
<form method="post" action="?/save"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{:else if form?.ok}
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-900/20 dark:text-emerald-300">Saved.</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</span>
<input name="name" required value={c.name} disabled={!data.isAdmin}
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 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
<input name="slug" required value={c.slug} disabled={!data.isAdmin}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">Used internally. Lowercase, dashes only. Must be unique across the whole system.</p>
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default currency (ISO 3)</span>
<input name="default_currency" maxlength="3" placeholder="THB" value={data.settings.default_currency ?? ''} disabled={!data.isAdmin}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm uppercase shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Matrix room id</span>
<input name="matrix_room_id" placeholder="!abc123:matrix.org" value={data.settings.matrix_room_id ?? ''} disabled={!data.isAdmin}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">Format <code class="font-mono">!roomid:server.tld</code>. The bot (configured via <code class="font-mono">MATRIX_ACCESS_TOKEN</code>) must already be a member.</p>
</label>
{#if data.isAdmin}
<div class="flex justify-end border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Saving…' : 'Save changes'}
</button>
</div>
{/if}
</form>
</div>
@@ -0,0 +1,43 @@
import { fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { requireCompany } from '$lib/server/auth/guards';
import { setActiveCompany } from '$lib/server/auth/session';
import { createCompanyWithAdmin } from '$lib/server/services/companies';
import type { Actions } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(255),
slug: z.string().trim().max(128).optional().or(z.literal('')),
default_currency: z.string().trim().length(3).optional().or(z.literal(''))
});
export const actions: Actions = {
default: async ({ request, locals }) => {
const { user, sessionId } = requireCompany(locals);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
const v = parsed.data;
const settingsObj: Record<string, string> = {};
if (v.default_currency) settingsObj.default_currency = v.default_currency.toUpperCase();
try {
const { id } = await createCompanyWithAdmin({
name: v.name,
slug: v.slug || null,
settings: Object.keys(settingsObj).length ? JSON.stringify(settingsObj) : null,
creatorUserId: user.id
});
// Switch the session's active company so the creator lands in the new tenant.
await setActiveCompany(sessionId, id);
throw redirect(303, '/admin/company');
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
const msg = (e as Error).message;
if (msg.includes('companies_slug_unique')) {
return fail(400, { error: 'A company with that slug already exists.', values: raw });
}
return fail(400, { error: msg, values: raw });
}
}
};
@@ -0,0 +1,53 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-xl space-y-6">
<div>
<a href="/admin/company" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← company settings</a>
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">Create new company</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
You'll be added as the admin automatically. Your session switches to the new
company once it's created — use the sidebar to switch back later.
</p>
</div>
<form method="post"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></span>
<input name="name" required value={v.name ?? ''}
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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
<input name="slug" value={v.slug ?? ''} placeholder="leave empty to derive from name"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono 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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default currency (ISO 3)</span>
<input name="default_currency" maxlength="3" placeholder="THB" value={v.default_currency ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm uppercase 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" />
</label>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/admin/company" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Creating…' : 'Create company'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,89 @@
import { fail } from '@sveltejs/kit';
import { requireAdmin, requireCompany } from '$lib/server/auth/guards';
import {
listCompanyUsers,
removeUserFromCompany,
resetUserPassword,
setUserActive,
setUserRoleInCompany,
updateDisplayName,
type CompanyRole
} from '$lib/server/services/users';
import type { Actions, PageServerLoad } from './$types';
const ROLES = ['admin', 'manager', 'user', 'viewer'] as const;
export const load: PageServerLoad = async ({ locals }) => {
const { company, user } = requireCompany(locals);
const rows = await listCompanyUsers(company.id);
return { users: rows, selfUserId: user.id, isAdmin: company.role === 'admin' };
};
export const actions: Actions = {
setRole: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const role = String(form.get('role') ?? '');
if (!userId || !ROLES.includes(role as CompanyRole)) {
return fail(400, { error: 'Invalid request' });
}
try {
await setUserRoleInCompany(company.id, userId, role as CompanyRole);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
remove: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await removeUserFromCompany(company.id, userId);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
setActive: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const active = form.get('active') === 'true';
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await setUserActive(company.id, userId, active);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
rename: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const displayName = String(form.get('display_name') ?? '');
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await updateDisplayName(company.id, userId, displayName);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
resetPassword: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const password = String(form.get('password') ?? '');
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await resetUserPassword(company.id, userId, password);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { COMPANY_ROLES, COMPANY_ROLE_LABEL, type CompanyRole } from '$lib/roles';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let resettingId = $state<string | null>(null);
let renamingId = $state<string | null>(null);
let resetPw = $state('');
let renameValue = $state('');
</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">Users</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{data.isAdmin
? 'Manage who has access to this company and what they can do.'
: 'Read-only view — only admins can invite or change roles.'}
</p>
</div>
{#if data.isAdmin}
<a href="/admin/users/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">
+ Invite user
</a>
{/if}
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<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">Email</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Role</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last login</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>
{#if data.isAdmin}
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.users as u}
{@const isSelf = u.userId === data.selfUserId}
<tr class={u.isActive ? '' : 'opacity-60'}>
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{#if renamingId === u.userId}
<form method="post" action="?/rename"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') renamingId = null;
}}
class="flex items-center gap-2">
<input type="hidden" name="user_id" value={u.userId} />
<input name="display_name" required bind:value={renameValue}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<button type="submit" class="rounded bg-primary-600 px-2 py-0.5 text-xs font-medium text-white hover:bg-primary-700">save</button>
<button type="button" onclick={() => (renamingId = null)} class="text-xs text-gray-500">×</button>
</form>
{:else}
{u.displayName}
{#if isSelf}<span class="ml-1 text-xs text-gray-400">(you)</span>{/if}
{/if}
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{u.email}</td>
<td class="px-4 py-2 text-sm">
{#if data.isAdmin && !isSelf}
<form method="post" action="?/setRole" use:enhance class="inline">
<input type="hidden" name="user_id" value={u.userId} />
<select name="role" onchange={(ev) => (ev.currentTarget.form as HTMLFormElement).requestSubmit()}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each COMPANY_ROLES as r}
<option value={r} selected={u.role === r}>{COMPANY_ROLE_LABEL[r]}</option>
{/each}
</select>
</form>
{:else}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 capitalize dark:bg-gray-700 dark:text-gray-200">{u.role}</span>
{/if}
</td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString() : 'never'}
</td>
<td class="px-4 py-2 text-xs">
{#if u.isActive}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">active</span>
{:else}
<span class="rounded-full bg-gray-200 px-2 py-0.5 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">inactive</span>
{/if}
</td>
{#if data.isAdmin}
<td class="px-4 py-2 text-right">
{#if resettingId === u.userId}
<form method="post" action="?/resetPassword"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') { resettingId = null; resetPw = ''; }
}}
class="flex items-center justify-end gap-2">
<input type="hidden" name="user_id" value={u.userId} />
<input name="password" type="text" required minlength="8" placeholder="new password" bind:value={resetPw}
class="w-40 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<button type="submit" class="rounded bg-primary-600 px-2 py-0.5 text-xs font-medium text-white hover:bg-primary-700">set</button>
<button type="button" onclick={() => { resettingId = null; resetPw = ''; }} class="text-xs text-gray-500">×</button>
</form>
{:else}
<div class="flex justify-end gap-2 text-xs">
<button type="button" onclick={() => { renamingId = u.userId; renameValue = u.displayName; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">rename</button>
<button type="button" onclick={() => { resettingId = u.userId; resetPw = ''; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">reset pw</button>
{#if !isSelf}
<form method="post" action="?/setActive" use:enhance class="inline">
<input type="hidden" name="user_id" value={u.userId} />
<input type="hidden" name="active" value={(!u.isActive).toString()} />
<button type="submit" class="text-gray-400 hover:text-amber-600 dark:hover:text-amber-400">{u.isActive ? 'deactivate' : 'reactivate'}</button>
</form>
<form method="post" action="?/remove" use:enhance class="inline">
<input type="hidden" name="user_id" value={u.userId} />
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
</form>
{/if}
</div>
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,45 @@
import { fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { requireAdmin } from '$lib/server/auth/guards';
import {
createUserAndAddToCompany,
type CompanyRole
} from '$lib/server/services/users';
import type { Actions } from './$types';
const Schema = z.object({
email: z.string().trim().email(),
display_name: z.string().trim().min(1).max(255),
password: z.string().min(8).max(256),
role: z.enum(['admin', 'manager', 'user', 'viewer'])
});
export const load = async ({ locals }: { locals: App.Locals }) => {
requireAdmin(locals);
};
export const actions: Actions = {
default: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const v = parsed.data;
try {
await createUserAndAddToCompany({
companyId: company.id,
email: v.email,
displayName: v.display_name,
password: v.password,
role: v.role as CompanyRole
});
throw redirect(303, '/admin/users');
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message, values: raw });
}
}
};
@@ -0,0 +1,70 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { COMPANY_ROLES, COMPANY_ROLE_DESCRIPTION, COMPANY_ROLE_LABEL } from '$lib/roles';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-xl space-y-6">
<div>
<a href="/admin/users" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to users</a>
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">Invite user</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Creates the user (or reuses an existing one with the same email) and adds them to this company.
Share the temporary password out-of-band; they can change it after logging in.
</p>
</div>
<form method="post"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Email <span class="text-red-500">*</span></span>
<input name="email" type="email" required value={v.email ?? ''} autocomplete="off"
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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Display name <span class="text-red-500">*</span></span>
<input name="display_name" required value={v.display_name ?? ''}
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" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Temporary password <span class="text-red-500">*</span></span>
<input name="password" type="text" required minlength="8" autocomplete="off"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono 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" />
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters. Share with the user via a secure channel.</p>
</label>
<div>
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Role <span class="text-red-500">*</span></span>
<div class="mt-1 space-y-2">
{#each COMPANY_ROLES as r}
<label class="flex items-start gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm hover:border-primary-300 dark:border-gray-700 dark:hover:border-primary-700">
<input type="radio" name="role" value={r} required checked={r === 'user'}
class="mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">{COMPANY_ROLE_LABEL[r]}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{COMPANY_ROLE_DESCRIPTION[r]}</div>
</div>
</label>
{/each}
</div>
</div>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/admin/users" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</div>
+27
View File
@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { and, asc, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetTypes } from '$lib/server/db/schema/assets';
import { listAssets } from '$lib/server/services/assets';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const typeSlug = url.searchParams.get('type') ?? undefined;
const q = url.searchParams.get('q') ?? undefined;
const types = await db
.select({ id: assetTypes.id, name: assetTypes.name, slug: assetTypes.slug })
.from(assetTypes)
.where(or(isNull(assetTypes.companyId), sql`${assetTypes.companyId} = ${locals.company.id}`)!)
.orderBy(asc(assetTypes.name));
const assets = await listAssets({
companyId: locals.company.id,
typeSlug,
q,
limit: 200
});
return { assets, types, filterType: typeSlug ?? '', filterQ: q ?? '' };
};
+74
View File
@@ -0,0 +1,74 @@
<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>
@@ -0,0 +1,73 @@
import { error } from '@sveltejs/kit';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import { projects } from '$lib/server/db/schema/projects';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { loadTypeWithFields } from '$lib/server/services/assets';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const [asset] = await db
.select()
.from(assets)
.where(
and(
eq(assets.id, params.id),
eq(assets.companyId, locals.company.id),
isNull(assets.deletedAt)
)
)
.limit(1);
if (!asset) throw error(404, 'Asset not found');
const tf = await loadTypeWithFields(asset.assetTypeId);
if (!tf) throw error(500, 'Asset type missing');
let currentLocationName: string | null = null;
let currentLocationHref: string | null = null;
let currentRoomLabel: string | null = null;
if (asset.currentPropertyId) {
const [p] = await db
.select({ name: properties.name })
.from(properties)
.where(eq(properties.id, asset.currentPropertyId))
.limit(1);
currentLocationName = p?.name ?? null;
currentLocationHref = `/properties/${asset.currentPropertyId}`;
if (asset.currentRoomId) {
const [r] = await db
.select({
name: propertyRooms.name,
floorLabel: propertyFloors.label
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.where(eq(propertyRooms.id, asset.currentRoomId))
.limit(1);
if (r) {
currentRoomLabel = r.floorLabel ? `Floor ${r.floorLabel} · ${r.name}` : r.name;
}
}
} else if (asset.currentProjectId) {
const [p] = await db
.select({ name: projects.name })
.from(projects)
.where(eq(projects.id, asset.currentProjectId))
.limit(1);
currentLocationName = p?.name ?? null;
currentLocationHref = `/projects/${asset.currentProjectId}/assets`;
}
return {
asset,
assetType: tf.type,
fieldDefs: tf.fields,
currentLocationName,
currentLocationHref,
currentRoomLabel
};
};
@@ -0,0 +1,47 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import TabNav from '$lib/components/TabNav.svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const tabs = $derived([
{ href: `/assets/${data.asset.id}`, label: 'Overview' },
{ href: `/assets/${data.asset.id}/maintenance`, label: 'Maintenance' },
{ href: `/assets/${data.asset.id}/history`, label: 'History' },
{ href: `/assets/${data.asset.id}/logs`, label: 'Logs' },
{ href: `/assets/${data.asset.id}/documents`, label: 'Documents' },
{ href: `/assets/${data.asset.id}/move`, label: 'Move' }
]);
</script>
<div class="space-y-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">
{data.assetType.name}
</div>
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">
{data.asset.name}
</h1>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
{#if data.currentLocationName && data.currentLocationHref}
<span>at <a href={data.currentLocationHref} class="text-primary-600 hover:underline dark:text-primary-400">{data.currentLocationName}</a> <span class="text-gray-400">({data.asset.currentContainerKind})</span></span>
{/if}
{#if data.currentRoomLabel}
<span>· {data.currentRoomLabel}</span>
{/if}
{#if data.asset.tag}<span>· tag <code class="font-mono text-xs">{data.asset.tag}</code></span>{/if}
{#if data.asset.serialNumber}<span>· s/n <code class="font-mono text-xs">{data.asset.serialNumber}</code></span>{/if}
</div>
</div>
<a href="/assets/{data.asset.id}/label"
class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Print label
</a>
</div>
<TabNav {tabs} />
{@render children()}
</div>
@@ -0,0 +1,87 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { and, asc, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '$lib/server/db/client';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { loadTypeWithFields, softDeleteAsset, updateAsset } from '$lib/server/services/assets';
import { gatherCustomFieldsFromForm } from '$lib/server/custom-fields-form';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, parent }) => {
if (!locals.company) throw error(401);
const { asset } = await parent();
let rooms: Array<{ id: string; name: string; floorLabel: string | null }> = [];
if (asset.currentContainerKind === 'property' && asset.currentPropertyId) {
rooms = await db
.select({
id: propertyRooms.id,
name: propertyRooms.name,
floorLabel: propertyFloors.label
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.where(
and(
eq(propertyRooms.propertyId, asset.currentPropertyId),
isNull(propertyRooms.deletedAt)
)
)
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name));
}
return { rooms };
};
const PatchSchema = z.object({
name: z.string().trim().min(1).max(255),
tag: z.string().trim().max(64).optional().or(z.literal('')),
serial_number: z.string().trim().max(128).optional().or(z.literal('')),
manufacturer: z.string().trim().max(128).optional().or(z.literal('')),
model: z.string().trim().max(128).optional().or(z.literal('')),
purchased_at: z.string().trim().optional().or(z.literal('')),
room_id: z.string().optional().or(z.literal(''))
});
const e2n = (s: string | undefined) => (!s ? null : s);
export const actions: Actions = {
save: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = PatchSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
const tf = await loadTypeWithFields(form.get('asset_type_id') as string);
if (!tf) return fail(400, { error: 'Asset type not found.' });
const cf = gatherCustomFieldsFromForm(form, tf.fields);
// Room field is only included when a property asset is being edited.
// Empty string = clear room; uuid = set; undefined = leave alone.
const roomPatch: { roomId?: string | null } = {};
if (form.has('room_id')) {
roomPatch.roomId = v.room_id ? v.room_id : null;
}
try {
await updateAsset(locals.company.id, params.id, {
name: v.name,
tag: e2n(v.tag),
serialNumber: e2n(v.serial_number),
manufacturer: e2n(v.manufacturer),
model: e2n(v.model),
purchasedAt: v.purchased_at ? new Date(v.purchased_at) : null,
customFields: cf,
...roomPatch
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await softDeleteAsset(locals.company.id, params.id);
throw redirect(303, '/assets');
}
};
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
import { enhance } from '$app/forms';
import CustomFieldsForm from '$lib/components/CustomFieldsForm.svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let saving = $state(false);
let confirmingDelete = $state(false);
const a = $derived(data.asset);
function dateInput(d: Date | string | null): string {
if (!d) return '';
const dt = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0, 10);
}
</script>
<form
method="post"
action="?/save"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{:else if form?.ok}
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-900/20 dark:text-emerald-300">Saved.</div>
{/if}
<input type="hidden" name="asset_type_id" value={a.assetTypeId} />
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required value={a.name}
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>
<label for="tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Asset tag</label>
<input id="tag" name="tag" value={a.tag ?? ''}
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>
<label for="serial_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Serial number</label>
<input id="serial_number" name="serial_number" value={a.serialNumber ?? ''}
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>
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
<input id="manufacturer" name="manufacturer" value={a.manufacturer ?? ''}
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>
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
<input id="model" name="model" value={a.model ?? ''}
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>
<label for="purchased_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Purchased on</label>
<input id="purchased_at" name="purchased_at" type="date" value={dateInput(a.purchasedAt)}
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>
{#if a.currentContainerKind === 'property'}
<div class="sm:col-span-2">
<label for="room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room</label>
<select id="room_id" name="room_id"
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="">— no specific room —</option>
{#each data.rooms as r}
<option value={r.id} selected={a.currentRoomId === r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
{/each}
</select>
{#if data.rooms.length === 0}
<p class="mt-1 text-xs text-gray-400">This property has no rooms yet. Add them from the property's Rooms tab.</p>
{/if}
</div>
{/if}
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">{data.assetType.name} details</div>
<CustomFieldsForm defs={data.fieldDefs} values={a.customFields as Record<string, unknown>} />
</div>
<div class="flex items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
{confirmingDelete ? 'Cancel delete' : 'Delete asset…'}
</button>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
{#if confirmingDelete}
<form method="post" action="?/delete"
class="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
<p class="font-medium">Delete this asset?</p>
<p class="mt-1">Soft-deletes the asset; history and documents stay on disk.</p>
<div class="mt-3 flex justify-end gap-2">
<button type="button" onclick={() => (confirmingDelete = false)} class="text-sm text-red-700 dark:text-red-300">Cancel</button>
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete</button>
</div>
</form>
{/if}
@@ -0,0 +1,58 @@
import { error, fail } from '@sveltejs/kit';
import {
deleteDocument,
listDocumentsForScope,
signedUrlForDocument,
uploadDocument
} from '$lib/server/services/documents';
import type { Actions, PageServerLoad } from './$types';
const MAX_BYTES = 50 * 1024 * 1024;
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const docs = await listDocumentsForScope(locals.company.id, 'asset', params.id);
const enriched = await Promise.all(
docs.map(async (d) => ({
...d,
downloadUrl: await signedUrlForDocument(d, 'attachment'),
previewUrl: await signedUrlForDocument(d, 'inline')
}))
);
return { documents: enriched };
};
export const actions: Actions = {
upload: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const file = form.get('file');
if (!(file instanceof File) || file.size === 0) {
return fail(400, { error: 'Pick a file to upload.' });
}
if (file.size > MAX_BYTES) return fail(413, { error: 'File too large (max 50 MB).' });
const buf = Buffer.from(await file.arrayBuffer());
try {
await uploadDocument({
companyId: locals.company.id,
uploadedBy: locals.user.id,
scopeType: 'asset',
scopeId: params.id,
filename: file.name || 'upload.bin',
mimeType: file.type || 'application/octet-stream',
body: buf
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
delete: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (!id) return fail(400, { error: 'Missing id' });
await deleteDocument(locals.company.id, id);
return { ok: true };
}
};
@@ -0,0 +1,63 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let uploading = $state(false);
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
</script>
<div class="space-y-4">
<form
method="post"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return ({ update }) => update().finally(() => (uploading = false));
}}
class="rounded-lg border border-dashed border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<input type="file" name="file" required
class="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-900/30 dark:file:text-primary-300" />
<button type="submit" disabled={uploading}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{uploading ? 'Uploading…' : 'Upload'}
</button>
</div>
{#if form?.error}<p class="mt-2 text-sm text-red-600 dark:text-red-400">{form.error}</p>{/if}
{#if form?.ok}<p class="mt-2 text-sm text-emerald-600 dark:text-emerald-400">Done.</p>{/if}
</form>
{#if data.documents.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 documents attached.
</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.documents as d}
<li class="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div class="min-w-0">
<a href={d.previewUrl} target="_blank" rel="noopener" class="block truncate font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{d.filename}</a>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{d.mimeType} · {fmtSize(d.sizeBytes)} · {new Date(d.uploadedAt).toLocaleString()}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<a href={d.downloadUrl} class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">Download</a>
<form method="post" action="?/delete" use:enhance>
<input type="hidden" name="id" value={d.id} />
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Delete</button>
</form>
</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,31 @@
import { error } from '@sveltejs/kit';
import { aliasedTable, desc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetLocationHistory } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import { users } from '$lib/server/db/schema/tenancy';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const fromProp = aliasedTable(properties, 'from_prop');
const toProp = aliasedTable(properties, 'to_prop');
const rows = await db
.select({
id: assetLocationHistory.id,
fromKind: assetLocationHistory.fromKind,
fromPropertyName: fromProp.name,
toKind: assetLocationHistory.toKind,
toPropertyName: toProp.name,
movedAt: assetLocationHistory.movedAt,
movedByName: users.displayName,
reason: assetLocationHistory.reason
})
.from(assetLocationHistory)
.leftJoin(fromProp, eq(fromProp.id, assetLocationHistory.fromPropertyId))
.leftJoin(toProp, eq(toProp.id, assetLocationHistory.toPropertyId))
.leftJoin(users, eq(users.id, assetLocationHistory.movedBy))
.where(eq(assetLocationHistory.assetId, params.id))
.orderBy(desc(assetLocationHistory.movedAt));
return { history: rows };
};
@@ -0,0 +1,34 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-3">
{#if data.history.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 movements recorded yet.
</div>
{:else}
<ol class="relative space-y-4 border-l border-gray-200 pl-4 dark:border-gray-700">
{#each data.history as h}
<li class="relative">
<span class="absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full bg-primary-500"></span>
<div class="flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-900 dark:text-gray-100">
{h.fromKind ? h.fromPropertyName ?? '(unknown)' : '— created —'}
</span>
<span class="text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-gray-100">{h.toPropertyName ?? '(unknown)'}</span>
</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{new Date(h.movedAt).toLocaleString()}
{#if h.movedByName}· by {h.movedByName}{/if}
</div>
{#if h.reason}
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{h.reason}</div>
{/if}
</li>
{/each}
</ol>
{/if}
</div>
@@ -0,0 +1,8 @@
import { env } from '$lib/server/env';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// Absolute URL is what scanners land on. The layout already loaded the asset,
// so we only need to hand down the base URL (not available in the client bundle).
return { publicBaseUrl: env.PUBLIC_BASE_URL.replace(/\/$/, '') };
};
@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const scanUrl = $derived(`${data.publicBaseUrl}/assets/${data.asset.id}`);
const qrSrc = $derived(`/api/qr?size=320&target=${encodeURIComponent(scanUrl)}`);
function doPrint() {
if (typeof window !== 'undefined') window.print();
}
</script>
<div class="space-y-4 print:space-y-0">
<div class="flex items-center justify-between print:hidden">
<a href="/assets/{data.asset.id}" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to asset</a>
<button type="button" onclick={doPrint}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
Print label
</button>
</div>
<!-- Printable card: centered, bounded so it fits on a typical inkjet label or half-A6 -->
<div class="label-card mx-auto max-w-sm rounded-lg border border-gray-300 bg-white p-6 text-gray-900 print:m-0 print:border-0 print:shadow-none">
<div class="flex items-center justify-between">
<div class="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
{data.assetType.name}
</div>
{#if data.asset.tag}
<div class="font-mono text-xs text-gray-600">{data.asset.tag}</div>
{/if}
</div>
<div class="mt-2 text-base font-semibold leading-tight">{data.asset.name}</div>
{#if data.asset.manufacturer || data.asset.model}
<div class="mt-0.5 text-xs text-gray-600">
{[data.asset.manufacturer, data.asset.model].filter(Boolean).join(' · ')}
</div>
{/if}
{#if data.asset.serialNumber}
<div class="mt-0.5 text-[11px] text-gray-500">s/n <span class="font-mono">{data.asset.serialNumber}</span></div>
{/if}
{#if data.currentLocationName}
<div class="mt-0.5 text-[11px] text-gray-500">@ {data.currentLocationName}</div>
{/if}
<div class="my-3 flex items-center justify-center">
<img src={qrSrc} alt="QR code for {data.asset.name}" class="h-48 w-48" />
</div>
<div class="text-center font-mono text-[10px] break-all text-gray-500">{scanUrl}</div>
</div>
</div>
<style>
@media print {
:global(body),
:global(html) {
background: white !important;
}
:global(aside),
:global(header),
:global(nav) {
display: none !important;
}
:global(main > div) {
padding: 0 !important;
max-width: none !important;
}
.label-card {
box-shadow: none !important;
border: 1px solid #d1d5db !important;
}
}
</style>
@@ -0,0 +1,39 @@
import { error, fail } from '@sveltejs/kit';
import { desc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetLogs } from '$lib/server/db/schema/assets';
import { users } from '$lib/server/db/schema/tenancy';
import { appendAssetLog } from '$lib/server/services/assets';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const rows = await db
.select({
id: assetLogs.id,
body: assetLogs.body,
createdAt: assetLogs.createdAt,
authorName: users.displayName
})
.from(assetLogs)
.leftJoin(users, eq(users.id, assetLogs.authorId))
.where(eq(assetLogs.assetId, params.id))
.orderBy(desc(assetLogs.createdAt))
.limit(200);
return { logs: rows };
};
export const actions: Actions = {
add: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const body = String(form.get('body') ?? '').trim();
if (!body) return fail(400, { error: 'Write something first.' });
try {
await appendAssetLog(locals.company.id, params.id, locals.user.id, body);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};
@@ -0,0 +1,58 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let body = $state('');
let posting = $state(false);
</script>
<div class="space-y-4">
<form
method="post"
action="?/add"
use:enhance={() => {
posting = true;
return ({ update }) =>
update().finally(() => {
posting = false;
body = '';
});
}}
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<textarea
name="body"
bind:value={body}
rows="3"
placeholder="Add a note — observation, repair, change, anything…"
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 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
></textarea>
{#if form?.error}
<p class="text-sm text-red-600 dark:text-red-400">{form.error}</p>
{/if}
<div class="flex justify-end">
<button type="submit" disabled={posting || !body.trim()}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{posting ? 'Posting…' : 'Post note'}
</button>
</div>
</form>
{#if data.logs.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 log entries yet.
</div>
{:else}
<ul class="space-y-3">
{#each data.logs as l}
<li class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 text-xs text-gray-500 dark:text-gray-400">
{l.authorName ?? '(unknown)'} · {new Date(l.createdAt).toLocaleString()}
</div>
<div class="whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-100">{l.body}</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,139 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { listTemplates } from '$lib/server/services/checklists';
import {
createSchedule,
deleteSchedule,
listEventsForAsset,
listSchedulesForAsset,
listUsageReadingsForAsset,
recordMaintenanceEvent,
recordUsageReading,
setScheduleActive,
type IntervalUnit,
type ScheduleKind
} from '$lib/server/services/maintenance';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const [schedules, events, readings, templates] = await Promise.all([
listSchedulesForAsset(locals.company.id, params.id),
listEventsForAsset(locals.company.id, params.id),
listUsageReadingsForAsset(locals.company.id, params.id),
listTemplates(locals.company.id)
]);
return {
schedules,
events,
readings,
templates
};
};
const ScheduleSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.enum(['time', 'usage']),
interval_value: z.coerce.number().int().positive(),
interval_unit: z.enum(['days', 'months', 'years', 'hours', 'cycles', 'km']),
checklist_template_id: z.string().uuid().optional().or(z.literal('')),
start_from: z.string().optional().or(z.literal('')),
start_usage: z.coerce.number().optional().or(z.literal('')),
notes: z.string().trim().max(2000).optional().or(z.literal(''))
});
export const actions: Actions = {
createSchedule: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = ScheduleSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
try {
await createSchedule({
companyId: locals.company.id,
createdBy: locals.user.id,
assetId: params.id,
name: v.name,
kind: v.kind as ScheduleKind,
intervalValue: v.interval_value,
intervalUnit: v.interval_unit as IntervalUnit,
startFrom: v.start_from ? new Date(v.start_from) : null,
startUsage:
typeof v.start_usage === 'number' ? v.start_usage : null,
checklistTemplateId: v.checklist_template_id || null,
notes: v.notes || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
completeEvent: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const scheduleId = String(form.get('schedule_id') ?? '');
const performedAtStr = String(form.get('performed_at') ?? '').trim();
const usageReadingStr = String(form.get('usage_reading') ?? '').trim();
const notes = String(form.get('notes') ?? '').trim() || null;
const instantiate = form.get('instantiate_checklist') === 'true';
if (!scheduleId) return fail(400, { error: 'Missing schedule_id' });
try {
const { eventId, checklistInstanceId } = await recordMaintenanceEvent({
companyId: locals.company.id,
performedBy: locals.user.id,
scheduleId,
performedAt: performedAtStr ? new Date(performedAtStr) : new Date(),
notes,
usageReading: usageReadingStr ? Number(usageReadingStr) : null,
instantiateChecklist: instantiate
});
if (checklistInstanceId) {
throw redirect(303, `/assets/${params.id}/maintenance/events/${eventId}`);
}
return { ok: true, eventId };
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
},
addUsageReading: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const reading = Number(form.get('reading') ?? '');
const unit = String(form.get('unit') ?? '') as IntervalUnit;
const notes = String(form.get('notes') ?? '').trim() || null;
if (!Number.isFinite(reading)) return fail(400, { error: 'Reading must be a number.' });
try {
await recordUsageReading({
companyId: locals.company.id,
recordedBy: locals.user.id,
assetId: params.id,
reading,
unit,
notes
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
toggleScheduleActive: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const scheduleId = String(form.get('schedule_id') ?? '');
const active = form.get('active') === 'true';
await setScheduleActive(locals.company.id, scheduleId, active);
return { ok: true };
},
deleteSchedule: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const scheduleId = String(form.get('schedule_id') ?? '');
await deleteSchedule(locals.company.id, scheduleId);
return { ok: true };
}
};
@@ -0,0 +1,280 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showSchedForm = $state(false);
let showUsageForm = $state(false);
let kind = $state<'time' | 'usage'>('time');
let openCompleteFor = $state<string | null>(null);
function statusFor(nextDueAt: Date | string | null | undefined): {
label: string;
cls: string;
} {
if (!nextDueAt) return { label: '—', cls: 'text-gray-400' };
const d = new Date(nextDueAt);
const ms = d.getTime() - Date.now();
const days = Math.round(ms / (1000 * 60 * 60 * 24));
if (days < 0)
return { label: `${-days} day${-days === 1 ? '' : 's'} overdue`, cls: 'text-red-600 dark:text-red-400 font-medium' };
if (days <= 7)
return { label: `due in ${days} day${days === 1 ? '' : 's'}`, cls: 'text-amber-600 dark:text-amber-400 font-medium' };
return { label: d.toLocaleDateString(), cls: 'text-gray-600 dark:text-gray-300' };
}
const TIME_UNITS = ['days', 'months', 'years', 'hours'];
const USAGE_UNITS = ['hours', 'cycles', 'km'];
</script>
<div class="space-y-6">
<!-- ============== schedules ============== -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schedules</h2>
<button type="button" onclick={() => (showSchedForm = !showSchedForm)}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showSchedForm ? 'Cancel' : '+ New schedule'}
</button>
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
{#if showSchedForm}
<form method="post" action="?/createSchedule" use:enhance
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="grid gap-3 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required placeholder="e.g. Filter replacement"
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>
<label for="kind" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Kind</label>
<select id="kind" name="kind" bind:value={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">
<option value="time">Time-based</option>
<option value="usage">Usage-based</option>
</select>
</div>
<div>
<label for="interval_value" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Every</label>
<div class="mt-1 flex gap-2">
<input id="interval_value" name="interval_value" type="number" min="1" required value="1"
class="block w-24 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" />
<select name="interval_unit"
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 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each (kind === 'time' ? TIME_UNITS : USAGE_UNITS) as u}
<option value={u}>{u}</option>
{/each}
</select>
</div>
</div>
{#if kind === 'time'}
<div>
<label for="start_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Anchor (optional)</label>
<input id="start_from" name="start_from" type="date"
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" />
<p class="mt-1 text-xs text-gray-400">First service due = anchor + interval. Defaults to today.</p>
</div>
{:else}
<div>
<label for="start_usage" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Current usage</label>
<input id="start_usage" name="start_usage" type="number" step="any" placeholder="0"
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" />
<p class="mt-1 text-xs text-gray-400">Next service due at this + interval.</p>
</div>
{/if}
<div class="sm:col-span-2">
<label for="checklist_template_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Checklist template (optional)</label>
<select id="checklist_template_id" name="checklist_template_id"
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="">— none —</option>
{#each data.templates as t}
<option value={t.id}>{t.name} ({t.itemCount} items)</option>
{/each}
</select>
</div>
<div class="sm:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<input id="notes" name="notes"
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="flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Create schedule</button>
</div>
</form>
{/if}
{#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 schedules yet.
</div>
{:else}
<ul class="space-y-2">
{#each data.schedules as s}
{@const st = statusFor(s.nextDueAt)}
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800 {!s.active ? 'opacity-60' : ''}">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{s.name}</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
every {s.intervalValue} {s.intervalUnit}
· {s.kind}
{#if s.kind === 'time'}
· next: <span class={st.cls}>{st.label}</span>
{:else}
· next at usage <span class="font-medium">{s.nextDueUsage ?? '—'} {s.intervalUnit}</span>
{/if}
{#if !s.active}
· <span class="rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">inactive</span>
{/if}
</div>
{#if s.notes}<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{s.notes}</div>{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button type="button" onclick={() => (openCompleteFor = openCompleteFor === s.id ? null : s.id)}
class="rounded-md bg-emerald-600 px-2 py-1 text-xs font-medium text-white hover:bg-emerald-700">
{openCompleteFor === s.id ? 'Cancel' : 'Complete'}
</button>
<form method="post" action="?/toggleScheduleActive" use:enhance>
<input type="hidden" name="schedule_id" value={s.id} />
<input type="hidden" name="active" value={(!s.active).toString()} />
<button type="submit" class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
{s.active ? 'Pause' : 'Resume'}
</button>
</form>
<form method="post" action="?/deleteSchedule" use:enhance>
<input type="hidden" name="schedule_id" value={s.id} />
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Delete</button>
</form>
</div>
</div>
{#if openCompleteFor === s.id}
<form method="post" action="?/completeEvent" use:enhance
class="mt-3 space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<input type="hidden" name="schedule_id" value={s.id} />
<div class="grid gap-3 sm:grid-cols-2">
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Performed at</span>
<input name="performed_at" type="datetime-local"
value={new Date().toISOString().slice(0, 16)}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
{#if s.kind === 'usage'}
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Current reading ({s.intervalUnit}) <span class="text-red-500">*</span></span>
<input name="usage_reading" type="number" step="any" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
{/if}
</div>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
<input name="notes"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
{#if s.checklistTemplateId}
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="instantiate_checklist" value="true" checked
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
Materialize the checklist for this event
</label>
{/if}
<div class="flex justify-end">
<button type="submit" class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700">Record event</button>
</div>
</form>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
<!-- ============== usage readings ============== -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Usage readings</h2>
<button type="button" onclick={() => (showUsageForm = !showUsageForm)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
{showUsageForm ? 'Cancel' : '+ Reading'}
</button>
</div>
{#if showUsageForm}
<form method="post" action="?/addUsageReading" use:enhance
class="grid gap-3 rounded-lg border border-gray-200 bg-white p-4 sm:grid-cols-3 dark:border-gray-700 dark:bg-gray-800">
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Reading</span>
<input name="reading" type="number" step="any" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
<select name="unit" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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">
{#each USAGE_UNITS as u}
<option value={u}>{u}</option>
{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
<input name="notes"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 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" />
</label>
<div class="sm:col-span-3 flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Add reading</button>
</div>
</form>
{/if}
{#if data.readings.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No readings recorded.</p>
{: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.readings as r}
<li class="flex items-center justify-between px-4 py-2 text-sm">
<div>
<span class="font-medium text-gray-900 dark:text-gray-100">{r.reading}</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">{r.unit}</span>
{#if r.notes}<span class="ml-3 text-xs text-gray-500 dark:text-gray-400">{r.notes}</span>{/if}
</div>
<span class="text-xs text-gray-400">{new Date(r.recordedAt).toLocaleString()}</span>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ============== events history ============== -->
<section class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Recent events</h2>
{#if data.events.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No events recorded.</p>
{:else}
<ol class="relative space-y-3 border-l border-gray-200 pl-4 dark:border-gray-700">
{#each data.events as e}
<li class="relative">
<span class="absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full bg-emerald-500"></span>
<div class="text-sm">
<a href="/assets/{data.asset.id}/maintenance/events/{e.id}" class="font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
{e.scheduleName ?? '(deleted schedule)'}
</a>
<span class="ml-2 text-xs text-gray-400">{new Date(e.performedAt).toLocaleString()}</span>
{#if e.checklistInstanceId}
<span class="ml-2 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">checklist</span>
{/if}
</div>
{#if e.notes}<div class="mt-0.5 text-xs text-gray-600 dark:text-gray-400">{e.notes}</div>{/if}
</li>
{/each}
</ol>
{/if}
</section>
</div>
@@ -0,0 +1,68 @@
import { error, fail } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import { maintenanceEvents, maintenanceSchedules } from '$lib/server/db/schema/maintenance';
import { users } from '$lib/server/db/schema/tenancy';
import { completeInstance, getInstance, setItemDone } from '$lib/server/services/checklists';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const [row] = await db
.select({
event: maintenanceEvents,
scheduleName: maintenanceSchedules.name,
performedByName: users.displayName
})
.from(maintenanceEvents)
.leftJoin(maintenanceSchedules, eq(maintenanceSchedules.id, maintenanceEvents.scheduleId))
.leftJoin(users, eq(users.id, maintenanceEvents.performedBy))
.innerJoin(assets, eq(assets.id, maintenanceEvents.assetId))
.where(
and(
eq(maintenanceEvents.id, params.eventId),
eq(maintenanceEvents.assetId, params.id),
eq(assets.companyId, locals.company.id)
)
)
.limit(1);
if (!row) throw error(404, 'Event not found');
let checklist: Awaited<ReturnType<typeof getInstance>> | null = null;
if (row.event.checklistInstanceId) {
checklist = await getInstance(locals.company.id, row.event.checklistInstanceId);
}
return {
event: row.event,
scheduleName: row.scheduleName,
performedByName: row.performedByName,
checklist
};
};
export const actions: Actions = {
toggleItem: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const instanceId = String(form.get('instance_id') ?? '');
const itemId = String(form.get('item_id') ?? '');
const done = form.get('done') === 'true';
if (!instanceId || !itemId) return fail(400, { error: 'Missing ids' });
try {
await setItemDone(locals.company.id, instanceId, itemId, done, locals.user.id);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
completeChecklist: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const instanceId = String(form.get('instance_id') ?? '');
if (!instanceId) return fail(400, { error: 'Missing instance_id' });
await completeInstance(locals.company.id, instanceId);
return { ok: true };
}
};
@@ -0,0 +1,82 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<div class="space-y-6">
<div>
<a href="/assets/{data.asset.id}/maintenance" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to maintenance</a>
<h2 class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{data.scheduleName ?? '(deleted schedule)'}
</h2>
<div class="text-sm text-gray-500 dark:text-gray-400">
Performed {new Date(data.event.performedAt).toLocaleString()}
{#if data.performedByName}· by {data.performedByName}{/if}
{#if data.event.usageReading}· at {data.event.usageReading}{/if}
</div>
{#if data.event.notes}<p class="mt-2 text-sm text-gray-700 dark:text-gray-200">{data.event.notes}</p>{/if}
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
{#if data.checklist}
{@const inst = data.checklist.instance}
{@const items = data.checklist.items}
{@const remaining = items.filter((i) => i.required && !i.done).length}
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Checklist · {inst.title}</h3>
{#if inst.completedAt}
<p class="text-xs text-emerald-600 dark:text-emerald-400">Completed {new Date(inst.completedAt).toLocaleString()}</p>
{:else}
<p class="text-xs text-gray-500 dark:text-gray-400">{remaining} required item{remaining === 1 ? '' : 's'} remaining</p>
{/if}
</div>
{#if !inst.completedAt}
<form method="post" action="?/completeChecklist" use:enhance>
<input type="hidden" name="instance_id" value={inst.id} />
<button type="submit" disabled={remaining > 0}
class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
title={remaining > 0 ? 'Complete all required items first' : ''}>
Mark checklist complete
</button>
</form>
{/if}
</div>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
{#each items as item}
<li class="flex items-start gap-3 py-2">
<form method="post" action="?/toggleItem" use:enhance class="pt-0.5">
<input type="hidden" name="instance_id" value={inst.id} />
<input type="hidden" name="item_id" value={item.id} />
<input type="hidden" name="done" value={(!item.done).toString()} />
<button type="submit" aria-label={item.done ? 'Mark incomplete' : 'Mark complete'}
class="inline-flex h-5 w-5 items-center justify-center rounded border {item.done ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-gray-300 dark:border-gray-600'}">
{#if item.done}
<svg viewBox="0 0 20 20" fill="currentColor" class="h-3 w-3"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
{/if}
</button>
</form>
<div class="min-w-0 flex-1">
<div class="text-sm {item.done ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}">{item.text}</div>
{#if item.required}
<span class="text-[10px] font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400">required</span>
{/if}
{#if item.done && item.doneAt}
<div class="text-xs text-gray-500 dark:text-gray-400">done {new Date(item.doneAt).toLocaleString()}</div>
{/if}
</div>
</li>
{/each}
</ul>
</div>
{:else}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No checklist was attached to this event.</p>
{/if}
</div>
@@ -0,0 +1,71 @@
import { error, fail, isHttpError, isRedirect, redirect } 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 { projects } from '$lib/server/db/schema/projects';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { moveAsset } from '$lib/server/services/assets';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(401);
const companyId = locals.company.id;
const [props, projs, rooms] = await Promise.all([
db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(and(eq(properties.companyId, companyId), isNull(properties.deletedAt)))
.orderBy(asc(properties.name)),
db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, companyId), isNull(projects.deletedAt)))
.orderBy(asc(projects.name)),
db
.select({
id: propertyRooms.id,
propertyId: propertyRooms.propertyId,
floorLabel: propertyFloors.label,
name: propertyRooms.name
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
.where(
and(
eq(properties.companyId, companyId),
isNull(propertyRooms.deletedAt),
isNull(properties.deletedAt)
)
)
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name))
]);
return { properties: props, projects: projs, rooms };
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const target = String(form.get('target') ?? '');
const reason = String(form.get('reason') ?? '').trim() || null;
const toRoomId = String(form.get('to_room_id') ?? '').trim() || null;
const [kind, id] = target.split(':', 2);
if ((kind !== 'property' && kind !== 'project') || !id) {
return fail(400, { error: 'Pick a destination.' });
}
try {
await moveAsset(locals.company.id, params.id, {
toKind: kind,
toId: id,
movedBy: locals.user.id,
reason,
toRoomId: kind === 'property' ? toRoomId : null
});
throw redirect(303, `/assets/${params.id}/history`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
}
};
@@ -0,0 +1,84 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let moving = $state(false);
let target = $state('');
const selectedPropertyId = $derived.by(() => {
const [kind, id] = target.split(':', 2);
return kind === 'property' ? id : '';
});
const roomsForProperty = $derived(
selectedPropertyId ? data.rooms.filter((r) => r.propertyId === selectedPropertyId) : []
);
</script>
<form
method="post"
use:enhance={() => {
moving = true;
return ({ update }) => update().finally(() => (moving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<div class="text-sm text-gray-600 dark:text-gray-300">
Currently at <strong>{data.currentLocationName ?? '(unknown)'}</strong>.
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<div>
<label for="target" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Move to</label>
<select id="target" name="target" required bind:value={target}
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="">— pick destination —</option>
{#if data.properties.length > 0}
<optgroup label="Properties">
{#each data.properties as p}
<option value="property:{p.id}">{p.name}</option>
{/each}
</optgroup>
{/if}
{#if data.projects.length > 0}
<optgroup label="Projects">
{#each data.projects as p}
<option value="project:{p.id}">{p.name}</option>
{/each}
</optgroup>
{/if}
</select>
</div>
{#if selectedPropertyId}
<div>
<label for="to_room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room (optional)</label>
<select id="to_room_id" name="to_room_id" disabled={roomsForProperty.length === 0}
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 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— no specific room —</option>
{#each roomsForProperty as r}
<option value={r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
{/each}
</select>
{#if roomsForProperty.length === 0}
<p class="mt-1 text-xs text-gray-400">The target property has no rooms yet.</p>
{/if}
</div>
{/if}
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Reason (optional)</label>
<input id="reason" name="reason" placeholder="e.g. relocated to new rack, returned from repair…"
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 class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="submit" disabled={moving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{moving ? 'Moving…' : 'Move asset'}
</button>
</div>
</form>
@@ -0,0 +1,56 @@
import { error } from '@sveltejs/kit';
import { csvResponse, toCsv } from '$lib/server/csv';
import { listAssets } from '$lib/server/services/assets';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const q = url.searchParams.get('q') ?? undefined;
const typeSlug = url.searchParams.get('type') ?? undefined;
const propertyId = url.searchParams.get('property') ?? undefined;
const projectId = url.searchParams.get('project') ?? undefined;
const rows = await listAssets({
companyId: locals.company.id,
q,
typeSlug,
propertyId,
projectId,
limit: 10_000
});
const body = toCsv(
rows.map((r) => ({
id: r.id,
name: r.name,
type: r.assetTypeName,
type_slug: r.assetTypeSlug,
tag: r.tag,
serial_number: r.serialNumber,
manufacturer: r.manufacturer,
model: r.model,
container_kind: r.currentContainerKind,
property_id: r.currentPropertyId,
updated_at: r.updatedAt
})),
[
'id',
'name',
'type',
'type_slug',
'tag',
'serial_number',
'manufacturer',
'model',
'container_kind',
'property_id',
'updated_at'
]
);
return csvResponse(`assets-${today()}.csv`, body);
};
function today(): string {
return new Date().toISOString().slice(0, 10);
}
+121
View File
@@ -0,0 +1,121 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { and, asc, eq, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetTypes } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { createAsset, loadTypeWithFields } from '$lib/server/services/assets';
import { gatherCustomFieldsFromForm } from '$lib/server/custom-fields-form';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const companyId = locals.company.id;
const typeId = url.searchParams.get('type_id') ?? '';
const propertyId = url.searchParams.get('property') ?? '';
const types = await db
.select({
id: assetTypes.id,
name: assetTypes.name,
slug: assetTypes.slug,
icon: assetTypes.icon,
description: assetTypes.description
})
.from(assetTypes)
.where(or(isNull(assetTypes.companyId), sql`${assetTypes.companyId} = ${companyId}`)!)
.orderBy(asc(assetTypes.name));
const props = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(and(eq(properties.companyId, companyId), isNull(properties.deletedAt)))
.orderBy(asc(properties.name));
// All rooms across all properties in this company — the client filters by
// selected property. Lightweight; one round-trip instead of a per-select fetch.
const rooms = await db
.select({
id: propertyRooms.id,
propertyId: propertyRooms.propertyId,
floorLabel: propertyFloors.label,
name: propertyRooms.name
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
.where(
and(
eq(properties.companyId, companyId),
isNull(propertyRooms.deletedAt),
isNull(properties.deletedAt)
)
)
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name));
let typeWithFields = null;
if (typeId) {
typeWithFields = await loadTypeWithFields(typeId);
if (!typeWithFields) throw error(404, 'Asset type not found');
}
return {
types,
properties: props,
rooms,
selectedTypeId: typeId,
selectedPropertyId: propertyId,
typeWithFields
};
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401);
const companyId = locals.company.id;
const form = await request.formData();
const assetTypeId = String(form.get('asset_type_id') ?? '');
const name = String(form.get('name') ?? '').trim();
const tag = (String(form.get('tag') ?? '').trim() || null) as string | null;
const serialNumber = (String(form.get('serial_number') ?? '').trim() || null) as
| string
| null;
const manufacturer = (String(form.get('manufacturer') ?? '').trim() || null) as string | null;
const model = (String(form.get('model') ?? '').trim() || null) as string | null;
const purchasedAtStr = String(form.get('purchased_at') ?? '').trim();
const propertyId = String(form.get('property_id') ?? '').trim();
const roomId = String(form.get('room_id') ?? '').trim() || null;
if (!assetTypeId) return fail(400, { error: 'Pick an asset type first.' });
if (!name) return fail(400, { error: 'Name is required.' });
if (!propertyId) return fail(400, { error: 'Pick a property to place this asset at.' });
const tf = await loadTypeWithFields(assetTypeId);
if (!tf) return fail(400, { error: 'Asset type not found.' });
const cf = gatherCustomFieldsFromForm(form, tf.fields);
try {
const { id } = await createAsset({
companyId,
createdBy: locals.user.id,
assetTypeId,
name,
tag,
serialNumber,
manufacturer,
model,
purchasedAt: purchasedAtStr ? new Date(purchasedAtStr) : null,
containerKind: 'property',
containerId: propertyId,
roomId,
customFields: cf
});
throw redirect(303, `/assets/${id}`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
}
};
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { enhance } from '$app/forms';
import CustomFieldsForm from '$lib/components/CustomFieldsForm.svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
// svelte-ignore state_referenced_locally
let selectedPropertyId = $state(data.selectedPropertyId ?? '');
const roomsForProperty = $derived(
selectedPropertyId
? data.rooms.filter((r) => r.propertyId === selectedPropertyId)
: []
);
</script>
<div class="mx-auto max-w-3xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New asset</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Pick a type to get the right typed fields, then fill in the rest.
</p>
</div>
{#if !data.typeWithFields}
<!-- Step 1: pick an asset type -->
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each data.types as t}
<a
href="/assets/new?type_id={t.id}{data.selectedPropertyId ? `&property=${data.selectedPropertyId}` : ''}"
class="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-primary-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-primary-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{t.name}</div>
{#if t.description}
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{t.description}</div>
{:else}
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">{t.slug}</div>
{/if}
</a>
{/each}
</div>
{:else}
<!-- Step 2: typed form -->
<form
method="post"
use:enhance={() => {
submitting = true;
return ({ update }) => update().finally(() => (submitting = false));
}}
class="space-y-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center justify-between border-b border-gray-200 pb-3 dark:border-gray-700">
<div>
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">Asset type</div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{data.typeWithFields.type.name}</div>
</div>
<a href="/assets/new" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Change</a>
</div>
<input type="hidden" name="asset_type_id" value={data.typeWithFields.type.id} />
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></label>
<input id="name" name="name" required
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>
<label for="property_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Property <span class="text-red-500">*</span></label>
<select id="property_id" name="property_id" required bind:value={selectedPropertyId}
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="">— pick a property —</option>
{#each data.properties as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room</label>
<select id="room_id" name="room_id" disabled={roomsForProperty.length === 0}
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 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— no specific room —</option>
{#each roomsForProperty as r}
<option value={r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
{/each}
</select>
{#if selectedPropertyId && roomsForProperty.length === 0}
<p class="mt-1 text-xs text-gray-400">This property has no rooms yet. Add them from the property's Rooms tab.</p>
{/if}
</div>
<div>
<label for="tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Asset tag</label>
<input id="tag" name="tag"
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>
<label for="serial_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Serial number</label>
<input id="serial_number" name="serial_number"
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>
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
<input id="manufacturer" name="manufacturer"
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>
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
<input id="model" name="model"
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>
<label for="purchased_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Purchased on</label>
<input id="purchased_at" name="purchased_at" type="date"
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="border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">{data.typeWithFields.type.name} details</div>
<CustomFieldsForm defs={data.typeWithFields.fields} />
</div>
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/assets" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={submitting}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{submitting ? 'Creating…' : 'Create asset'}
</button>
</div>
</form>
{/if}
</div>
@@ -0,0 +1,32 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { createTemplate, listTemplates } from '$lib/server/services/checklists';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company');
const templates = await listTemplates(locals.company.id);
return { templates };
};
const NewSchema = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().max(10_000).optional().or(z.literal(''))
});
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = NewSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const { id } = await createTemplate({
companyId: locals.company.id,
createdBy: locals.user.id,
name: parsed.data.name,
description: parsed.data.description || null
});
throw redirect(303, `/checklists/${id}`);
}
};
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let creating = $state(false);
let showForm = $state(false);
</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">Checklist templates</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Reusable checklists you can attach to maintenance, tasks, or anywhere ad-hoc.
</p>
</div>
<button type="button" onclick={() => (showForm = !showForm)}
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">
{showForm ? 'Cancel' : '+ New template'}
</button>
</div>
{#if showForm}
<form method="post" action="?/create"
use:enhance={() => {
creating = true;
return ({ update }) => update().finally(() => (creating = false));
}}
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required placeholder="e.g. Quarterly AC service"
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>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" name="description" rows="2"
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"></textarea>
</div>
<div class="flex justify-end">
<button type="submit" disabled={creating}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{creating ? 'Creating…' : 'Create template'}
</button>
</div>
</form>
{/if}
{#if data.templates.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 templates yet. Create one to attach it to a maintenance schedule.
</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">Description</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Items</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.templates as t}
<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="/checklists/{t.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{t.name}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 truncate">{t.description ?? '—'}</td>
<td class="px-4 py-2 text-right text-sm text-gray-700 dark:text-gray-300">{t.itemCount}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,50 @@
import { error, fail, redirect } from '@sveltejs/kit';
import {
addTemplateItem,
deleteTemplate,
getTemplate,
removeTemplateItem,
updateTemplate
} from '$lib/server/services/checklists';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const tpl = await getTemplate(locals.company.id, params.id);
if (!tpl) throw error(404, 'Template not found');
return { template: tpl.template, items: tpl.items };
};
export const actions: Actions = {
saveMeta: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim() || null;
if (!name) return fail(400, { error: 'Name is required.' });
await updateTemplate(locals.company.id, params.id, { name, description });
return { ok: true };
},
addItem: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const text = String(form.get('text') ?? '').trim();
const required = form.get('required') === 'true';
if (!text) return fail(400, { error: 'Item text is required.' });
await addTemplateItem(locals.company.id, params.id, text, required);
return { ok: true };
},
removeItem: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const itemId = String(form.get('item_id') ?? '');
if (!itemId) return fail(400, { error: 'Missing item_id' });
await removeTemplateItem(locals.company.id, params.id, itemId);
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await deleteTemplate(locals.company.id, params.id);
throw redirect(303, '/checklists');
}
};
@@ -0,0 +1,87 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let confirmingDelete = $state(false);
</script>
<div class="space-y-6">
<div>
<a href="/checklists" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all templates</a>
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<form method="post" action="?/saveMeta" use:enhance
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required value={data.template.name}
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>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" name="description" rows="2"
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">{data.template.description ?? ''}</textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Save</button>
</div>
</form>
<div class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Items</h2>
{#if data.items.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No items yet.</p>
{: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.items as item, i}
<li class="flex items-center justify-between gap-3 px-4 py-2 text-sm">
<div class="min-w-0">
<span class="mr-2 inline-block w-6 text-right text-xs text-gray-400">{i + 1}.</span>
<span class="text-gray-900 dark:text-gray-100">{item.text}</span>
{#if item.required}
<span class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">required</span>
{/if}
</div>
<form method="post" action="?/removeItem" use:enhance>
<input type="hidden" name="item_id" value={item.id} />
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Remove</button>
</form>
</li>
{/each}
</ul>
{/if}
<form method="post" action="?/addItem" use:enhance
class="flex flex-col gap-2 rounded-lg border border-dashed border-gray-300 bg-white p-3 sm:flex-row sm:items-end dark:border-gray-700 dark:bg-gray-800">
<div class="flex-1">
<label for="text" class="block text-xs font-medium text-gray-500 dark:text-gray-400">New item</label>
<input id="text" name="text" required placeholder="e.g. Replace filter"
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>
<label class="inline-flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="required" value="true" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
required
</label>
<button type="submit" class="rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700">Add</button>
</form>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
{confirmingDelete ? 'Cancel delete' : 'Delete template…'}
</button>
{#if confirmingDelete}
<form method="post" action="?/delete" class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
<p>Hard-delete this template. Existing checklist instances created from it will keep their items but lose the template link.</p>
<div class="mt-2 flex justify-end gap-2">
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete</button>
</div>
</form>
{/if}
</div>
</div>
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { listDueAndOverdue } from '$lib/server/services/maintenance';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company');
const rows = await listDueAndOverdue({
companyId: locals.company.id,
limit: 200,
upcomingDays: 60
});
const now = Date.now();
const overdue = rows.filter((r) => r.nextDueAt && new Date(r.nextDueAt).getTime() < now);
const upcoming = rows.filter((r) => r.nextDueAt && new Date(r.nextDueAt).getTime() >= now);
return { overdue, upcoming };
};
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function dayDelta(d: Date | string): number {
return Math.round((new Date(d).getTime() - Date.now()) / 86400000);
}
</script>
<div class="space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Maintenance</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Time-based schedules across every asset, sorted by next-due. Usage-based schedules appear on each asset's own maintenance tab.
</p>
</div>
<a href="/maintenance/export.csv" class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">Export CSV</a>
</div>
<section class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">Overdue ({data.overdue.length})</h2>
{#if data.overdue.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">Nothing overdue. Nice.</p>
{:else}
<ul class="overflow-hidden rounded-lg border border-red-200 bg-white dark:border-red-700/50 dark:bg-gray-800">
{#each data.overdue as r}
{@const d = dayDelta(r.nextDueAt!)}
<li class="flex items-center justify-between gap-3 border-b border-gray-100 px-4 py-2 last:border-0 dark:border-gray-700">
<div class="min-w-0">
<a href="/assets/{r.assetId}/maintenance" class="text-sm font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{r.assetName}</a>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{r.scheduleName}</span>
</div>
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300">
{-d} day{-d === 1 ? '' : 's'} overdue
</span>
</li>
{/each}
</ul>
{/if}
</section>
<section class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Upcoming ({data.upcoming.length})</h2>
{#if data.upcoming.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No schedules due in the next 60 days.</p>
{:else}
<ul class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
{#each data.upcoming as r}
{@const d = dayDelta(r.nextDueAt!)}
<li class="flex items-center justify-between gap-3 border-b border-gray-100 px-4 py-2 last:border-0 dark:border-gray-700">
<div class="min-w-0">
<a href="/assets/{r.assetId}/maintenance" class="text-sm font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{r.assetName}</a>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{r.scheduleName}</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">in {d} day{d === 1 ? '' : 's'} ({new Date(r.nextDueAt!).toLocaleDateString()})</span>
</li>
{/each}
</ul>
{/if}
</section>
</div>
@@ -0,0 +1,32 @@
import { error } from '@sveltejs/kit';
import { csvResponse, toCsv } from '$lib/server/csv';
import { listDueAndOverdue } from '$lib/server/services/maintenance';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const upcomingDays = Number.parseInt(url.searchParams.get('days') ?? '60', 10) || 60;
const rows = await listDueAndOverdue({
companyId: locals.company.id,
limit: 10_000,
upcomingDays
});
const body = toCsv(
rows.map((r) => ({
schedule_id: r.scheduleId,
schedule_name: r.scheduleName,
asset_id: r.assetId,
asset_name: r.assetName,
next_due_at: r.nextDueAt,
interval: `${r.intervalValue} ${r.intervalUnit}`
})),
['schedule_id', 'schedule_name', 'asset_id', 'asset_name', 'next_due_at', 'interval']
);
return csvResponse(`maintenance-${today()}.csv`, body);
};
function today(): string {
return new Date().toISOString().slice(0, 10);
}
@@ -0,0 +1,26 @@
import { fail } from '@sveltejs/kit';
import { requireCompany } from '$lib/server/auth/guards';
import { listForUser, markAllRead, markRead } from '$lib/server/services/notifications';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { user, company } = requireCompany(locals);
const items = await listForUser(user.id, company.id, 200);
return { notifications: items };
};
export const actions: Actions = {
read: async ({ request, locals }) => {
const { user } = requireCompany(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (!id) return fail(400, { error: 'Missing id' });
await markRead(user.id, [id]);
return { ok: true };
},
readAll: async ({ locals }) => {
const { user, company } = requireCompany(locals);
await markAllRead(user.id, company.id);
return { ok: true };
}
};
@@ -0,0 +1,66 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { NOTIFICATION_KIND_LABEL, type NotificationKind } from '$lib/notifications';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const unread = $derived(data.notifications.filter((n) => n.readAt === null).length);
</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">Notifications</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{unread} unread · {data.notifications.length} total.
<a href="/settings/notifications" class="ml-2 text-primary-600 hover:underline dark:text-primary-400">Channel settings</a>
</p>
</div>
{#if unread > 0}
<form method="post" action="?/readAll" use:enhance>
<button type="submit" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Mark all read
</button>
</form>
{/if}
</div>
{#if data.notifications.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">
Nothing here yet — new in-app notifications will show up as soon as you're assigned a task or a decision is logged.
</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.notifications as n}
<li class="flex gap-3 px-4 py-3 text-sm {n.readAt === null ? 'bg-primary-50/40 dark:bg-primary-900/10' : ''}">
<div class="mt-0.5 shrink-0">
{#if n.readAt === null}
<span class="inline-block h-2 w-2 rounded-full bg-primary-500" aria-label="Unread"></span>
{:else}
<span class="inline-block h-2 w-2 rounded-full bg-transparent"></span>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{NOTIFICATION_KIND_LABEL[n.kind as NotificationKind] ?? n.kind}
</span>
<span>{new Date(n.createdAt).toLocaleString()}</span>
</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{n.title}</div>
<div class="mt-0.5 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{n.body}</div>
{#if n.link}
<a href={n.link} class="mt-1 inline-block text-xs text-primary-600 hover:underline dark:text-primary-400">Open →</a>
{/if}
</div>
{#if n.readAt === null}
<form method="post" action="?/read" use:enhance class="shrink-0">
<input type="hidden" name="id" value={n.id} />
<button type="submit" class="text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">mark read</button>
</form>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { listProjects } from '$lib/server/services/projects';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company');
const rows = await listProjects(locals.company.id);
return { projects: rows };
};
+55
View File
@@ -0,0 +1,55 @@
<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">Projects</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Construction sites, rollouts, retrofits — anything with work packages, tasks, and decisions.
</p>
</div>
<a href="/projects/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 project
</a>
</div>
{#if data.projects.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 projects yet.</p>
<p class="mt-1">Create one to start tracking work packages, tasks, and decisions.</p>
<a href="/projects/new" class="mt-4 inline-block text-primary-600 hover:underline dark:text-primary-400">Create a project →</a>
</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">Code</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>
<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.projects as p}
<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="/projects/{p.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{p.name}</a>
{#if p.description}<div class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">{p.description}</div>{/if}
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{p.code ?? '—'}</td>
<td class="px-4 py-2 text-xs">
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">{p.status}</span>
</td>
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">{new Date(p.updatedAt).toLocaleDateString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show More