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:
@@ -2,6 +2,8 @@ import { redirect } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { companies, companyUsers } from '$lib/server/db/schema/tenancy';
|
||||
import { setActiveCompany } from '$lib/server/auth/session';
|
||||
import { unreadCountForUser } from '$lib/server/services/notifications';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import type { SessionCompany } from '$lib/server/auth/types';
|
||||
|
||||
@@ -11,7 +13,6 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
throw redirect(303, `/login?next=${encodeURIComponent(target)}`);
|
||||
}
|
||||
|
||||
// Load the user's companies so the sidebar switcher can render.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: companies.id,
|
||||
@@ -30,9 +31,22 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
role: r.role
|
||||
}));
|
||||
|
||||
// If the session has no active company but the user belongs to at least one,
|
||||
// auto-select the first one. Saves a click and matches the budget app's UX.
|
||||
let active = locals.company;
|
||||
if (!active && userCompanies.length > 0 && locals.sessionId) {
|
||||
const first = userCompanies[0];
|
||||
await setActiveCompany(locals.sessionId, first.id);
|
||||
active = first;
|
||||
locals.company = first;
|
||||
}
|
||||
|
||||
const unreadCount = active ? await unreadCountForUser(locals.user.id, active.id) : 0;
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
company: locals.company,
|
||||
companies: userCompanies
|
||||
company: active,
|
||||
companies: userCompanies,
|
||||
unreadCount
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
<div class="flex min-h-screen">
|
||||
<Sidebar
|
||||
user={data.user}
|
||||
company={data.company}
|
||||
companies={data.companies}
|
||||
open={sidebarOpen}
|
||||
@@ -18,7 +17,7 @@
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar onmenu={() => (sidebarOpen = true)} />
|
||||
<TopBar user={data.user} unreadCount={data.unreadCount} onmenu={() => (sidebarOpen = true)} />
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 lg:px-6">
|
||||
{@render children()}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { countOverdueForCompany, listDueAndOverdue } from '$lib/server/services/maintenance';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.company) {
|
||||
return { overdueCount: 0, upcomingSoon: [] as Awaited<ReturnType<typeof listDueAndOverdue>> };
|
||||
}
|
||||
const [overdueCount, upcomingSoon] = await Promise.all([
|
||||
countOverdueForCompany(locals.company.id),
|
||||
listDueAndOverdue({ companyId: locals.company.id, limit: 5, upcomingDays: 14 })
|
||||
]);
|
||||
return { overdueCount, upcomingSoon };
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function dayDelta(d: Date | string): number {
|
||||
return Math.round((new Date(d).getTime() - Date.now()) / 86400000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -18,13 +22,19 @@
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<a href="/maintenance" class="block rounded-lg border border-gray-200 bg-white p-4 hover:border-primary-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-primary-700">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Overdue maintenance
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">—</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Will populate once maintenance schedules exist.</p>
|
||||
</div>
|
||||
<p class="mt-2 text-3xl font-bold {data.overdueCount > 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'}">{data.overdueCount}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if data.overdueCount === 0}
|
||||
All time-based schedules are on track.
|
||||
{:else}
|
||||
Time-based schedules past their next-due date.
|
||||
{/if}
|
||||
</p>
|
||||
</a>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
My open tasks
|
||||
@@ -41,8 +51,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if data.upcomingSoon.length > 0}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700/50 dark:bg-amber-900/20">
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-700 dark:text-amber-400">Due in the next 14 days</p>
|
||||
<ul class="space-y-1 text-sm">
|
||||
{#each data.upcomingSoon as r}
|
||||
{@const d = dayDelta(r.nextDueAt!)}
|
||||
<li class="flex items-center justify-between gap-3">
|
||||
<a href="/assets/{r.assetId}/maintenance" class="truncate text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
|
||||
{r.assetName} — <span class="text-gray-500 dark:text-gray-400">{r.scheduleName}</span>
|
||||
</a>
|
||||
<span class="shrink-0 text-xs {d < 0 ? 'text-red-600 dark:text-red-400 font-medium' : 'text-amber-700 dark:text-amber-400'}">
|
||||
{d < 0 ? `${-d}d overdue` : `in ${d}d`}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a href="/maintenance" class="mt-2 inline-block text-xs text-amber-800 hover:underline dark:text-amber-300">View all →</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">Phase 0 complete.</p>
|
||||
<p class="mt-1">Auth, layout shell, storage interface, and the tenancy schema are wired. The remaining schema modules (projects, properties, assets, maintenance, checklists, decisions, documents, wiki, audit) land in Phase 1.</p>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">Phase 5 (partial) shipped.</p>
|
||||
<p class="mt-1">
|
||||
Printable QR labels per asset, S3 storage backend (switch via <code class="text-[11px]">STORAGE_BACKEND=s3</code>),
|
||||
and CSV exports for assets / maintenance / project decisions are live. Notifications and cross-app APIs land in a later session.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { asc, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { assetTypes, assetFieldDefs } from '$lib/server/db/schema/assets';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const types = await db
|
||||
.select({
|
||||
id: assetTypes.id,
|
||||
name: assetTypes.name,
|
||||
slug: assetTypes.slug,
|
||||
icon: assetTypes.icon,
|
||||
description: assetTypes.description,
|
||||
companyId: assetTypes.companyId,
|
||||
schemaVersion: assetTypes.schemaVersion,
|
||||
fieldCount: sql<number>`(
|
||||
select count(*)::int from ${assetFieldDefs}
|
||||
where ${assetFieldDefs.assetTypeId} = ${assetTypes.id}
|
||||
and ${assetFieldDefs.deprecatedAt} is null
|
||||
)`
|
||||
})
|
||||
.from(assetTypes)
|
||||
.where(or(isNull(assetTypes.companyId), eq(assetTypes.companyId, locals.company.id))!)
|
||||
.orderBy(asc(assetTypes.name));
|
||||
return { types };
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Asset types</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Catalog of "kinds of thing" you can track. System types are shared across every company; company types are yours to add and edit.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/admin/asset-types/new"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
|
||||
+ New type
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Slug</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Scope</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Fields</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schema v</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.types as t}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="/admin/asset-types/{t.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{t.name}</a>
|
||||
{#if t.description}<div class="text-xs text-gray-500 dark:text-gray-400">{t.description}</div>{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{t.slug}</td>
|
||||
<td class="px-4 py-2 text-xs">
|
||||
{#if t.companyId === null}
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">system</span>
|
||||
{:else}
|
||||
<span class="rounded-full bg-primary-100 px-2 py-0.5 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">company</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-sm text-gray-700 dark:text-gray-300">{t.fieldCount}</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">v{t.schemaVersion}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,192 @@
|
||||
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { loadTypeWithFields } from '$lib/server/services/assets';
|
||||
import {
|
||||
addFieldDef,
|
||||
deleteCompanyAssetType,
|
||||
removeFieldDef,
|
||||
updateCompanyAssetType,
|
||||
updateFieldDef,
|
||||
type FieldType
|
||||
} from '$lib/server/services/asset-types';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const tf = await loadTypeWithFields(params.id);
|
||||
if (!tf) throw error(404, 'Asset type not found');
|
||||
// Tenant guard: company-scoped types must belong to the active company.
|
||||
if (tf.type.companyId !== null && tf.type.companyId !== locals.company.id) {
|
||||
throw error(404, 'Asset type not found');
|
||||
}
|
||||
const editable = tf.type.companyId === locals.company.id;
|
||||
return { type: tf.type, fields: tf.fields, editable };
|
||||
};
|
||||
|
||||
const FIELD_TYPES = [
|
||||
'text',
|
||||
'textarea',
|
||||
'int',
|
||||
'float',
|
||||
'bool',
|
||||
'date',
|
||||
'ip',
|
||||
'cidr',
|
||||
'mac',
|
||||
'enum',
|
||||
'multi_enum',
|
||||
'url',
|
||||
'email',
|
||||
'asset_ref'
|
||||
] as const;
|
||||
|
||||
const MetaSchema = z.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
icon: z.string().trim().max(64).optional().or(z.literal('')),
|
||||
description: z.string().trim().max(2000).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
const FieldSchema = z.object({
|
||||
key: z.string().trim().max(64).optional().or(z.literal('')),
|
||||
label: z.string().trim().min(1).max(128),
|
||||
type: z.enum(FIELD_TYPES),
|
||||
required: z.string().optional(),
|
||||
enum_values: z.string().trim().max(2000).optional().or(z.literal('')),
|
||||
unit: z.string().trim().max(32).optional().or(z.literal('')),
|
||||
placeholder: z.string().trim().max(255).optional().or(z.literal('')),
|
||||
help_text: z.string().trim().max(2000).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
const FieldPatchSchema = z.object({
|
||||
label: z.string().trim().min(1).max(128),
|
||||
required: z.string().optional(),
|
||||
enum_values: z.string().trim().max(2000).optional().or(z.literal('')),
|
||||
unit: z.string().trim().max(32).optional().or(z.literal('')),
|
||||
placeholder: z.string().trim().max(255).optional().or(z.literal('')),
|
||||
help_text: z.string().trim().max(2000).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
function parseEnumValues(raw: string | undefined): string[] | null {
|
||||
if (!raw) return null;
|
||||
const parts = raw
|
||||
.split(/[,\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts : null;
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
saveMeta: async ({ request, locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = MetaSchema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
try {
|
||||
await updateCompanyAssetType(locals.company.id, params.id, {
|
||||
name: parsed.data.name,
|
||||
icon: parsed.data.icon || null,
|
||||
description: parsed.data.description || null
|
||||
});
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
deleteType: async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
try {
|
||||
await deleteCompanyAssetType(locals.company.id, params.id);
|
||||
} catch (e) {
|
||||
if (isRedirect(e) || isHttpError(e)) throw e;
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
throw redirect(303, '/admin/asset-types');
|
||||
},
|
||||
|
||||
addField: async ({ request, locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = FieldSchema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
const v = parsed.data;
|
||||
try {
|
||||
await addFieldDef(locals.company.id, params.id, {
|
||||
key: v.key || v.label,
|
||||
label: v.label,
|
||||
type: v.type as FieldType,
|
||||
required: v.required === 'true',
|
||||
enumValues: parseEnumValues(v.enum_values),
|
||||
unit: v.unit || null,
|
||||
placeholder: v.placeholder || null,
|
||||
helpText: v.help_text || null
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes('asset_field_defs_type_key_uq')) {
|
||||
return fail(400, { error: 'A field with that key already exists on this type.' });
|
||||
}
|
||||
return fail(400, { error: msg });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
updateField: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const fieldId = String(form.get('field_id') ?? '');
|
||||
if (!fieldId) return fail(400, { error: 'Missing field_id' });
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = FieldPatchSchema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
const v = parsed.data;
|
||||
const enumVals = parseEnumValues(v.enum_values);
|
||||
try {
|
||||
await updateFieldDef(locals.company.id, fieldId, {
|
||||
label: v.label,
|
||||
required: v.required === 'true',
|
||||
enumValues: raw.enum_values !== undefined ? enumVals : undefined,
|
||||
unit: v.unit || null,
|
||||
placeholder: v.placeholder || null,
|
||||
helpText: v.help_text || null
|
||||
});
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
removeField: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const fieldId = String(form.get('field_id') ?? '');
|
||||
const force = form.get('force') === 'true';
|
||||
if (!fieldId) return fail(400, { error: 'Missing field_id' });
|
||||
try {
|
||||
const res = await removeFieldDef(locals.company.id, fieldId, { force });
|
||||
return {
|
||||
ok: true,
|
||||
deprecated: !res.hardDeleted
|
||||
};
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
|
||||
restoreField: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const fieldId = String(form.get('field_id') ?? '');
|
||||
if (!fieldId) return fail(400, { error: 'Missing field_id' });
|
||||
try {
|
||||
await updateFieldDef(locals.company.id, fieldId, {
|
||||
deprecatedAt: null
|
||||
} as never);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,291 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import {
|
||||
FIELD_TYPES,
|
||||
FIELD_TYPE_LABEL,
|
||||
needsEnumValues,
|
||||
type FieldType
|
||||
} from '$lib/field-types';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let editingMeta = $state(false);
|
||||
let adding = $state(false);
|
||||
let editingFieldId = $state<string | null>(null);
|
||||
let confirmingDelete = $state(false);
|
||||
|
||||
// Controls conditional rendering of the "values" textarea in the new-field form.
|
||||
let newFieldType = $state<FieldType>('text');
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a href="/admin/asset-types" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all asset types</a>
|
||||
<div class="mt-1 flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{data.type.name}</h1>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>slug <code class="font-mono text-xs">{data.type.slug}</code></span>
|
||||
<span>schema v{data.type.schemaVersion}</span>
|
||||
<span>
|
||||
{#if data.editable}
|
||||
<span class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">company type</span>
|
||||
{:else}
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">system type · read-only</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if data.type.description}
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{data.type.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.editable && !editingMeta}
|
||||
<button type="button" onclick={() => (editingMeta = true)}
|
||||
class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Edit
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if editingMeta}
|
||||
<form method="post" action="?/saveMeta"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingMeta = false;
|
||||
}}
|
||||
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</span>
|
||||
<input name="name" required value={data.type.name}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Icon</span>
|
||||
<input name="icon" value={data.type.icon ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</span>
|
||||
<textarea name="description" rows="3"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{data.type.description ?? ''}</textarea>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editingMeta = false)} class="text-sm text-gray-600 dark:text-gray-400">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Fields</h2>
|
||||
{#if data.editable}
|
||||
<button type="button" onclick={() => (adding = !adding)}
|
||||
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
|
||||
{adding ? 'Cancel' : '+ Add field'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if adding}
|
||||
<form method="post" action="?/addField"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
adding = false;
|
||||
newFieldType = 'text';
|
||||
}
|
||||
}}
|
||||
class="mb-3 grid gap-3 rounded-lg border border-gray-200 bg-white p-4 sm:grid-cols-2 dark:border-gray-700 dark:bg-gray-800">
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Label <span class="text-red-500">*</span></span>
|
||||
<input name="label" required placeholder="e.g. Max pressure"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Key</span>
|
||||
<input name="key" placeholder="leave empty to derive from label"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Type <span class="text-red-500">*</span></span>
|
||||
<select name="type" required bind:value={newFieldType}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
{#each FIELD_TYPES as t}<option value={t}>{FIELD_TYPE_LABEL[t]}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
|
||||
<input name="unit" placeholder="e.g. kW, °C, mm"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
{#if needsEnumValues(newFieldType)}
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Values (comma or newline separated) <span class="text-red-500">*</span></span>
|
||||
<textarea name="enum_values" rows="2" required placeholder="small, medium, large"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Placeholder</span>
|
||||
<input name="placeholder"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Help text</span>
|
||||
<input name="help_text"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 sm:col-span-2">
|
||||
<input type="checkbox" name="required" value="true"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
|
||||
Required on new assets
|
||||
</label>
|
||||
<div class="sm:col-span-2 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Add field</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.fields.length === 0}
|
||||
<p class="text-sm text-gray-500 italic dark:text-gray-400">No fields defined.</p>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Key</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Label</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Type</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Options</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Required</th>
|
||||
{#if data.editable}
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"> </th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.fields as f}
|
||||
<tr class={f.deprecatedAt ? 'opacity-50' : ''}>
|
||||
{#if editingFieldId === f.id}
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{f.key}</td>
|
||||
<td colspan={data.editable ? 5 : 4} class="px-4 py-2">
|
||||
<form method="post" action="?/updateField"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingFieldId = null;
|
||||
}}
|
||||
class="grid gap-2 sm:grid-cols-2">
|
||||
<input type="hidden" name="field_id" value={f.id} />
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Label</span>
|
||||
<input name="label" required value={f.label}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
|
||||
<input name="unit" value={f.unit ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Placeholder</span>
|
||||
<input name="placeholder" value={f.placeholder ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
{#if needsEnumValues(f.type as FieldType)}
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Values</span>
|
||||
<textarea name="enum_values" rows="2" required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{(f.enumValues ?? []).join(', ')}</textarea>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Help text</span>
|
||||
<input name="help_text" value={f.helpText ?? ''} class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 sm:col-span-2 dark:text-gray-300">
|
||||
<input type="checkbox" name="required" value="true" checked={f.required}
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
|
||||
Required
|
||||
</label>
|
||||
<div class="sm:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editingFieldId = null)} class="text-xs text-gray-500">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-primary-600 px-2 py-1 text-xs font-medium text-white hover:bg-primary-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
{:else}
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-700 dark:text-gray-300">{f.key}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">
|
||||
{f.label}
|
||||
{#if f.unit}<span class="text-xs text-gray-400">({f.unit})</span>{/if}
|
||||
{#if f.deprecatedAt}<span class="ml-1 rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">deprecated</span>{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">{FIELD_TYPE_LABEL[f.type as FieldType] ?? f.type}</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if f.enumValues && f.enumValues.length > 0}
|
||||
{f.enumValues.join(', ')}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-xs">
|
||||
{#if f.required}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">required</span>
|
||||
{:else}
|
||||
<span class="text-gray-400 dark:text-gray-500">optional</span>
|
||||
{/if}
|
||||
</td>
|
||||
{#if data.editable}
|
||||
<td class="px-4 py-2 text-right">
|
||||
<div class="flex justify-end gap-2 text-xs">
|
||||
<button type="button" onclick={() => (editingFieldId = f.id)} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">edit</button>
|
||||
{#if f.deprecatedAt}
|
||||
<form method="post" action="?/restoreField" use:enhance class="inline">
|
||||
<input type="hidden" name="field_id" value={f.id} />
|
||||
<button type="submit" class="text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400">restore</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="post" action="?/removeField" use:enhance class="inline">
|
||||
<input type="hidden" name="field_id" value={f.id} />
|
||||
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{#if data.editable}
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Field <code class="font-mono">key</code> and <code class="font-mono">type</code> are immutable after creation — changing them against existing JSONB data would corrupt it. Remove + re-add (and optionally script a JSONB migration) if you need to change a field's shape.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.editable}
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
|
||||
{confirmingDelete ? 'Cancel delete' : 'Delete this type…'}
|
||||
</button>
|
||||
{#if confirmingDelete}
|
||||
<form method="post" action="?/deleteType" use:enhance
|
||||
class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
<p>Hard-delete this asset type and all of its field defs. Only works if no assets of this type exist — move or soft-delete them first.</p>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete type</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { createCompanyAssetType } from '$lib/server/services/asset-types';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
slug: z.string().trim().max(64).optional().or(z.literal('')),
|
||||
icon: z.string().trim().max(64).optional().or(z.literal('')),
|
||||
description: z.string().trim().max(2000).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = Schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
|
||||
}
|
||||
const v = parsed.data;
|
||||
try {
|
||||
const { id } = await createCompanyAssetType({
|
||||
companyId: locals.company.id,
|
||||
name: v.name,
|
||||
slug: v.slug || null,
|
||||
icon: v.icon || null,
|
||||
description: v.description || null
|
||||
});
|
||||
throw redirect(303, `/admin/asset-types/${id}`);
|
||||
} catch (e) {
|
||||
if (isRedirect(e) || isHttpError(e)) throw e;
|
||||
const msg = (e as Error).message ?? 'create failed';
|
||||
if (msg.includes('asset_types_company_slug_uq')) {
|
||||
return fail(400, { error: 'A type with that slug already exists in this company.', values: raw });
|
||||
}
|
||||
return fail(400, { error: msg, values: raw });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
let saving = $state(false);
|
||||
const v = $derived((form?.values ?? {}) as Record<string, string>);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl space-y-6">
|
||||
<div>
|
||||
<a href="/admin/asset-types" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all asset types</a>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">New asset type</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Custom company-scoped type. After creating it you can add typed fields
|
||||
(IP, enum, int, etc.) on the next screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return ({ update }) => update().finally(() => (saving = false));
|
||||
}}
|
||||
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></span>
|
||||
<input name="name" required value={v.name ?? ''} placeholder="e.g. Security Camera"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
|
||||
<input name="slug" value={v.slug ?? ''} placeholder="leave empty to derive from name"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<p class="mt-1 text-xs text-gray-400">Lowercase snake_case. Used internally; must be unique within your company.</p>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Icon</span>
|
||||
<input name="icon" value={v.icon ?? ''} placeholder="optional — icon name or emoji"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</span>
|
||||
<textarea name="description" rows="3"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{v.description ?? ''}</textarea>
|
||||
</label>
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<a href="/admin/asset-types" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
|
||||
<button type="submit" disabled={saving}
|
||||
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
|
||||
{saving ? 'Creating…' : 'Create type'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin, requireCompany } from '$lib/server/auth/guards';
|
||||
import { getCompany, updateCompany } from '$lib/server/services/companies';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
slug: z.string().trim().min(1).max(128),
|
||||
default_currency: z.string().trim().length(3).optional().or(z.literal('')),
|
||||
matrix_room_id: z.string().trim().max(255).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
interface CompanySettings {
|
||||
default_currency?: string | null;
|
||||
matrix_room_id?: string | null;
|
||||
}
|
||||
|
||||
function parseSettings(raw: string | null | undefined): CompanySettings {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as CompanySettings;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { company } = requireCompany(locals);
|
||||
const full = await getCompany(company.id);
|
||||
if (!full) throw new Error('active company row missing');
|
||||
return {
|
||||
fullCompany: full,
|
||||
settings: parseSettings(full.settings),
|
||||
isAdmin: company.role === 'admin'
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, locals }) => {
|
||||
const { company } = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = Schema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
const v = parsed.data;
|
||||
const existing = await getCompany(company.id);
|
||||
if (!existing) return fail(404, { error: 'Company not found' });
|
||||
const settings = parseSettings(existing.settings);
|
||||
if (v.default_currency) settings.default_currency = v.default_currency.toUpperCase();
|
||||
else delete settings.default_currency;
|
||||
if (v.matrix_room_id) {
|
||||
const trimmed = v.matrix_room_id.trim();
|
||||
if (!/^![^:\s]+:[^:\s]+$/.test(trimmed)) {
|
||||
return fail(400, { error: 'Matrix room id must look like !roomid:server' });
|
||||
}
|
||||
settings.matrix_room_id = trimmed;
|
||||
} else {
|
||||
delete settings.matrix_room_id;
|
||||
}
|
||||
try {
|
||||
await updateCompany(company.id, {
|
||||
name: v.name,
|
||||
slug: v.slug,
|
||||
settings: Object.keys(settings).length > 0 ? JSON.stringify(settings) : null
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes('companies_slug_unique')) {
|
||||
return fail(400, { error: 'A company with that slug already exists.' });
|
||||
}
|
||||
return fail(400, { error: msg });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let saving = $state(false);
|
||||
const c = $derived(data.fullCompany);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Company settings</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{data.isAdmin ? 'Edit the active company.' : 'Read-only — only admins can change these.'}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/admin/company/new" class="text-sm text-primary-600 hover:underline dark:text-primary-400">+ Create new company</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="?/save"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return ({ update }) => update().finally(() => (saving = false));
|
||||
}}
|
||||
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{:else if form?.ok}
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-900/20 dark:text-emerald-300">Saved.</div>
|
||||
{/if}
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</span>
|
||||
<input name="name" required value={c.name} disabled={!data.isAdmin}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
|
||||
<input name="slug" required value={c.slug} disabled={!data.isAdmin}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<p class="mt-1 text-xs text-gray-400">Used internally. Lowercase, dashes only. Must be unique across the whole system.</p>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default currency (ISO 3)</span>
|
||||
<input name="default_currency" maxlength="3" placeholder="THB" value={data.settings.default_currency ?? ''} disabled={!data.isAdmin}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm uppercase shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Matrix room id</span>
|
||||
<input name="matrix_room_id" placeholder="!abc123:matrix.org" value={data.settings.matrix_room_id ?? ''} disabled={!data.isAdmin}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-60 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<p class="mt-1 text-xs text-gray-400">Format <code class="font-mono">!roomid:server.tld</code>. The bot (configured via <code class="font-mono">MATRIX_ACCESS_TOKEN</code>) must already be a member.</p>
|
||||
</label>
|
||||
{#if data.isAdmin}
|
||||
<div class="flex justify-end border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button type="submit" disabled={saving}
|
||||
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { requireCompany } from '$lib/server/auth/guards';
|
||||
import { setActiveCompany } from '$lib/server/auth/session';
|
||||
import { createCompanyWithAdmin } from '$lib/server/services/companies';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
slug: z.string().trim().max(128).optional().or(z.literal('')),
|
||||
default_currency: z.string().trim().length(3).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
const { user, sessionId } = requireCompany(locals);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = Schema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
|
||||
const v = parsed.data;
|
||||
const settingsObj: Record<string, string> = {};
|
||||
if (v.default_currency) settingsObj.default_currency = v.default_currency.toUpperCase();
|
||||
try {
|
||||
const { id } = await createCompanyWithAdmin({
|
||||
name: v.name,
|
||||
slug: v.slug || null,
|
||||
settings: Object.keys(settingsObj).length ? JSON.stringify(settingsObj) : null,
|
||||
creatorUserId: user.id
|
||||
});
|
||||
// Switch the session's active company so the creator lands in the new tenant.
|
||||
await setActiveCompany(sessionId, id);
|
||||
throw redirect(303, '/admin/company');
|
||||
} catch (e) {
|
||||
if (isRedirect(e) || isHttpError(e)) throw e;
|
||||
const msg = (e as Error).message;
|
||||
if (msg.includes('companies_slug_unique')) {
|
||||
return fail(400, { error: 'A company with that slug already exists.', values: raw });
|
||||
}
|
||||
return fail(400, { error: msg, values: raw });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
let saving = $state(false);
|
||||
const v = $derived((form?.values ?? {}) as Record<string, string>);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl space-y-6">
|
||||
<div>
|
||||
<a href="/admin/company" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← company settings</a>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">Create new company</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
You'll be added as the admin automatically. Your session switches to the new
|
||||
company once it's created — use the sidebar to switch back later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return ({ update }) => update().finally(() => (saving = false));
|
||||
}}
|
||||
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></span>
|
||||
<input name="name" required value={v.name ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Slug</span>
|
||||
<input name="slug" value={v.slug ?? ''} placeholder="leave empty to derive from name"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default currency (ISO 3)</span>
|
||||
<input name="default_currency" maxlength="3" placeholder="THB" value={v.default_currency ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm uppercase shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<a href="/admin/company" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
|
||||
<button type="submit" disabled={saving}
|
||||
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
|
||||
{saving ? 'Creating…' : 'Create company'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,89 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireAdmin, requireCompany } from '$lib/server/auth/guards';
|
||||
import {
|
||||
listCompanyUsers,
|
||||
removeUserFromCompany,
|
||||
resetUserPassword,
|
||||
setUserActive,
|
||||
setUserRoleInCompany,
|
||||
updateDisplayName,
|
||||
type CompanyRole
|
||||
} from '$lib/server/services/users';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const ROLES = ['admin', 'manager', 'user', 'viewer'] as const;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { company, user } = requireCompany(locals);
|
||||
const rows = await listCompanyUsers(company.id);
|
||||
return { users: rows, selfUserId: user.id, isAdmin: company.role === 'admin' };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
setRole: async ({ request, locals }) => {
|
||||
const { company } = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const userId = String(form.get('user_id') ?? '');
|
||||
const role = String(form.get('role') ?? '');
|
||||
if (!userId || !ROLES.includes(role as CompanyRole)) {
|
||||
return fail(400, { error: 'Invalid request' });
|
||||
}
|
||||
try {
|
||||
await setUserRoleInCompany(company.id, userId, role as CompanyRole);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
remove: async ({ request, locals }) => {
|
||||
const { company } = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const userId = String(form.get('user_id') ?? '');
|
||||
if (!userId) return fail(400, { error: 'Missing user_id' });
|
||||
try {
|
||||
await removeUserFromCompany(company.id, userId);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
setActive: async ({ request, locals }) => {
|
||||
const { company } = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const userId = String(form.get('user_id') ?? '');
|
||||
const active = form.get('active') === 'true';
|
||||
if (!userId) return fail(400, { error: 'Missing user_id' });
|
||||
try {
|
||||
await setUserActive(company.id, userId, active);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
rename: async ({ request, locals }) => {
|
||||
const { company } = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const userId = String(form.get('user_id') ?? '');
|
||||
const displayName = String(form.get('display_name') ?? '');
|
||||
if (!userId) return fail(400, { error: 'Missing user_id' });
|
||||
try {
|
||||
await updateDisplayName(company.id, userId, displayName);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
resetPassword: async ({ request, locals }) => {
|
||||
const { company } = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const userId = String(form.get('user_id') ?? '');
|
||||
const password = String(form.get('password') ?? '');
|
||||
if (!userId) return fail(400, { error: 'Missing user_id' });
|
||||
try {
|
||||
await resetUserPassword(company.id, userId, password);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { COMPANY_ROLES, COMPANY_ROLE_LABEL, type CompanyRole } from '$lib/roles';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let resettingId = $state<string | null>(null);
|
||||
let renamingId = $state<string | null>(null);
|
||||
let resetPw = $state('');
|
||||
let renameValue = $state('');
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Users</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{data.isAdmin
|
||||
? 'Manage who has access to this company and what they can do.'
|
||||
: 'Read-only view — only admins can invite or change roles.'}
|
||||
</p>
|
||||
</div>
|
||||
{#if data.isAdmin}
|
||||
<a href="/admin/users/new"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
|
||||
+ Invite user
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Email</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Role</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last login</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
|
||||
{#if data.isAdmin}
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Actions</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.users as u}
|
||||
{@const isSelf = u.userId === data.selfUserId}
|
||||
<tr class={u.isActive ? '' : 'opacity-60'}>
|
||||
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{#if renamingId === u.userId}
|
||||
<form method="post" action="?/rename"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update();
|
||||
if (result.type === 'success') renamingId = null;
|
||||
}}
|
||||
class="flex items-center gap-2">
|
||||
<input type="hidden" name="user_id" value={u.userId} />
|
||||
<input name="display_name" required bind:value={renameValue}
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<button type="submit" class="rounded bg-primary-600 px-2 py-0.5 text-xs font-medium text-white hover:bg-primary-700">save</button>
|
||||
<button type="button" onclick={() => (renamingId = null)} class="text-xs text-gray-500">×</button>
|
||||
</form>
|
||||
{:else}
|
||||
{u.displayName}
|
||||
{#if isSelf}<span class="ml-1 text-xs text-gray-400">(you)</span>{/if}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{u.email}</td>
|
||||
<td class="px-4 py-2 text-sm">
|
||||
{#if data.isAdmin && !isSelf}
|
||||
<form method="post" action="?/setRole" use:enhance class="inline">
|
||||
<input type="hidden" name="user_id" value={u.userId} />
|
||||
<select name="role" onchange={(ev) => (ev.currentTarget.form as HTMLFormElement).requestSubmit()}
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
{#each COMPANY_ROLES as r}
|
||||
<option value={r} selected={u.role === r}>{COMPANY_ROLE_LABEL[r]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 capitalize dark:bg-gray-700 dark:text-gray-200">{u.role}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString() : 'never'}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs">
|
||||
{#if u.isActive}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">active</span>
|
||||
{:else}
|
||||
<span class="rounded-full bg-gray-200 px-2 py-0.5 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">inactive</span>
|
||||
{/if}
|
||||
</td>
|
||||
{#if data.isAdmin}
|
||||
<td class="px-4 py-2 text-right">
|
||||
{#if resettingId === u.userId}
|
||||
<form method="post" action="?/resetPassword"
|
||||
use:enhance={() => async ({ update, result }) => {
|
||||
await update();
|
||||
if (result.type === 'success') { resettingId = null; resetPw = ''; }
|
||||
}}
|
||||
class="flex items-center justify-end gap-2">
|
||||
<input type="hidden" name="user_id" value={u.userId} />
|
||||
<input name="password" type="text" required minlength="8" placeholder="new password" bind:value={resetPw}
|
||||
class="w-40 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<button type="submit" class="rounded bg-primary-600 px-2 py-0.5 text-xs font-medium text-white hover:bg-primary-700">set</button>
|
||||
<button type="button" onclick={() => { resettingId = null; resetPw = ''; }} class="text-xs text-gray-500">×</button>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="flex justify-end gap-2 text-xs">
|
||||
<button type="button" onclick={() => { renamingId = u.userId; renameValue = u.displayName; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">rename</button>
|
||||
<button type="button" onclick={() => { resettingId = u.userId; resetPw = ''; }} class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">reset pw</button>
|
||||
{#if !isSelf}
|
||||
<form method="post" action="?/setActive" use:enhance class="inline">
|
||||
<input type="hidden" name="user_id" value={u.userId} />
|
||||
<input type="hidden" name="active" value={(!u.isActive).toString()} />
|
||||
<button type="submit" class="text-gray-400 hover:text-amber-600 dark:hover:text-amber-400">{u.isActive ? 'deactivate' : 'reactivate'}</button>
|
||||
</form>
|
||||
<form method="post" action="?/remove" use:enhance class="inline">
|
||||
<input type="hidden" name="user_id" value={u.userId} />
|
||||
<button type="submit" class="text-gray-400 hover:text-red-600 dark:hover:text-red-400">remove</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from '$lib/server/auth/guards';
|
||||
import {
|
||||
createUserAndAddToCompany,
|
||||
type CompanyRole
|
||||
} from '$lib/server/services/users';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
const Schema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
display_name: z.string().trim().min(1).max(255),
|
||||
password: z.string().min(8).max(256),
|
||||
role: z.enum(['admin', 'manager', 'user', 'viewer'])
|
||||
});
|
||||
|
||||
export const load = async ({ locals }: { locals: App.Locals }) => {
|
||||
requireAdmin(locals);
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
const { company } = requireAdmin(locals);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = Schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input', values: raw });
|
||||
}
|
||||
const v = parsed.data;
|
||||
try {
|
||||
await createUserAndAddToCompany({
|
||||
companyId: company.id,
|
||||
email: v.email,
|
||||
displayName: v.display_name,
|
||||
password: v.password,
|
||||
role: v.role as CompanyRole
|
||||
});
|
||||
throw redirect(303, '/admin/users');
|
||||
} catch (e) {
|
||||
if (isRedirect(e) || isHttpError(e)) throw e;
|
||||
return fail(400, { error: (e as Error).message, values: raw });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { COMPANY_ROLES, COMPANY_ROLE_DESCRIPTION, COMPANY_ROLE_LABEL } from '$lib/roles';
|
||||
import type { ActionData } from './$types';
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
let saving = $state(false);
|
||||
const v = $derived((form?.values ?? {}) as Record<string, string>);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-xl space-y-6">
|
||||
<div>
|
||||
<a href="/admin/users" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to users</a>
|
||||
<h1 class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100">Invite user</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Creates the user (or reuses an existing one with the same email) and adds them to this company.
|
||||
Share the temporary password out-of-band; they can change it after logging in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return ({ update }) => update().finally(() => (saving = false));
|
||||
}}
|
||||
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Email <span class="text-red-500">*</span></span>
|
||||
<input name="email" type="email" required value={v.email ?? ''} autocomplete="off"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Display name <span class="text-red-500">*</span></span>
|
||||
<input name="display_name" required value={v.display_name ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Temporary password <span class="text-red-500">*</span></span>
|
||||
<input name="password" type="text" required minlength="8" autocomplete="off"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-mono shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<p class="mt-1 text-xs text-gray-400">Minimum 8 characters. Share with the user via a secure channel.</p>
|
||||
</label>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Role <span class="text-red-500">*</span></span>
|
||||
<div class="mt-1 space-y-2">
|
||||
{#each COMPANY_ROLES as r}
|
||||
<label class="flex items-start gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm hover:border-primary-300 dark:border-gray-700 dark:hover:border-primary-700">
|
||||
<input type="radio" name="role" value={r} required checked={r === 'user'}
|
||||
class="mt-0.5 h-4 w-4 border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
|
||||
<div>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">{COMPANY_ROLE_LABEL[r]}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{COMPANY_ROLE_DESCRIPTION[r]}</div>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<a href="/admin/users" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
|
||||
<button type="submit" disabled={saving}
|
||||
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
|
||||
{saving ? 'Creating…' : 'Create user'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { and, asc, isNull, or, sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { assetTypes } from '$lib/server/db/schema/assets';
|
||||
import { listAssets } from '$lib/server/services/assets';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const typeSlug = url.searchParams.get('type') ?? undefined;
|
||||
const q = url.searchParams.get('q') ?? undefined;
|
||||
|
||||
const types = await db
|
||||
.select({ id: assetTypes.id, name: assetTypes.name, slug: assetTypes.slug })
|
||||
.from(assetTypes)
|
||||
.where(or(isNull(assetTypes.companyId), sql`${assetTypes.companyId} = ${locals.company.id}`)!)
|
||||
.orderBy(asc(assetTypes.name));
|
||||
|
||||
const assets = await listAssets({
|
||||
companyId: locals.company.id,
|
||||
typeSlug,
|
||||
q,
|
||||
limit: 200
|
||||
});
|
||||
|
||||
return { assets, types, filterType: typeSlug ?? '', filterQ: q ?? '' };
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Assets</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Everything you track — switches, ACs, filters, sensors, generators…
|
||||
</p>
|
||||
</div>
|
||||
<a href="/assets/new"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
|
||||
+ New asset
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="get" class="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.filterQ}
|
||||
placeholder="Search by name, tag, or serial…"
|
||||
class="block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 sm:max-w-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
<select name="type" class="block rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100">
|
||||
<option value="">All types</option>
|
||||
{#each data.types as t}
|
||||
<option value={t.slug} selected={data.filterType === t.slug}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">Filter</button>
|
||||
<a href="/assets/export.csv?{new URLSearchParams({ q: data.filterQ, type: data.filterType }).toString()}"
|
||||
class="self-center text-sm text-gray-600 hover:text-primary-600 dark:text-gray-400 dark:hover:text-primary-400">
|
||||
Export CSV →
|
||||
</a>
|
||||
</form>
|
||||
|
||||
{#if data.assets.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">No assets match.</p>
|
||||
<p class="mt-1">Adjust the filter above, or <a href="/assets/new" class="text-primary-600 hover:underline dark:text-primary-400">add a new asset</a>.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Type</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tag</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Serial</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.assets as a}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="/assets/{a.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{a.name}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.assetTypeName}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.tag ?? '—'}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">{a.serialNumber ?? '—'}</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">{new Date(a.updatedAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { assets } from '$lib/server/db/schema/assets';
|
||||
import { properties } from '$lib/server/db/schema/properties';
|
||||
import { projects } from '$lib/server/db/schema/projects';
|
||||
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
|
||||
import { loadTypeWithFields } from '$lib/server/services/assets';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
|
||||
const [asset] = await db
|
||||
.select()
|
||||
.from(assets)
|
||||
.where(
|
||||
and(
|
||||
eq(assets.id, params.id),
|
||||
eq(assets.companyId, locals.company.id),
|
||||
isNull(assets.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!asset) throw error(404, 'Asset not found');
|
||||
|
||||
const tf = await loadTypeWithFields(asset.assetTypeId);
|
||||
if (!tf) throw error(500, 'Asset type missing');
|
||||
|
||||
let currentLocationName: string | null = null;
|
||||
let currentLocationHref: string | null = null;
|
||||
let currentRoomLabel: string | null = null;
|
||||
if (asset.currentPropertyId) {
|
||||
const [p] = await db
|
||||
.select({ name: properties.name })
|
||||
.from(properties)
|
||||
.where(eq(properties.id, asset.currentPropertyId))
|
||||
.limit(1);
|
||||
currentLocationName = p?.name ?? null;
|
||||
currentLocationHref = `/properties/${asset.currentPropertyId}`;
|
||||
if (asset.currentRoomId) {
|
||||
const [r] = await db
|
||||
.select({
|
||||
name: propertyRooms.name,
|
||||
floorLabel: propertyFloors.label
|
||||
})
|
||||
.from(propertyRooms)
|
||||
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
|
||||
.where(eq(propertyRooms.id, asset.currentRoomId))
|
||||
.limit(1);
|
||||
if (r) {
|
||||
currentRoomLabel = r.floorLabel ? `Floor ${r.floorLabel} · ${r.name}` : r.name;
|
||||
}
|
||||
}
|
||||
} else if (asset.currentProjectId) {
|
||||
const [p] = await db
|
||||
.select({ name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, asset.currentProjectId))
|
||||
.limit(1);
|
||||
currentLocationName = p?.name ?? null;
|
||||
currentLocationHref = `/projects/${asset.currentProjectId}/assets`;
|
||||
}
|
||||
|
||||
return {
|
||||
asset,
|
||||
assetType: tf.type,
|
||||
fieldDefs: tf.fields,
|
||||
currentLocationName,
|
||||
currentLocationHref,
|
||||
currentRoomLabel
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import TabNav from '$lib/components/TabNav.svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
|
||||
const tabs = $derived([
|
||||
{ href: `/assets/${data.asset.id}`, label: 'Overview' },
|
||||
{ href: `/assets/${data.asset.id}/maintenance`, label: 'Maintenance' },
|
||||
{ href: `/assets/${data.asset.id}/history`, label: 'History' },
|
||||
{ href: `/assets/${data.asset.id}/logs`, label: 'Logs' },
|
||||
{ href: `/assets/${data.asset.id}/documents`, label: 'Documents' },
|
||||
{ href: `/assets/${data.asset.id}/move`, label: 'Move' }
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{data.assetType.name}
|
||||
</div>
|
||||
<h1 class="truncate text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{data.asset.name}
|
||||
</h1>
|
||||
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{#if data.currentLocationName && data.currentLocationHref}
|
||||
<span>at <a href={data.currentLocationHref} class="text-primary-600 hover:underline dark:text-primary-400">{data.currentLocationName}</a> <span class="text-gray-400">({data.asset.currentContainerKind})</span></span>
|
||||
{/if}
|
||||
{#if data.currentRoomLabel}
|
||||
<span>· {data.currentRoomLabel}</span>
|
||||
{/if}
|
||||
{#if data.asset.tag}<span>· tag <code class="font-mono text-xs">{data.asset.tag}</code></span>{/if}
|
||||
{#if data.asset.serialNumber}<span>· s/n <code class="font-mono text-xs">{data.asset.serialNumber}</code></span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<a href="/assets/{data.asset.id}/label"
|
||||
class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Print label
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<TabNav {tabs} />
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
|
||||
import { loadTypeWithFields, softDeleteAsset, updateAsset } from '$lib/server/services/assets';
|
||||
import { gatherCustomFieldsFromForm } from '$lib/server/custom-fields-form';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, parent }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const { asset } = await parent();
|
||||
let rooms: Array<{ id: string; name: string; floorLabel: string | null }> = [];
|
||||
if (asset.currentContainerKind === 'property' && asset.currentPropertyId) {
|
||||
rooms = await db
|
||||
.select({
|
||||
id: propertyRooms.id,
|
||||
name: propertyRooms.name,
|
||||
floorLabel: propertyFloors.label
|
||||
})
|
||||
.from(propertyRooms)
|
||||
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
|
||||
.where(
|
||||
and(
|
||||
eq(propertyRooms.propertyId, asset.currentPropertyId),
|
||||
isNull(propertyRooms.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name));
|
||||
}
|
||||
return { rooms };
|
||||
};
|
||||
|
||||
const PatchSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
tag: z.string().trim().max(64).optional().or(z.literal('')),
|
||||
serial_number: z.string().trim().max(128).optional().or(z.literal('')),
|
||||
manufacturer: z.string().trim().max(128).optional().or(z.literal('')),
|
||||
model: z.string().trim().max(128).optional().or(z.literal('')),
|
||||
purchased_at: z.string().trim().optional().or(z.literal('')),
|
||||
room_id: z.string().optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
const e2n = (s: string | undefined) => (!s ? null : s);
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = PatchSchema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
const v = parsed.data;
|
||||
|
||||
const tf = await loadTypeWithFields(form.get('asset_type_id') as string);
|
||||
if (!tf) return fail(400, { error: 'Asset type not found.' });
|
||||
const cf = gatherCustomFieldsFromForm(form, tf.fields);
|
||||
|
||||
// Room field is only included when a property asset is being edited.
|
||||
// Empty string = clear room; uuid = set; undefined = leave alone.
|
||||
const roomPatch: { roomId?: string | null } = {};
|
||||
if (form.has('room_id')) {
|
||||
roomPatch.roomId = v.room_id ? v.room_id : null;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAsset(locals.company.id, params.id, {
|
||||
name: v.name,
|
||||
tag: e2n(v.tag),
|
||||
serialNumber: e2n(v.serial_number),
|
||||
manufacturer: e2n(v.manufacturer),
|
||||
model: e2n(v.model),
|
||||
purchasedAt: v.purchased_at ? new Date(v.purchased_at) : null,
|
||||
customFields: cf,
|
||||
...roomPatch
|
||||
});
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
delete: async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
await softDeleteAsset(locals.company.id, params.id);
|
||||
throw redirect(303, '/assets');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import CustomFieldsForm from '$lib/components/CustomFieldsForm.svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let saving = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
const a = $derived(data.asset);
|
||||
|
||||
function dateInput(d: Date | string | null): string {
|
||||
if (!d) return '';
|
||||
const dt = typeof d === 'string' ? new Date(d) : d;
|
||||
if (Number.isNaN(dt.getTime())) return '';
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
action="?/save"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return ({ update }) => update().finally(() => (saving = false));
|
||||
}}
|
||||
class="space-y-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{:else if form?.ok}
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700 dark:border-emerald-700/50 dark:bg-emerald-900/20 dark:text-emerald-300">Saved.</div>
|
||||
{/if}
|
||||
|
||||
<input type="hidden" name="asset_type_id" value={a.assetTypeId} />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input id="name" name="name" required value={a.name}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Asset tag</label>
|
||||
<input id="tag" name="tag" value={a.tag ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="serial_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Serial number</label>
|
||||
<input id="serial_number" name="serial_number" value={a.serialNumber ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
|
||||
<input id="manufacturer" name="manufacturer" value={a.manufacturer ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
|
||||
<input id="model" name="model" value={a.model ?? ''}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="purchased_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Purchased on</label>
|
||||
<input id="purchased_at" name="purchased_at" type="date" value={dateInput(a.purchasedAt)}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
{#if a.currentContainerKind === 'property'}
|
||||
<div class="sm:col-span-2">
|
||||
<label for="room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room</label>
|
||||
<select id="room_id" name="room_id"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
<option value="">— no specific room —</option>
|
||||
{#each data.rooms as r}
|
||||
<option value={r.id} selected={a.currentRoomId === r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if data.rooms.length === 0}
|
||||
<p class="mt-1 text-xs text-gray-400">This property has no rooms yet. Add them from the property's Rooms tab.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">{data.assetType.name} details</div>
|
||||
<CustomFieldsForm defs={data.fieldDefs} values={a.customFields as Record<string, unknown>} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
|
||||
{confirmingDelete ? 'Cancel delete' : 'Delete asset…'}
|
||||
</button>
|
||||
<button type="submit" disabled={saving}
|
||||
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if confirmingDelete}
|
||||
<form method="post" action="?/delete"
|
||||
class="rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
<p class="font-medium">Delete this asset?</p>
|
||||
<p class="mt-1">Soft-deletes the asset; history and documents stay on disk.</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (confirmingDelete = false)} class="text-sm text-red-700 dark:text-red-300">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import {
|
||||
deleteDocument,
|
||||
listDocumentsForScope,
|
||||
signedUrlForDocument,
|
||||
uploadDocument
|
||||
} from '$lib/server/services/documents';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const docs = await listDocumentsForScope(locals.company.id, 'asset', params.id);
|
||||
const enriched = await Promise.all(
|
||||
docs.map(async (d) => ({
|
||||
...d,
|
||||
downloadUrl: await signedUrlForDocument(d, 'attachment'),
|
||||
previewUrl: await signedUrlForDocument(d, 'inline')
|
||||
}))
|
||||
);
|
||||
return { documents: enriched };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
upload: async ({ request, locals, params }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return fail(400, { error: 'Pick a file to upload.' });
|
||||
}
|
||||
if (file.size > MAX_BYTES) return fail(413, { error: 'File too large (max 50 MB).' });
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
try {
|
||||
await uploadDocument({
|
||||
companyId: locals.company.id,
|
||||
uploadedBy: locals.user.id,
|
||||
scopeType: 'asset',
|
||||
scopeId: params.id,
|
||||
filename: file.name || 'upload.bin',
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
body: buf
|
||||
});
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
await deleteDocument(locals.company.id, id);
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let uploading = $state(false);
|
||||
|
||||
function fmtSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<form
|
||||
method="post"
|
||||
action="?/upload"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={() => {
|
||||
uploading = true;
|
||||
return ({ update }) => update().finally(() => (uploading = false));
|
||||
}}
|
||||
class="rounded-lg border border-dashed border-gray-300 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input type="file" name="file" required
|
||||
class="block w-full text-sm text-gray-700 file:mr-3 file:rounded-md file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-900/30 dark:file:text-primary-300" />
|
||||
<button type="submit" disabled={uploading}
|
||||
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
|
||||
{uploading ? 'Uploading…' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
{#if form?.error}<p class="mt-2 text-sm text-red-600 dark:text-red-400">{form.error}</p>{/if}
|
||||
{#if form?.ok}<p class="mt-2 text-sm text-emerald-600 dark:text-emerald-400">Done.</p>{/if}
|
||||
</form>
|
||||
|
||||
{#if data.documents.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
No documents attached.
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#each data.documents as d}
|
||||
<li class="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<div class="min-w-0">
|
||||
<a href={d.previewUrl} target="_blank" rel="noopener" class="block truncate font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{d.filename}</a>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{d.mimeType} · {fmtSize(d.sizeBytes)} · {new Date(d.uploadedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<a href={d.downloadUrl} class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">Download</a>
|
||||
<form method="post" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="id" value={d.id} />
|
||||
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { aliasedTable, desc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { assetLocationHistory } from '$lib/server/db/schema/assets';
|
||||
import { properties } from '$lib/server/db/schema/properties';
|
||||
import { users } from '$lib/server/db/schema/tenancy';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const fromProp = aliasedTable(properties, 'from_prop');
|
||||
const toProp = aliasedTable(properties, 'to_prop');
|
||||
const rows = await db
|
||||
.select({
|
||||
id: assetLocationHistory.id,
|
||||
fromKind: assetLocationHistory.fromKind,
|
||||
fromPropertyName: fromProp.name,
|
||||
toKind: assetLocationHistory.toKind,
|
||||
toPropertyName: toProp.name,
|
||||
movedAt: assetLocationHistory.movedAt,
|
||||
movedByName: users.displayName,
|
||||
reason: assetLocationHistory.reason
|
||||
})
|
||||
.from(assetLocationHistory)
|
||||
.leftJoin(fromProp, eq(fromProp.id, assetLocationHistory.fromPropertyId))
|
||||
.leftJoin(toProp, eq(toProp.id, assetLocationHistory.toPropertyId))
|
||||
.leftJoin(users, eq(users.id, assetLocationHistory.movedBy))
|
||||
.where(eq(assetLocationHistory.assetId, params.id))
|
||||
.orderBy(desc(assetLocationHistory.movedAt));
|
||||
return { history: rows };
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#if data.history.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
No movements recorded yet.
|
||||
</div>
|
||||
{:else}
|
||||
<ol class="relative space-y-4 border-l border-gray-200 pl-4 dark:border-gray-700">
|
||||
{#each data.history as h}
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full bg-primary-500"></span>
|
||||
<div class="flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{h.fromKind ? h.fromPropertyName ?? '(unknown)' : '— created —'}
|
||||
</span>
|
||||
<span class="text-gray-400">→</span>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{h.toPropertyName ?? '(unknown)'}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(h.movedAt).toLocaleString()}
|
||||
{#if h.movedByName}· by {h.movedByName}{/if}
|
||||
</div>
|
||||
{#if h.reason}
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">{h.reason}</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { env } from '$lib/server/env';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Absolute URL is what scanners land on. The layout already loaded the asset,
|
||||
// so we only need to hand down the base URL (not available in the client bundle).
|
||||
return { publicBaseUrl: env.PUBLIC_BASE_URL.replace(/\/$/, '') };
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const scanUrl = $derived(`${data.publicBaseUrl}/assets/${data.asset.id}`);
|
||||
const qrSrc = $derived(`/api/qr?size=320&target=${encodeURIComponent(scanUrl)}`);
|
||||
|
||||
function doPrint() {
|
||||
if (typeof window !== 'undefined') window.print();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 print:space-y-0">
|
||||
<div class="flex items-center justify-between print:hidden">
|
||||
<a href="/assets/{data.asset.id}" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to asset</a>
|
||||
<button type="button" onclick={doPrint}
|
||||
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
|
||||
Print label
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printable card: centered, bounded so it fits on a typical inkjet label or half-A6 -->
|
||||
<div class="label-card mx-auto max-w-sm rounded-lg border border-gray-300 bg-white p-6 text-gray-900 print:m-0 print:border-0 print:shadow-none">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-[10px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
{data.assetType.name}
|
||||
</div>
|
||||
{#if data.asset.tag}
|
||||
<div class="font-mono text-xs text-gray-600">{data.asset.tag}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-base font-semibold leading-tight">{data.asset.name}</div>
|
||||
|
||||
{#if data.asset.manufacturer || data.asset.model}
|
||||
<div class="mt-0.5 text-xs text-gray-600">
|
||||
{[data.asset.manufacturer, data.asset.model].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.asset.serialNumber}
|
||||
<div class="mt-0.5 text-[11px] text-gray-500">s/n <span class="font-mono">{data.asset.serialNumber}</span></div>
|
||||
{/if}
|
||||
{#if data.currentLocationName}
|
||||
<div class="mt-0.5 text-[11px] text-gray-500">@ {data.currentLocationName}</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-3 flex items-center justify-center">
|
||||
<img src={qrSrc} alt="QR code for {data.asset.name}" class="h-48 w-48" />
|
||||
</div>
|
||||
|
||||
<div class="text-center font-mono text-[10px] break-all text-gray-500">{scanUrl}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
:global(body),
|
||||
:global(html) {
|
||||
background: white !important;
|
||||
}
|
||||
:global(aside),
|
||||
:global(header),
|
||||
:global(nav) {
|
||||
display: none !important;
|
||||
}
|
||||
:global(main > div) {
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
.label-card {
|
||||
box-shadow: none !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { assetLogs } from '$lib/server/db/schema/assets';
|
||||
import { users } from '$lib/server/db/schema/tenancy';
|
||||
import { appendAssetLog } from '$lib/server/services/assets';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: assetLogs.id,
|
||||
body: assetLogs.body,
|
||||
createdAt: assetLogs.createdAt,
|
||||
authorName: users.displayName
|
||||
})
|
||||
.from(assetLogs)
|
||||
.leftJoin(users, eq(users.id, assetLogs.authorId))
|
||||
.where(eq(assetLogs.assetId, params.id))
|
||||
.orderBy(desc(assetLogs.createdAt))
|
||||
.limit(200);
|
||||
return { logs: rows };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
add: async ({ request, locals, params }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const body = String(form.get('body') ?? '').trim();
|
||||
if (!body) return fail(400, { error: 'Write something first.' });
|
||||
try {
|
||||
await appendAssetLog(locals.company.id, params.id, locals.user.id, body);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let body = $state('');
|
||||
let posting = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<form
|
||||
method="post"
|
||||
action="?/add"
|
||||
use:enhance={() => {
|
||||
posting = true;
|
||||
return ({ update }) =>
|
||||
update().finally(() => {
|
||||
posting = false;
|
||||
body = '';
|
||||
});
|
||||
}}
|
||||
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<textarea
|
||||
name="body"
|
||||
bind:value={body}
|
||||
rows="3"
|
||||
placeholder="Add a note — observation, repair, change, anything…"
|
||||
class="block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
></textarea>
|
||||
{#if form?.error}
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{form.error}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" disabled={posting || !body.trim()}
|
||||
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
|
||||
{posting ? 'Posting…' : 'Post note'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if data.logs.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
No log entries yet.
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-3">
|
||||
{#each data.logs as l}
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{l.authorName ?? '(unknown)'} · {new Date(l.createdAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-100">{l.body}</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { listTemplates } from '$lib/server/services/checklists';
|
||||
import {
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
listEventsForAsset,
|
||||
listSchedulesForAsset,
|
||||
listUsageReadingsForAsset,
|
||||
recordMaintenanceEvent,
|
||||
recordUsageReading,
|
||||
setScheduleActive,
|
||||
type IntervalUnit,
|
||||
type ScheduleKind
|
||||
} from '$lib/server/services/maintenance';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const [schedules, events, readings, templates] = await Promise.all([
|
||||
listSchedulesForAsset(locals.company.id, params.id),
|
||||
listEventsForAsset(locals.company.id, params.id),
|
||||
listUsageReadingsForAsset(locals.company.id, params.id),
|
||||
listTemplates(locals.company.id)
|
||||
]);
|
||||
return {
|
||||
schedules,
|
||||
events,
|
||||
readings,
|
||||
templates
|
||||
};
|
||||
};
|
||||
|
||||
const ScheduleSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
kind: z.enum(['time', 'usage']),
|
||||
interval_value: z.coerce.number().int().positive(),
|
||||
interval_unit: z.enum(['days', 'months', 'years', 'hours', 'cycles', 'km']),
|
||||
checklist_template_id: z.string().uuid().optional().or(z.literal('')),
|
||||
start_from: z.string().optional().or(z.literal('')),
|
||||
start_usage: z.coerce.number().optional().or(z.literal('')),
|
||||
notes: z.string().trim().max(2000).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
export const actions: Actions = {
|
||||
createSchedule: async ({ request, locals, params }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = ScheduleSchema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
const v = parsed.data;
|
||||
try {
|
||||
await createSchedule({
|
||||
companyId: locals.company.id,
|
||||
createdBy: locals.user.id,
|
||||
assetId: params.id,
|
||||
name: v.name,
|
||||
kind: v.kind as ScheduleKind,
|
||||
intervalValue: v.interval_value,
|
||||
intervalUnit: v.interval_unit as IntervalUnit,
|
||||
startFrom: v.start_from ? new Date(v.start_from) : null,
|
||||
startUsage:
|
||||
typeof v.start_usage === 'number' ? v.start_usage : null,
|
||||
checklistTemplateId: v.checklist_template_id || null,
|
||||
notes: v.notes || null
|
||||
});
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
completeEvent: async ({ request, locals, params }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const scheduleId = String(form.get('schedule_id') ?? '');
|
||||
const performedAtStr = String(form.get('performed_at') ?? '').trim();
|
||||
const usageReadingStr = String(form.get('usage_reading') ?? '').trim();
|
||||
const notes = String(form.get('notes') ?? '').trim() || null;
|
||||
const instantiate = form.get('instantiate_checklist') === 'true';
|
||||
|
||||
if (!scheduleId) return fail(400, { error: 'Missing schedule_id' });
|
||||
|
||||
try {
|
||||
const { eventId, checklistInstanceId } = await recordMaintenanceEvent({
|
||||
companyId: locals.company.id,
|
||||
performedBy: locals.user.id,
|
||||
scheduleId,
|
||||
performedAt: performedAtStr ? new Date(performedAtStr) : new Date(),
|
||||
notes,
|
||||
usageReading: usageReadingStr ? Number(usageReadingStr) : null,
|
||||
instantiateChecklist: instantiate
|
||||
});
|
||||
if (checklistInstanceId) {
|
||||
throw redirect(303, `/assets/${params.id}/maintenance/events/${eventId}`);
|
||||
}
|
||||
return { ok: true, eventId };
|
||||
} catch (e) {
|
||||
if (isRedirect(e) || isHttpError(e)) throw e;
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
},
|
||||
addUsageReading: async ({ request, locals, params }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const reading = Number(form.get('reading') ?? '');
|
||||
const unit = String(form.get('unit') ?? '') as IntervalUnit;
|
||||
const notes = String(form.get('notes') ?? '').trim() || null;
|
||||
if (!Number.isFinite(reading)) return fail(400, { error: 'Reading must be a number.' });
|
||||
try {
|
||||
await recordUsageReading({
|
||||
companyId: locals.company.id,
|
||||
recordedBy: locals.user.id,
|
||||
assetId: params.id,
|
||||
reading,
|
||||
unit,
|
||||
notes
|
||||
});
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
toggleScheduleActive: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const scheduleId = String(form.get('schedule_id') ?? '');
|
||||
const active = form.get('active') === 'true';
|
||||
await setScheduleActive(locals.company.id, scheduleId, active);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteSchedule: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const scheduleId = String(form.get('schedule_id') ?? '');
|
||||
await deleteSchedule(locals.company.id, scheduleId);
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let showSchedForm = $state(false);
|
||||
let showUsageForm = $state(false);
|
||||
let kind = $state<'time' | 'usage'>('time');
|
||||
let openCompleteFor = $state<string | null>(null);
|
||||
|
||||
function statusFor(nextDueAt: Date | string | null | undefined): {
|
||||
label: string;
|
||||
cls: string;
|
||||
} {
|
||||
if (!nextDueAt) return { label: '—', cls: 'text-gray-400' };
|
||||
const d = new Date(nextDueAt);
|
||||
const ms = d.getTime() - Date.now();
|
||||
const days = Math.round(ms / (1000 * 60 * 60 * 24));
|
||||
if (days < 0)
|
||||
return { label: `${-days} day${-days === 1 ? '' : 's'} overdue`, cls: 'text-red-600 dark:text-red-400 font-medium' };
|
||||
if (days <= 7)
|
||||
return { label: `due in ${days} day${days === 1 ? '' : 's'}`, cls: 'text-amber-600 dark:text-amber-400 font-medium' };
|
||||
return { label: d.toLocaleDateString(), cls: 'text-gray-600 dark:text-gray-300' };
|
||||
}
|
||||
|
||||
const TIME_UNITS = ['days', 'months', 'years', 'hours'];
|
||||
const USAGE_UNITS = ['hours', 'cycles', 'km'];
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- ============== schedules ============== -->
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Schedules</h2>
|
||||
<button type="button" onclick={() => (showSchedForm = !showSchedForm)}
|
||||
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">
|
||||
{showSchedForm ? 'Cancel' : '+ New schedule'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showSchedForm}
|
||||
<form method="post" action="?/createSchedule" use:enhance
|
||||
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input id="name" name="name" required placeholder="e.g. Filter replacement"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="kind" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Kind</label>
|
||||
<select id="kind" name="kind" bind:value={kind}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
<option value="time">Time-based</option>
|
||||
<option value="usage">Usage-based</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="interval_value" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Every</label>
|
||||
<div class="mt-1 flex gap-2">
|
||||
<input id="interval_value" name="interval_value" type="number" min="1" required value="1"
|
||||
class="block w-24 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<select name="interval_unit"
|
||||
class="block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
{#each (kind === 'time' ? TIME_UNITS : USAGE_UNITS) as u}
|
||||
<option value={u}>{u}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{#if kind === 'time'}
|
||||
<div>
|
||||
<label for="start_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Anchor (optional)</label>
|
||||
<input id="start_from" name="start_from" type="date"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<p class="mt-1 text-xs text-gray-400">First service due = anchor + interval. Defaults to today.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label for="start_usage" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Current usage</label>
|
||||
<input id="start_usage" name="start_usage" type="number" step="any" placeholder="0"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
<p class="mt-1 text-xs text-gray-400">Next service due at this + interval.</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="sm:col-span-2">
|
||||
<label for="checklist_template_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Checklist template (optional)</label>
|
||||
<select id="checklist_template_id" name="checklist_template_id"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
<option value="">— none —</option>
|
||||
{#each data.templates as t}
|
||||
<option value={t.id}>{t.name} ({t.itemCount} items)</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
|
||||
<input id="notes" name="notes"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Create schedule</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.schedules.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-6 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
No schedules yet.
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each data.schedules as s}
|
||||
{@const st = statusFor(s.nextDueAt)}
|
||||
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800 {!s.active ? 'opacity-60' : ''}">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{s.name}</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
every {s.intervalValue} {s.intervalUnit}
|
||||
· {s.kind}
|
||||
{#if s.kind === 'time'}
|
||||
· next: <span class={st.cls}>{st.label}</span>
|
||||
{:else}
|
||||
· next at usage <span class="font-medium">{s.nextDueUsage ?? '—'} {s.intervalUnit}</span>
|
||||
{/if}
|
||||
{#if !s.active}
|
||||
· <span class="rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">inactive</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if s.notes}<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">{s.notes}</div>{/if}
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button type="button" onclick={() => (openCompleteFor = openCompleteFor === s.id ? null : s.id)}
|
||||
class="rounded-md bg-emerald-600 px-2 py-1 text-xs font-medium text-white hover:bg-emerald-700">
|
||||
{openCompleteFor === s.id ? 'Cancel' : 'Complete'}
|
||||
</button>
|
||||
<form method="post" action="?/toggleScheduleActive" use:enhance>
|
||||
<input type="hidden" name="schedule_id" value={s.id} />
|
||||
<input type="hidden" name="active" value={(!s.active).toString()} />
|
||||
<button type="submit" class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
{s.active ? 'Pause' : 'Resume'}
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="?/deleteSchedule" use:enhance>
|
||||
<input type="hidden" name="schedule_id" value={s.id} />
|
||||
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if openCompleteFor === s.id}
|
||||
<form method="post" action="?/completeEvent" use:enhance
|
||||
class="mt-3 space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<input type="hidden" name="schedule_id" value={s.id} />
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Performed at</span>
|
||||
<input name="performed_at" type="datetime-local"
|
||||
value={new Date().toISOString().slice(0, 16)}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
{#if s.kind === 'usage'}
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Current reading ({s.intervalUnit}) <span class="text-red-500">*</span></span>
|
||||
<input name="usage_reading" type="number" step="any" required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
||||
<input name="notes"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
{#if s.checklistTemplateId}
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" name="instantiate_checklist" value="true" checked
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
|
||||
Materialize the checklist for this event
|
||||
</label>
|
||||
{/if}
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700">Record event</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ============== usage readings ============== -->
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Usage readings</h2>
|
||||
<button type="button" onclick={() => (showUsageForm = !showUsageForm)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
{showUsageForm ? 'Cancel' : '+ Reading'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showUsageForm}
|
||||
<form method="post" action="?/addUsageReading" use:enhance
|
||||
class="grid gap-3 rounded-lg border border-gray-200 bg-white p-4 sm:grid-cols-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Reading</span>
|
||||
<input name="reading" type="number" step="any" required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Unit</span>
|
||||
<select name="unit" required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
{#each USAGE_UNITS as u}
|
||||
<option value={u}>{u}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="block text-xs font-medium text-gray-700 dark:text-gray-300">Notes</span>
|
||||
<input name="notes"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</label>
|
||||
<div class="sm:col-span-3 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Add reading</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.readings.length === 0}
|
||||
<p class="text-sm text-gray-500 italic dark:text-gray-400">No readings recorded.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#each data.readings as r}
|
||||
<li class="flex items-center justify-between px-4 py-2 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{r.reading}</span>
|
||||
<span class="ml-1 text-gray-500 dark:text-gray-400">{r.unit}</span>
|
||||
{#if r.notes}<span class="ml-3 text-xs text-gray-500 dark:text-gray-400">{r.notes}</span>{/if}
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{new Date(r.recordedAt).toLocaleString()}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ============== events history ============== -->
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Recent events</h2>
|
||||
{#if data.events.length === 0}
|
||||
<p class="text-sm text-gray-500 italic dark:text-gray-400">No events recorded.</p>
|
||||
{:else}
|
||||
<ol class="relative space-y-3 border-l border-gray-200 pl-4 dark:border-gray-700">
|
||||
{#each data.events as e}
|
||||
<li class="relative">
|
||||
<span class="absolute -left-[21px] top-1.5 inline-block h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<div class="text-sm">
|
||||
<a href="/assets/{data.asset.id}/maintenance/events/{e.id}" class="font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">
|
||||
{e.scheduleName ?? '(deleted schedule)'}
|
||||
</a>
|
||||
<span class="ml-2 text-xs text-gray-400">{new Date(e.performedAt).toLocaleString()}</span>
|
||||
{#if e.checklistInstanceId}
|
||||
<span class="ml-2 rounded-full bg-primary-100 px-2 py-0.5 text-[10px] font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">checklist</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if e.notes}<div class="mt-0.5 text-xs text-gray-600 dark:text-gray-400">{e.notes}</div>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { assets } from '$lib/server/db/schema/assets';
|
||||
import { maintenanceEvents, maintenanceSchedules } from '$lib/server/db/schema/maintenance';
|
||||
import { users } from '$lib/server/db/schema/tenancy';
|
||||
import { completeInstance, getInstance, setItemDone } from '$lib/server/services/checklists';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const [row] = await db
|
||||
.select({
|
||||
event: maintenanceEvents,
|
||||
scheduleName: maintenanceSchedules.name,
|
||||
performedByName: users.displayName
|
||||
})
|
||||
.from(maintenanceEvents)
|
||||
.leftJoin(maintenanceSchedules, eq(maintenanceSchedules.id, maintenanceEvents.scheduleId))
|
||||
.leftJoin(users, eq(users.id, maintenanceEvents.performedBy))
|
||||
.innerJoin(assets, eq(assets.id, maintenanceEvents.assetId))
|
||||
.where(
|
||||
and(
|
||||
eq(maintenanceEvents.id, params.eventId),
|
||||
eq(maintenanceEvents.assetId, params.id),
|
||||
eq(assets.companyId, locals.company.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) throw error(404, 'Event not found');
|
||||
|
||||
let checklist: Awaited<ReturnType<typeof getInstance>> | null = null;
|
||||
if (row.event.checklistInstanceId) {
|
||||
checklist = await getInstance(locals.company.id, row.event.checklistInstanceId);
|
||||
}
|
||||
|
||||
return {
|
||||
event: row.event,
|
||||
scheduleName: row.scheduleName,
|
||||
performedByName: row.performedByName,
|
||||
checklist
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
toggleItem: async ({ request, locals }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const instanceId = String(form.get('instance_id') ?? '');
|
||||
const itemId = String(form.get('item_id') ?? '');
|
||||
const done = form.get('done') === 'true';
|
||||
if (!instanceId || !itemId) return fail(400, { error: 'Missing ids' });
|
||||
try {
|
||||
await setItemDone(locals.company.id, instanceId, itemId, done, locals.user.id);
|
||||
} catch (e) {
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
completeChecklist: async ({ request, locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const instanceId = String(form.get('instance_id') ?? '');
|
||||
if (!instanceId) return fail(400, { error: 'Missing instance_id' });
|
||||
await completeInstance(locals.company.id, instanceId);
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a href="/assets/{data.asset.id}/maintenance" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← back to maintenance</a>
|
||||
<h2 class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{data.scheduleName ?? '(deleted schedule)'}
|
||||
</h2>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Performed {new Date(data.event.performedAt).toLocaleString()}
|
||||
{#if data.performedByName}· by {data.performedByName}{/if}
|
||||
{#if data.event.usageReading}· at {data.event.usageReading}{/if}
|
||||
</div>
|
||||
{#if data.event.notes}<p class="mt-2 text-sm text-gray-700 dark:text-gray-200">{data.event.notes}</p>{/if}
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.checklist}
|
||||
{@const inst = data.checklist.instance}
|
||||
{@const items = data.checklist.items}
|
||||
{@const remaining = items.filter((i) => i.required && !i.done).length}
|
||||
<div class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Checklist · {inst.title}</h3>
|
||||
{#if inst.completedAt}
|
||||
<p class="text-xs text-emerald-600 dark:text-emerald-400">Completed {new Date(inst.completedAt).toLocaleString()}</p>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{remaining} required item{remaining === 1 ? '' : 's'} remaining</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !inst.completedAt}
|
||||
<form method="post" action="?/completeChecklist" use:enhance>
|
||||
<input type="hidden" name="instance_id" value={inst.id} />
|
||||
<button type="submit" disabled={remaining > 0}
|
||||
class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
title={remaining > 0 ? 'Complete all required items first' : ''}>
|
||||
Mark checklist complete
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each items as item}
|
||||
<li class="flex items-start gap-3 py-2">
|
||||
<form method="post" action="?/toggleItem" use:enhance class="pt-0.5">
|
||||
<input type="hidden" name="instance_id" value={inst.id} />
|
||||
<input type="hidden" name="item_id" value={item.id} />
|
||||
<input type="hidden" name="done" value={(!item.done).toString()} />
|
||||
<button type="submit" aria-label={item.done ? 'Mark incomplete' : 'Mark complete'}
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded border {item.done ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-gray-300 dark:border-gray-600'}">
|
||||
{#if item.done}
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" class="h-3 w-3"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm {item.done ? 'text-gray-400 line-through dark:text-gray-500' : 'text-gray-900 dark:text-gray-100'}">{item.text}</div>
|
||||
{#if item.required}
|
||||
<span class="text-[10px] font-medium uppercase tracking-wider text-amber-600 dark:text-amber-400">required</span>
|
||||
{/if}
|
||||
{#if item.done && item.doneAt}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">done {new Date(item.doneAt).toLocaleString()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500 italic dark:text-gray-400">No checklist was attached to this event.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { properties } from '$lib/server/db/schema/properties';
|
||||
import { projects } from '$lib/server/db/schema/projects';
|
||||
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
|
||||
import { moveAsset } from '$lib/server/services/assets';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const companyId = locals.company.id;
|
||||
const [props, projs, rooms] = await Promise.all([
|
||||
db
|
||||
.select({ id: properties.id, name: properties.name })
|
||||
.from(properties)
|
||||
.where(and(eq(properties.companyId, companyId), isNull(properties.deletedAt)))
|
||||
.orderBy(asc(properties.name)),
|
||||
db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), isNull(projects.deletedAt)))
|
||||
.orderBy(asc(projects.name)),
|
||||
db
|
||||
.select({
|
||||
id: propertyRooms.id,
|
||||
propertyId: propertyRooms.propertyId,
|
||||
floorLabel: propertyFloors.label,
|
||||
name: propertyRooms.name
|
||||
})
|
||||
.from(propertyRooms)
|
||||
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
|
||||
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
|
||||
.where(
|
||||
and(
|
||||
eq(properties.companyId, companyId),
|
||||
isNull(propertyRooms.deletedAt),
|
||||
isNull(properties.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name))
|
||||
]);
|
||||
return { properties: props, projects: projs, rooms };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals, params }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const target = String(form.get('target') ?? '');
|
||||
const reason = String(form.get('reason') ?? '').trim() || null;
|
||||
const toRoomId = String(form.get('to_room_id') ?? '').trim() || null;
|
||||
const [kind, id] = target.split(':', 2);
|
||||
if ((kind !== 'property' && kind !== 'project') || !id) {
|
||||
return fail(400, { error: 'Pick a destination.' });
|
||||
}
|
||||
try {
|
||||
await moveAsset(locals.company.id, params.id, {
|
||||
toKind: kind,
|
||||
toId: id,
|
||||
movedBy: locals.user.id,
|
||||
reason,
|
||||
toRoomId: kind === 'property' ? toRoomId : null
|
||||
});
|
||||
throw redirect(303, `/assets/${params.id}/history`);
|
||||
} catch (e) {
|
||||
if (isRedirect(e) || isHttpError(e)) throw e;
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let moving = $state(false);
|
||||
let target = $state('');
|
||||
|
||||
const selectedPropertyId = $derived.by(() => {
|
||||
const [kind, id] = target.split(':', 2);
|
||||
return kind === 'property' ? id : '';
|
||||
});
|
||||
const roomsForProperty = $derived(
|
||||
selectedPropertyId ? data.rooms.filter((r) => r.propertyId === selectedPropertyId) : []
|
||||
);
|
||||
</script>
|
||||
|
||||
<form
|
||||
method="post"
|
||||
use:enhance={() => {
|
||||
moving = true;
|
||||
return ({ update }) => update().finally(() => (moving = false));
|
||||
}}
|
||||
class="space-y-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Currently at <strong>{data.currentLocationName ?? '(unknown)'}</strong>.
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="target" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Move to</label>
|
||||
<select id="target" name="target" required bind:value={target}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
<option value="">— pick destination —</option>
|
||||
{#if data.properties.length > 0}
|
||||
<optgroup label="Properties">
|
||||
{#each data.properties as p}
|
||||
<option value="property:{p.id}">{p.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
{#if data.projects.length > 0}
|
||||
<optgroup label="Projects">
|
||||
{#each data.projects as p}
|
||||
<option value="project:{p.id}">{p.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if selectedPropertyId}
|
||||
<div>
|
||||
<label for="to_room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room (optional)</label>
|
||||
<select id="to_room_id" name="to_room_id" disabled={roomsForProperty.length === 0}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
<option value="">— no specific room —</option>
|
||||
{#each roomsForProperty as r}
|
||||
<option value={r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if roomsForProperty.length === 0}
|
||||
<p class="mt-1 text-xs text-gray-400">The target property has no rooms yet.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Reason (optional)</label>
|
||||
<input id="reason" name="reason" placeholder="e.g. relocated to new rack, returned from repair…"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button type="submit" disabled={moving}
|
||||
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
|
||||
{moving ? 'Moving…' : 'Move asset'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { csvResponse, toCsv } from '$lib/server/csv';
|
||||
import { listAssets } from '$lib/server/services/assets';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const q = url.searchParams.get('q') ?? undefined;
|
||||
const typeSlug = url.searchParams.get('type') ?? undefined;
|
||||
const propertyId = url.searchParams.get('property') ?? undefined;
|
||||
const projectId = url.searchParams.get('project') ?? undefined;
|
||||
|
||||
const rows = await listAssets({
|
||||
companyId: locals.company.id,
|
||||
q,
|
||||
typeSlug,
|
||||
propertyId,
|
||||
projectId,
|
||||
limit: 10_000
|
||||
});
|
||||
|
||||
const body = toCsv(
|
||||
rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: r.assetTypeName,
|
||||
type_slug: r.assetTypeSlug,
|
||||
tag: r.tag,
|
||||
serial_number: r.serialNumber,
|
||||
manufacturer: r.manufacturer,
|
||||
model: r.model,
|
||||
container_kind: r.currentContainerKind,
|
||||
property_id: r.currentPropertyId,
|
||||
updated_at: r.updatedAt
|
||||
})),
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
'type',
|
||||
'type_slug',
|
||||
'tag',
|
||||
'serial_number',
|
||||
'manufacturer',
|
||||
'model',
|
||||
'container_kind',
|
||||
'property_id',
|
||||
'updated_at'
|
||||
]
|
||||
);
|
||||
|
||||
return csvResponse(`assets-${today()}.csv`, body);
|
||||
};
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { error, fail, isHttpError, isRedirect, redirect } from '@sveltejs/kit';
|
||||
import { and, asc, eq, isNull, or, sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db/client';
|
||||
import { assetTypes } from '$lib/server/db/schema/assets';
|
||||
import { properties } from '$lib/server/db/schema/properties';
|
||||
import { propertyFloors, propertyRooms } from '$lib/server/db/schema/rooms';
|
||||
import { createAsset, loadTypeWithFields } from '$lib/server/services/assets';
|
||||
import { gatherCustomFieldsFromForm } from '$lib/server/custom-fields-form';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const companyId = locals.company.id;
|
||||
const typeId = url.searchParams.get('type_id') ?? '';
|
||||
const propertyId = url.searchParams.get('property') ?? '';
|
||||
|
||||
const types = await db
|
||||
.select({
|
||||
id: assetTypes.id,
|
||||
name: assetTypes.name,
|
||||
slug: assetTypes.slug,
|
||||
icon: assetTypes.icon,
|
||||
description: assetTypes.description
|
||||
})
|
||||
.from(assetTypes)
|
||||
.where(or(isNull(assetTypes.companyId), sql`${assetTypes.companyId} = ${companyId}`)!)
|
||||
.orderBy(asc(assetTypes.name));
|
||||
|
||||
const props = await db
|
||||
.select({ id: properties.id, name: properties.name })
|
||||
.from(properties)
|
||||
.where(and(eq(properties.companyId, companyId), isNull(properties.deletedAt)))
|
||||
.orderBy(asc(properties.name));
|
||||
|
||||
// All rooms across all properties in this company — the client filters by
|
||||
// selected property. Lightweight; one round-trip instead of a per-select fetch.
|
||||
const rooms = await db
|
||||
.select({
|
||||
id: propertyRooms.id,
|
||||
propertyId: propertyRooms.propertyId,
|
||||
floorLabel: propertyFloors.label,
|
||||
name: propertyRooms.name
|
||||
})
|
||||
.from(propertyRooms)
|
||||
.leftJoin(propertyFloors, eq(propertyFloors.id, propertyRooms.floorId))
|
||||
.innerJoin(properties, eq(properties.id, propertyRooms.propertyId))
|
||||
.where(
|
||||
and(
|
||||
eq(properties.companyId, companyId),
|
||||
isNull(propertyRooms.deletedAt),
|
||||
isNull(properties.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(propertyFloors.order), asc(propertyFloors.label), asc(propertyRooms.name));
|
||||
|
||||
let typeWithFields = null;
|
||||
if (typeId) {
|
||||
typeWithFields = await loadTypeWithFields(typeId);
|
||||
if (!typeWithFields) throw error(404, 'Asset type not found');
|
||||
}
|
||||
|
||||
return {
|
||||
types,
|
||||
properties: props,
|
||||
rooms,
|
||||
selectedTypeId: typeId,
|
||||
selectedPropertyId: propertyId,
|
||||
typeWithFields
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const companyId = locals.company.id;
|
||||
|
||||
const form = await request.formData();
|
||||
const assetTypeId = String(form.get('asset_type_id') ?? '');
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const tag = (String(form.get('tag') ?? '').trim() || null) as string | null;
|
||||
const serialNumber = (String(form.get('serial_number') ?? '').trim() || null) as
|
||||
| string
|
||||
| null;
|
||||
const manufacturer = (String(form.get('manufacturer') ?? '').trim() || null) as string | null;
|
||||
const model = (String(form.get('model') ?? '').trim() || null) as string | null;
|
||||
const purchasedAtStr = String(form.get('purchased_at') ?? '').trim();
|
||||
const propertyId = String(form.get('property_id') ?? '').trim();
|
||||
const roomId = String(form.get('room_id') ?? '').trim() || null;
|
||||
|
||||
if (!assetTypeId) return fail(400, { error: 'Pick an asset type first.' });
|
||||
if (!name) return fail(400, { error: 'Name is required.' });
|
||||
if (!propertyId) return fail(400, { error: 'Pick a property to place this asset at.' });
|
||||
|
||||
const tf = await loadTypeWithFields(assetTypeId);
|
||||
if (!tf) return fail(400, { error: 'Asset type not found.' });
|
||||
|
||||
const cf = gatherCustomFieldsFromForm(form, tf.fields);
|
||||
|
||||
try {
|
||||
const { id } = await createAsset({
|
||||
companyId,
|
||||
createdBy: locals.user.id,
|
||||
assetTypeId,
|
||||
name,
|
||||
tag,
|
||||
serialNumber,
|
||||
manufacturer,
|
||||
model,
|
||||
purchasedAt: purchasedAtStr ? new Date(purchasedAtStr) : null,
|
||||
containerKind: 'property',
|
||||
containerId: propertyId,
|
||||
roomId,
|
||||
customFields: cf
|
||||
});
|
||||
throw redirect(303, `/assets/${id}`);
|
||||
} catch (e) {
|
||||
if (isRedirect(e) || isHttpError(e)) throw e;
|
||||
return fail(400, { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import CustomFieldsForm from '$lib/components/CustomFieldsForm.svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let submitting = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let selectedPropertyId = $state(data.selectedPropertyId ?? '');
|
||||
const roomsForProperty = $derived(
|
||||
selectedPropertyId
|
||||
? data.rooms.filter((r) => r.propertyId === selectedPropertyId)
|
||||
: []
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">New asset</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Pick a type to get the right typed fields, then fill in the rest.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !data.typeWithFields}
|
||||
<!-- Step 1: pick an asset type -->
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.types as t}
|
||||
<a
|
||||
href="/assets/new?type_id={t.id}{data.selectedPropertyId ? `&property=${data.selectedPropertyId}` : ''}"
|
||||
class="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:border-primary-300 hover:shadow dark:border-gray-700 dark:bg-gray-800 dark:hover:border-primary-700"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{t.name}</div>
|
||||
{#if t.description}
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{t.description}</div>
|
||||
{:else}
|
||||
<div class="mt-1 text-xs text-gray-400 dark:text-gray-500">{t.slug}</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Step 2: typed form -->
|
||||
<form
|
||||
method="post"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return ({ update }) => update().finally(() => (submitting = false));
|
||||
}}
|
||||
class="space-y-6 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-200 pb-3 dark:border-gray-700">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wider text-gray-400 dark:text-gray-500">Asset type</div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{data.typeWithFields.type.name}</div>
|
||||
</div>
|
||||
<a href="/assets/new" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Change</a>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="asset_type_id" value={data.typeWithFields.type.id} />
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name <span class="text-red-500">*</span></label>
|
||||
<input id="name" name="name" required
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="property_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Property <span class="text-red-500">*</span></label>
|
||||
<select id="property_id" name="property_id" required bind:value={selectedPropertyId}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
<option value="">— pick a property —</option>
|
||||
{#each data.properties as p}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="room_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Room</label>
|
||||
<select id="room_id" name="room_id" disabled={roomsForProperty.length === 0}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">
|
||||
<option value="">— no specific room —</option>
|
||||
{#each roomsForProperty as r}
|
||||
<option value={r.id}>{r.floorLabel ? `${r.floorLabel} · ${r.name}` : r.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selectedPropertyId && roomsForProperty.length === 0}
|
||||
<p class="mt-1 text-xs text-gray-400">This property has no rooms yet. Add them from the property's Rooms tab.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label for="tag" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Asset tag</label>
|
||||
<input id="tag" name="tag"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="serial_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Serial number</label>
|
||||
<input id="serial_number" name="serial_number"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="manufacturer" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Manufacturer</label>
|
||||
<input id="manufacturer" name="manufacturer"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Model</label>
|
||||
<input id="model" name="model"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="purchased_at" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Purchased on</label>
|
||||
<input id="purchased_at" name="purchased_at" type="date"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">{data.typeWithFields.type.name} details</div>
|
||||
<CustomFieldsForm defs={data.typeWithFields.fields} />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<a href="/assets" class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">Cancel</a>
|
||||
<button type="submit" disabled={submitting}
|
||||
class="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700 disabled:opacity-60">
|
||||
{submitting ? 'Creating…' : 'Create asset'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { z } from 'zod';
|
||||
import { createTemplate, listTemplates } from '$lib/server/services/checklists';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const templates = await listTemplates(locals.company.id);
|
||||
return { templates };
|
||||
};
|
||||
|
||||
const NewSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
description: z.string().trim().max(10_000).optional().or(z.literal(''))
|
||||
});
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals }) => {
|
||||
if (!locals.user || !locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const raw = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
const parsed = NewSchema.safeParse(raw);
|
||||
if (!parsed.success) return fail(400, { error: parsed.error.errors[0]?.message ?? 'Invalid input' });
|
||||
const { id } = await createTemplate({
|
||||
companyId: locals.company.id,
|
||||
createdBy: locals.user.id,
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description || null
|
||||
});
|
||||
throw redirect(303, `/checklists/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let creating = $state(false);
|
||||
let showForm = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Checklist templates</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Reusable checklists you can attach to maintenance, tasks, or anywhere ad-hoc.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => (showForm = !showForm)}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
|
||||
{showForm ? 'Cancel' : '+ New template'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<form method="post" action="?/create"
|
||||
use:enhance={() => {
|
||||
creating = true;
|
||||
return ({ update }) => update().finally(() => (creating = false));
|
||||
}}
|
||||
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-2 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input id="name" name="name" required placeholder="e.g. Quarterly AC service"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<textarea id="description" name="description" rows="2"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" disabled={creating}
|
||||
class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-60">
|
||||
{creating ? 'Creating…' : 'Create template'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.templates.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
No templates yet. Create one to attach it to a maintenance schedule.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Description</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Items</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.templates as t}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="/checklists/{t.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{t.name}</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400 truncate">{t.description ?? '—'}</td>
|
||||
<td class="px-4 py-2 text-right text-sm text-gray-700 dark:text-gray-300">{t.itemCount}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import {
|
||||
addTemplateItem,
|
||||
deleteTemplate,
|
||||
getTemplate,
|
||||
removeTemplateItem,
|
||||
updateTemplate
|
||||
} from '$lib/server/services/checklists';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const tpl = await getTemplate(locals.company.id, params.id);
|
||||
if (!tpl) throw error(404, 'Template not found');
|
||||
return { template: tpl.template, items: tpl.items };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveMeta: async ({ request, locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim() || null;
|
||||
if (!name) return fail(400, { error: 'Name is required.' });
|
||||
await updateTemplate(locals.company.id, params.id, { name, description });
|
||||
return { ok: true };
|
||||
},
|
||||
addItem: async ({ request, locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const text = String(form.get('text') ?? '').trim();
|
||||
const required = form.get('required') === 'true';
|
||||
if (!text) return fail(400, { error: 'Item text is required.' });
|
||||
await addTemplateItem(locals.company.id, params.id, text, required);
|
||||
return { ok: true };
|
||||
},
|
||||
removeItem: async ({ request, locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
const form = await request.formData();
|
||||
const itemId = String(form.get('item_id') ?? '');
|
||||
if (!itemId) return fail(400, { error: 'Missing item_id' });
|
||||
await removeTemplateItem(locals.company.id, params.id, itemId);
|
||||
return { ok: true };
|
||||
},
|
||||
delete: async ({ locals, params }) => {
|
||||
if (!locals.company) throw error(401);
|
||||
await deleteTemplate(locals.company.id, params.id);
|
||||
throw redirect(303, '/checklists');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let confirmingDelete = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<a href="/checklists" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← all templates</a>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700/50 dark:bg-red-900/20 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form method="post" action="?/saveMeta" use:enhance
|
||||
class="space-y-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
||||
<input id="name" name="name" required value={data.template.name}
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<textarea id="description" name="description" rows="2"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100">{data.template.description ?? ''}</textarea>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Items</h2>
|
||||
{#if data.items.length === 0}
|
||||
<p class="text-sm text-gray-500 italic dark:text-gray-400">No items yet.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#each data.items as item, i}
|
||||
<li class="flex items-center justify-between gap-3 px-4 py-2 text-sm">
|
||||
<div class="min-w-0">
|
||||
<span class="mr-2 inline-block w-6 text-right text-xs text-gray-400">{i + 1}.</span>
|
||||
<span class="text-gray-900 dark:text-gray-100">{item.text}</span>
|
||||
{#if item.required}
|
||||
<span class="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">required</span>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="post" action="?/removeItem" use:enhance>
|
||||
<input type="hidden" name="item_id" value={item.id} />
|
||||
<button type="submit" class="rounded-md border border-red-300 px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700/50 dark:text-red-300 dark:hover:bg-red-900/20">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<form method="post" action="?/addItem" use:enhance
|
||||
class="flex flex-col gap-2 rounded-lg border border-dashed border-gray-300 bg-white p-3 sm:flex-row sm:items-end dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex-1">
|
||||
<label for="text" class="block text-xs font-medium text-gray-500 dark:text-gray-400">New item</label>
|
||||
<input id="text" name="text" required placeholder="e.g. Replace filter"
|
||||
class="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" name="required" value="true" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-900" />
|
||||
required
|
||||
</label>
|
||||
<button type="submit" class="rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button type="button" onclick={() => (confirmingDelete = !confirmingDelete)} class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">
|
||||
{confirmingDelete ? 'Cancel delete' : 'Delete template…'}
|
||||
</button>
|
||||
{#if confirmingDelete}
|
||||
<form method="post" action="?/delete" class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-sm text-red-800 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
<p>Hard-delete this template. Existing checklist instances created from it will keep their items but lose the template link.</p>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { listDueAndOverdue } from '$lib/server/services/maintenance';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const rows = await listDueAndOverdue({
|
||||
companyId: locals.company.id,
|
||||
limit: 200,
|
||||
upcomingDays: 60
|
||||
});
|
||||
const now = Date.now();
|
||||
const overdue = rows.filter((r) => r.nextDueAt && new Date(r.nextDueAt).getTime() < now);
|
||||
const upcoming = rows.filter((r) => r.nextDueAt && new Date(r.nextDueAt).getTime() >= now);
|
||||
return { overdue, upcoming };
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function dayDelta(d: Date | string): number {
|
||||
return Math.round((new Date(d).getTime() - Date.now()) / 86400000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Maintenance</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Time-based schedules across every asset, sorted by next-due. Usage-based schedules appear on each asset's own maintenance tab.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/maintenance/export.csv" class="shrink-0 rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">Export CSV</a>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">Overdue ({data.overdue.length})</h2>
|
||||
{#if data.overdue.length === 0}
|
||||
<p class="text-sm text-gray-500 italic dark:text-gray-400">Nothing overdue. Nice.</p>
|
||||
{:else}
|
||||
<ul class="overflow-hidden rounded-lg border border-red-200 bg-white dark:border-red-700/50 dark:bg-gray-800">
|
||||
{#each data.overdue as r}
|
||||
{@const d = dayDelta(r.nextDueAt!)}
|
||||
<li class="flex items-center justify-between gap-3 border-b border-gray-100 px-4 py-2 last:border-0 dark:border-gray-700">
|
||||
<div class="min-w-0">
|
||||
<a href="/assets/{r.assetId}/maintenance" class="text-sm font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{r.assetName}</a>
|
||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">— {r.scheduleName}</span>
|
||||
</div>
|
||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{-d} day{-d === 1 ? '' : 's'} overdue
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Upcoming ({data.upcoming.length})</h2>
|
||||
{#if data.upcoming.length === 0}
|
||||
<p class="text-sm text-gray-500 italic dark:text-gray-400">No schedules due in the next 60 days.</p>
|
||||
{:else}
|
||||
<ul class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
{#each data.upcoming as r}
|
||||
{@const d = dayDelta(r.nextDueAt!)}
|
||||
<li class="flex items-center justify-between gap-3 border-b border-gray-100 px-4 py-2 last:border-0 dark:border-gray-700">
|
||||
<div class="min-w-0">
|
||||
<a href="/assets/{r.assetId}/maintenance" class="text-sm font-medium text-gray-900 hover:text-primary-600 dark:text-gray-100 dark:hover:text-primary-400">{r.assetName}</a>
|
||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">— {r.scheduleName}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">in {d} day{d === 1 ? '' : 's'} ({new Date(r.nextDueAt!).toLocaleDateString()})</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { csvResponse, toCsv } from '$lib/server/csv';
|
||||
import { listDueAndOverdue } from '$lib/server/services/maintenance';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const upcomingDays = Number.parseInt(url.searchParams.get('days') ?? '60', 10) || 60;
|
||||
const rows = await listDueAndOverdue({
|
||||
companyId: locals.company.id,
|
||||
limit: 10_000,
|
||||
upcomingDays
|
||||
});
|
||||
|
||||
const body = toCsv(
|
||||
rows.map((r) => ({
|
||||
schedule_id: r.scheduleId,
|
||||
schedule_name: r.scheduleName,
|
||||
asset_id: r.assetId,
|
||||
asset_name: r.assetName,
|
||||
next_due_at: r.nextDueAt,
|
||||
interval: `${r.intervalValue} ${r.intervalUnit}`
|
||||
})),
|
||||
['schedule_id', 'schedule_name', 'asset_id', 'asset_name', 'next_due_at', 'interval']
|
||||
);
|
||||
|
||||
return csvResponse(`maintenance-${today()}.csv`, body);
|
||||
};
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { requireCompany } from '$lib/server/auth/guards';
|
||||
import { listForUser, markAllRead, markRead } from '$lib/server/services/notifications';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { user, company } = requireCompany(locals);
|
||||
const items = await listForUser(user.id, company.id, 200);
|
||||
return { notifications: items };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
read: async ({ request, locals }) => {
|
||||
const { user } = requireCompany(locals);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
if (!id) return fail(400, { error: 'Missing id' });
|
||||
await markRead(user.id, [id]);
|
||||
return { ok: true };
|
||||
},
|
||||
readAll: async ({ locals }) => {
|
||||
const { user, company } = requireCompany(locals);
|
||||
await markAllRead(user.id, company.id);
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { NOTIFICATION_KIND_LABEL, type NotificationKind } from '$lib/notifications';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const unread = $derived(data.notifications.filter((n) => n.readAt === null).length);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Notifications</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{unread} unread · {data.notifications.length} total.
|
||||
<a href="/settings/notifications" class="ml-2 text-primary-600 hover:underline dark:text-primary-400">Channel settings</a>
|
||||
</p>
|
||||
</div>
|
||||
{#if unread > 0}
|
||||
<form method="post" action="?/readAll" use:enhance>
|
||||
<button type="submit" class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||
Mark all read
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.notifications.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
Nothing here yet — new in-app notifications will show up as soon as you're assigned a task or a decision is logged.
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-gray-200 overflow-hidden rounded-lg border border-gray-200 bg-white dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800">
|
||||
{#each data.notifications as n}
|
||||
<li class="flex gap-3 px-4 py-3 text-sm {n.readAt === null ? 'bg-primary-50/40 dark:bg-primary-900/10' : ''}">
|
||||
<div class="mt-0.5 shrink-0">
|
||||
{#if n.readAt === null}
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-primary-500" aria-label="Unread"></span>
|
||||
{:else}
|
||||
<span class="inline-block h-2 w-2 rounded-full bg-transparent"></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">
|
||||
{NOTIFICATION_KIND_LABEL[n.kind as NotificationKind] ?? n.kind}
|
||||
</span>
|
||||
<span>{new Date(n.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{n.title}</div>
|
||||
<div class="mt-0.5 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{n.body}</div>
|
||||
{#if n.link}
|
||||
<a href={n.link} class="mt-1 inline-block text-xs text-primary-600 hover:underline dark:text-primary-400">Open →</a>
|
||||
{/if}
|
||||
</div>
|
||||
{#if n.readAt === null}
|
||||
<form method="post" action="?/read" use:enhance class="shrink-0">
|
||||
<input type="hidden" name="id" value={n.id} />
|
||||
<button type="submit" class="text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200">mark read</button>
|
||||
</form>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { listProjects } from '$lib/server/services/projects';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (!locals.company) throw error(400, 'No active company');
|
||||
const rows = await listProjects(locals.company.id);
|
||||
return { projects: rows };
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Projects</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Construction sites, rollouts, retrofits — anything with work packages, tasks, and decisions.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/projects/new"
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary-600 px-3 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700">
|
||||
+ New project
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if data.projects.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">No projects yet.</p>
|
||||
<p class="mt-1">Create one to start tracking work packages, tasks, and decisions.</p>
|
||||
<a href="/projects/new" class="mt-4 inline-block text-primary-600 hover:underline dark:text-primary-400">Create a project →</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/40">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Name</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Code</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.projects as p}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td class="px-4 py-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
<a href="/projects/{p.id}" class="hover:text-primary-600 dark:hover:text-primary-400">{p.name}</a>
|
||||
{#if p.description}<div class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">{p.description}</div>{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-xs font-mono text-gray-500 dark:text-gray-400">{p.code ?? '—'}</td>
|
||||
<td class="px-4 py-2 text-xs">
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-200">{p.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-xs text-gray-400 dark:text-gray-500">{new Date(p.updatedAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user