From f3e4f42272ffda6aac3d99adcd0221a6d2538491 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Fri, 5 Jun 2026 23:25:44 +0100 Subject: [PATCH] feat: add room and service management --- src/lib/schemas/rooms.schema.ts | 65 +++++++ src/lib/schemas/services.schema.ts | 12 ++ src/routes/dashboard/rooms/+page.server.ts | 163 ++++++++++++++++++ src/routes/dashboard/rooms/+page.svelte | 152 ++++++++++++++++ .../rooms/archive-room-dialog.svelte | 38 ++++ .../dashboard/rooms/create-room-dialog.svelte | 56 ++++++ .../dashboard/rooms/edit-room-dialog.svelte | 55 ++++++ .../dashboard/rooms/room-type-fields.svelte | 108 ++++++++++++ src/routes/dashboard/rooms/rooms-table.svelte | 103 +++++++++++ src/routes/dashboard/services/+page.server.ts | 95 ++++++++++ src/routes/dashboard/services/+page.svelte | 133 ++++++++++++++ .../services/archive-service-dialog.svelte | 40 +++++ .../services/create-service-dialog.svelte | 96 +++++++++++ .../services/edit-service-dialog.svelte | 95 ++++++++++ .../dashboard/services/services-table.svelte | 70 ++++++++ 15 files changed, 1281 insertions(+) create mode 100644 src/lib/schemas/rooms.schema.ts create mode 100644 src/lib/schemas/services.schema.ts create mode 100644 src/routes/dashboard/rooms/+page.server.ts create mode 100644 src/routes/dashboard/rooms/+page.svelte create mode 100644 src/routes/dashboard/rooms/archive-room-dialog.svelte create mode 100644 src/routes/dashboard/rooms/create-room-dialog.svelte create mode 100644 src/routes/dashboard/rooms/edit-room-dialog.svelte create mode 100644 src/routes/dashboard/rooms/room-type-fields.svelte create mode 100644 src/routes/dashboard/rooms/rooms-table.svelte create mode 100644 src/routes/dashboard/services/+page.server.ts create mode 100644 src/routes/dashboard/services/+page.svelte create mode 100644 src/routes/dashboard/services/archive-service-dialog.svelte create mode 100644 src/routes/dashboard/services/create-service-dialog.svelte create mode 100644 src/routes/dashboard/services/edit-service-dialog.svelte create mode 100644 src/routes/dashboard/services/services-table.svelte diff --git a/src/lib/schemas/rooms.schema.ts b/src/lib/schemas/rooms.schema.ts new file mode 100644 index 0000000..60f5e56 --- /dev/null +++ b/src/lib/schemas/rooms.schema.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { idSchema, moneyGbp, positiveInt, requiredText } from './shared.schema'; + +export const roomTypes = ['meeting_room', 'private_office', 'coworking_desk'] as const; + +const optionalPositiveInt = (label: string) => + z.preprocess((value) => (value === '' ? undefined : value), positiveInt(label).optional()); + +const typeFields = { + meeting_room: [ + 'sqFt', + 'internalPricePerHourGbp', + 'externalPricePerHourGbp', + 'internalPricePerDayGbp', + 'externalPricePerDayGbp', + 'maxAttendees' + ], + private_office: ['sqFt', 'workstations', 'pricePerMonthGbp'], + coworking_desk: [ + 'internalPricePerHourGbp', + 'externalPricePerHourGbp', + 'internalPricePerDayGbp', + 'externalPricePerDayGbp' + ] +} as const satisfies Record<(typeof roomTypes)[number], readonly string[]>; + +const fieldLabels: Record = { + sqFt: 'Sq ft.', + internalPricePerHourGbp: 'Internal price per hour', + externalPricePerHourGbp: 'External price per hour', + internalPricePerDayGbp: 'Internal price per day', + externalPricePerDayGbp: 'External price per day', + maxAttendees: 'Max attendees', + workstations: 'Number of workstations', + pricePerMonthGbp: 'Price per month' +}; + +export const roomCreateSchema = z + .object({ + name: requiredText('Room name'), + type: z.enum(roomTypes).default('meeting_room'), + sqFt: optionalPositiveInt('Sq ft.'), + internalPricePerHourGbp: moneyGbp, + externalPricePerHourGbp: moneyGbp, + internalPricePerDayGbp: moneyGbp, + externalPricePerDayGbp: moneyGbp, + maxAttendees: optionalPositiveInt('Max attendees'), + workstations: optionalPositiveInt('Number of workstations'), + pricePerMonthGbp: moneyGbp + }) + .superRefine((data, ctx) => { + for (const field of typeFields[data.type]) { + const value = data[field as keyof typeof data]; + + if (value === undefined || value === null || value === '') { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${fieldLabels[field]} is required.` + }); + } + } + }); + +export const roomEditSchema = roomCreateSchema.extend(idSchema.shape); diff --git a/src/lib/schemas/services.schema.ts b/src/lib/schemas/services.schema.ts new file mode 100644 index 0000000..d328851 --- /dev/null +++ b/src/lib/schemas/services.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema'; + +export const serviceCreateSchema = z.object({ + name: requiredText('Service name'), + category: optionalText(120), + unit: requiredText('Unit', 80).default('each'), + priceGbp: moneyGbp, + description: optionalText(1000) +}); + +export const serviceEditSchema = serviceCreateSchema.extend(idSchema.shape); diff --git a/src/routes/dashboard/rooms/+page.server.ts b/src/routes/dashboard/rooms/+page.server.ts new file mode 100644 index 0000000..51c8397 --- /dev/null +++ b/src/routes/dashboard/rooms/+page.server.ts @@ -0,0 +1,163 @@ +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 { rooms } from '$lib/server/db/schema'; +import { archiveSchema } from '$lib/schemas/shared.schema'; +import { roomCreateSchema, roomEditSchema } from '$lib/schemas/rooms.schema'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const records = await db + .select() + .from(rooms) + .where(isNull(rooms.archivedAt)) + .orderBy(asc(rooms.name)); + + return { + records: records.map((record) => ({ + ...record, + formValues: { + id: record.id, + name: record.name ?? '', + type: record.type ?? 'meeting_room', + sqFt: record.sqFt ?? '', + internalPricePerHourGbp: record.internalPricePerHourGbp ?? 0, + externalPricePerHourGbp: record.externalPricePerHourGbp ?? 0, + internalPricePerDayGbp: record.internalPricePerDayGbp ?? 0, + externalPricePerDayGbp: record.externalPricePerDayGbp ?? 0, + maxAttendees: record.maxAttendees ?? '', + workstations: record.workstations ?? '', + pricePerMonthGbp: record.pricePerMonthGbp ?? 0 + } + })), + createForm: await superValidate(zod4(roomCreateSchema), { id: 'rooms-create' }), + editForm: await superValidate(zod4(roomEditSchema), { id: 'rooms-edit' }), + archiveForm: await superValidate(zod4(archiveSchema), { id: 'rooms-archive' }) + }; +}; + +export const actions: Actions = { + create: async (event) => { + const form = await superValidate(event, zod4(roomCreateSchema), { id: 'rooms-create' }); + + if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); + + const sharedValues = { + name: form.data.name, + type: form.data.type, + sqFt: null, + internalPricePerHourGbp: null, + externalPricePerHourGbp: null, + internalPricePerDayGbp: null, + externalPricePerDayGbp: null, + maxAttendees: null, + workstations: null, + pricePerMonthGbp: null, + updatedAt: new Date() + }; + const roomValues = + form.data.type === 'meeting_room' + ? { + ...sharedValues, + sqFt: form.data.sqFt ?? null, + internalPricePerHourGbp: form.data.internalPricePerHourGbp, + externalPricePerHourGbp: form.data.externalPricePerHourGbp, + internalPricePerDayGbp: form.data.internalPricePerDayGbp, + externalPricePerDayGbp: form.data.externalPricePerDayGbp, + maxAttendees: form.data.maxAttendees ?? null + } + : form.data.type === 'private_office' + ? { + ...sharedValues, + sqFt: form.data.sqFt ?? null, + workstations: form.data.workstations ?? null, + pricePerMonthGbp: form.data.pricePerMonthGbp + } + : { + ...sharedValues, + internalPricePerHourGbp: form.data.internalPricePerHourGbp, + externalPricePerHourGbp: form.data.externalPricePerHourGbp, + internalPricePerDayGbp: form.data.internalPricePerDayGbp, + externalPricePerDayGbp: form.data.externalPricePerDayGbp + }; + + try { + await db.insert(rooms).values({ + id: crypto.randomUUID(), + ...roomValues, + createdAt: new Date() + }); + } catch { + return message(form, 'Unable to create room.', { status: 400 }); + } + + return message(form, 'Room created.'); + }, + + edit: async (event) => { + const form = await superValidate(event, zod4(roomEditSchema), { id: 'rooms-edit' }); + + if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); + + const sharedValues = { + name: form.data.name, + type: form.data.type, + sqFt: null, + internalPricePerHourGbp: null, + externalPricePerHourGbp: null, + internalPricePerDayGbp: null, + externalPricePerDayGbp: null, + maxAttendees: null, + workstations: null, + pricePerMonthGbp: null, + updatedAt: new Date() + }; + const roomValues = + form.data.type === 'meeting_room' + ? { + ...sharedValues, + sqFt: form.data.sqFt ?? null, + internalPricePerHourGbp: form.data.internalPricePerHourGbp, + externalPricePerHourGbp: form.data.externalPricePerHourGbp, + internalPricePerDayGbp: form.data.internalPricePerDayGbp, + externalPricePerDayGbp: form.data.externalPricePerDayGbp, + maxAttendees: form.data.maxAttendees ?? null + } + : form.data.type === 'private_office' + ? { + ...sharedValues, + sqFt: form.data.sqFt ?? null, + workstations: form.data.workstations ?? null, + pricePerMonthGbp: form.data.pricePerMonthGbp + } + : { + ...sharedValues, + internalPricePerHourGbp: form.data.internalPricePerHourGbp, + externalPricePerHourGbp: form.data.externalPricePerHourGbp, + internalPricePerDayGbp: form.data.internalPricePerDayGbp, + externalPricePerDayGbp: form.data.externalPricePerDayGbp + }; + + try { + await db.update(rooms).set(roomValues).where(eq(rooms.id, form.data.id)); + } catch { + return message(form, 'Unable to update room.', { status: 400 }); + } + + return message(form, 'Room updated.'); + }, + + archive: async (event) => { + const form = await superValidate(event, zod4(archiveSchema), { id: 'rooms-archive' }); + + if (!form.valid) return message(form, 'Room id is required.', { status: 400 }); + + await db + .update(rooms) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where(eq(rooms.id, form.data.id)); + + return message(form, 'Room archived.'); + } +}; diff --git a/src/routes/dashboard/rooms/+page.svelte b/src/routes/dashboard/rooms/+page.svelte new file mode 100644 index 0000000..58895ae --- /dev/null +++ b/src/routes/dashboard/rooms/+page.svelte @@ -0,0 +1,152 @@ + + + + Rooms | Clearity + + +
+
+
+

Rooms

+

+ Manage meeting rooms, private offices, and coworking desks. +

+
+ + +
+ + +
+ + (editingId = null)} +/> + + (archivingId = null)} +/> diff --git a/src/routes/dashboard/rooms/archive-room-dialog.svelte b/src/routes/dashboard/rooms/archive-room-dialog.svelte new file mode 100644 index 0000000..ff4704b --- /dev/null +++ b/src/routes/dashboard/rooms/archive-room-dialog.svelte @@ -0,0 +1,38 @@ + + + !open && onClose()}> + + + Archive room? + This will hide {recordName(archivingRecord)} from active rooms. The record will remain in the + database. + + + Cancel + {#if archivingRecord} +
+ + Archive room +
+ {/if} +
+
+
diff --git a/src/routes/dashboard/rooms/create-room-dialog.svelte b/src/routes/dashboard/rooms/create-room-dialog.svelte new file mode 100644 index 0000000..7fad6aa --- /dev/null +++ b/src/routes/dashboard/rooms/create-room-dialog.svelte @@ -0,0 +1,56 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + Create room + Add a new room record. + +
+ + + + {#snippet children({ props })} + Name + + {/snippet} + + + + + + + {#snippet child({ props })}{/snippet} + + + +
+
diff --git a/src/routes/dashboard/rooms/edit-room-dialog.svelte b/src/routes/dashboard/rooms/edit-room-dialog.svelte new file mode 100644 index 0000000..e01dc1c --- /dev/null +++ b/src/routes/dashboard/rooms/edit-room-dialog.svelte @@ -0,0 +1,55 @@ + + + !open && onClose()}> + + + Edit room + Update this room record. + + {#if editingRecord} +
+ + + + + {#snippet children({ props })} + Name + + {/snippet} + + + + + + + {#snippet child({ props })}{/snippet} + + + + {/if} +
+
diff --git a/src/routes/dashboard/rooms/room-type-fields.svelte b/src/routes/dashboard/rooms/room-type-fields.svelte new file mode 100644 index 0000000..622dd77 --- /dev/null +++ b/src/routes/dashboard/rooms/room-type-fields.svelte @@ -0,0 +1,108 @@ + + +{#snippet numberField(name: string, label: string, min = 0)} + + + + {#snippet children({ props })} + {label} + + {/snippet} + + + + +{/snippet} + + + + + {#snippet children({ props })} + + {/snippet} + + Type + + + {#each roomTypes as roomType (roomType.value)} + + {roomType.label} + + {/each} + + + + {@render numberField('sqFt', 'Sq ft.', 1)} +
+ {#each bookablePriceFields as field (field.name)} + + {/each} +
+ {@render numberField('maxAttendees', 'Max attendees', 1)} +
+ + + {@render numberField('sqFt', 'Sq ft.', 1)} + {@render numberField('workstations', 'Number of workstations', 1)} + + + + + {#each bookablePriceFields as field (field.name)} + + {/each} + +
+ +
+
diff --git a/src/routes/dashboard/rooms/rooms-table.svelte b/src/routes/dashboard/rooms/rooms-table.svelte new file mode 100644 index 0000000..3a90893 --- /dev/null +++ b/src/routes/dashboard/rooms/rooms-table.svelte @@ -0,0 +1,103 @@ + + +{#if data.records.length > 0} +
+ + + + Name + Type + Details + Pricing + Actions + + + + {#each data.records as record (record.id)} + + {formatValue(record.name)} + {formatRoomType(record.type)} + {details(record)} + {pricing(record)} + + + {#snippet child({ props })}{/snippet} + + openEdit(record)}>Edit + openArchive(record)} + >Archive + + + + + {/each} + + +
+{:else} +
+ No rooms have been created yet. +
+{/if} diff --git a/src/routes/dashboard/services/+page.server.ts b/src/routes/dashboard/services/+page.server.ts new file mode 100644 index 0000000..65d67b0 --- /dev/null +++ b/src/routes/dashboard/services/+page.server.ts @@ -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.'); + } +}; diff --git a/src/routes/dashboard/services/+page.svelte b/src/routes/dashboard/services/+page.svelte new file mode 100644 index 0000000..faa51e6 --- /dev/null +++ b/src/routes/dashboard/services/+page.svelte @@ -0,0 +1,133 @@ + + + + Services | Clearity + + +
+
+
+

Services

+

Manage billable services and operational add-ons.

+
+ + +
+ + +
+ + (editingId = null)} +/> + + (archivingId = null)} +/> diff --git a/src/routes/dashboard/services/archive-service-dialog.svelte b/src/routes/dashboard/services/archive-service-dialog.svelte new file mode 100644 index 0000000..bcfe6bc --- /dev/null +++ b/src/routes/dashboard/services/archive-service-dialog.svelte @@ -0,0 +1,40 @@ + + + !open && onClose()}> + + + Archive service? + This will hide {recordName(archivingRecord)} from active services. The record will remain in + the database. + + + Cancel + {#if archivingRecord} +
+ + Archive service +
+ {/if} +
+
+
diff --git a/src/routes/dashboard/services/create-service-dialog.svelte b/src/routes/dashboard/services/create-service-dialog.svelte new file mode 100644 index 0000000..1b724d7 --- /dev/null +++ b/src/routes/dashboard/services/create-service-dialog.svelte @@ -0,0 +1,96 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + Create service + Add a new service record. + +
+ + + + {#snippet children({ props })} + Name + + {/snippet} + + + + + + + + {#snippet children({ props })} + Category + + {/snippet} + + + + + + + + {#snippet children({ props })} + Unit + + {/snippet} + + + + + + + + + {#snippet children({ props })} + Description +