Phases 1-5 + rooms/floors, accounts, custom types, users, notifications

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

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

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

Graph
- graphify-out/ committed: GRAPH_REPORT.md, graph.html, graph.json
This commit is contained in:
2026-04-23 15:18:11 +07:00
parent ad155d6344
commit b59904fdae
387 changed files with 70371 additions and 82 deletions
+17 -3
View File
@@ -2,6 +2,8 @@ import { redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { companies, companyUsers } from '$lib/server/db/schema/tenancy';
import { setActiveCompany } from '$lib/server/auth/session';
import { unreadCountForUser } from '$lib/server/services/notifications';
import type { LayoutServerLoad } from './$types';
import type { SessionCompany } from '$lib/server/auth/types';
@@ -11,7 +13,6 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
throw redirect(303, `/login?next=${encodeURIComponent(target)}`);
}
// Load the user's companies so the sidebar switcher can render.
const rows = await db
.select({
id: companies.id,
@@ -30,9 +31,22 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
role: r.role
}));
// If the session has no active company but the user belongs to at least one,
// auto-select the first one. Saves a click and matches the budget app's UX.
let active = locals.company;
if (!active && userCompanies.length > 0 && locals.sessionId) {
const first = userCompanies[0];
await setActiveCompany(locals.sessionId, first.id);
active = first;
locals.company = first;
}
const unreadCount = active ? await unreadCountForUser(locals.user.id, active.id) : 0;
return {
user: locals.user,
company: locals.company,
companies: userCompanies
company: active,
companies: userCompanies,
unreadCount
};
};
+1 -2
View File
@@ -10,7 +10,6 @@
<div class="flex min-h-screen">
<Sidebar
user={data.user}
company={data.company}
companies={data.companies}
open={sidebarOpen}
@@ -18,7 +17,7 @@
/>
<div class="flex min-w-0 flex-1 flex-col">
<TopBar onmenu={() => (sidebarOpen = true)} />
<TopBar user={data.user} unreadCount={data.unreadCount} onmenu={() => (sidebarOpen = true)} />
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-4 py-6 lg:px-6">
{@render children()}
+13
View File
@@ -0,0 +1,13 @@
import { countOverdueForCompany, listDueAndOverdue } from '$lib/server/services/maintenance';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) {
return { overdueCount: 0, upcomingSoon: [] as Awaited<ReturnType<typeof listDueAndOverdue>> };
}
const [overdueCount, upcomingSoon] = await Promise.all([
countOverdueForCompany(locals.company.id),
listDueAndOverdue({ companyId: locals.company.id, limit: 5, upcomingDays: 14 })
]);
return { overdueCount, upcomingSoon };
};
+39 -6
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function dayDelta(d: Date | string): number {
return Math.round((new Date(d).getTime() - Date.now()) / 86400000);
}
</script>
<div class="space-y-6">
@@ -18,13 +22,19 @@
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<a href="/maintenance" class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-primary-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-primary-700">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Overdue maintenance
</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100"></p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once maintenance schedules exist.</p>
</div>
<p class="mt-2 text-3xl font-bold {data.overdueCount > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'}">{data.overdueCount}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{#if data.overdueCount === 0}
All time-based schedules are on track.
{:else}
Time-based schedules past their next-due date.
{/if}
</p>
</a>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
My open tasks
@@ -41,8 +51,31 @@
</div>
</div>
{#if data.upcomingSoon.length > 0}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700/50 dark:bg-amber-900/20">
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">Due in the next 14 days</p>
<ul class="space-y-1 text-sm">
{#each data.upcomingSoon as r}
{@const d = dayDelta(r.nextDueAt!)}
<li class="flex items-center justify-between gap-3">
<a href="/assets/{r.assetId}/maintenance" class="truncate text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
{r.assetName}<span class="text-gray-500 dark:text-gray-400">{r.scheduleName}</span>
</a>
<span class="shrink-0 text-xs {d < 0 ? 'text-red-600 dark:text-red-400 font-medium' : 'text-amber-700 dark:text-amber-400'}">
{d < 0 ? `${-d}d overdue` : `in ${d}d`}
</span>
</li>
{/each}
</ul>
<a href="/maintenance" class="mt-2 inline-block text-xs text-amber-800 hover:underline dark:text-amber-300">View all →</a>
</div>
{/if}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-200">Phase 0 complete.</p>
<p class="mt-1">Auth, layout shell, storage interface, and the tenancy schema are wired. The remaining schema modules (projects, properties, assets, maintenance, checklists, decisions, documents, wiki, audit) land in Phase 1.</p>
<p class="font-medium text-gray-700 dark:text-gray-200">Phase 5 (partial) shipped.</p>
<p class="mt-1">
Printable QR labels per asset, S3 storage backend (switch via <code class="text-[11px]">STORAGE_BACKEND=s3</code>),
and CSV exports for assets / maintenance / project decisions are live. Notifications and cross-app APIs land in a later session.
</p>
</div>
</div>
@@ -0,0 +1,28 @@
import { error } from '@sveltejs/kit';
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetTypes, assetFieldDefs } from '$lib/server/db/schema/assets';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(401);
const types = await db
.select({
id: assetTypes.id,
name: assetTypes.name,
slug: assetTypes.slug,
icon: assetTypes.icon,
description: assetTypes.description,
companyId: assetTypes.companyId,
schemaVersion: assetTypes.schemaVersion,
fieldCount: sql<number>`(
select count(*)::int from ${assetFieldDefs}
where ${assetFieldDefs.assetTypeId} = ${assetTypes.id}
and ${assetFieldDefs.deprecatedAt} is null
)`
})
.from(assetTypes)
.where(or(isNull(assetTypes.companyId), eq(assetTypes.companyId, locals.company.id))!)
.orderBy(asc(assetTypes.name));
return { types };
};
@@ -0,0 +1,53 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Asset types</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Catalog of "kinds of thing" you can track. System types are shared across every company; company types are yours to add and edit.
</p>
</div>
<a href="/admin/asset-types/new"
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
+ New type
</a>
</div>
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Slug</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Scope</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Fields</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schema v</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.types as t}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="/admin/asset-types/{t.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{t.name}</a>
{#if t.description}<div class="text-xs text-gray-500 dark:text-gray-400">{t.description}</div>{/if}
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{t.slug}</td>
<td class="px-4 py-2 text-xs">
{#if t.companyId === null}
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">system</span>
{:else}
<span class="rounded-full bg-primary-100 px-2 py-0.5 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">company</span>
{/if}
</td>
<td class="px-4 py-2 text-right text-sm text-gray-700 dark:text-gray-300">{t.fieldCount}</td>
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">v{t.schemaVersion}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,192 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { loadTypeWithFields } from '$lib/server/services/assets';
import {
addFieldDef,
deleteCompanyAssetType,
removeFieldDef,
updateCompanyAssetType,
updateFieldDef,
type FieldType
} from '$lib/server/services/asset-types';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const tf = await loadTypeWithFields(params.id);
if (!tf) throw error(404, 'Asset type not found');
// Tenant guard: company-scoped types must belong to the active company.
if (tf.type.companyId !== null && tf.type.companyId !== locals.company.id) {
throw error(404, 'Asset type not found');
}
const editable = tf.type.companyId === locals.company.id;
return { type: tf.type, fields: tf.fields, editable };
};
const FIELD_TYPES = [
'text',
'textarea',
'int',
'float',
'bool',
'date',
'ip',
'cidr',
'mac',
'enum',
'multi_enum',
'url',
'email',
'asset_ref'
] as const;
const MetaSchema = z.object({
name: z.string().trim().min(1).max(128),
icon: z.string().trim().max(64).optional().or(z.literal('')),
description: z.string().trim().max(2000).optional().or(z.literal(''))
});
const FieldSchema = z.object({
key: z.string().trim().max(64).optional().or(z.literal('')),
label: z.string().trim().min(1).max(128),
type: z.enum(FIELD_TYPES),
required: z.string().optional(),
enum_values: z.string().trim().max(2000).optional().or(z.literal('')),
unit: z.string().trim().max(32).optional().or(z.literal('')),
placeholder: z.string().trim().max(255).optional().or(z.literal('')),
help_text: z.string().trim().max(2000).optional().or(z.literal(''))
});
const FieldPatchSchema = z.object({
label: z.string().trim().min(1).max(128),
required: z.string().optional(),
enum_values: z.string().trim().max(2000).optional().or(z.literal('')),
unit: z.string().trim().max(32).optional().or(z.literal('')),
placeholder: z.string().trim().max(255).optional().or(z.literal('')),
help_text: z.string().trim().max(2000).optional().or(z.literal(''))
});
function parseEnumValues(raw: string | undefined): string[] | null {
if (!raw) return null;
const parts = raw
.split(/[,\n]/)
.map((s) => s.trim())
.filter(Boolean);
return parts.length > 0 ? parts : null;
}
export const actions: Actions = {
saveMeta: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = MetaSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
try {
await updateCompanyAssetType(locals.company.id, params.id, {
name: parsed.data.name,
icon: parsed.data.icon || null,
description: parsed.data.description || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
deleteType: async ({ locals, params }) => {
if (!locals.company) throw error(401);
try {
await deleteCompanyAssetType(locals.company.id, params.id);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
throw redirect(303, '/admin/asset-types');
},
addField: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = FieldSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
try {
await addFieldDef(locals.company.id, params.id, {
key: v.key || v.label,
label: v.label,
type: v.type as FieldType,
required: v.required === 'true',
enumValues: parseEnumValues(v.enum_values),
unit: v.unit || null,
placeholder: v.placeholder || null,
helpText: v.help_text || null
});
} catch (e) {
const msg = (e as Error).message;
if (msg.includes('asset_field_defs_type_key_uq')) {
return fail(400, { error: 'A field with that key already exists on this type.' });
}
return fail(400, { error: msg });
}
return { ok: true };
},
updateField: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const fieldId = String(form.get('field_id') ?? '');
if (!fieldId) return fail(400, { error: 'Missing field_id' });
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = FieldPatchSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
const enumVals = parseEnumValues(v.enum_values);
try {
await updateFieldDef(locals.company.id, fieldId, {
label: v.label,
required: v.required === 'true',
enumValues: raw.enum_values !== undefined ? enumVals : undefined,
unit: v.unit || null,
placeholder: v.placeholder || null,
helpText: v.help_text || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
removeField: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const fieldId = String(form.get('field_id') ?? '');
const force = form.get('force') === 'true';
if (!fieldId) return fail(400, { error: 'Missing field_id' });
try {
const res = await removeFieldDef(locals.company.id, fieldId, { force });
return {
ok: true,
deprecated: !res.hardDeleted
};
} catch (e) {
return fail(400, { error: (e as Error).message });
}
},
restoreField: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const fieldId = String(form.get('field_id') ?? '');
if (!fieldId) return fail(400, { error: 'Missing field_id' });
try {
await updateFieldDef(locals.company.id, fieldId, {
deprecatedAt: null
} as never);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};
@@ -0,0 +1,291 @@
<script lang="ts">
import { enhance } from '$app/forms';
import {
FIELD_TYPES,
FIELD_TYPE_LABEL,
needsEnumValues,
type FieldType
} from '$lib/field-types';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let editingMeta = $state(false);
let adding = $state(false);
let editingFieldId = $state<string | null>(null);
let confirmingDelete = $state(false);
// Controls conditional rendering of the "values" textarea in the new-field form.
let newFieldType = $state<FieldType>('text');
</script>
<div class="space-y-6">
<div>
<a href="/admin/asset-types" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all asset types</a>
<div class="mt-1 flex items-start justify-between gap-3">
<div class="min-w-0">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{data.type.name}</h1>
<div class="mt-1 flex flex-wrap gap-x-3 text-sm text-gray-500 dark:text-gray-400">
<span>slug <code class="font-mono text-xs">{data.type.slug}</code></span>
<span>schema v{data.type.schemaVersion}</span>
<span>
{#if data.editable}
<span class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">company type</span>
{:else}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">system type · read-only</span>
{/if}
</span>
</div>
{#if data.type.description}
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{data.type.description}</p>
{/if}
</div>
{#if data.editable && !editingMeta}
<button type="button" onclick={() => (editingMeta = true)}
class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Edit
</button>
{/if}
</div>
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
{#if editingMeta}
<form method="post" action="?/saveMeta"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingMeta = false;
}}
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</span>
<input name="name" required value={data.type.name}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Icon</span>
<input name="icon" value={data.type.icon ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</span>
<textarea name="description" rows="3"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{data.type.description ?? ''}</textarea>
</label>
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (editingMeta = false)} class="text-sm text-gray-600 dark:text-gray-400">Cancel</button>
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Save</button>
</div>
</form>
{/if}
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Fields</h2>
{#if data.editable}
<button type="button" onclick={() => (adding = !adding)}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{adding ? 'Cancel' : '+ Add field'}
</button>
{/if}
</div>
{#if adding}
<form method="post" action="?/addField"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') {
adding = false;
newFieldType = 'text';
}
}}
class="mb-3 grid gap-3 rounded-lg border border-gray-200 bg-white p-4 sm:grid-cols-2 dark:border-gray-700 dark:bg-gray-800">
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Label <span class="text-red-500">*</span></span>
<input name="label" required placeholder="e.g. Max pressure"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Key</span>
<input name="key" placeholder="leave empty to derive from label"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Type <span class="text-red-500">*</span></span>
<select name="type" required bind:value={newFieldType}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each FIELD_TYPES as t}<option value={t}>{FIELD_TYPE_LABEL[t]}</option>{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
<input name="unit" placeholder="e.g. kW, °C, mm"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
{#if needsEnumValues(newFieldType)}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Values (comma or newline separated) <span class="text-red-500">*</span></span>
<textarea name="enum_values" rows="2" required placeholder="small, medium, large"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"></textarea>
</label>
{/if}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Placeholder</span>
<input name="placeholder"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Help text</span>
<input name="help_text"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 sm:col-span-2">
<input type="checkbox" name="required" value="true"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
Required on new assets
</label>
<div class="sm:col-span-2 flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Add field</button>
</div>
</form>
{/if}
{#if data.fields.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No fields defined.</p>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Key</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Label</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Type</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Options</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Required</th>
{#if data.editable}
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">&nbsp;</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.fields as f}
<tr class={f.deprecatedAt ? 'opacity-50' : ''}>
{#if editingFieldId === f.id}
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{f.key}</td>
<td colspan={data.editable ? 5 : 4} class="px-4 py-2">
<form method="post" action="?/updateField"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingFieldId = null;
}}
class="grid gap-2 sm:grid-cols-2">
<input type="hidden" name="field_id" value={f.id} />
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Label</span>
<input name="label" required value={f.label}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
<input name="unit" value={f.unit ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Placeholder</span>
<input name="placeholder" value={f.placeholder ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
{#if needsEnumValues(f.type as FieldType)}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Values</span>
<textarea name="enum_values" rows="2" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{(f.enumValues ?? []).join(', ')}</textarea>
</label>
{/if}
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Help text</span>
<input name="help_text" value={f.helpText ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 sm:col-span-2 dark:text-gray-300">
<input type="checkbox" name="required" value="true" checked={f.required}
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
Required
</label>
<div class="sm:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editingFieldId = null)} class="text-xs text-gray-500">Cancel</button>
<button type="submit" class="rounded-md bg-primary-600 px-2 py-1 text-xs font-medium text-white hover:bg-primary-700">Save</button>
</div>
</form>
</td>
{:else}
<td class="px-4 py-2 text-xs font-mono text-gray-700 dark:text-gray-300">{f.key}</td>
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">
{f.label}
{#if f.unit}<span class="text-xs text-gray-400">({f.unit})</span>{/if}
{#if f.deprecatedAt}<span class="ml-1 rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">deprecated</span>{/if}
</td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">{FIELD_TYPE_LABEL[f.type as FieldType] ?? f.type}</td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
{#if f.enumValues && f.enumValues.length > 0}
{f.enumValues.join(', ')}
{:else}
{/if}
</td>
<td class="px-4 py-2 text-right text-xs">
{#if f.required}
<span class="rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">required</span>
{:else}
<span class="text-gray-400 dark:text-gray-500">optional</span>
{/if}
</td>
{#if data.editable}
<td class="px-4 py-2 text-right">
<div class="flex justify-end gap-2 text-xs">
<button type="button" onclick={() => (editingFieldId = f.id)} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">edit</button>
{#if f.deprecatedAt}
<form method="post" action="?/restoreField" use:enhance class="inline">
<input type="hidden" name="field_id" value={f.id} />
<button type="submit" class="text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400">restore</button>
</form>
{:else}
<form method="post" action="?/removeField" use:enhance class="inline">
<input type="hidden" name="field_id" value={f.id} />
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
</form>
{/if}
</div>
</td>
{/if}
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
{#if data.editable}
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Field <code class="font-mono">key</code> and <code class="font-mono">type</code> are immutable after creation — changing them against existing JSONB data would corrupt it. Remove + re-add (and optionally script a JSONB migration) if you need to change a field's shape.
</p>
{/if}
{/if}
</div>
{#if data.editable}
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
{confirmingDelete ? 'Cancel delete' : 'Delete this type…'}
</button>
{#if confirmingDelete}
<form method="post" action="?/deleteType" use:enhance
class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
<p>Hard-delete this asset type and all of its field defs. Only works if no assets of this type exist — move or soft-delete them first.</p>
<div class="mt-2 flex justify-end gap-2">
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete type</button>
</div>
</form>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,41 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { createCompanyAssetType } from '$lib/server/services/asset-types';
import type { Actions } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(128),
slug: z.string().trim().max(64).optional().or(z.literal('')),
icon: z.string().trim().max(64).optional().or(z.literal('')),
description: z.string().trim().max(2000).optional().or(z.literal(''))
});
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const v = parsed.data;
try {
const { id } = await createCompanyAssetType({
companyId: locals.company.id,
name: v.name,
slug: v.slug || null,
icon: v.icon || null,
description: v.description || null
});
throw redirect(303, `/admin/asset-types/${id}`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
const msg = (e as Error).message ?? 'create failed';
if (msg.includes('asset_types_company_slug_uq')) {
return fail(400, { error: 'A type with that slug already exists in this company.', values: raw });
}
return fail(400, { error: msg, values: raw });
}
}
};
@@ -0,0 +1,59 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-xl space-y-6">
<div>
<a href="/admin/asset-types" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all asset types</a>
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">New asset type</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Custom company-scoped type. After creating it you can add typed fields
(IP, enum, int, etc.) on the next screen.
</p>
</div>
<form method="post"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></span>
<input name="name" required value={v.name ?? ''} placeholder="e.g. Security Camera"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
<input name="slug" value={v.slug ?? ''} placeholder="leave empty to derive from name"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">Lowercase snake_case. Used internally; must be unique within your company.</p>
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Icon</span>
<input name="icon" value={v.icon ?? ''} placeholder="optional icon name or emoji"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</span>
<textarea name="description" rows="3"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{v.description ?? ''}</textarea>
</label>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/admin/asset-types" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Creating…' : 'Create type'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,76 @@
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
import { requireAdmin, requireCompany } from '$lib/server/auth/guards';
import { getCompany, updateCompany } from '$lib/server/services/companies';
import type { Actions, PageServerLoad } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(255),
slug: z.string().trim().min(1).max(128),
default_currency: z.string().trim().length(3).optional().or(z.literal('')),
matrix_room_id: z.string().trim().max(255).optional().or(z.literal(''))
});
interface CompanySettings {
default_currency?: string | null;
matrix_room_id?: string | null;
}
function parseSettings(raw: string | null | undefined): CompanySettings {
if (!raw) return {};
try {
return JSON.parse(raw) as CompanySettings;
} catch {
return {};
}
}
export const load: PageServerLoad = async ({ locals }) => {
const { company } = requireCompany(locals);
const full = await getCompany(company.id);
if (!full) throw new Error('active company row missing');
return {
fullCompany: full,
settings: parseSettings(full.settings),
isAdmin: company.role === 'admin'
};
};
export const actions: Actions = {
save: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
const existing = await getCompany(company.id);
if (!existing) return fail(404, { error: 'Company not found' });
const settings = parseSettings(existing.settings);
if (v.default_currency) settings.default_currency = v.default_currency.toUpperCase();
else delete settings.default_currency;
if (v.matrix_room_id) {
const trimmed = v.matrix_room_id.trim();
if (!/^![^:\s]+:[^:\s]+$/.test(trimmed)) {
return fail(400, { error: 'Matrix room id must look like !roomid:server' });
}
settings.matrix_room_id = trimmed;
} else {
delete settings.matrix_room_id;
}
try {
await updateCompany(company.id, {
name: v.name,
slug: v.slug,
settings: Object.keys(settings).length > 0 ? JSON.stringify(settings) : null
});
} catch (e) {
const msg = (e as Error).message;
if (msg.includes('companies_slug_unique')) {
return fail(400, { error: 'A company with that slug already exists.' });
}
return fail(400, { error: msg });
}
return { ok: true };
}
};
@@ -0,0 +1,64 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let saving = $state(false);
const c = $derived(data.fullCompany);
</script>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Company settings</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{data.isAdmin ? 'Edit the active company.' : 'Read-only — only admins can change these.'}
</p>
</div>
<a href="/admin/company/new" class="text-sm text-primary-600 hover:underline dark:text-primary-400">+ Create new company</a>
</div>
<form method="post" action="?/save"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{:else if form?.ok}
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-900/20 dark:text-emerald-300">Saved.</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</span>
<input name="name" required value={c.name} disabled={!data.isAdmin}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
<input name="slug" required value={c.slug} disabled={!data.isAdmin}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">Used internally. Lowercase, dashes only. Must be unique across the whole system.</p>
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default currency (ISO 3)</span>
<input name="default_currency" maxlength="3" placeholder="THB" value={data.settings.default_currency ?? ''} disabled={!data.isAdmin}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm uppercase shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Matrix room id</span>
<input name="matrix_room_id" placeholder="!abc123:matrix.org" value={data.settings.matrix_room_id ?? ''} disabled={!data.isAdmin}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">Format <code class="font-mono">!roomid:server.tld</code>. The bot (configured via <code class="font-mono">MATRIX_ACCESS_TOKEN</code>) must already be a member.</p>
</label>
{#if data.isAdmin}
<div class="flex justify-end border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Saving…' : 'Save changes'}
</button>
</div>
{/if}
</form>
</div>
@@ -0,0 +1,43 @@
import { fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { requireCompany } from '$lib/server/auth/guards';
import { setActiveCompany } from '$lib/server/auth/session';
import { createCompanyWithAdmin } from '$lib/server/services/companies';
import type { Actions } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(255),
slug: z.string().trim().max(128).optional().or(z.literal('')),
default_currency: z.string().trim().length(3).optional().or(z.literal(''))
});
export const actions: Actions = {
default: async ({ request, locals }) => {
const { user, sessionId } = requireCompany(locals);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
const v = parsed.data;
const settingsObj: Record<string, string> = {};
if (v.default_currency) settingsObj.default_currency = v.default_currency.toUpperCase();
try {
const { id } = await createCompanyWithAdmin({
name: v.name,
slug: v.slug || null,
settings: Object.keys(settingsObj).length ? JSON.stringify(settingsObj) : null,
creatorUserId: user.id
});
// Switch the session's active company so the creator lands in the new tenant.
await setActiveCompany(sessionId, id);
throw redirect(303, '/admin/company');
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
const msg = (e as Error).message;
if (msg.includes('companies_slug_unique')) {
return fail(400, { error: 'A company with that slug already exists.', values: raw });
}
return fail(400, { error: msg, values: raw });
}
}
};
@@ -0,0 +1,53 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-xl space-y-6">
<div>
<a href="/admin/company" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← company settings</a>
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">Create new company</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
You'll be added as the admin automatically. Your session switches to the new
company once it's created — use the sidebar to switch back later.
</p>
</div>
<form method="post"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></span>
<input name="name" required value={v.name ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
<input name="slug" value={v.slug ?? ''} placeholder="leave empty to derive from name"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default currency (ISO 3)</span>
<input name="default_currency" maxlength="3" placeholder="THB" value={v.default_currency ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm uppercase shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/admin/company" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Creating…' : 'Create company'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,89 @@
import { fail } from '@sveltejs/kit';
import { requireAdmin, requireCompany } from '$lib/server/auth/guards';
import {
listCompanyUsers,
removeUserFromCompany,
resetUserPassword,
setUserActive,
setUserRoleInCompany,
updateDisplayName,
type CompanyRole
} from '$lib/server/services/users';
import type { Actions, PageServerLoad } from './$types';
const ROLES = ['admin', 'manager', 'user', 'viewer'] as const;
export const load: PageServerLoad = async ({ locals }) => {
const { company, user } = requireCompany(locals);
const rows = await listCompanyUsers(company.id);
return { users: rows, selfUserId: user.id, isAdmin: company.role === 'admin' };
};
export const actions: Actions = {
setRole: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const role = String(form.get('role') ?? '');
if (!userId || !ROLES.includes(role as CompanyRole)) {
return fail(400, { error: 'Invalid request' });
}
try {
await setUserRoleInCompany(company.id, userId, role as CompanyRole);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
remove: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await removeUserFromCompany(company.id, userId);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
setActive: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const active = form.get('active') === 'true';
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await setUserActive(company.id, userId, active);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
rename: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const displayName = String(form.get('display_name') ?? '');
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await updateDisplayName(company.id, userId, displayName);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
resetPassword: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const userId = String(form.get('user_id') ?? '');
const password = String(form.get('password') ?? '');
if (!userId) return fail(400, { error: 'Missing user_id' });
try {
await resetUserPassword(company.id, userId, password);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { COMPANY_ROLES, COMPANY_ROLE_LABEL, type CompanyRole } from '$lib/roles';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let resettingId = $state<string | null>(null);
let renamingId = $state<string | null>(null);
let resetPw = $state('');
let renameValue = $state('');
</script>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Users</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{data.isAdmin
? 'Manage who has access to this company and what they can do.'
: 'Read-only view — only admins can invite or change roles.'}
</p>
</div>
{#if data.isAdmin}
<a href="/admin/users/new"
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
+ Invite user
</a>
{/if}
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Email</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Role</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last login</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
{#if data.isAdmin}
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
{/if}
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.users as u}
{@const isSelf = u.userId === data.selfUserId}
<tr class={u.isActive ? '' : 'opacity-60'}>
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{#if renamingId === u.userId}
<form method="post" action="?/rename"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') renamingId = null;
}}
class="flex items-center gap-2">
<input type="hidden" name="user_id" value={u.userId} />
<input name="display_name" required bind:value={renameValue}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<button type="submit" class="rounded bg-primary-600 px-2 py-0.5 text-xs font-medium text-white hover:bg-primary-700">save</button>
<button type="button" onclick={() => (renamingId = null)} class="text-xs text-gray-500">×</button>
</form>
{:else}
{u.displayName}
{#if isSelf}<span class="ml-1 text-xs text-gray-400">(you)</span>{/if}
{/if}
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{u.email}</td>
<td class="px-4 py-2 text-sm">
{#if data.isAdmin && !isSelf}
<form method="post" action="?/setRole" use:enhance class="inline">
<input type="hidden" name="user_id" value={u.userId} />
<select name="role" onchange={(ev) => (ev.currentTarget.form as HTMLFormElement).requestSubmit()}
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each COMPANY_ROLES as r}
<option value={r} selected={u.role === r}>{COMPANY_ROLE_LABEL[r]}</option>
{/each}
</select>
</form>
{:else}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 capitalize dark:bg-gray-700 dark:text-gray-200">{u.role}</span>
{/if}
</td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString() : 'never'}
</td>
<td class="px-4 py-2 text-xs">
{#if u.isActive}
<span class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">active</span>
{:else}
<span class="rounded-full bg-gray-200 px-2 py-0.5 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">inactive</span>
{/if}
</td>
{#if data.isAdmin}
<td class="px-4 py-2 text-right">
{#if resettingId === u.userId}
<form method="post" action="?/resetPassword"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') { resettingId = null; resetPw = ''; }
}}
class="flex items-center justify-end gap-2">
<input type="hidden" name="user_id" value={u.userId} />
<input name="password" type="text" required minlength="8" placeholder="new password" bind:value={resetPw}
class="w-40 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<button type="submit" class="rounded bg-primary-600 px-2 py-0.5 text-xs font-medium text-white hover:bg-primary-700">set</button>
<button type="button" onclick={() => { resettingId = null; resetPw = ''; }} class="text-xs text-gray-500">×</button>
</form>
{:else}
<div class="flex justify-end gap-2 text-xs">
<button type="button" onclick={() => { renamingId = u.userId; renameValue = u.displayName; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">rename</button>
<button type="button" onclick={() => { resettingId = u.userId; resetPw = ''; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">reset pw</button>
{#if !isSelf}
<form method="post" action="?/setActive" use:enhance class="inline">
<input type="hidden" name="user_id" value={u.userId} />
<input type="hidden" name="active" value={(!u.isActive).toString()} />
<button type="submit" class="text-gray-400 hover:text-amber-600 dark:hover:text-amber-400">{u.isActive ? 'deactivate' : 'reactivate'}</button>
</form>
<form method="post" action="?/remove" use:enhance class="inline">
<input type="hidden" name="user_id" value={u.userId} />
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
</form>
{/if}
</div>
{/if}
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,45 @@
import { fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { requireAdmin } from '$lib/server/auth/guards';
import {
createUserAndAddToCompany,
type CompanyRole
} from '$lib/server/services/users';
import type { Actions } from './$types';
const Schema = z.object({
email: z.string().trim().email(),
display_name: z.string().trim().min(1).max(255),
password: z.string().min(8).max(256),
role: z.enum(['admin', 'manager', 'user', 'viewer'])
});
export const load = async ({ locals }: { locals: App.Locals }) => {
requireAdmin(locals);
};
export const actions: Actions = {
default: async ({ request, locals }) => {
const { company } = requireAdmin(locals);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = Schema.safeParse(raw);
if (!parsed.success) {
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
}
const v = parsed.data;
try {
await createUserAndAddToCompany({
companyId: company.id,
email: v.email,
displayName: v.display_name,
password: v.password,
role: v.role as CompanyRole
});
throw redirect(303, '/admin/users');
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message, values: raw });
}
}
};
@@ -0,0 +1,70 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { COMPANY_ROLES, COMPANY_ROLE_DESCRIPTION, COMPANY_ROLE_LABEL } from '$lib/roles';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-xl space-y-6">
<div>
<a href="/admin/users" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to users</a>
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">Invite user</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Creates the user (or reuses an existing one with the same email) and adds them to this company.
Share the temporary password out-of-band; they can change it after logging in.
</p>
</div>
<form method="post"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Email <span class="text-red-500">*</span></span>
<input name="email" type="email" required value={v.email ?? ''} autocomplete="off"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Display name <span class="text-red-500">*</span></span>
<input name="display_name" required value={v.display_name ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Temporary password <span class="text-red-500">*</span></span>
<input name="password" type="text" required minlength="8" autocomplete="off"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters. Share with the user via a secure channel.</p>
</label>
<div>
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Role <span class="text-red-500">*</span></span>
<div class="mt-1 space-y-2">
{#each COMPANY_ROLES as r}
<label class="flex items-start gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm hover:border-primary-300 dark:border-gray-700 dark:hover:border-primary-700">
<input type="radio" name="role" value={r} required checked={r === 'user'}
class="mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
<div>
<div class="font-medium text-gray-900 dark:text-gray-100">{COMPANY_ROLE_LABEL[r]}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{COMPANY_ROLE_DESCRIPTION[r]}</div>
</div>
</label>
{/each}
</div>
</div>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/admin/users" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Creating…' : 'Create user'}
</button>
</div>
</form>
</div>
+27
View File
@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import { and, asc, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetTypes } from '$lib/server/db/schema/assets';
import { listAssets } from '$lib/server/services/assets';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const typeSlug = url.searchParams.get('type') ?? undefined;
const q = url.searchParams.get('q') ?? undefined;
const types = await db
.select({ id: assetTypes.id, name: assetTypes.name, slug: assetTypes.slug })
.from(assetTypes)
.where(or(isNull(assetTypes.companyId), sql`${assetTypes.companyId} = ${locals.company.id}`)!)
.orderBy(asc(assetTypes.name));
const assets = await listAssets({
companyId: locals.company.id,
typeSlug,
q,
limit: 200
});
return { assets, types, filterType: typeSlug ?? '', filterQ: q ?? '' };
};
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Assets</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Everything you track — switches, ACs, filters, sensors, generators…
</p>
</div>
<a href="/assets/new"
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
+ New asset
</a>
</div>
<form method="get" class="flex flex-col gap-3 sm:flex-row">
<input
type="search"
name="q"
value={data.filterQ}
placeholder="Search by name, tag, or serial…"
class="block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 sm:max-w-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
<select name="type" class="block rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100">
<option value="">All types</option>
{#each data.types as t}
<option value={t.slug} selected={data.filterType === t.slug}>{t.name}</option>
{/each}
</select>
<button type="submit" class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">Filter</button>
<a href="/assets/export.csv?{new URLSearchParams({ q: data.filterQ, type: data.filterType }).toString()}"
class="self-center text-sm text-gray-600 hover:text-primary-600 dark:text-gray-400 dark:hover:text-primary-400">
Export CSV →
</a>
</form>
{#if data.assets.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-200">No assets match.</p>
<p class="mt-1">Adjust the filter above, or <a href="/assets/new" class="text-primary-600 hover:underline dark:text-primary-400">add a new asset</a>.</p>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Type</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tag</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Serial</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.assets as a}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="/assets/{a.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{a.name}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.assetTypeName}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.tag ?? '—'}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.serialNumber ?? '—'}</td>
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">{new Date(a.updatedAt).toLocaleDateString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,73 @@
import { error } from '@sveltejs/kit';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import { projects } from '$lib/server/db/schema/projects';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { loadTypeWithFields } from '$lib/server/services/assets';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const [asset] = await db
.select()
.from(assets)
.where(
and(
eq(assets.id, params.id),
eq(assets.companyId, locals.company.id),
isNull(assets.deletedAt)
)
)
.limit(1);
if (!asset) throw error(404, 'Asset not found');
const tf = await loadTypeWithFields(asset.assetTypeId);
if (!tf) throw error(500, 'Asset type missing');
let currentLocationName: string | null = null;
let currentLocationHref: string | null = null;
let currentRoomLabel: string | null = null;
if (asset.currentPropertyId) {
const [p] = await db
.select({ name: properties.name })
.from(properties)
.where(eq(properties.id, asset.currentPropertyId))
.limit(1);
currentLocationName = p?.name ?? null;
currentLocationHref = `/properties/${asset.currentPropertyId}`;
if (asset.currentRoomId) {
const [r] = await db
.select({
name: propertyRooms.name,
floorLabel: propertyFloors.label
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.where(eq(propertyRooms.id, asset.currentRoomId))
.limit(1);
if (r) {
currentRoomLabel = r.floorLabel ? `Floor ${r.floorLabel} · ${r.name}` : r.name;
}
}
} else if (asset.currentProjectId) {
const [p] = await db
.select({ name: projects.name })
.from(projects)
.where(eq(projects.id, asset.currentProjectId))
.limit(1);
currentLocationName = p?.name ?? null;
currentLocationHref = `/projects/${asset.currentProjectId}/assets`;
}
return {
asset,
assetType: tf.type,
fieldDefs: tf.fields,
currentLocationName,
currentLocationHref,
currentRoomLabel
};
};
@@ -0,0 +1,47 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import TabNav from '$lib/components/TabNav.svelte';
import type { LayoutData } from './$types';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
const tabs = $derived([
{ href: `/assets/${data.asset.id}`, label: 'Overview' },
{ href: `/assets/${data.asset.id}/maintenance`, label: 'Maintenance' },
{ href: `/assets/${data.asset.id}/history`, label: 'History' },
{ href: `/assets/${data.asset.id}/logs`, label: 'Logs' },
{ href: `/assets/${data.asset.id}/documents`, label: 'Documents' },
{ href: `/assets/${data.asset.id}/move`, label: 'Move' }
]);
</script>
<div class="space-y-6">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">
{data.assetType.name}
</div>
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">
{data.asset.name}
</h1>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
{#if data.currentLocationName && data.currentLocationHref}
<span>at <a href={data.currentLocationHref} class="text-primary-600 hover:underline dark:text-primary-400">{data.currentLocationName}</a> <span class="text-gray-400">({data.asset.currentContainerKind})</span></span>
{/if}
{#if data.currentRoomLabel}
<span>· {data.currentRoomLabel}</span>
{/if}
{#if data.asset.tag}<span>· tag <code class="font-mono text-xs">{data.asset.tag}</code></span>{/if}
{#if data.asset.serialNumber}<span>· s/n <code class="font-mono text-xs">{data.asset.serialNumber}</code></span>{/if}
</div>
</div>
<a href="/assets/{data.asset.id}/label"
class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Print label
</a>
</div>
<TabNav {tabs} />
{@render children()}
</div>
@@ -0,0 +1,87 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { and, asc, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '$lib/server/db/client';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { loadTypeWithFields, softDeleteAsset, updateAsset } from '$lib/server/services/assets';
import { gatherCustomFieldsFromForm } from '$lib/server/custom-fields-form';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, parent }) => {
if (!locals.company) throw error(401);
const { asset } = await parent();
let rooms: Array<{ id: string; name: string; floorLabel: string | null }> = [];
if (asset.currentContainerKind === 'property' && asset.currentPropertyId) {
rooms = await db
.select({
id: propertyRooms.id,
name: propertyRooms.name,
floorLabel: propertyFloors.label
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.where(
and(
eq(propertyRooms.propertyId, asset.currentPropertyId),
isNull(propertyRooms.deletedAt)
)
)
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name));
}
return { rooms };
};
const PatchSchema = z.object({
name: z.string().trim().min(1).max(255),
tag: z.string().trim().max(64).optional().or(z.literal('')),
serial_number: z.string().trim().max(128).optional().or(z.literal('')),
manufacturer: z.string().trim().max(128).optional().or(z.literal('')),
model: z.string().trim().max(128).optional().or(z.literal('')),
purchased_at: z.string().trim().optional().or(z.literal('')),
room_id: z.string().optional().or(z.literal(''))
});
const e2n = (s: string | undefined) => (!s ? null : s);
export const actions: Actions = {
save: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = PatchSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
const tf = await loadTypeWithFields(form.get('asset_type_id') as string);
if (!tf) return fail(400, { error: 'Asset type not found.' });
const cf = gatherCustomFieldsFromForm(form, tf.fields);
// Room field is only included when a property asset is being edited.
// Empty string = clear room; uuid = set; undefined = leave alone.
const roomPatch: { roomId?: string | null } = {};
if (form.has('room_id')) {
roomPatch.roomId = v.room_id ? v.room_id : null;
}
try {
await updateAsset(locals.company.id, params.id, {
name: v.name,
tag: e2n(v.tag),
serialNumber: e2n(v.serial_number),
manufacturer: e2n(v.manufacturer),
model: e2n(v.model),
purchasedAt: v.purchased_at ? new Date(v.purchased_at) : null,
customFields: cf,
...roomPatch
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await softDeleteAsset(locals.company.id, params.id);
throw redirect(303, '/assets');
}
};
+110
View File
@@ -0,0 +1,110 @@
<script lang="ts">
import { enhance } from '$app/forms';
import CustomFieldsForm from '$lib/components/CustomFieldsForm.svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let saving = $state(false);
let confirmingDelete = $state(false);
const a = $derived(data.asset);
function dateInput(d: Date | string | null): string {
if (!d) return '';
const dt = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(dt.getTime())) return '';
return dt.toISOString().slice(0, 10);
}
</script>
<form
method="post"
action="?/save"
use:enhance={() => {
saving = true;
return ({ update }) => update().finally(() => (saving = false));
}}
class="space-y-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{:else if form?.ok}
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-900/20 dark:text-emerald-300">Saved.</div>
{/if}
<input type="hidden" name="asset_type_id" value={a.assetTypeId} />
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required value={a.name}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Asset tag</label>
<input id="tag" name="tag" value={a.tag ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Serial number</label>
<input id="serial_number" name="serial_number" value={a.serialNumber ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
<input id="manufacturer" name="manufacturer" value={a.manufacturer ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
<input id="model" name="model" value={a.model ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="purchased_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Purchased on</label>
<input id="purchased_at" name="purchased_at" type="date" value={dateInput(a.purchasedAt)}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
{#if a.currentContainerKind === 'property'}
<div class="sm:col-span-2">
<label for="room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room</label>
<select id="room_id" name="room_id"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— no specific room —</option>
{#each data.rooms as r}
<option value={r.id} selected={a.currentRoomId === r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
{/each}
</select>
{#if data.rooms.length === 0}
<p class="mt-1 text-xs text-gray-400">This property has no rooms yet. Add them from the property's Rooms tab.</p>
{/if}
</div>
{/if}
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">{data.assetType.name} details</div>
<CustomFieldsForm defs={data.fieldDefs} values={a.customFields as Record<string, unknown>} />
</div>
<div class="flex items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
{confirmingDelete ? 'Cancel delete' : 'Delete asset…'}
</button>
<button type="submit" disabled={saving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
{#if confirmingDelete}
<form method="post" action="?/delete"
class="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
<p class="font-medium">Delete this asset?</p>
<p class="mt-1">Soft-deletes the asset; history and documents stay on disk.</p>
<div class="mt-3 flex justify-end gap-2">
<button type="button" onclick={() => (confirmingDelete = false)} class="text-sm text-red-700 dark:text-red-300">Cancel</button>
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete</button>
</div>
</form>
{/if}
@@ -0,0 +1,58 @@
import { error, fail } from '@sveltejs/kit';
import {
deleteDocument,
listDocumentsForScope,
signedUrlForDocument,
uploadDocument
} from '$lib/server/services/documents';
import type { Actions, PageServerLoad } from './$types';
const MAX_BYTES = 50 * 1024 * 1024;
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const docs = await listDocumentsForScope(locals.company.id, 'asset', params.id);
const enriched = await Promise.all(
docs.map(async (d) => ({
...d,
downloadUrl: await signedUrlForDocument(d, 'attachment'),
previewUrl: await signedUrlForDocument(d, 'inline')
}))
);
return { documents: enriched };
};
export const actions: Actions = {
upload: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const file = form.get('file');
if (!(file instanceof File) || file.size === 0) {
return fail(400, { error: 'Pick a file to upload.' });
}
if (file.size > MAX_BYTES) return fail(413, { error: 'File too large (max 50 MB).' });
const buf = Buffer.from(await file.arrayBuffer());
try {
await uploadDocument({
companyId: locals.company.id,
uploadedBy: locals.user.id,
scopeType: 'asset',
scopeId: params.id,
filename: file.name || 'upload.bin',
mimeType: file.type || 'application/octet-stream',
body: buf
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
delete: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (!id) return fail(400, { error: 'Missing id' });
await deleteDocument(locals.company.id, id);
return { ok: true };
}
};
@@ -0,0 +1,63 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let uploading = $state(false);
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
</script>
<div class="space-y-4">
<form
method="post"
action="?/upload"
enctype="multipart/form-data"
use:enhance={() => {
uploading = true;
return ({ update }) => update().finally(() => (uploading = false));
}}
class="rounded-lg border border-dashed border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<input type="file" name="file" required
class="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-900/30 dark:file:text-primary-300" />
<button type="submit" disabled={uploading}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{uploading ? 'Uploading…' : 'Upload'}
</button>
</div>
{#if form?.error}<p class="mt-2 text-sm text-red-600 dark:text-red-400">{form.error}</p>{/if}
{#if form?.ok}<p class="mt-2 text-sm text-emerald-600 dark:text-emerald-400">Done.</p>{/if}
</form>
{#if data.documents.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No documents attached.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.documents as d}
<li class="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div class="min-w-0">
<a href={d.previewUrl} target="_blank" rel="noopener" class="block truncate font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{d.filename}</a>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{d.mimeType} · {fmtSize(d.sizeBytes)} · {new Date(d.uploadedAt).toLocaleString()}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<a href={d.downloadUrl} class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">Download</a>
<form method="post" action="?/delete" use:enhance>
<input type="hidden" name="id" value={d.id} />
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Delete</button>
</form>
</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,31 @@
import { error } from '@sveltejs/kit';
import { aliasedTable, desc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetLocationHistory } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import { users } from '$lib/server/db/schema/tenancy';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const fromProp = aliasedTable(properties, 'from_prop');
const toProp = aliasedTable(properties, 'to_prop');
const rows = await db
.select({
id: assetLocationHistory.id,
fromKind: assetLocationHistory.fromKind,
fromPropertyName: fromProp.name,
toKind: assetLocationHistory.toKind,
toPropertyName: toProp.name,
movedAt: assetLocationHistory.movedAt,
movedByName: users.displayName,
reason: assetLocationHistory.reason
})
.from(assetLocationHistory)
.leftJoin(fromProp, eq(fromProp.id, assetLocationHistory.fromPropertyId))
.leftJoin(toProp, eq(toProp.id, assetLocationHistory.toPropertyId))
.leftJoin(users, eq(users.id, assetLocationHistory.movedBy))
.where(eq(assetLocationHistory.assetId, params.id))
.orderBy(desc(assetLocationHistory.movedAt));
return { history: rows };
};
@@ -0,0 +1,34 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-3">
{#if data.history.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No movements recorded yet.
</div>
{:else}
<ol class="relative space-y-4 border-l border-gray-200 pl-4 dark:border-gray-700">
{#each data.history as h}
<li class="relative">
<span class="absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full bg-primary-500"></span>
<div class="flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-900 dark:text-gray-100">
{h.fromKind ? h.fromPropertyName ?? '(unknown)' : '— created —'}
</span>
<span class="text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-gray-100">{h.toPropertyName ?? '(unknown)'}</span>
</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{new Date(h.movedAt).toLocaleString()}
{#if h.movedByName}· by {h.movedByName}{/if}
</div>
{#if h.reason}
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{h.reason}</div>
{/if}
</li>
{/each}
</ol>
{/if}
</div>
@@ -0,0 +1,8 @@
import { env } from '$lib/server/env';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
// Absolute URL is what scanners land on. The layout already loaded the asset,
// so we only need to hand down the base URL (not available in the client bundle).
return { publicBaseUrl: env.PUBLIC_BASE_URL.replace(/\/$/, '') };
};
@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const scanUrl = $derived(`${data.publicBaseUrl}/assets/${data.asset.id}`);
const qrSrc = $derived(`/api/qr?size=320&target=${encodeURIComponent(scanUrl)}`);
function doPrint() {
if (typeof window !== 'undefined') window.print();
}
</script>
<div class="space-y-4 print:space-y-0">
<div class="flex items-center justify-between print:hidden">
<a href="/assets/{data.asset.id}" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to asset</a>
<button type="button" onclick={doPrint}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
Print label
</button>
</div>
<!-- Printable card: centered, bounded so it fits on a typical inkjet label or half-A6 -->
<div class="label-card mx-auto max-w-sm rounded-lg border border-gray-300 bg-white p-6 text-gray-900 print:m-0 print:border-0 print:shadow-none">
<div class="flex items-center justify-between">
<div class="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
{data.assetType.name}
</div>
{#if data.asset.tag}
<div class="font-mono text-xs text-gray-600">{data.asset.tag}</div>
{/if}
</div>
<div class="mt-2 text-base font-semibold leading-tight">{data.asset.name}</div>
{#if data.asset.manufacturer || data.asset.model}
<div class="mt-0.5 text-xs text-gray-600">
{[data.asset.manufacturer, data.asset.model].filter(Boolean).join(' · ')}
</div>
{/if}
{#if data.asset.serialNumber}
<div class="mt-0.5 text-[11px] text-gray-500">s/n <span class="font-mono">{data.asset.serialNumber}</span></div>
{/if}
{#if data.currentLocationName}
<div class="mt-0.5 text-[11px] text-gray-500">@ {data.currentLocationName}</div>
{/if}
<div class="my-3 flex items-center justify-center">
<img src={qrSrc} alt="QR code for {data.asset.name}" class="h-48 w-48" />
</div>
<div class="text-center font-mono text-[10px] break-all text-gray-500">{scanUrl}</div>
</div>
</div>
<style>
@media print {
:global(body),
:global(html) {
background: white !important;
}
:global(aside),
:global(header),
:global(nav) {
display: none !important;
}
:global(main > div) {
padding: 0 !important;
max-width: none !important;
}
.label-card {
box-shadow: none !important;
border: 1px solid #d1d5db !important;
}
}
</style>
@@ -0,0 +1,39 @@
import { error, fail } from '@sveltejs/kit';
import { desc, eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetLogs } from '$lib/server/db/schema/assets';
import { users } from '$lib/server/db/schema/tenancy';
import { appendAssetLog } from '$lib/server/services/assets';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const rows = await db
.select({
id: assetLogs.id,
body: assetLogs.body,
createdAt: assetLogs.createdAt,
authorName: users.displayName
})
.from(assetLogs)
.leftJoin(users, eq(users.id, assetLogs.authorId))
.where(eq(assetLogs.assetId, params.id))
.orderBy(desc(assetLogs.createdAt))
.limit(200);
return { logs: rows };
};
export const actions: Actions = {
add: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const body = String(form.get('body') ?? '').trim();
if (!body) return fail(400, { error: 'Write something first.' });
try {
await appendAssetLog(locals.company.id, params.id, locals.user.id, body);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};
@@ -0,0 +1,58 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let body = $state('');
let posting = $state(false);
</script>
<div class="space-y-4">
<form
method="post"
action="?/add"
use:enhance={() => {
posting = true;
return ({ update }) =>
update().finally(() => {
posting = false;
body = '';
});
}}
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<textarea
name="body"
bind:value={body}
rows="3"
placeholder="Add a note — observation, repair, change, anything…"
class="block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
></textarea>
{#if form?.error}
<p class="text-sm text-red-600 dark:text-red-400">{form.error}</p>
{/if}
<div class="flex justify-end">
<button type="submit" disabled={posting || !body.trim()}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{posting ? 'Posting…' : 'Post note'}
</button>
</div>
</form>
{#if data.logs.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No log entries yet.
</div>
{:else}
<ul class="space-y-3">
{#each data.logs as l}
<li class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-1 text-xs text-gray-500 dark:text-gray-400">
{l.authorName ?? '(unknown)'} · {new Date(l.createdAt).toLocaleString()}
</div>
<div class="whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-100">{l.body}</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,139 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { listTemplates } from '$lib/server/services/checklists';
import {
createSchedule,
deleteSchedule,
listEventsForAsset,
listSchedulesForAsset,
listUsageReadingsForAsset,
recordMaintenanceEvent,
recordUsageReading,
setScheduleActive,
type IntervalUnit,
type ScheduleKind
} from '$lib/server/services/maintenance';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const [schedules, events, readings, templates] = await Promise.all([
listSchedulesForAsset(locals.company.id, params.id),
listEventsForAsset(locals.company.id, params.id),
listUsageReadingsForAsset(locals.company.id, params.id),
listTemplates(locals.company.id)
]);
return {
schedules,
events,
readings,
templates
};
};
const ScheduleSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.enum(['time', 'usage']),
interval_value: z.coerce.number().int().positive(),
interval_unit: z.enum(['days', 'months', 'years', 'hours', 'cycles', 'km']),
checklist_template_id: z.string().uuid().optional().or(z.literal('')),
start_from: z.string().optional().or(z.literal('')),
start_usage: z.coerce.number().optional().or(z.literal('')),
notes: z.string().trim().max(2000).optional().or(z.literal(''))
});
export const actions: Actions = {
createSchedule: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = ScheduleSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
try {
await createSchedule({
companyId: locals.company.id,
createdBy: locals.user.id,
assetId: params.id,
name: v.name,
kind: v.kind as ScheduleKind,
intervalValue: v.interval_value,
intervalUnit: v.interval_unit as IntervalUnit,
startFrom: v.start_from ? new Date(v.start_from) : null,
startUsage:
typeof v.start_usage === 'number' ? v.start_usage : null,
checklistTemplateId: v.checklist_template_id || null,
notes: v.notes || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
completeEvent: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const scheduleId = String(form.get('schedule_id') ?? '');
const performedAtStr = String(form.get('performed_at') ?? '').trim();
const usageReadingStr = String(form.get('usage_reading') ?? '').trim();
const notes = String(form.get('notes') ?? '').trim() || null;
const instantiate = form.get('instantiate_checklist') === 'true';
if (!scheduleId) return fail(400, { error: 'Missing schedule_id' });
try {
const { eventId, checklistInstanceId } = await recordMaintenanceEvent({
companyId: locals.company.id,
performedBy: locals.user.id,
scheduleId,
performedAt: performedAtStr ? new Date(performedAtStr) : new Date(),
notes,
usageReading: usageReadingStr ? Number(usageReadingStr) : null,
instantiateChecklist: instantiate
});
if (checklistInstanceId) {
throw redirect(303, `/assets/${params.id}/maintenance/events/${eventId}`);
}
return { ok: true, eventId };
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
},
addUsageReading: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const reading = Number(form.get('reading') ?? '');
const unit = String(form.get('unit') ?? '') as IntervalUnit;
const notes = String(form.get('notes') ?? '').trim() || null;
if (!Number.isFinite(reading)) return fail(400, { error: 'Reading must be a number.' });
try {
await recordUsageReading({
companyId: locals.company.id,
recordedBy: locals.user.id,
assetId: params.id,
reading,
unit,
notes
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
toggleScheduleActive: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const scheduleId = String(form.get('schedule_id') ?? '');
const active = form.get('active') === 'true';
await setScheduleActive(locals.company.id, scheduleId, active);
return { ok: true };
},
deleteSchedule: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const scheduleId = String(form.get('schedule_id') ?? '');
await deleteSchedule(locals.company.id, scheduleId);
return { ok: true };
}
};
@@ -0,0 +1,280 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showSchedForm = $state(false);
let showUsageForm = $state(false);
let kind = $state<'time' | 'usage'>('time');
let openCompleteFor = $state<string | null>(null);
function statusFor(nextDueAt: Date | string | null | undefined): {
label: string;
cls: string;
} {
if (!nextDueAt) return { label: '—', cls: 'text-gray-400' };
const d = new Date(nextDueAt);
const ms = d.getTime() - Date.now();
const days = Math.round(ms / (1000 * 60 * 60 * 24));
if (days < 0)
return { label: `${-days} day${-days === 1 ? '' : 's'} overdue`, cls: 'text-red-600 dark:text-red-400 font-medium' };
if (days <= 7)
return { label: `due in ${days} day${days === 1 ? '' : 's'}`, cls: 'text-amber-600 dark:text-amber-400 font-medium' };
return { label: d.toLocaleDateString(), cls: 'text-gray-600 dark:text-gray-300' };
}
const TIME_UNITS = ['days', 'months', 'years', 'hours'];
const USAGE_UNITS = ['hours', 'cycles', 'km'];
</script>
<div class="space-y-6">
<!-- ============== schedules ============== -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schedules</h2>
<button type="button" onclick={() => (showSchedForm = !showSchedForm)}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showSchedForm ? 'Cancel' : '+ New schedule'}
</button>
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
{#if showSchedForm}
<form method="post" action="?/createSchedule" use:enhance
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="grid gap-3 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required placeholder="e.g. Filter replacement"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="kind" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Kind</label>
<select id="kind" name="kind" bind:value={kind}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="time">Time-based</option>
<option value="usage">Usage-based</option>
</select>
</div>
<div>
<label for="interval_value" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Every</label>
<div class="mt-1 flex gap-2">
<input id="interval_value" name="interval_value" type="number" min="1" required value="1"
class="block w-24 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<select name="interval_unit"
class="block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each (kind === 'time' ? TIME_UNITS : USAGE_UNITS) as u}
<option value={u}>{u}</option>
{/each}
</select>
</div>
</div>
{#if kind === 'time'}
<div>
<label for="start_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Anchor (optional)</label>
<input id="start_from" name="start_from" type="date"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">First service due = anchor + interval. Defaults to today.</p>
</div>
{:else}
<div>
<label for="start_usage" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Current usage</label>
<input id="start_usage" name="start_usage" type="number" step="any" placeholder="0"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<p class="mt-1 text-xs text-gray-400">Next service due at this + interval.</p>
</div>
{/if}
<div class="sm:col-span-2">
<label for="checklist_template_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Checklist template (optional)</label>
<select id="checklist_template_id" name="checklist_template_id"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— none —</option>
{#each data.templates as t}
<option value={t.id}>{t.name} ({t.itemCount} items)</option>
{/each}
</select>
</div>
<div class="sm:col-span-2">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<input id="notes" name="notes"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Create schedule</button>
</div>
</form>
{/if}
{#if data.schedules.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No schedules yet.
</div>
{:else}
<ul class="space-y-2">
{#each data.schedules as s}
{@const st = statusFor(s.nextDueAt)}
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800 {!s.active ? 'opacity-60' : ''}">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{s.name}</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
every {s.intervalValue} {s.intervalUnit}
· {s.kind}
{#if s.kind === 'time'}
· next: <span class={st.cls}>{st.label}</span>
{:else}
· next at usage <span class="font-medium">{s.nextDueUsage ?? '—'} {s.intervalUnit}</span>
{/if}
{#if !s.active}
· <span class="rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">inactive</span>
{/if}
</div>
{#if s.notes}<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{s.notes}</div>{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button type="button" onclick={() => (openCompleteFor = openCompleteFor === s.id ? null : s.id)}
class="rounded-md bg-emerald-600 px-2 py-1 text-xs font-medium text-white hover:bg-emerald-700">
{openCompleteFor === s.id ? 'Cancel' : 'Complete'}
</button>
<form method="post" action="?/toggleScheduleActive" use:enhance>
<input type="hidden" name="schedule_id" value={s.id} />
<input type="hidden" name="active" value={(!s.active).toString()} />
<button type="submit" class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
{s.active ? 'Pause' : 'Resume'}
</button>
</form>
<form method="post" action="?/deleteSchedule" use:enhance>
<input type="hidden" name="schedule_id" value={s.id} />
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Delete</button>
</form>
</div>
</div>
{#if openCompleteFor === s.id}
<form method="post" action="?/completeEvent" use:enhance
class="mt-3 space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<input type="hidden" name="schedule_id" value={s.id} />
<div class="grid gap-3 sm:grid-cols-2">
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Performed at</span>
<input name="performed_at" type="datetime-local"
value={new Date().toISOString().slice(0, 16)}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
{#if s.kind === 'usage'}
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Current reading ({s.intervalUnit}) <span class="text-red-500">*</span></span>
<input name="usage_reading" type="number" step="any" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
{/if}
</div>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
<input name="notes"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
{#if s.checklistTemplateId}
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="instantiate_checklist" value="true" checked
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
Materialize the checklist for this event
</label>
{/if}
<div class="flex justify-end">
<button type="submit" class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700">Record event</button>
</div>
</form>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
<!-- ============== usage readings ============== -->
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Usage readings</h2>
<button type="button" onclick={() => (showUsageForm = !showUsageForm)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
{showUsageForm ? 'Cancel' : '+ Reading'}
</button>
</div>
{#if showUsageForm}
<form method="post" action="?/addUsageReading" use:enhance
class="grid gap-3 rounded-lg border border-gray-200 bg-white p-4 sm:grid-cols-3 dark:border-gray-700 dark:bg-gray-800">
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Reading</span>
<input name="reading" type="number" step="any" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
<select name="unit" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
{#each USAGE_UNITS as u}
<option value={u}>{u}</option>
{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
<input name="notes"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</label>
<div class="sm:col-span-3 flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Add reading</button>
</div>
</form>
{/if}
{#if data.readings.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No readings recorded.</p>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.readings as r}
<li class="flex items-center justify-between px-4 py-2 text-sm">
<div>
<span class="font-medium text-gray-900 dark:text-gray-100">{r.reading}</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">{r.unit}</span>
{#if r.notes}<span class="ml-3 text-xs text-gray-500 dark:text-gray-400">{r.notes}</span>{/if}
</div>
<span class="text-xs text-gray-400">{new Date(r.recordedAt).toLocaleString()}</span>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ============== events history ============== -->
<section class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Recent events</h2>
{#if data.events.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No events recorded.</p>
{:else}
<ol class="relative space-y-3 border-l border-gray-200 pl-4 dark:border-gray-700">
{#each data.events as e}
<li class="relative">
<span class="absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full bg-emerald-500"></span>
<div class="text-sm">
<a href="/assets/{data.asset.id}/maintenance/events/{e.id}" class="font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
{e.scheduleName ?? '(deleted schedule)'}
</a>
<span class="ml-2 text-xs text-gray-400">{new Date(e.performedAt).toLocaleString()}</span>
{#if e.checklistInstanceId}
<span class="ml-2 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">checklist</span>
{/if}
</div>
{#if e.notes}<div class="mt-0.5 text-xs text-gray-600 dark:text-gray-400">{e.notes}</div>{/if}
</li>
{/each}
</ol>
{/if}
</section>
</div>
@@ -0,0 +1,68 @@
import { error, fail } from '@sveltejs/kit';
import { and, eq } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assets } from '$lib/server/db/schema/assets';
import { maintenanceEvents, maintenanceSchedules } from '$lib/server/db/schema/maintenance';
import { users } from '$lib/server/db/schema/tenancy';
import { completeInstance, getInstance, setItemDone } from '$lib/server/services/checklists';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const [row] = await db
.select({
event: maintenanceEvents,
scheduleName: maintenanceSchedules.name,
performedByName: users.displayName
})
.from(maintenanceEvents)
.leftJoin(maintenanceSchedules, eq(maintenanceSchedules.id, maintenanceEvents.scheduleId))
.leftJoin(users, eq(users.id, maintenanceEvents.performedBy))
.innerJoin(assets, eq(assets.id, maintenanceEvents.assetId))
.where(
and(
eq(maintenanceEvents.id, params.eventId),
eq(maintenanceEvents.assetId, params.id),
eq(assets.companyId, locals.company.id)
)
)
.limit(1);
if (!row) throw error(404, 'Event not found');
let checklist: Awaited<ReturnType<typeof getInstance>> | null = null;
if (row.event.checklistInstanceId) {
checklist = await getInstance(locals.company.id, row.event.checklistInstanceId);
}
return {
event: row.event,
scheduleName: row.scheduleName,
performedByName: row.performedByName,
checklist
};
};
export const actions: Actions = {
toggleItem: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const instanceId = String(form.get('instance_id') ?? '');
const itemId = String(form.get('item_id') ?? '');
const done = form.get('done') === 'true';
if (!instanceId || !itemId) return fail(400, { error: 'Missing ids' });
try {
await setItemDone(locals.company.id, instanceId, itemId, done, locals.user.id);
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
completeChecklist: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const instanceId = String(form.get('instance_id') ?? '');
if (!instanceId) return fail(400, { error: 'Missing instance_id' });
await completeInstance(locals.company.id, instanceId);
return { ok: true };
}
};
@@ -0,0 +1,82 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<div class="space-y-6">
<div>
<a href="/assets/{data.asset.id}/maintenance" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to maintenance</a>
<h2 class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{data.scheduleName ?? '(deleted schedule)'}
</h2>
<div class="text-sm text-gray-500 dark:text-gray-400">
Performed {new Date(data.event.performedAt).toLocaleString()}
{#if data.performedByName}· by {data.performedByName}{/if}
{#if data.event.usageReading}· at {data.event.usageReading}{/if}
</div>
{#if data.event.notes}<p class="mt-2 text-sm text-gray-700 dark:text-gray-200">{data.event.notes}</p>{/if}
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
{#if data.checklist}
{@const inst = data.checklist.instance}
{@const items = data.checklist.items}
{@const remaining = items.filter((i) => i.required && !i.done).length}
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Checklist · {inst.title}</h3>
{#if inst.completedAt}
<p class="text-xs text-emerald-600 dark:text-emerald-400">Completed {new Date(inst.completedAt).toLocaleString()}</p>
{:else}
<p class="text-xs text-gray-500 dark:text-gray-400">{remaining} required item{remaining === 1 ? '' : 's'} remaining</p>
{/if}
</div>
{#if !inst.completedAt}
<form method="post" action="?/completeChecklist" use:enhance>
<input type="hidden" name="instance_id" value={inst.id} />
<button type="submit" disabled={remaining > 0}
class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
title={remaining > 0 ? 'Complete all required items first' : ''}>
Mark checklist complete
</button>
</form>
{/if}
</div>
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
{#each items as item}
<li class="flex items-start gap-3 py-2">
<form method="post" action="?/toggleItem" use:enhance class="pt-0.5">
<input type="hidden" name="instance_id" value={inst.id} />
<input type="hidden" name="item_id" value={item.id} />
<input type="hidden" name="done" value={(!item.done).toString()} />
<button type="submit" aria-label={item.done ? 'Mark incomplete' : 'Mark complete'}
class="inline-flex h-5 w-5 items-center justify-center rounded border {item.done ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-gray-300 dark:border-gray-600'}">
{#if item.done}
<svg viewBox="0 0 20 20" fill="currentColor" class="h-3 w-3"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
{/if}
</button>
</form>
<div class="min-w-0 flex-1">
<div class="text-sm {item.done ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}">{item.text}</div>
{#if item.required}
<span class="text-[10px] font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400">required</span>
{/if}
{#if item.done && item.doneAt}
<div class="text-xs text-gray-500 dark:text-gray-400">done {new Date(item.doneAt).toLocaleString()}</div>
{/if}
</div>
</li>
{/each}
</ul>
</div>
{:else}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No checklist was attached to this event.</p>
{/if}
</div>
@@ -0,0 +1,71 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { and, asc, eq, isNull } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { properties } from '$lib/server/db/schema/properties';
import { projects } from '$lib/server/db/schema/projects';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { moveAsset } from '$lib/server/services/assets';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(401);
const companyId = locals.company.id;
const [props, projs, rooms] = await Promise.all([
db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(and(eq(properties.companyId, companyId), isNull(properties.deletedAt)))
.orderBy(asc(properties.name)),
db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.companyId, companyId), isNull(projects.deletedAt)))
.orderBy(asc(projects.name)),
db
.select({
id: propertyRooms.id,
propertyId: propertyRooms.propertyId,
floorLabel: propertyFloors.label,
name: propertyRooms.name
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
.where(
and(
eq(properties.companyId, companyId),
isNull(propertyRooms.deletedAt),
isNull(properties.deletedAt)
)
)
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name))
]);
return { properties: props, projects: projs, rooms };
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const target = String(form.get('target') ?? '');
const reason = String(form.get('reason') ?? '').trim() || null;
const toRoomId = String(form.get('to_room_id') ?? '').trim() || null;
const [kind, id] = target.split(':', 2);
if ((kind !== 'property' && kind !== 'project') || !id) {
return fail(400, { error: 'Pick a destination.' });
}
try {
await moveAsset(locals.company.id, params.id, {
toKind: kind,
toId: id,
movedBy: locals.user.id,
reason,
toRoomId: kind === 'property' ? toRoomId : null
});
throw redirect(303, `/assets/${params.id}/history`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
}
};
@@ -0,0 +1,84 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let moving = $state(false);
let target = $state('');
const selectedPropertyId = $derived.by(() => {
const [kind, id] = target.split(':', 2);
return kind === 'property' ? id : '';
});
const roomsForProperty = $derived(
selectedPropertyId ? data.rooms.filter((r) => r.propertyId === selectedPropertyId) : []
);
</script>
<form
method="post"
use:enhance={() => {
moving = true;
return ({ update }) => update().finally(() => (moving = false));
}}
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<div class="text-sm text-gray-600 dark:text-gray-300">
Currently at <strong>{data.currentLocationName ?? '(unknown)'}</strong>.
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<div>
<label for="target" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Move to</label>
<select id="target" name="target" required bind:value={target}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— pick destination —</option>
{#if data.properties.length > 0}
<optgroup label="Properties">
{#each data.properties as p}
<option value="property:{p.id}">{p.name}</option>
{/each}
</optgroup>
{/if}
{#if data.projects.length > 0}
<optgroup label="Projects">
{#each data.projects as p}
<option value="project:{p.id}">{p.name}</option>
{/each}
</optgroup>
{/if}
</select>
</div>
{#if selectedPropertyId}
<div>
<label for="to_room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room (optional)</label>
<select id="to_room_id" name="to_room_id" disabled={roomsForProperty.length === 0}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— no specific room —</option>
{#each roomsForProperty as r}
<option value={r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
{/each}
</select>
{#if roomsForProperty.length === 0}
<p class="mt-1 text-xs text-gray-400">The target property has no rooms yet.</p>
{/if}
</div>
{/if}
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Reason (optional)</label>
<input id="reason" name="reason" placeholder="e.g. relocated to new rack, returned from repair…"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="submit" disabled={moving}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{moving ? 'Moving…' : 'Move asset'}
</button>
</div>
</form>
@@ -0,0 +1,56 @@
import { error } from '@sveltejs/kit';
import { csvResponse, toCsv } from '$lib/server/csv';
import { listAssets } from '$lib/server/services/assets';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const q = url.searchParams.get('q') ?? undefined;
const typeSlug = url.searchParams.get('type') ?? undefined;
const propertyId = url.searchParams.get('property') ?? undefined;
const projectId = url.searchParams.get('project') ?? undefined;
const rows = await listAssets({
companyId: locals.company.id,
q,
typeSlug,
propertyId,
projectId,
limit: 10_000
});
const body = toCsv(
rows.map((r) => ({
id: r.id,
name: r.name,
type: r.assetTypeName,
type_slug: r.assetTypeSlug,
tag: r.tag,
serial_number: r.serialNumber,
manufacturer: r.manufacturer,
model: r.model,
container_kind: r.currentContainerKind,
property_id: r.currentPropertyId,
updated_at: r.updatedAt
})),
[
'id',
'name',
'type',
'type_slug',
'tag',
'serial_number',
'manufacturer',
'model',
'container_kind',
'property_id',
'updated_at'
]
);
return csvResponse(`assets-${today()}.csv`, body);
};
function today(): string {
return new Date().toISOString().slice(0, 10);
}
+121
View File
@@ -0,0 +1,121 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { and, asc, eq, isNull, or, sql } from 'drizzle-orm';
import { db } from '$lib/server/db/client';
import { assetTypes } from '$lib/server/db/schema/assets';
import { properties } from '$lib/server/db/schema/properties';
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
import { createAsset, loadTypeWithFields } from '$lib/server/services/assets';
import { gatherCustomFieldsFromForm } from '$lib/server/custom-fields-form';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const companyId = locals.company.id;
const typeId = url.searchParams.get('type_id') ?? '';
const propertyId = url.searchParams.get('property') ?? '';
const types = await db
.select({
id: assetTypes.id,
name: assetTypes.name,
slug: assetTypes.slug,
icon: assetTypes.icon,
description: assetTypes.description
})
.from(assetTypes)
.where(or(isNull(assetTypes.companyId), sql`${assetTypes.companyId} = ${companyId}`)!)
.orderBy(asc(assetTypes.name));
const props = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(and(eq(properties.companyId, companyId), isNull(properties.deletedAt)))
.orderBy(asc(properties.name));
// All rooms across all properties in this company — the client filters by
// selected property. Lightweight; one round-trip instead of a per-select fetch.
const rooms = await db
.select({
id: propertyRooms.id,
propertyId: propertyRooms.propertyId,
floorLabel: propertyFloors.label,
name: propertyRooms.name
})
.from(propertyRooms)
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
.where(
and(
eq(properties.companyId, companyId),
isNull(propertyRooms.deletedAt),
isNull(properties.deletedAt)
)
)
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name));
let typeWithFields = null;
if (typeId) {
typeWithFields = await loadTypeWithFields(typeId);
if (!typeWithFields) throw error(404, 'Asset type not found');
}
return {
types,
properties: props,
rooms,
selectedTypeId: typeId,
selectedPropertyId: propertyId,
typeWithFields
};
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401);
const companyId = locals.company.id;
const form = await request.formData();
const assetTypeId = String(form.get('asset_type_id') ?? '');
const name = String(form.get('name') ?? '').trim();
const tag = (String(form.get('tag') ?? '').trim() || null) as string | null;
const serialNumber = (String(form.get('serial_number') ?? '').trim() || null) as
| string
| null;
const manufacturer = (String(form.get('manufacturer') ?? '').trim() || null) as string | null;
const model = (String(form.get('model') ?? '').trim() || null) as string | null;
const purchasedAtStr = String(form.get('purchased_at') ?? '').trim();
const propertyId = String(form.get('property_id') ?? '').trim();
const roomId = String(form.get('room_id') ?? '').trim() || null;
if (!assetTypeId) return fail(400, { error: 'Pick an asset type first.' });
if (!name) return fail(400, { error: 'Name is required.' });
if (!propertyId) return fail(400, { error: 'Pick a property to place this asset at.' });
const tf = await loadTypeWithFields(assetTypeId);
if (!tf) return fail(400, { error: 'Asset type not found.' });
const cf = gatherCustomFieldsFromForm(form, tf.fields);
try {
const { id } = await createAsset({
companyId,
createdBy: locals.user.id,
assetTypeId,
name,
tag,
serialNumber,
manufacturer,
model,
purchasedAt: purchasedAtStr ? new Date(purchasedAtStr) : null,
containerKind: 'property',
containerId: propertyId,
roomId,
customFields: cf
});
throw redirect(303, `/assets/${id}`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
}
};
+136
View File
@@ -0,0 +1,136 @@
<script lang="ts">
import { enhance } from '$app/forms';
import CustomFieldsForm from '$lib/components/CustomFieldsForm.svelte';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
// svelte-ignore state_referenced_locally
let selectedPropertyId = $state(data.selectedPropertyId ?? '');
const roomsForProperty = $derived(
selectedPropertyId
? data.rooms.filter((r) => r.propertyId === selectedPropertyId)
: []
);
</script>
<div class="mx-auto max-w-3xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New asset</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Pick a type to get the right typed fields, then fill in the rest.
</p>
</div>
{#if !data.typeWithFields}
<!-- Step 1: pick an asset type -->
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each data.types as t}
<a
href="/assets/new?type_id={t.id}{data.selectedPropertyId ? `&property=${data.selectedPropertyId}` : ''}"
class="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-primary-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-primary-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{t.name}</div>
{#if t.description}
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{t.description}</div>
{:else}
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">{t.slug}</div>
{/if}
</a>
{/each}
</div>
{:else}
<!-- Step 2: typed form -->
<form
method="post"
use:enhance={() => {
submitting = true;
return ({ update }) => update().finally(() => (submitting = false));
}}
class="space-y-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center justify-between border-b border-gray-200 pb-3 dark:border-gray-700">
<div>
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">Asset type</div>
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{data.typeWithFields.type.name}</div>
</div>
<a href="/assets/new" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Change</a>
</div>
<input type="hidden" name="asset_type_id" value={data.typeWithFields.type.id} />
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></label>
<input id="name" name="name" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="property_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Property <span class="text-red-500">*</span></label>
<select id="property_id" name="property_id" required bind:value={selectedPropertyId}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— pick a property —</option>
{#each data.properties as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
</div>
<div>
<label for="room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room</label>
<select id="room_id" name="room_id" disabled={roomsForProperty.length === 0}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
<option value="">— no specific room —</option>
{#each roomsForProperty as r}
<option value={r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
{/each}
</select>
{#if selectedPropertyId && roomsForProperty.length === 0}
<p class="mt-1 text-xs text-gray-400">This property has no rooms yet. Add them from the property's Rooms tab.</p>
{/if}
</div>
<div>
<label for="tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Asset tag</label>
<input id="tag" name="tag"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Serial number</label>
<input id="serial_number" name="serial_number"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
<input id="manufacturer" name="manufacturer"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
<input id="model" name="model"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="purchased_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Purchased on</label>
<input id="purchased_at" name="purchased_at" type="date"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">{data.typeWithFields.type.name} details</div>
<CustomFieldsForm defs={data.typeWithFields.fields} />
</div>
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/assets" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
<button type="submit" disabled={submitting}
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
{submitting ? 'Creating…' : 'Create asset'}
</button>
</div>
</form>
{/if}
</div>
@@ -0,0 +1,32 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { createTemplate, listTemplates } from '$lib/server/services/checklists';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company');
const templates = await listTemplates(locals.company.id);
return { templates };
};
const NewSchema = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().max(10_000).optional().or(z.literal(''))
});
export const actions: Actions = {
create: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401);
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = NewSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const { id } = await createTemplate({
companyId: locals.company.id,
createdBy: locals.user.id,
name: parsed.data.name,
description: parsed.data.description || null
});
throw redirect(303, `/checklists/${id}`);
}
};
+81
View File
@@ -0,0 +1,81 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let creating = $state(false);
let showForm = $state(false);
</script>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Checklist templates</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Reusable checklists you can attach to maintenance, tasks, or anywhere ad-hoc.
</p>
</div>
<button type="button" onclick={() => (showForm = !showForm)}
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
{showForm ? 'Cancel' : '+ New template'}
</button>
</div>
{#if showForm}
<form method="post" action="?/create"
use:enhance={() => {
creating = true;
return ({ update }) => update().finally(() => (creating = false));
}}
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required placeholder="e.g. Quarterly AC service"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" name="description" rows="2"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"></textarea>
</div>
<div class="flex justify-end">
<button type="submit" disabled={creating}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{creating ? 'Creating…' : 'Create template'}
</button>
</div>
</form>
{/if}
{#if data.templates.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No templates yet. Create one to attach it to a maintenance schedule.
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Description</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Items</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.templates as t}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="/checklists/{t.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{t.name}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 truncate">{t.description ?? '—'}</td>
<td class="px-4 py-2 text-right text-sm text-gray-700 dark:text-gray-300">{t.itemCount}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,50 @@
import { error, fail, redirect } from '@sveltejs/kit';
import {
addTemplateItem,
deleteTemplate,
getTemplate,
removeTemplateItem,
updateTemplate
} from '$lib/server/services/checklists';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const tpl = await getTemplate(locals.company.id, params.id);
if (!tpl) throw error(404, 'Template not found');
return { template: tpl.template, items: tpl.items };
};
export const actions: Actions = {
saveMeta: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim() || null;
if (!name) return fail(400, { error: 'Name is required.' });
await updateTemplate(locals.company.id, params.id, { name, description });
return { ok: true };
},
addItem: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const text = String(form.get('text') ?? '').trim();
const required = form.get('required') === 'true';
if (!text) return fail(400, { error: 'Item text is required.' });
await addTemplateItem(locals.company.id, params.id, text, required);
return { ok: true };
},
removeItem: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const itemId = String(form.get('item_id') ?? '');
if (!itemId) return fail(400, { error: 'Missing item_id' });
await removeTemplateItem(locals.company.id, params.id, itemId);
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await deleteTemplate(locals.company.id, params.id);
throw redirect(303, '/checklists');
}
};
@@ -0,0 +1,87 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let confirmingDelete = $state(false);
</script>
<div class="space-y-6">
<div>
<a href="/checklists" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all templates</a>
</div>
{#if form?.error}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
{/if}
<form method="post" action="?/saveMeta" use:enhance
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input id="name" name="name" required value={data.template.name}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" name="description" rows="2"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{data.template.description ?? ''}</textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Save</button>
</div>
</form>
<div class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Items</h2>
{#if data.items.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No items yet.</p>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.items as item, i}
<li class="flex items-center justify-between gap-3 px-4 py-2 text-sm">
<div class="min-w-0">
<span class="mr-2 inline-block w-6 text-right text-xs text-gray-400">{i + 1}.</span>
<span class="text-gray-900 dark:text-gray-100">{item.text}</span>
{#if item.required}
<span class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">required</span>
{/if}
</div>
<form method="post" action="?/removeItem" use:enhance>
<input type="hidden" name="item_id" value={item.id} />
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Remove</button>
</form>
</li>
{/each}
</ul>
{/if}
<form method="post" action="?/addItem" use:enhance
class="flex flex-col gap-2 rounded-lg border border-dashed border-gray-300 bg-white p-3 sm:flex-row sm:items-end dark:border-gray-700 dark:bg-gray-800">
<div class="flex-1">
<label for="text" class="block text-xs font-medium text-gray-500 dark:text-gray-400">New item</label>
<input id="text" name="text" required placeholder="e.g. Replace filter"
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
</div>
<label class="inline-flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="required" value="true" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
required
</label>
<button type="submit" class="rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700">Add</button>
</form>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
{confirmingDelete ? 'Cancel delete' : 'Delete template…'}
</button>
{#if confirmingDelete}
<form method="post" action="?/delete" class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
<p>Hard-delete this template. Existing checklist instances created from it will keep their items but lose the template link.</p>
<div class="mt-2 flex justify-end gap-2">
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete</button>
</div>
</form>
{/if}
</div>
</div>
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import { listDueAndOverdue } from '$lib/server/services/maintenance';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company');
const rows = await listDueAndOverdue({
companyId: locals.company.id,
limit: 200,
upcomingDays: 60
});
const now = Date.now();
const overdue = rows.filter((r) => r.nextDueAt && new Date(r.nextDueAt).getTime() < now);
const upcoming = rows.filter((r) => r.nextDueAt && new Date(r.nextDueAt).getTime() >= now);
return { overdue, upcoming };
};
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function dayDelta(d: Date | string): number {
return Math.round((new Date(d).getTime() - Date.now()) / 86400000);
}
</script>
<div class="space-y-6">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Maintenance</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Time-based schedules across every asset, sorted by next-due. Usage-based schedules appear on each asset's own maintenance tab.
</p>
</div>
<a href="/maintenance/export.csv" class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">Export CSV</a>
</div>
<section class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">Overdue ({data.overdue.length})</h2>
{#if data.overdue.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">Nothing overdue. Nice.</p>
{:else}
<ul class="overflow-hidden rounded-lg border border-red-200 bg-white dark:border-red-700/50 dark:bg-gray-800">
{#each data.overdue as r}
{@const d = dayDelta(r.nextDueAt!)}
<li class="flex items-center justify-between gap-3 border-b border-gray-100 px-4 py-2 last:border-0 dark:border-gray-700">
<div class="min-w-0">
<a href="/assets/{r.assetId}/maintenance" class="text-sm font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{r.assetName}</a>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{r.scheduleName}</span>
</div>
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300">
{-d} day{-d === 1 ? '' : 's'} overdue
</span>
</li>
{/each}
</ul>
{/if}
</section>
<section class="space-y-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Upcoming ({data.upcoming.length})</h2>
{#if data.upcoming.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No schedules due in the next 60 days.</p>
{:else}
<ul class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
{#each data.upcoming as r}
{@const d = dayDelta(r.nextDueAt!)}
<li class="flex items-center justify-between gap-3 border-b border-gray-100 px-4 py-2 last:border-0 dark:border-gray-700">
<div class="min-w-0">
<a href="/assets/{r.assetId}/maintenance" class="text-sm font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{r.assetName}</a>
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">{r.scheduleName}</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">in {d} day{d === 1 ? '' : 's'} ({new Date(r.nextDueAt!).toLocaleDateString()})</span>
</li>
{/each}
</ul>
{/if}
</section>
</div>
@@ -0,0 +1,32 @@
import { error } from '@sveltejs/kit';
import { csvResponse, toCsv } from '$lib/server/csv';
import { listDueAndOverdue } from '$lib/server/services/maintenance';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, url }) => {
if (!locals.company) throw error(400, 'No active company');
const upcomingDays = Number.parseInt(url.searchParams.get('days') ?? '60', 10) || 60;
const rows = await listDueAndOverdue({
companyId: locals.company.id,
limit: 10_000,
upcomingDays
});
const body = toCsv(
rows.map((r) => ({
schedule_id: r.scheduleId,
schedule_name: r.scheduleName,
asset_id: r.assetId,
asset_name: r.assetName,
next_due_at: r.nextDueAt,
interval: `${r.intervalValue} ${r.intervalUnit}`
})),
['schedule_id', 'schedule_name', 'asset_id', 'asset_name', 'next_due_at', 'interval']
);
return csvResponse(`maintenance-${today()}.csv`, body);
};
function today(): string {
return new Date().toISOString().slice(0, 10);
}
@@ -0,0 +1,26 @@
import { fail } from '@sveltejs/kit';
import { requireCompany } from '$lib/server/auth/guards';
import { listForUser, markAllRead, markRead } from '$lib/server/services/notifications';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { user, company } = requireCompany(locals);
const items = await listForUser(user.id, company.id, 200);
return { notifications: items };
};
export const actions: Actions = {
read: async ({ request, locals }) => {
const { user } = requireCompany(locals);
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (!id) return fail(400, { error: 'Missing id' });
await markRead(user.id, [id]);
return { ok: true };
},
readAll: async ({ locals }) => {
const { user, company } = requireCompany(locals);
await markAllRead(user.id, company.id);
return { ok: true };
}
};
@@ -0,0 +1,66 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { NOTIFICATION_KIND_LABEL, type NotificationKind } from '$lib/notifications';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const unread = $derived(data.notifications.filter((n) => n.readAt === null).length);
</script>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Notifications</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{unread} unread · {data.notifications.length} total.
<a href="/settings/notifications" class="ml-2 text-primary-600 hover:underline dark:text-primary-400">Channel settings</a>
</p>
</div>
{#if unread > 0}
<form method="post" action="?/readAll" use:enhance>
<button type="submit" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
Mark all read
</button>
</form>
{/if}
</div>
{#if data.notifications.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
Nothing here yet — new in-app notifications will show up as soon as you're assigned a task or a decision is logged.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.notifications as n}
<li class="flex gap-3 px-4 py-3 text-sm {n.readAt === null ? 'bg-primary-50/40 dark:bg-primary-900/10' : ''}">
<div class="mt-0.5 shrink-0">
{#if n.readAt === null}
<span class="inline-block h-2 w-2 rounded-full bg-primary-500" aria-label="Unread"></span>
{:else}
<span class="inline-block h-2 w-2 rounded-full bg-transparent"></span>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-baseline gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{NOTIFICATION_KIND_LABEL[n.kind as NotificationKind] ?? n.kind}
</span>
<span>{new Date(n.createdAt).toLocaleString()}</span>
</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{n.title}</div>
<div class="mt-0.5 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{n.body}</div>
{#if n.link}
<a href={n.link} class="mt-1 inline-block text-xs text-primary-600 hover:underline dark:text-primary-400">Open →</a>
{/if}
</div>
{#if n.readAt === null}
<form method="post" action="?/read" use:enhance class="shrink-0">
<input type="hidden" name="id" value={n.id} />
<button type="submit" class="text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">mark read</button>
</form>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { listProjects } from '$lib/server/services/projects';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company');
const rows = await listProjects(locals.company.id);
return { projects: rows };
};
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-6">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Projects</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Construction sites, rollouts, retrofits — anything with work packages, tasks, and decisions.
</p>
</div>
<a href="/projects/new"
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
+ New project
</a>
</div>
{#if data.projects.length === 0}
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-200">No projects yet.</p>
<p class="mt-1">Create one to start tracking work packages, tasks, and decisions.</p>
<a href="/projects/new" class="mt-4 inline-block text-primary-600 hover:underline dark:text-primary-400">Create a project →</a>
</div>
{:else}
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700/40">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Code</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each data.projects as p}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
<a href="/projects/{p.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{p.name}</a>
{#if p.description}<div class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">{p.description}</div>{/if}
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{p.code ?? '—'}</td>
<td class="px-4 py-2 text-xs">
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">{p.status}</span>
</td>
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">{new Date(p.updatedAt).toLocaleDateString()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';
import { getProject } from '$lib/server/services/projects';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const project = await getProject(locals.company.id, params.id);
if (!project) throw error(404, 'Project not found');
return { project };
};
@@ -0,0 +1,36 @@
<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: `/projects/${data.project.id}`, label: 'Overview' },
{ href: `/projects/${data.project.id}/work`, label: 'Work' },
{ href: `/projects/${data.project.id}/decisions`, label: 'Decisions' },
{ href: `/projects/${data.project.id}/assets`, label: 'Assets' },
{ href: `/projects/${data.project.id}/documents`, label: 'Documents' },
{ href: `/projects/${data.project.id}/wiki`, label: 'Wiki' }
]);
</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">
Project{#if data.project.code} · <code class="font-mono">{data.project.code}</code>{/if}
</div>
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">{data.project.name}</h1>
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
<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">{data.project.status}</span>
{#if data.project.startDate}<span>start {new Date(data.project.startDate).toLocaleDateString()}</span>{/if}
{#if data.project.endDate}<span>· end {new Date(data.project.endDate).toLocaleDateString()}</span>{/if}
</div>
</div>
</div>
<TabNav {tabs} />
{@render children()}
</div>
@@ -0,0 +1,40 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { softDeleteProject, updateProject } from '$lib/server/services/projects';
import type { Actions } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(255),
code: z.string().trim().max(64).optional().or(z.literal('')),
description: z.string().trim().max(10_000).optional().or(z.literal('')),
status: z.enum(['active', 'on_hold', 'done', 'cancelled']),
start_date: z.string().optional().or(z.literal('')),
end_date: 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 = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
await updateProject(locals.company.id, params.id, {
name: v.name,
code: e2n(v.code),
description: e2n(v.description),
status: v.status,
startDate: v.start_date ? new Date(v.start_date) : null,
endDate: v.end_date ? new Date(v.end_date) : null
});
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await softDeleteProject(locals.company.id, params.id);
throw redirect(303, '/projects');
}
};
@@ -0,0 +1,89 @@
<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);
let confirmingDelete = $state(false);
const p = $derived(data.project);
function dateInput(d: Date | string | null): string {
if (!d) return '';
const dt = typeof d === 'string' ? new Date(d) : d;
return Number.isNaN(dt.getTime()) ? '' : dt.toISOString().slice(0, 10);
}
</script>
<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}
<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={p.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="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Code</label>
<input id="code" name="code" value={p.code ?? ''}
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" />
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select id="status" name="status"
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">
{#each ['active', 'on_hold', 'done', 'cancelled'] as s}
<option value={s} selected={p.status === s}>{s.replace('_', ' ')}</option>
{/each}
</select>
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start date</label>
<input id="start_date" name="start_date" type="date" value={dateInput(p.startDate)}
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="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End date</label>
<input id="end_date" name="end_date" type="date" value={dateInput(p.endDate)}
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="sm:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" name="description" rows="4"
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.description ?? ''}</textarea>
</div>
</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 project…'}
</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 project?</p>
<p class="mt-1">Soft-deletes the project. Work packages, tasks, decisions, and documents stay in the DB; assets currently placed here will need to be moved manually.</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,9 @@
import { error } from '@sveltejs/kit';
import { listAssets } from '$lib/server/services/assets';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const rows = await listAssets({ companyId: locals.company.id, projectId: params.id });
return { assets: rows };
};
@@ -0,0 +1,43 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const projId = $derived(data.project.id);
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">{data.assets.length} asset{data.assets.length === 1 ? '' : 's'} placed at this project.</p>
<a href="/assets" class="text-sm text-primary-600 hover:underline dark:text-primary-400">Move an existing asset here →</a>
</div>
{#if data.assets.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 assets at this project yet. Move an asset's location from its detail page (Move tab).
</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>
</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>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,64 @@
import { error, fail } from '@sveltejs/kit';
import { z } from 'zod';
import { createDecision, listDecisionsForScope, softDeleteDecision } from '$lib/server/services/decisions';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const decisions = await listDecisionsForScope(locals.company.id, 'project', params.id);
return { decisions };
};
const Schema = z.object({
title: z.string().trim().min(1).max(255),
body_md: z.string().trim().min(1).max(50_000),
alternatives_considered: z.string().trim().max(10_000).optional().or(z.literal('')),
cost_impact: z.string().trim().optional().or(z.literal('')),
currency: z.string().trim().length(3).optional().or(z.literal('')),
decided_at: z.string().min(1, 'Decided date is required'),
tags: z.string().trim().max(500).optional().or(z.literal(''))
});
export const actions: Actions = {
create: 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 = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
const v = parsed.data;
const cost = v.cost_impact ? Number(v.cost_impact) : null;
if (v.cost_impact && !Number.isFinite(cost)) {
return fail(400, { error: 'Cost impact must be a number.', values: raw });
}
const tags = v.tags
? v.tags.split(',').map((t) => t.trim()).filter(Boolean)
: null;
try {
await createDecision({
companyId: locals.company.id,
decidedBy: locals.user.id,
scopeType: 'project',
scopeId: params.id,
title: v.title,
bodyMd: v.body_md,
alternativesConsidered: v.alternatives_considered || null,
costImpact: cost,
currency: v.currency || null,
decidedAt: new Date(v.decided_at),
tags
});
} catch (e) {
return fail(400, { error: (e as Error).message, values: raw });
}
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 softDeleteDecision(locals.company.id, id);
return { ok: true };
}
};
@@ -0,0 +1,128 @@
<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 saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
function fmtMoney(amount: string | number | null, currency: string | null): string {
if (amount === null || amount === undefined) return '—';
const n = typeof amount === 'string' ? Number(amount) : amount;
if (!Number.isFinite(n)) return '—';
return `${n.toLocaleString()} ${currency ?? ''}`.trim();
}
</script>
<div class="space-y-6">
<div class="flex items-center justify-between gap-3">
<p class="text-sm text-gray-500 dark:text-gray-400">{data.decisions.length} decision{data.decisions.length === 1 ? '' : 's'} logged for this project.</p>
<div class="flex shrink-0 gap-2">
{#if data.decisions.length > 0}
<a href="/projects/{data.project.id}/decisions/export.csv" 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">Export CSV</a>
{/if}
<button type="button" onclick={() => (creating = !creating)} class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{creating ? 'Cancel' : '+ New decision'}
</button>
</div>
</div>
{#if creating}
<form method="post" action="?/create"
use:enhance={() => {
saving = true;
return ({ update, result }) =>
update().finally(() => {
saving = false;
if (result.type === 'success') creating = 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}
<div>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title <span class="text-red-500">*</span></label>
<input id="title" name="title" required value={v.title ?? ''}
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="body_md" class="block text-sm font-medium text-gray-700 dark:text-gray-300">What was decided <span class="text-red-500">*</span></label>
<textarea id="body_md" name="body_md" rows="4" 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">{v.body_md ?? ''}</textarea>
</div>
<div>
<label for="alternatives_considered" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Alternatives considered</label>
<textarea id="alternatives_considered" name="alternatives_considered" rows="3"
placeholder="What other options did we look at, and why did we rule them out?"
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.alternatives_considered ?? ''}</textarea>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<label for="cost_impact" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Cost impact</label>
<input id="cost_impact" name="cost_impact" type="number" step="any" placeholder="positive or negative" value={v.cost_impact ?? ''}
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="currency" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Currency (ISO 3)</label>
<input id="currency" name="currency" maxlength="3" placeholder="THB" value={v.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" />
</div>
<div>
<label for="decided_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Decided on <span class="text-red-500">*</span></label>
<input id="decided_at" name="decided_at" type="date" required value={v.decided_at ?? new Date().toISOString().slice(0, 10)}
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>
<label for="tags" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tags</label>
<input id="tags" name="tags" placeholder="comma-separated, e.g. budget, schedule, vendor" value={v.tags ?? ''}
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={saving} class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Saving…' : 'Log decision'}
</button>
</div>
</form>
{/if}
{#if data.decisions.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 decisions logged yet.
</div>
{:else}
<ul class="space-y-3">
{#each data.decisions as d}
<li class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-start justify-between gap-3">
<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>{new Date(d.decidedAt).toLocaleDateString()}</span>
{#if d.decidedByName}<span>· by {d.decidedByName}</span>{/if}
{#if d.costImpact !== null}<span>· cost {fmtMoney(d.costImpact, d.currency)}</span>{/if}
</div>
<h3 class="mt-1 text-base font-semibold text-gray-900 dark:text-gray-100">{d.title}</h3>
<div class="mt-2 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">{d.bodyMd}</div>
{#if d.alternativesConsidered}
<div class="mt-3 rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900/40">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Alternatives considered</div>
<div class="mt-1 whitespace-pre-wrap text-gray-700 dark:text-gray-300">{d.alternativesConsidered}</div>
</div>
{/if}
{#if d.tags && d.tags.length > 0}
<div class="mt-3 flex flex-wrap gap-1">
{#each d.tags as tag}
<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">{tag}</span>
{/each}
</div>
{/if}
</div>
<form method="post" action="?/delete" use:enhance>
<input type="hidden" name="id" value={d.id} />
<button type="submit" class="text-xs text-gray-400 hover:text-red-600 dark:hover:text-red-400">delete</button>
</form>
</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,40 @@
import { error } from '@sveltejs/kit';
import { csvResponse, toCsv } from '$lib/server/csv';
import { listDecisionsForScope } from '$lib/server/services/decisions';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company');
const rows = await listDecisionsForScope(locals.company.id, 'project', params.id);
const body = toCsv(
rows.map((r) => ({
id: r.id,
decided_at: r.decidedAt,
title: r.title,
body_md: r.bodyMd,
alternatives_considered: r.alternativesConsidered,
cost_impact: r.costImpact,
currency: r.currency,
tags: r.tags,
decided_by: r.decidedByName
})),
[
'id',
'decided_at',
'title',
'body_md',
'alternatives_considered',
'cost_impact',
'currency',
'tags',
'decided_by'
]
);
return csvResponse(`project-${params.id.slice(0, 8)}-decisions-${today()}.csv`, body);
};
function today(): string {
return new Date().toISOString().slice(0, 10);
}
@@ -0,0 +1,56 @@
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, 'project', 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.' });
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: 'project',
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,59 @@
<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 to this project.
</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,18 @@
import { error } from '@sveltejs/kit';
import { listPagesForScope, searchPages } from '$lib/server/services/wiki';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params, url }) => {
if (!locals.company) throw error(401);
const q = url.searchParams.get('q')?.trim() ?? '';
const pages = q
? await searchPages({
companyId: locals.company.id,
scopeType: 'project',
scopeId: params.id,
q,
limit: 100
})
: await listPagesForScope(locals.company.id, 'project', params.id);
return { pages, q };
};
@@ -0,0 +1,41 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const projId = $derived(data.project.id);
</script>
<div class="space-y-4">
<div class="flex items-end justify-between gap-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
Wiki pages scoped to this project. Global pages are at
<a href="/wiki" class="text-primary-600 hover:underline dark:text-primary-400">/wiki</a>.
</p>
<a href="/projects/{projId}/wiki/new" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">+ New page</a>
</div>
<form method="get" class="flex gap-2">
<input type="search" name="q" value={data.q} placeholder="Search title or body…"
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" />
<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">Search</button>
{#if data.q}<a href="/projects/{projId}/wiki" class="self-center text-xs text-gray-500 dark:text-gray-400">clear</a>{/if}
</form>
{#if data.pages.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">
{#if data.q}No pages match "{data.q}".{:else}No project wiki pages yet.{/if}
</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.pages as p}
<li class="px-4 py-3 text-sm hover:bg-gray-50 dark:hover:bg-gray-700/30">
<a href="/projects/{projId}/wiki/{p.slug}" class="block">
<div class="font-medium text-gray-900 dark:text-gray-100">{p.title}</div>
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
<code class="font-mono">{p.slug}</code> · updated {new Date(p.updatedAt).toLocaleDateString()}
</div>
</a>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,26 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { getPageWithCurrentRevision, softDeletePage } from '$lib/server/services/wiki';
import { renderMarkdown } from '$lib/server/markdown';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const found = await getPageWithCurrentRevision(locals.company.id, 'project', params.id, params.slug);
if (!found) throw error(404, 'Page not found');
return {
wikiPage: found.page,
revision: found.revision,
editedByName: found.editedByName,
bodyHtml: renderMarkdown(found.revision.bodyMd)
};
};
export const actions: Actions = {
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
const found = await getPageWithCurrentRevision(locals.company.id, 'project', params.id, params.slug);
if (!found) return fail(404, { error: 'Not found' });
await softDeletePage(locals.company.id, found.page.id);
throw redirect(303, `/projects/${params.id}/wiki`);
}
};
@@ -0,0 +1,37 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let confirmingDelete = $state(false);
</script>
<div class="space-y-6">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<a href="/projects/{data.project.id}/wiki" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all pages</a>
<h2 class="mt-1 text-xl font-semibold text-gray-900 dark:text-gray-100">{data.revision.title}</h2>
<div class="mt-1 flex flex-wrap gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span>rev {data.revision.revision}</span>
<span>· edited {new Date(data.revision.editedAt).toLocaleString()}</span>
{#if data.editedByName}<span>· by {data.editedByName}</span>{/if}
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}/history" class="ml-2 text-primary-600 hover:underline dark:text-primary-400">history</a>
</div>
</div>
<div class="flex shrink-0 gap-2">
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}/edit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Edit</a>
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">
{confirmingDelete ? 'Cancel' : 'Delete'}
</button>
</div>
</div>
{#if confirmingDelete}
<form method="post" action="?/delete" use:enhance class="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">
Soft-delete this page? <button type="submit" class="ml-2 rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700">Yes, delete</button>
</form>
{/if}
<article class="prose prose-sm max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:prose-invert dark:border-gray-700 dark:bg-gray-800">
{@html data.bodyHtml}
</article>
</div>
@@ -0,0 +1,44 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { getPageWithCurrentRevision, upsertPage } from '$lib/server/services/wiki';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const found = await getPageWithCurrentRevision(locals.company.id, 'project', params.id, params.slug);
if (!found) throw error(404, 'Page not found');
return { wikiPage: found.page, revision: found.revision };
};
const Schema = z.object({
title: z.string().trim().min(1).max(255),
body_md: z.string().min(1).max(200_000),
comment: z.string().trim().max(500).optional().or(z.literal(''))
});
export const actions: Actions = {
default: 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 = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
try {
await upsertPage({
companyId: locals.company.id,
editedBy: locals.user.id,
scopeType: 'project',
scopeId: params.id,
slug: params.slug,
title: v.title,
bodyMd: v.body_md,
comment: v.comment || null
});
throw redirect(303, `/projects/${params.id}/wiki/${params.slug}`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message });
}
}
};
@@ -0,0 +1,44 @@
<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);
</script>
<div class="space-y-4">
<div>
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to page</a>
<h2 class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">Edit · {data.revision.title}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Saving creates a new revision (currently rev {data.revision.revision}).</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}
<div>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
<input id="title" name="title" required value={data.revision.title}
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="body_md" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Body (Markdown)</label>
<textarea id="body_md" name="body_md" rows="22" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono 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.revision.bodyMd}</textarea>
</div>
<div>
<label for="comment" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Revision comment</label>
<input id="comment" name="comment" placeholder="what changed?"
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">
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}" 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 ? 'Saving…' : 'Save revision'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,11 @@
import { error } from '@sveltejs/kit';
import { getPageWithCurrentRevision, listRevisions } from '$lib/server/services/wiki';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const found = await getPageWithCurrentRevision(locals.company.id, 'project', params.id, params.slug);
if (!found) throw error(404, 'Page not found');
const revs = await listRevisions(locals.company.id, found.page.id);
return { wikiPage: found.page, current: found.revision, revisions: revs };
};
@@ -0,0 +1,30 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<div class="space-y-4">
<div>
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to page</a>
<h2 class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">History · {data.current.title}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{data.revisions.length} revision{data.revisions.length === 1 ? '' : 's'}.</p>
</div>
<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.revisions as r}
<li class="px-4 py-3 text-sm">
<div class="flex items-baseline justify-between gap-3">
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}/revisions/{r.revision}" class="font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
rev {r.revision} {data.current.revision === r.revision ? '· current' : ''}
</a>
<span class="text-xs text-gray-500 dark:text-gray-400">
{new Date(r.editedAt).toLocaleString()}
{#if r.editedByName}· by {r.editedByName}{/if}
</span>
</div>
<div class="mt-0.5 text-sm text-gray-600 dark:text-gray-300">{r.title}</div>
{#if r.comment}<div class="mt-0.5 text-xs italic text-gray-500 dark:text-gray-400">{r.comment}</div>{/if}
</li>
{/each}
</ul>
</div>
@@ -0,0 +1,21 @@
import { error } from '@sveltejs/kit';
import { getPageWithCurrentRevision, getRevision } from '$lib/server/services/wiki';
import { renderMarkdown } from '$lib/server/markdown';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const found = await getPageWithCurrentRevision(locals.company.id, 'project', params.id, params.slug);
if (!found) throw error(404, 'Page not found');
const revNum = Number.parseInt(params.rev, 10);
if (!Number.isFinite(revNum)) throw error(400, 'Invalid revision');
const rev = await getRevision(locals.company.id, found.page.id, revNum);
if (!rev) throw error(404, 'Revision not found');
return {
wikiPage: found.page,
current: found.revision,
revision: rev.revision,
editedByName: rev.editedByName,
bodyHtml: renderMarkdown(rev.revision.bodyMd)
};
};
@@ -0,0 +1,27 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const isCurrent = $derived(data.current.revision === data.revision.revision);
</script>
<div class="space-y-4">
<div>
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}/history" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← history</a>
<h2 class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">{data.revision.title}</h2>
<div class="mt-1 flex flex-wrap items-baseline gap-x-3 text-xs text-gray-500 dark:text-gray-400">
<span class="rounded-full {isCurrent ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'} px-2 py-0.5 font-medium">
rev {data.revision.revision}{isCurrent ? ' · current' : ''}
</span>
<span>{new Date(data.revision.editedAt).toLocaleString()}</span>
{#if data.editedByName}<span>· by {data.editedByName}</span>{/if}
{#if !isCurrent}
<a href="/projects/{data.project.id}/wiki/{data.wikiPage.slug}" class="ml-2 text-primary-600 hover:underline dark:text-primary-400">view current →</a>
{/if}
</div>
{#if data.revision.comment}<p class="mt-2 text-sm italic text-gray-600 dark:text-gray-300">{data.revision.comment}</p>{/if}
</div>
<article class="prose prose-sm max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:prose-invert dark:border-gray-700 dark:bg-gray-800">
{@html data.bodyHtml}
</article>
</div>
@@ -0,0 +1,39 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { slugify, upsertPage } from '$lib/server/services/wiki';
import type { Actions } from './$types';
const Schema = z.object({
title: z.string().trim().min(1).max(255),
slug: z.string().trim().max(128).optional().or(z.literal('')),
body_md: z.string().min(1).max(200_000),
comment: z.string().trim().max(500).optional().or(z.literal(''))
});
export const actions: Actions = {
default: 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 = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
const v = parsed.data;
const slug = slugify(v.slug || v.title);
try {
await upsertPage({
companyId: locals.company.id,
editedBy: locals.user.id,
scopeType: 'project',
scopeId: params.id,
slug,
title: v.title,
bodyMd: v.body_md,
comment: v.comment || null
});
throw redirect(303, `/projects/${params.id}/wiki/${slug}`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
return fail(400, { error: (e as Error).message, values: raw });
}
}
};
@@ -0,0 +1,52 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let saving = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="space-y-4">
<div>
<a href="/projects/{data.project.id}/wiki" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all pages</a>
<h2 class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">New page</h2>
</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}
<div class="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
<input id="title" name="title" required value={v.title ?? ''}
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="sm:col-span-2">
<label for="slug" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</label>
<input id="slug" name="slug" placeholder="leave empty to derive from title" value={v.slug ?? ''}
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" />
</div>
</div>
<div>
<label for="body_md" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Body (Markdown)</label>
<textarea id="body_md" name="body_md" rows="18" required
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 font-mono 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.body_md ?? ''}</textarea>
</div>
<div>
<label for="comment" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Revision comment</label>
<input id="comment" name="comment" value={v.comment ?? ''}
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">
<a href="/projects/{data.project.id}/wiki" 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 ? 'Saving…' : 'Create page'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,39 @@
import { error, fail } from '@sveltejs/kit';
import { z } from 'zod';
import {
createWorkPackage,
listWorkPackagesForProject
} from '$lib/server/services/work-packages';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const wps = await listWorkPackagesForProject(locals.company.id, params.id);
return { workPackages: wps };
};
const Schema = 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, params }) => {
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' });
try {
await createWorkPackage({
companyId: locals.company.id,
projectId: params.id,
name: parsed.data.name,
description: parsed.data.description || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};
@@ -0,0 +1,65 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showForm = $state(false);
let creating = $state(false);
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">{data.workPackages.length} work package{data.workPackages.length === 1 ? '' : 's'}.</p>
<button type="button" onclick={() => (showForm = !showForm)}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showForm ? 'Cancel' : '+ New work package'}
</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. Foundation works"
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'}
</button>
</div>
</form>
{/if}
{#if data.workPackages.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 work packages yet.
</div>
{:else}
<ul class="space-y-2">
{#each data.workPackages as wp}
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<a href="/projects/{data.project.id}/work/{wp.id}" class="text-sm font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{wp.name}</a>
{#if wp.description}<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{wp.description}</div>{/if}
</div>
<div class="shrink-0 text-xs text-gray-500 dark:text-gray-400">
{wp.openTasks}/{wp.totalTasks} open
</div>
</div>
</li>
{/each}
</ul>
{/if}
</div>
@@ -0,0 +1,63 @@
import { error, fail } from '@sveltejs/kit';
import { z } from 'zod';
import { getWorkPackage, softDeleteWorkPackage, updateWorkPackage } from '$lib/server/services/work-packages';
import { createTask, listTasksForWorkPackage } from '$lib/server/services/tasks';
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const wp = await getWorkPackage(locals.company.id, params.wpId);
if (!wp) throw error(404, 'Work package not found');
const tasks = await listTasksForWorkPackage(locals.company.id, params.wpId);
return { workPackage: wp, tasks };
};
const TaskSchema = z.object({
title: z.string().trim().min(1).max(255),
due_at: z.string().optional().or(z.literal(''))
});
const WpPatchSchema = 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 = {
createTask: 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 = TaskSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
try {
await createTask({
companyId: locals.company.id,
createdBy: locals.user.id,
workPackageId: params.wpId,
title: parsed.data.title,
dueAt: parsed.data.due_at ? new Date(parsed.data.due_at) : null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
saveWp: 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 = WpPatchSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
await updateWorkPackage(locals.company.id, params.wpId, {
name: parsed.data.name,
description: parsed.data.description || null
});
return { ok: true };
},
deleteWp: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await softDeleteWorkPackage(locals.company.id, params.wpId);
throw redirect(303, `/projects/${params.id}/work`);
}
};
@@ -0,0 +1,116 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let editing = $state(false);
let creating = $state(false);
let confirmingDelete = $state(false);
const wp = $derived(data.workPackage);
const STATUS_CLS: Record<string, string> = {
todo: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200',
doing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
done: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
blocked: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
};
</script>
<div class="space-y-6">
<div>
<a href="/projects/{data.project.id}/work" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all work packages</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}
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
{#if editing}
<form method="post" action="?/saveWp" class="space-y-3"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editing = false;
}}>
<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={wp.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">{wp.description ?? ''}</textarea>
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick={() => (editing = 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>
{:else}
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{wp.name}</h2>
{#if wp.description}<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">{wp.description}</p>{/if}
</div>
<div class="flex shrink-0 gap-2">
<button type="button" onclick={() => (editing = true)} 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">Edit</button>
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} 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>
</div>
</div>
{/if}
</div>
{#if confirmingDelete}
<form method="post" action="?/deleteWp" class="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">
Soft-delete this work package and its tasks? <button type="submit" class="ml-2 rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700">Yes, delete</button>
</form>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tasks</h3>
<button type="button" onclick={() => (creating = !creating)} class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{creating ? 'Cancel' : '+ Task'}
</button>
</div>
{#if creating}
<form method="post" action="?/createTask"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') creating = false;
}}
class="grid gap-3 rounded-lg border border-gray-200 bg-white p-3 sm:grid-cols-3 dark:border-gray-700 dark:bg-gray-800">
<div class="sm:col-span-2">
<label for="title" class="block text-xs font-medium text-gray-700 dark:text-gray-300">Title</label>
<input id="title" name="title" 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" />
</div>
<div>
<label for="due_at" class="block text-xs font-medium text-gray-700 dark:text-gray-300">Due</label>
<input id="due_at" name="due_at" type="date"
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" />
</div>
<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">Create task</button>
</div>
</form>
{/if}
{#if data.tasks.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 tasks yet.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.tasks as t}
<li class="flex items-center justify-between gap-3 px-4 py-2 text-sm">
<a href="/projects/{data.project.id}/work/{wp.id}/{t.id}" class="min-w-0 flex-1 truncate text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{t.title}</a>
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {STATUS_CLS[t.status] ?? STATUS_CLS.todo}">{t.status}</span>
{#if t.dueAt}<span class="shrink-0 text-xs text-gray-500 dark:text-gray-400">{new Date(t.dueAt).toLocaleDateString()}</span>{/if}
</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,72 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import {
addSubtask,
getTaskWithSubtasks,
removeSubtask,
softDeleteTask,
toggleSubtask,
updateTask,
type TaskStatus
} from '$lib/server/services/tasks';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const t = await getTaskWithSubtasks(locals.company.id, params.taskId);
if (!t) throw error(404, 'Task not found');
return { task: t.task, subtasks: t.subtasks, workPackageName: t.workPackageName };
};
const TaskPatchSchema = z.object({
title: z.string().trim().min(1).max(255),
description: z.string().trim().max(50_000).optional().or(z.literal('')),
status: z.enum(['todo', 'doing', 'done', 'blocked']),
due_at: z.string().optional().or(z.literal(''))
});
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 = TaskPatchSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
await updateTask(locals.company.id, params.taskId, {
title: v.title,
description: v.description || null,
status: v.status as TaskStatus,
dueAt: v.due_at ? new Date(v.due_at) : null
});
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await softDeleteTask(locals.company.id, params.taskId);
throw redirect(303, `/projects/${params.id}/work/${params.wpId}`);
},
addSubtask: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
if (!name) return fail(400, { error: 'Name required' });
await addSubtask(locals.company.id, params.taskId, name);
return { ok: true };
},
toggleSubtask: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const subtaskId = String(form.get('subtask_id') ?? '');
const done = form.get('done') === 'true';
await toggleSubtask(locals.company.id, params.taskId, subtaskId, done);
return { ok: true };
},
removeSubtask: async ({ request, locals, params }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const subtaskId = String(form.get('subtask_id') ?? '');
await removeSubtask(locals.company.id, params.taskId, subtaskId);
return { ok: true };
}
};
@@ -0,0 +1,112 @@
<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);
let confirmingDelete = $state(false);
let newSubtask = $state('');
const t = $derived(data.task);
function dateInput(d: Date | string | null): string {
if (!d) return '';
const dt = typeof d === 'string' ? new Date(d) : d;
return Number.isNaN(dt.getTime()) ? '' : dt.toISOString().slice(0, 10);
}
const requiredOpen = $derived(data.subtasks.filter((s) => !s.done).length);
</script>
<div class="space-y-6">
<div>
<a href="/projects/{data.project.id}/work/{data.task.workPackageId}" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">{data.workPackageName}</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>{: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}
<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">
<div>
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
<input id="title" name="title" required value={t.title}
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="grid gap-4 sm:grid-cols-2">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select id="status" name="status"
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">
{#each ['todo', 'doing', 'done', 'blocked'] as s}
<option value={s} selected={t.status === s}>{s}</option>
{/each}
</select>
</div>
<div>
<label for="due_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Due</label>
<input id="due_at" name="due_at" type="date" value={dateInput(t.dueAt)}
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>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" name="description" rows="4"
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">{t.description ?? ''}</textarea>
</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 task…'}
</button>
<button type="submit" disabled={saving} class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</form>
{#if confirmingDelete}
<form method="post" action="?/delete" class="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">
Soft-delete this task and its subtasks? <button type="submit" class="ml-2 rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700">Yes, delete</button>
</form>
{/if}
<div class="space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Subtasks ({data.subtasks.length - requiredOpen}/{data.subtasks.length})
</h3>
{#if data.subtasks.length > 0}
<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.subtasks as s}
<li class="flex items-start gap-3 px-4 py-2 text-sm">
<form method="post" action="?/toggleSubtask" use:enhance class="pt-0.5">
<input type="hidden" name="subtask_id" value={s.id} />
<input type="hidden" name="done" value={(!s.done).toString()} />
<button type="submit" aria-label={s.done ? 'Mark incomplete' : 'Mark complete'}
class="inline-flex h-5 w-5 items-center justify-center rounded border {s.done ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-gray-300 dark:border-gray-600'}">
{#if s.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 {s.done ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}">{s.name}</div>
<form method="post" action="?/removeSubtask" use:enhance>
<input type="hidden" name="subtask_id" value={s.id} />
<button type="submit" class="text-xs text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
</form>
</li>
{/each}
</ul>
{/if}
<form method="post" action="?/addSubtask"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') newSubtask = '';
}}
class="flex gap-2 rounded-lg border border-dashed border-gray-300 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
<input name="name" required bind:value={newSubtask} placeholder="Add a subtask…"
class="flex-1 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" />
<button type="submit" disabled={!newSubtask.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">Add</button>
</form>
</div>
</div>
@@ -0,0 +1,48 @@
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import { createProject } from '$lib/server/services/projects';
import type { Actions } from './$types';
const Schema = z.object({
name: z.string().trim().min(1).max(255),
code: z.string().trim().max(64).optional().or(z.literal('')),
description: z.string().trim().max(10_000).optional().or(z.literal('')),
status: z.enum(['active', 'on_hold', 'done', 'cancelled']).default('active'),
start_date: z.string().optional().or(z.literal('')),
end_date: z.string().optional().or(z.literal(''))
});
const e2n = (s: string | undefined) => (!s ? null : s);
export const actions: Actions = {
default: 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 = 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 createProject({
companyId: locals.company.id,
createdBy: locals.user.id,
name: v.name,
code: e2n(v.code),
description: e2n(v.description),
status: v.status,
startDate: v.start_date ? new Date(v.start_date) : null,
endDate: v.end_date ? new Date(v.end_date) : null
});
throw redirect(303, `/projects/${id}`);
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e;
const msg = (e as Error).message ?? 'create failed';
if (msg.includes('projects_company_code_uq')) {
return fail(400, { error: 'A project with that code already exists.', values: raw });
}
return fail(400, { error: msg, values: raw });
}
}
};
@@ -0,0 +1,70 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let submitting = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New project</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
A container for work packages, tasks, decisions, and the assets dedicated to that project (e.g. a construction site).
</p>
</div>
<form method="post"
use:enhance={() => {
submitting = true;
return ({ update }) => update().finally(() => (submitting = 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}
<div>
<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 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" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Code</label>
<input id="code" name="code" placeholder="e.g. B4L-2026-001" value={v.code ?? ''}
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" />
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
<select id="status" name="status"
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="active" selected={v.status !== 'on_hold' && v.status !== 'done' && v.status !== 'cancelled'}>active</option>
<option value="on_hold" selected={v.status === 'on_hold'}>on hold</option>
<option value="done" selected={v.status === 'done'}>done</option>
<option value="cancelled" selected={v.status === 'cancelled'}>cancelled</option>
</select>
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Start date</label>
<input id="start_date" name="start_date" type="date" value={v.start_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>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">End date</label>
<input id="end_date" name="end_date" type="date" value={v.end_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>
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea id="description" 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>
</div>
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/projects" 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 project'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { listProperties } from '$lib/server/services/properties';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.company) throw error(400, 'No active company. Pick one from the sidebar.');
const rows = await listProperties(locals.company.id);
return { properties: rows };
};
+61
View File
@@ -0,0 +1,61 @@
<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">Properties</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Sites, warehouses, and other places that hold assets.
</p>
</div>
<a
href="/properties/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"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="h-4 w-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New property
</a>
</div>
{#if data.properties.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 properties yet.</p>
<p class="mt-1">Create your first one to start placing assets.</p>
<a href="/properties/new" class="mt-4 inline-block text-primary-600 hover:underline dark:text-primary-400">Create a property →</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">Kind</th>
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Address</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.properties 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="/properties/{p.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{p.name}</a>
</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{p.kind ?? '—'}</td>
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
{[p.addressLine1, p.city, p.region].filter(Boolean).join(', ') || '—'}
</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>
@@ -0,0 +1,10 @@
import { error } from '@sveltejs/kit';
import { getProperty } from '$lib/server/services/properties';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(400, 'No active company');
const property = await getProperty(locals.company.id, params.id);
if (!property) throw error(404, 'Property not found');
return { property };
};
@@ -0,0 +1,39 @@
<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: `/properties/${data.property.id}`, label: 'Overview' },
{ href: `/properties/${data.property.id}/rooms`, label: 'Rooms' },
{ href: `/properties/${data.property.id}/assets`, label: 'Assets' },
{ href: `/properties/${data.property.id}/accounts`, label: 'Accounts' },
{ href: `/properties/${data.property.id}/documents`, label: 'Documents' }
]);
</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.property.kind ?? 'Property'}
</div>
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">
{data.property.name}
</h1>
{#if data.property.addressLine1 || data.property.city}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{[data.property.addressLine1, data.property.city, data.property.region, data.property.countryCode]
.filter(Boolean)
.join(', ')}
</p>
{/if}
</div>
</div>
<TabNav {tabs} />
{@render children()}
</div>
@@ -0,0 +1,49 @@
import { error, fail } from '@sveltejs/kit';
import { z } from 'zod';
import { updateProperty, softDeleteProperty } from '$lib/server/services/properties';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
const PatchSchema = z.object({
name: z.string().trim().min(1).max(255),
kind: z.string().trim().max(64).optional().or(z.literal('')),
addressLine1: z.string().trim().max(255).optional().or(z.literal('')),
addressLine2: z.string().trim().max(255).optional().or(z.literal('')),
city: z.string().trim().max(128).optional().or(z.literal('')),
region: z.string().trim().max(128).optional().or(z.literal('')),
postalCode: z.string().trim().max(32).optional().or(z.literal('')),
countryCode: z.string().trim().length(2).optional().or(z.literal('')),
notes: z.string().trim().max(10_000).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;
await updateProperty(locals.company.id, params.id, {
name: v.name,
kind: e2n(v.kind),
addressLine1: e2n(v.addressLine1),
addressLine2: e2n(v.addressLine2),
city: e2n(v.city),
region: e2n(v.region),
postalCode: e2n(v.postalCode),
countryCode: e2n(v.countryCode),
notes: e2n(v.notes)
});
return { ok: true };
},
delete: async ({ locals, params }) => {
if (!locals.company) throw error(401);
await softDeleteProperty(locals.company.id, params.id);
throw redirect(303, '/properties');
}
};
@@ -0,0 +1,96 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
let confirmingDelete = $state(false);
const p = $derived(data.property);
</script>
<form
method="post"
action="?/save"
use:enhance={() => {
submitting = true;
return ({ update }) => update().finally(() => (submitting = 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}
<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={p.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="kind" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Kind</label>
<input id="kind" name="kind" value={p.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" />
</div>
<div>
<label for="countryCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country (ISO 2)</label>
<input id="countryCode" name="countryCode" maxlength="2" value={p.countryCode ?? ''}
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" />
</div>
<div class="sm:col-span-2">
<label for="addressLine1" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 1</label>
<input id="addressLine1" name="addressLine1" value={p.addressLine1 ?? ''}
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="sm:col-span-2">
<label for="addressLine2" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 2</label>
<input id="addressLine2" name="addressLine2" value={p.addressLine2 ?? ''}
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="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
<input id="city" name="city" value={p.city ?? ''}
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="region" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Region / State</label>
<input id="region" name="region" value={p.region ?? ''}
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="postalCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Postal code</label>
<input id="postalCode" name="postalCode" value={p.postalCode ?? ''}
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>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea id="notes" name="notes" rows="4"
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.notes ?? ''}</textarea>
</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 property…'}
</button>
<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 ? '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 property?</p>
<p class="mt-1">This soft-deletes the property. Assets currently here will need to be moved manually before this is fully cleaned up.</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,93 @@
import { error, fail } from '@sveltejs/kit';
import { z } from 'zod';
import {
createAccount,
deleteAccount,
listAccounts,
updateAccount,
type AccountKind
} from '$lib/server/services/accounts';
import type { Actions, PageServerLoad } from './$types';
const KINDS = [
'water',
'electricity',
'gas',
'internet',
'phone',
'cable',
'waste',
'other'
] as const;
const Schema = z.object({
kind: z.enum(KINDS),
provider: z.string().trim().max(128).optional().or(z.literal('')),
label: z.string().trim().max(128).optional().or(z.literal('')),
account_number: z.string().trim().max(128).optional().or(z.literal('')),
meter_number: z.string().trim().max(128).optional().or(z.literal('')),
notes: z.string().trim().max(2000).optional().or(z.literal(''))
});
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const accounts = await listAccounts(locals.company.id, params.id);
return { accounts };
};
export const actions: Actions = {
create: 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 = Schema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
const v = parsed.data;
try {
await createAccount({
companyId: locals.company.id,
propertyId: params.id,
kind: v.kind as AccountKind,
provider: v.provider || null,
label: v.label || null,
accountNumber: v.account_number || null,
meterNumber: v.meter_number || null,
notes: v.notes || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
update: 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' });
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;
try {
await updateAccount(locals.company.id, id, {
kind: v.kind as AccountKind,
provider: v.provider || null,
label: v.label || null,
accountNumber: v.account_number || null,
meterNumber: v.meter_number || null,
notes: v.notes || null
});
} 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 deleteAccount(locals.company.id, id);
return { ok: true };
}
};
@@ -0,0 +1,181 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import { ACCOUNT_KIND_LABEL, type AccountKind } from '$lib/accounts';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showForm = $state(false);
let editingId = $state<string | null>(null);
const KINDS: AccountKind[] = [
'electricity',
'water',
'gas',
'internet',
'phone',
'cable',
'waste',
'other'
];
interface Bucket {
kind: AccountKind;
accounts: typeof data.accounts;
}
const buckets = $derived.by(() => {
const byKind = new Map<AccountKind, Bucket>();
for (const k of KINDS) byKind.set(k, { kind: k, accounts: [] });
for (const a of data.accounts) byKind.get(a.kind as AccountKind)?.accounts.push(a);
return Array.from(byKind.values()).filter((b) => b.accounts.length > 0);
});
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
{data.accounts.length} account{data.accounts.length === 1 ? '' : 's'} on file.
</p>
<button type="button" onclick={() => { showForm = !showForm; editingId = null; }}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showForm ? 'Cancel' : '+ New account'}
</button>
</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 showForm}
<form method="post" action="?/create"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') showForm = false;
}}
class="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">Kind <span class="text-red-500">*</span></span>
<select name="kind" 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 KINDS as k}<option value={k}>{ACCOUNT_KIND_LABEL[k]}</option>{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Provider</span>
<input name="provider" placeholder="e.g. MEA, AIS, TrueOnline"
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">Label</span>
<input name="label" placeholder="e.g. Main building, Subunit A"
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">Account number</span>
<input name="account_number"
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">Meter number</span>
<input name="meter_number"
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 sm:col-span-2">
<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-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 account</button>
</div>
</form>
{/if}
{#if data.accounts.length === 0 && !showForm}
<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 utility or service accounts recorded for this property yet.
</div>
{:else}
<div class="space-y-4">
{#each buckets as b}
<div>
<div class="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{ACCOUNT_KIND_LABEL[b.kind]} <span class="ml-1 font-normal normal-case text-gray-400">· {b.accounts.length}</span>
</div>
<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 b.accounts as a}
<li class="px-4 py-3 text-sm">
{#if editingId === a.id}
<form method="post" action="?/update"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingId = null;
}}
class="grid gap-3 sm:grid-cols-2">
<input type="hidden" name="id" value={a.id} />
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Kind</span>
<select name="kind" 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">
{#each KINDS as k}<option value={k} selected={a.kind === k}>{ACCOUNT_KIND_LABEL[k]}</option>{/each}
</select>
</label>
<label class="block">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Provider</span>
<input name="provider" value={a.provider ?? ''} 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">Label</span>
<input name="label" value={a.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">Account #</span>
<input name="account_number" value={a.accountNumber ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-mono 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">Meter #</span>
<input name="meter_number" value={a.meterNumber ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm font-mono 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">Notes</span>
<input name="notes" value={a.notes ?? ''} 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>
<div class="sm:col-span-2 flex justify-end gap-2">
<button type="button" onclick={() => (editingId = 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>
{:else}
<div class="flex items-start justify-between gap-3">
<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">
{#if a.provider}<span class="font-medium text-gray-700 dark:text-gray-200">{a.provider}</span>{/if}
{#if a.label}<span>· {a.label}</span>{/if}
</div>
<div class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm">
{#if a.accountNumber}
<span><span class="text-xs text-gray-400">acct</span> <code class="font-mono text-gray-900 dark:text-gray-100">{a.accountNumber}</code></span>
{/if}
{#if a.meterNumber}
<span><span class="text-xs text-gray-400">meter</span> <code class="font-mono text-gray-900 dark:text-gray-100">{a.meterNumber}</code></span>
{/if}
{#if !a.accountNumber && !a.meterNumber}
<span class="text-xs italic text-gray-400">no numbers recorded</span>
{/if}
</div>
{#if a.notes}<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{a.notes}</div>{/if}
</div>
<div class="flex shrink-0 gap-2 text-xs">
<button type="button" onclick={() => { editingId = a.id; showForm = false; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">edit</button>
<form method="post" action="?/delete" use:enhance class="inline">
<input type="hidden" name="id" value={a.id} />
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">delete</button>
</form>
</div>
</div>
{/if}
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { listAssets } from '$lib/server/services/assets';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const rows = await listAssets({ companyId: locals.company.id, propertyId: params.id });
return { assets: rows };
};
@@ -0,0 +1,78 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const propId = $derived(data.property.id);
// Group assets by their room (including an "Unassigned" bucket).
interface Bucket {
key: string; // roomId or "" for unassigned
label: string;
assets: typeof data.assets;
}
const groups = $derived.by(() => {
const byRoom = new Map<string, Bucket>();
for (const a of data.assets) {
const key = a.currentRoomId ?? '';
const label = a.currentRoomId
? a.floorLabel
? `Floor ${a.floorLabel} · ${a.roomName}`
: (a.roomName ?? 'Room')
: 'Unassigned';
if (!byRoom.has(key)) byRoom.set(key, { key, label, assets: [] });
byRoom.get(key)!.assets.push(a);
}
// Unassigned at the bottom for readability.
return Array.from(byRoom.values()).sort((a, b) => {
if (a.key === '' && b.key !== '') return 1;
if (a.key !== '' && b.key === '') return -1;
return a.label.localeCompare(b.label);
});
});
</script>
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">{data.assets.length} asset{data.assets.length === 1 ? '' : 's'} at this property.</p>
<a href="/assets/new?property={propId}" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">+ Add asset</a>
</div>
{#if data.assets.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 assets yet at this property.
</div>
{:else}
<div class="space-y-4">
{#each groups as g}
<div>
<div class="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{g.label} <span class="ml-1 font-normal normal-case text-gray-400">· {g.assets.length}</span>
</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">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>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{#each g.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>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -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, 'property', 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: 'property',
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,72 @@
<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>
{:else 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-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
No documents uploaded for this property yet.
</div>
{:else}
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
{#each data.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,122 @@
import { error, fail } from '@sveltejs/kit';
import { z } from 'zod';
import {
createFloor,
createRoom,
deleteFloor,
listFloors,
listRoomsWithCounts,
softDeleteRoom,
updateFloor,
updateRoom
} from '$lib/server/services/rooms';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.company) throw error(401);
const [floors, rooms] = await Promise.all([
listFloors(locals.company.id, params.id),
listRoomsWithCounts(locals.company.id, params.id)
]);
return { floors, rooms };
};
const FloorSchema = z.object({
label: z.string().trim().min(1).max(32),
name: z.string().trim().max(255).optional().or(z.literal(''))
});
const RoomSchema = z.object({
name: z.string().trim().min(1).max(255),
floor_id: z.string().uuid().optional().or(z.literal('')),
notes: z.string().trim().max(2000).optional().or(z.literal(''))
});
export const actions: Actions = {
createFloor: 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 = FloorSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
try {
await createFloor({
companyId: locals.company.id,
propertyId: params.id,
label: parsed.data.label,
name: parsed.data.name || null
});
} catch (e) {
const msg = (e as Error).message;
if (msg.includes('floors_property_label_uq')) {
return fail(400, { error: `A floor labeled "${parsed.data.label}" already exists.` });
}
return fail(400, { error: msg });
}
return { ok: true };
},
updateFloor: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const label = String(form.get('label') ?? '').trim();
const name = String(form.get('name') ?? '').trim() || null;
if (!id || !label) return fail(400, { error: 'Missing id or label' });
try {
await updateFloor(locals.company.id, id, { label, name });
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
deleteFloor: 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 deleteFloor(locals.company.id, id);
return { ok: true };
},
createRoom: 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 = RoomSchema.safeParse(raw);
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
try {
await createRoom({
companyId: locals.company.id,
propertyId: params.id,
floorId: parsed.data.floor_id || null,
name: parsed.data.name,
notes: parsed.data.notes || null
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
updateRoom: async ({ request, locals }) => {
if (!locals.company) throw error(401);
const form = await request.formData();
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '').trim();
const floorId = String(form.get('floor_id') ?? '').trim() || null;
const notes = String(form.get('notes') ?? '').trim() || null;
if (!id || !name) return fail(400, { error: 'Missing id or name' });
try {
await updateRoom(locals.company.id, id, { name, floorId, notes });
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
},
deleteRoom: 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 softDeleteRoom(locals.company.id, id);
return { ok: true };
}
};
@@ -0,0 +1,215 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showFloorForm = $state(false);
let showRoomForm = $state(false);
let editingRoomId = $state<string | null>(null);
let editingFloorId = $state<string | null>(null);
// Group rooms by floor (plus an unassigned bucket).
interface Bucket {
floorId: string | null;
floorLabel: string | null;
floorName: string | null;
rooms: typeof data.rooms;
}
const buckets = $derived.by(() => {
const byFloor = new Map<string | null, Bucket>();
for (const f of data.floors) {
byFloor.set(f.id, { floorId: f.id, floorLabel: f.label, floorName: f.name, rooms: [] });
}
byFloor.set(null, { floorId: null, floorLabel: null, floorName: null, rooms: [] });
for (const r of data.rooms) {
const b = byFloor.get(r.floorId);
if (b) b.rooms.push(r);
}
return Array.from(byFloor.values()).filter((b) => b.floorId !== null || b.rooms.length > 0);
});
</script>
<div class="space-y-6">
{#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}
<!-- ============== floors ============== -->
<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">Floors</h2>
<button type="button" onclick={() => (showFloorForm = !showFloorForm)}
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">
{showFloorForm ? 'Cancel' : '+ Floor'}
</button>
</div>
{#if showFloorForm}
<form method="post" action="?/createFloor"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') showFloorForm = false;
}}
class="grid gap-3 rounded-lg border border-gray-200 bg-white p-3 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">Label <span class="text-red-500">*</span></span>
<input name="label" required placeholder="1, B1, M, roof"
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">Name</span>
<input name="name" placeholder="Ground floor, Basement parking…"
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 floor</button>
</div>
</form>
{/if}
{#if data.floors.length === 0}
<p class="text-sm text-gray-500 italic dark:text-gray-400">No floors defined. Single-level? You can skip this and just add rooms.</p>
{:else}
<ul class="flex flex-wrap gap-2">
{#each data.floors as f}
<li>
{#if editingFloorId === f.id}
<form method="post" action="?/updateFloor"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingFloorId = null;
}}
class="inline-flex items-center gap-2 rounded-md border border-primary-300 bg-primary-50 px-2 py-1 text-xs dark:border-primary-700 dark:bg-primary-900/20">
<input type="hidden" name="id" value={f.id} />
<input name="label" required value={f.label} class="w-16 rounded-sm border border-gray-300 bg-white px-2 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
<input name="name" placeholder="name" value={f.name ?? ''} class="w-40 rounded-sm border border-gray-300 bg-white px-2 py-0.5 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-[10px] font-medium text-white hover:bg-primary-700">save</button>
<button type="button" onclick={() => (editingFloorId = null)} class="text-gray-500">×</button>
</form>
{:else}
<div class="inline-flex items-center gap-2 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-sm dark:border-gray-700 dark:bg-gray-800">
<span class="font-mono text-xs font-semibold text-gray-700 dark:text-gray-200">{f.label}</span>
{#if f.name}<span class="text-gray-500 dark:text-gray-400">· {f.name}</span>{/if}
<button type="button" onclick={() => (editingFloorId = f.id)} class="ml-2 text-[10px] text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">edit</button>
<form method="post" action="?/deleteFloor" use:enhance class="inline">
<input type="hidden" name="id" value={f.id} />
<button type="submit" class="text-[10px] text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
</form>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
<!-- ============== rooms ============== -->
<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">Rooms</h2>
<button type="button" onclick={() => (showRoomForm = !showRoomForm)}
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
{showRoomForm ? 'Cancel' : '+ Room'}
</button>
</div>
{#if showRoomForm}
<form method="post" action="?/createRoom"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') showRoomForm = false;
}}
class="grid gap-3 rounded-lg border border-gray-200 bg-white p-3 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">Floor</span>
<select name="floor_id"
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">
<option value="">— no floor —</option>
{#each data.floors as f}
<option value={f.id}>{f.label}{f.name ? ` · ${f.name}` : ''}</option>
{/each}
</select>
</label>
<label class="block sm:col-span-2">
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></span>
<input name="name" required placeholder="Server room, Office 201, Rack A…"
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-3">
<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 room</button>
</div>
</form>
{/if}
{#if buckets.length === 0 || buckets.every((b) => b.rooms.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 rooms yet.
</div>
{:else}
<div class="space-y-4">
{#each buckets as b}
{#if b.rooms.length > 0}
<div>
<div class="mb-1 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{#if b.floorLabel}
Floor {b.floorLabel}{b.floorName ? ` · ${b.floorName}` : ''}
{:else}
Unassigned floor
{/if}
</div>
<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 b.rooms as r}
<li class="px-4 py-2 text-sm">
{#if editingRoomId === r.id}
<form method="post" action="?/updateRoom"
use:enhance={() => async ({ update, result }) => {
await update();
if (result.type === 'success') editingRoomId = null;
}}
class="grid gap-2 sm:grid-cols-3">
<input type="hidden" name="id" value={r.id} />
<select name="floor_id" 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">
<option value="">— no floor —</option>
{#each data.floors as f}
<option value={f.id} selected={r.floorId === f.id}>{f.label}{f.name ? ` · ${f.name}` : ''}</option>
{/each}
</select>
<input name="name" required value={r.name} 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" />
<input name="notes" value={r.notes ?? ''} placeholder="notes" 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" />
<div class="sm:col-span-3 flex justify-end gap-2">
<button type="button" onclick={() => (editingRoomId = 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>
{:else}
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="font-medium text-gray-900 dark:text-gray-100">{r.name}</div>
{#if r.notes}<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">{r.notes}</div>{/if}
</div>
<div class="flex shrink-0 items-center gap-2 text-xs">
<span class="text-gray-500 dark:text-gray-400">{r.assetCount} asset{r.assetCount === 1 ? '' : 's'}</span>
<button type="button" onclick={() => (editingRoomId = r.id)} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">edit</button>
<form method="post" action="?/deleteRoom" use:enhance class="inline">
<input type="hidden" name="id" value={r.id} />
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">delete</button>
</form>
</div>
</div>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
</div>
{/if}
</section>
</div>
@@ -0,0 +1,51 @@
import { fail, redirect, error } from '@sveltejs/kit';
import { z } from 'zod';
import { createProperty } from '$lib/server/services/properties';
import type { Actions } from './$types';
const PropertySchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(255),
kind: z.string().trim().max(64).optional().or(z.literal('')),
addressLine1: z.string().trim().max(255).optional().or(z.literal('')),
addressLine2: z.string().trim().max(255).optional().or(z.literal('')),
city: z.string().trim().max(128).optional().or(z.literal('')),
region: z.string().trim().max(128).optional().or(z.literal('')),
postalCode: z.string().trim().max(32).optional().or(z.literal('')),
countryCode: z.string().trim().length(2).optional().or(z.literal('')),
notes: z.string().trim().max(10_000).optional().or(z.literal(''))
});
function emptyToNull(s: string | undefined): string | null {
return !s ? null : s;
}
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user || !locals.company) throw error(401, 'Not authenticated');
const form = await request.formData();
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
const parsed = PropertySchema.safeParse(raw);
if (!parsed.success) {
return fail(400, {
error: parsed.error.errors[0]?.message ?? 'Invalid input',
values: raw
});
}
const v = parsed.data;
const { id } = await createProperty({
companyId: locals.company.id,
createdBy: locals.user.id,
name: v.name,
kind: emptyToNull(v.kind),
addressLine1: emptyToNull(v.addressLine1),
addressLine2: emptyToNull(v.addressLine2),
city: emptyToNull(v.city),
region: emptyToNull(v.region),
postalCode: emptyToNull(v.postalCode),
countryCode: emptyToNull(v.countryCode),
notes: emptyToNull(v.notes)
});
throw redirect(303, `/properties/${id}`);
}
};
@@ -0,0 +1,92 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let submitting = $state(false);
const v = $derived((form?.values ?? {}) as Record<string, string>);
</script>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New property</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
A property is a place where assets live (a warehouse, an office, a datacenter, a site).
</p>
</div>
<form
method="post"
use:enhance={() => {
submitting = true;
return ({ update }) => update().finally(() => (submitting = 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}
<div>
<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 autocomplete="off" 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 placeholder:text-gray-400 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>
<input id="kind" name="kind" placeholder="warehouse, office, datacenter, …" value={v.kind ?? ''}
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 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="grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="addressLine1" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 1</label>
<input id="addressLine1" name="addressLine1" value={v.addressLine1 ?? ''}
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="sm:col-span-2">
<label for="addressLine2" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Address line 2</label>
<input id="addressLine2" name="addressLine2" value={v.addressLine2 ?? ''}
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="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
<input id="city" name="city" value={v.city ?? ''}
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="region" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Region / State</label>
<input id="region" name="region" value={v.region ?? ''}
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="postalCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Postal code</label>
<input id="postalCode" name="postalCode" value={v.postalCode ?? ''}
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="countryCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country (ISO 2)</label>
<input id="countryCode" name="countryCode" maxlength="2" placeholder="TH" value={v.countryCode ?? ''}
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" />
</div>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
<textarea id="notes" name="notes" 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.notes ?? ''}</textarea>
</div>
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<a href="/properties" 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 property'}
</button>
</div>
</form>
</div>
@@ -0,0 +1,42 @@
import { fail } from '@sveltejs/kit';
import { requireCompany } from '$lib/server/auth/guards';
import {
getUserPrefs,
updateUserPrefs
} from '$lib/server/services/notifications';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { user } = requireCompany(locals);
const prefs = await getUserPrefs(user.id);
return {
prefs,
// Client needs to know whether the transports are even configured on the
// server so we can show "(not configured)" labels.
emailAvailable: Boolean(
(process.env.SMTP_HOST && process.env.SMTP_PORT && process.env.SMTP_FROM) || false
),
matrixAvailable: Boolean(process.env.MATRIX_HOMESERVER && process.env.MATRIX_ACCESS_TOKEN)
};
};
export const actions: Actions = {
save: async ({ request, locals }) => {
const { user } = requireCompany(locals);
const form = await request.formData();
const emailNotifications = form.get('email_notifications') === 'true';
const matrixNotifications = form.get('matrix_notifications') === 'true';
const matrixUserIdRaw = form.get('matrix_user_id');
const matrixUserId = matrixUserIdRaw === null ? undefined : String(matrixUserIdRaw);
try {
await updateUserPrefs(user.id, {
emailNotifications,
matrixNotifications,
matrixUserId
});
} catch (e) {
return fail(400, { error: (e as Error).message });
}
return { ok: true };
}
};

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