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:
@@ -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'
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '')}"`
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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="#"');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
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;
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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"> </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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 ?? '' };
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user