feat: add room and service management

This commit is contained in:
2026-06-05 23:25:44 +01:00
parent 631a5112f4
commit f3e4f42272
15 changed files with 1281 additions and 0 deletions
@@ -0,0 +1,95 @@
import { asc, eq, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db';
import { services } from '$lib/server/db/schema';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { serviceCreateSchema, serviceEditSchema } from '$lib/schemas/services.schema';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const records = await db
.select()
.from(services)
.where(isNull(services.archivedAt))
.orderBy(asc(services.name));
return {
records: records.map((record) => ({
...record,
formValues: {
id: record.id,
name: record.name ?? '',
category: record.category ?? '',
unit: record.unit ?? '',
priceGbp: record.priceGbp ?? '',
description: record.description ?? ''
}
})),
createForm: await superValidate(zod4(serviceCreateSchema), { id: 'services-create' }),
editForm: await superValidate(zod4(serviceEditSchema), { id: 'services-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'services-archive' })
};
};
export const actions: Actions = {
create: async (event) => {
const form = await superValidate(event, zod4(serviceCreateSchema), { id: 'services-create' });
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
try {
await db.insert(services).values({
id: crypto.randomUUID(),
name: form.data.name,
category: form.data.category || null,
unit: form.data.unit,
priceGbp: form.data.priceGbp,
description: form.data.description || null,
updatedAt: new Date(),
createdAt: new Date()
});
} catch {
return message(form, 'Unable to create service.', { status: 400 });
}
return message(form, 'Service created.');
},
edit: async (event) => {
const form = await superValidate(event, zod4(serviceEditSchema), { id: 'services-edit' });
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
try {
await db
.update(services)
.set({
name: form.data.name,
category: form.data.category || null,
unit: form.data.unit,
priceGbp: form.data.priceGbp,
description: form.data.description || null,
updatedAt: new Date()
})
.where(eq(services.id, form.data.id));
} catch {
return message(form, 'Unable to update service.', { status: 400 });
}
return message(form, 'Service updated.');
},
archive: async (event) => {
const form = await superValidate(event, zod4(archiveSchema), { id: 'services-archive' });
if (!form.valid) return message(form, 'Service id is required.', { status: 400 });
await db
.update(services)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(eq(services.id, form.data.id));
return message(form, 'Service archived.');
}
};
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import CreateServiceDialog from './create-service-dialog.svelte';
import ServicesTable from './services-table.svelte';
import EditServiceDialog from './edit-service-dialog.svelte';
import ArchiveServiceDialog from './archive-service-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { serviceCreateSchema, serviceEditSchema } from '$lib/schemas/services.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(serviceCreateSchema),
onUpdated: ({ form }) => handleFormToast(form, 'services-create', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'services-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(serviceEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'services-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'services-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'services-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'services-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this service'
);
}
</script>
<svelte:head>
<title>Services | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Services</h1>
<p class="text-sm text-muted-foreground">Manage billable services and operational add-ons.</p>
</div>
<CreateServiceDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ServicesTable {data} {openEdit} {openArchive} {formatValue} {formatMoney} {recordName} />
</div>
<EditServiceDialog
{editingRecord}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveServiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive service?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active services. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive service</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,96 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create service</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create service</Dialog.Title>
<Dialog.Description>Add a new service record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="name">
<Field.Field>
<Control id="create-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="category">
<Field.Field>
<Control id="create-category">
{#snippet children({ props })}
<Field.Label>Category</Field.Label>
<Input {...props} type="text" bind:value={$createData.category} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="unit">
<Field.Field>
<Control id="create-unit">
{#snippet children({ props })}
<Field.Label>Unit</Field.Label>
<Input {...props} type="text" bind:value={$createData.unit} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={createForm}
data={createData}
name="priceGbp"
label="Price"
id="create-priceGbp"
/>
<FormField form={createForm} name="description">
<Field.Field>
<Control id="create-description">
{#snippet children({ props })}
<Field.Label>Description</Field.Label>
<Textarea {...props} bind:value={$createData.description} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Create service</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,95 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit service</Dialog.Title>
<Dialog.Description>Update this service record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="category">
<Field.Field>
<Control id="edit-category">
{#snippet children({ props })}
<Field.Label>Category</Field.Label>
<Input {...props} type="text" bind:value={$editData.category} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="unit">
<Field.Field>
<Control id="edit-unit">
{#snippet children({ props })}
<Field.Label>Unit</Field.Label>
<Input {...props} type="text" bind:value={$editData.unit} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={editForm}
data={editData}
name="priceGbp"
label="Price"
id="edit-priceGbp"
/>
<FormField form={editForm} name="description">
<Field.Field>
<Control id="edit-description">
{#snippet children({ props })}
<Field.Label>Description</Field.Label>
<Textarea {...props} bind:value={$editData.description} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,70 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openArchive,
formatValue,
formatMoney,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
formatMoney: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Category</Table.Head>
<Table.Head>Unit</Table.Head>
<Table.Head>Price</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.name)}</Table.Cell>
<Table.Cell class="">{formatValue(record.category)}</Table.Cell>
<Table.Cell class="">{formatValue(record.unit)}</Table.Cell>
<Table.Cell class="">{formatMoney(record.priceGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No services have been created yet.
</div>
{/if}