From 631a5112f4a0765c132598958a4ec78cf57f7373 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Fri, 5 Jun 2026 23:25:36 +0100 Subject: [PATCH] feat: add client workspace management --- src/lib/schemas/addresses.schema.ts | 16 + src/lib/schemas/bookings.schema.ts | 14 + src/lib/schemas/clients.schema.ts | 27 ++ src/lib/schemas/contacts.schema.ts | 20 ++ src/lib/schemas/contracts.schema.ts | 14 + src/lib/schemas/invoices.schema.ts | 16 + src/lib/schemas/shared.schema.ts | 39 +++ .../dashboard/addresses/+page.server.ts | 48 +++ src/routes/dashboard/addresses/+page.svelte | 97 ++++++ .../addresses/addresses-table.svelte | 66 ++++ .../addresses/archive-address-dialog.svelte | 40 +++ src/routes/dashboard/admins/+page.server.ts | 144 +++++++++ src/routes/dashboard/admins/+page.svelte | 130 ++++++++ .../dashboard/admins/admins-table.svelte | 76 +++++ .../admins/create-admin-dialog.svelte | 85 +++++ .../admins/delete-admin-dialog.svelte | 35 +++ .../dashboard/admins/edit-admin-dialog.svelte | 82 +++++ src/routes/dashboard/bookings/+page.server.ts | 65 ++++ src/routes/dashboard/bookings/+page.svelte | 111 +++++++ .../bookings/archive-booking-dialog.svelte | 40 +++ .../dashboard/bookings/bookings-table.svelte | 75 +++++ src/routes/dashboard/clients/+page.server.ts | 130 ++++++++ src/routes/dashboard/clients/+page.svelte | 139 +++++++++ .../dashboard/clients/[id]/+layout.server.ts | 113 +++++++ .../dashboard/clients/[id]/+layout.svelte | 220 +++++++++++++ .../dashboard/clients/[id]/+page.server.ts | 7 + .../clients/[id]/addresses/+page.server.ts | 130 ++++++++ .../clients/[id]/addresses/+page.svelte | 133 ++++++++ .../[id]/addresses/addresses-table.svelte | 74 +++++ .../addresses/archive-address-dialog.svelte | 40 +++ .../addresses/create-address-dialog.svelte | 136 ++++++++ .../[id]/addresses/edit-address-dialog.svelte | 135 ++++++++ .../clients/[id]/bookings/+page.server.ts | 143 +++++++++ .../clients/[id]/bookings/+page.svelte | 156 ++++++++++ .../bookings/archive-booking-dialog.svelte | 40 +++ .../[id]/bookings/bookings-table.svelte | 78 +++++ .../bookings/create-booking-dialog.svelte | 128 ++++++++ .../[id]/bookings/edit-booking-dialog.svelte | 127 ++++++++ .../clients/[id]/contacts/+page.server.ts | 124 ++++++++ .../clients/[id]/contacts/+page.svelte | 133 ++++++++ .../contacts/archive-contact-dialog.svelte | 40 +++ .../[id]/contacts/contacts-table.svelte | 74 +++++ .../contacts/create-contact-dialog.svelte | 115 +++++++ .../[id]/contacts/edit-contact-dialog.svelte | 114 +++++++ .../clients/[id]/contracts/+page.server.ts | 126 ++++++++ .../clients/[id]/contracts/+page.svelte | 159 ++++++++++ .../contracts/archive-contract-dialog.svelte | 40 +++ .../[id]/contracts/contracts-table.svelte | 82 +++++ .../contracts/create-contract-dialog.svelte | 114 +++++++ .../contracts/edit-contract-dialog.svelte | 113 +++++++ .../clients/[id]/invoices/+page.server.ts | 130 ++++++++ .../clients/[id]/invoices/+page.svelte | 159 ++++++++++ .../invoices/archive-invoice-dialog.svelte | 40 +++ .../invoices/create-invoice-dialog.svelte | 129 ++++++++ .../[id]/invoices/edit-invoice-dialog.svelte | 122 ++++++++ .../[id]/invoices/invoices-table.svelte | 82 +++++ .../clients/archive-client-dialog.svelte | 39 +++ .../dashboard/clients/clients-table.svelte | 86 ++++++ .../clients/create-client-dialog.svelte | 290 ++++++++++++++++++ .../clients/edit-client-dialog.svelte | 114 +++++++ src/routes/dashboard/contacts/+page.server.ts | 48 +++ src/routes/dashboard/contacts/+page.svelte | 97 ++++++ .../contacts/archive-contact-dialog.svelte | 40 +++ .../dashboard/contacts/contacts-table.svelte | 66 ++++ .../dashboard/contracts/+page.server.ts | 48 +++ src/routes/dashboard/contracts/+page.svelte | 122 ++++++++ .../contracts/archive-contract-dialog.svelte | 40 +++ .../contracts/contracts-table.svelte | 79 +++++ src/routes/dashboard/invoices/+page.server.ts | 48 +++ src/routes/dashboard/invoices/+page.svelte | 122 ++++++++ .../invoices/archive-invoice-dialog.svelte | 40 +++ .../dashboard/invoices/invoices-table.svelte | 79 +++++ 72 files changed, 6423 insertions(+) create mode 100644 src/lib/schemas/addresses.schema.ts create mode 100644 src/lib/schemas/bookings.schema.ts create mode 100644 src/lib/schemas/clients.schema.ts create mode 100644 src/lib/schemas/contacts.schema.ts create mode 100644 src/lib/schemas/contracts.schema.ts create mode 100644 src/lib/schemas/invoices.schema.ts create mode 100644 src/lib/schemas/shared.schema.ts create mode 100644 src/routes/dashboard/addresses/+page.server.ts create mode 100644 src/routes/dashboard/addresses/+page.svelte create mode 100644 src/routes/dashboard/addresses/addresses-table.svelte create mode 100644 src/routes/dashboard/addresses/archive-address-dialog.svelte create mode 100644 src/routes/dashboard/admins/+page.server.ts create mode 100644 src/routes/dashboard/admins/+page.svelte create mode 100644 src/routes/dashboard/admins/admins-table.svelte create mode 100644 src/routes/dashboard/admins/create-admin-dialog.svelte create mode 100644 src/routes/dashboard/admins/delete-admin-dialog.svelte create mode 100644 src/routes/dashboard/admins/edit-admin-dialog.svelte create mode 100644 src/routes/dashboard/bookings/+page.server.ts create mode 100644 src/routes/dashboard/bookings/+page.svelte create mode 100644 src/routes/dashboard/bookings/archive-booking-dialog.svelte create mode 100644 src/routes/dashboard/bookings/bookings-table.svelte create mode 100644 src/routes/dashboard/clients/+page.server.ts create mode 100644 src/routes/dashboard/clients/+page.svelte create mode 100644 src/routes/dashboard/clients/[id]/+layout.server.ts create mode 100644 src/routes/dashboard/clients/[id]/+layout.svelte create mode 100644 src/routes/dashboard/clients/[id]/+page.server.ts create mode 100644 src/routes/dashboard/clients/[id]/addresses/+page.server.ts create mode 100644 src/routes/dashboard/clients/[id]/addresses/+page.svelte create mode 100644 src/routes/dashboard/clients/[id]/addresses/addresses-table.svelte create mode 100644 src/routes/dashboard/clients/[id]/addresses/archive-address-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/addresses/create-address-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/addresses/edit-address-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/bookings/+page.server.ts create mode 100644 src/routes/dashboard/clients/[id]/bookings/+page.svelte create mode 100644 src/routes/dashboard/clients/[id]/bookings/archive-booking-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/bookings/bookings-table.svelte create mode 100644 src/routes/dashboard/clients/[id]/bookings/create-booking-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/bookings/edit-booking-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/contacts/+page.server.ts create mode 100644 src/routes/dashboard/clients/[id]/contacts/+page.svelte create mode 100644 src/routes/dashboard/clients/[id]/contacts/archive-contact-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/contacts/contacts-table.svelte create mode 100644 src/routes/dashboard/clients/[id]/contacts/create-contact-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/contacts/edit-contact-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/contracts/+page.server.ts create mode 100644 src/routes/dashboard/clients/[id]/contracts/+page.svelte create mode 100644 src/routes/dashboard/clients/[id]/contracts/archive-contract-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/contracts/contracts-table.svelte create mode 100644 src/routes/dashboard/clients/[id]/contracts/create-contract-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/contracts/edit-contract-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/invoices/+page.server.ts create mode 100644 src/routes/dashboard/clients/[id]/invoices/+page.svelte create mode 100644 src/routes/dashboard/clients/[id]/invoices/archive-invoice-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/invoices/create-invoice-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/invoices/edit-invoice-dialog.svelte create mode 100644 src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte create mode 100644 src/routes/dashboard/clients/archive-client-dialog.svelte create mode 100644 src/routes/dashboard/clients/clients-table.svelte create mode 100644 src/routes/dashboard/clients/create-client-dialog.svelte create mode 100644 src/routes/dashboard/clients/edit-client-dialog.svelte create mode 100644 src/routes/dashboard/contacts/+page.server.ts create mode 100644 src/routes/dashboard/contacts/+page.svelte create mode 100644 src/routes/dashboard/contacts/archive-contact-dialog.svelte create mode 100644 src/routes/dashboard/contacts/contacts-table.svelte create mode 100644 src/routes/dashboard/contracts/+page.server.ts create mode 100644 src/routes/dashboard/contracts/+page.svelte create mode 100644 src/routes/dashboard/contracts/archive-contract-dialog.svelte create mode 100644 src/routes/dashboard/contracts/contracts-table.svelte create mode 100644 src/routes/dashboard/invoices/+page.server.ts create mode 100644 src/routes/dashboard/invoices/+page.svelte create mode 100644 src/routes/dashboard/invoices/archive-invoice-dialog.svelte create mode 100644 src/routes/dashboard/invoices/invoices-table.svelte diff --git a/src/lib/schemas/addresses.schema.ts b/src/lib/schemas/addresses.schema.ts new file mode 100644 index 0000000..388f2a9 --- /dev/null +++ b/src/lib/schemas/addresses.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { booleanString, idSchema, optionalText, requiredText } from './shared.schema'; + +export const addressCreateSchema = z.object({ + clientId: requiredText('Client'), + label: requiredText('Label', 80), + line1: requiredText('Address line 1'), + line2: optionalText(160), + city: requiredText('City', 100), + region: optionalText(100), + postcode: requiredText('Postcode', 24), + country: requiredText('Country', 80).default('United Kingdom'), + isPrimary: booleanString +}); + +export const addressEditSchema = addressCreateSchema.extend(idSchema.shape); diff --git a/src/lib/schemas/bookings.schema.ts b/src/lib/schemas/bookings.schema.ts new file mode 100644 index 0000000..aa0b298 --- /dev/null +++ b/src/lib/schemas/bookings.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { idSchema, optionalText, requiredText } from './shared.schema'; + +export const bookingCreateSchema = z.object({ + clientId: requiredText('Client'), + roomId: requiredText('Room'), + serviceId: optionalText(80), + startsAt: requiredText('Start time', 40), + endsAt: requiredText('End time', 40), + status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'), + notes: optionalText(1000) +}); + +export const bookingEditSchema = bookingCreateSchema.extend(idSchema.shape); diff --git a/src/lib/schemas/clients.schema.ts b/src/lib/schemas/clients.schema.ts new file mode 100644 index 0000000..2998926 --- /dev/null +++ b/src/lib/schemas/clients.schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { idSchema, optionalEmail, optionalText, optionalUrl, requiredText } from './shared.schema'; + +export const clientCreateSchema = z.object({ + name: requiredText('Client name'), + type: z.enum(['business', 'sport', 'individual']).default('business'), + status: z.enum(['active', 'prospect', 'paused']).default('active'), + website: optionalUrl, + notes: optionalText(1000) +}); + +export const clientEditSchema = clientCreateSchema.extend(idSchema.shape); + +export const clientOnboardingCreateSchema = clientCreateSchema.extend({ + primaryContactName: requiredText('Primary contact name'), + primaryContactRole: optionalText(120), + primaryContactEmail: optionalEmail, + primaryContactPhone: optionalText(80), + primaryContactNotes: optionalText(1000), + primaryAddressLabel: requiredText('Address label', 80).default('Primary'), + primaryAddressLine1: requiredText('Address line 1'), + primaryAddressLine2: optionalText(160), + primaryAddressCity: requiredText('City', 100), + primaryAddressRegion: optionalText(100), + primaryAddressPostcode: requiredText('Postcode', 24), + primaryAddressCountry: requiredText('Country', 80).default('United Kingdom') +}); diff --git a/src/lib/schemas/contacts.schema.ts b/src/lib/schemas/contacts.schema.ts new file mode 100644 index 0000000..7efaa92 --- /dev/null +++ b/src/lib/schemas/contacts.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { + booleanString, + idSchema, + optionalEmail, + optionalText, + requiredText +} from './shared.schema'; + +export const contactCreateSchema = z.object({ + clientId: requiredText('Client'), + name: requiredText('Contact name'), + role: optionalText(120), + email: optionalEmail, + phone: optionalText(80), + isPrimary: booleanString, + notes: optionalText(1000) +}); + +export const contactEditSchema = contactCreateSchema.extend(idSchema.shape); diff --git a/src/lib/schemas/contracts.schema.ts b/src/lib/schemas/contracts.schema.ts new file mode 100644 index 0000000..0fd5cc5 --- /dev/null +++ b/src/lib/schemas/contracts.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema'; + +export const contractCreateSchema = z.object({ + clientId: requiredText('Client'), + title: requiredText('Contract title'), + startDate: requiredText('Start date', 40), + endDate: optionalText(40), + status: z.enum(['draft', 'active', 'expired', 'cancelled']).default('draft'), + valueGbp: moneyGbp, + notes: optionalText(1000) +}); + +export const contractEditSchema = contractCreateSchema.extend(idSchema.shape); diff --git a/src/lib/schemas/invoices.schema.ts b/src/lib/schemas/invoices.schema.ts new file mode 100644 index 0000000..50c407a --- /dev/null +++ b/src/lib/schemas/invoices.schema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema'; + +export const invoiceCreateSchema = z.object({ + clientId: requiredText('Client'), + invoiceNumber: requiredText('Invoice number', 80), + issueDate: requiredText('Issue date', 40), + dueDate: requiredText('Due date', 40), + status: z.enum(['draft', 'sent', 'paid', 'overdue', 'void']).default('draft'), + subtotalGbp: moneyGbp, + taxGbp: moneyGbp, + totalGbp: moneyGbp, + notes: optionalText(1000) +}); + +export const invoiceEditSchema = invoiceCreateSchema.extend(idSchema.shape); diff --git a/src/lib/schemas/shared.schema.ts b/src/lib/schemas/shared.schema.ts new file mode 100644 index 0000000..c115a08 --- /dev/null +++ b/src/lib/schemas/shared.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +export const idSchema = z.object({ + id: z.string().uuid() +}); + +export const archiveSchema = idSchema; + +export const requiredText = (label: string, max = 160) => + z.string().trim().min(1, `${label} is required.`).max(max, `${label} is too long.`); + +export const optionalText = (max = 500) => z.string().trim().max(max).optional().default(''); + +export const optionalEmail = z + .string() + .trim() + .email('Enter a valid email address.') + .or(z.literal('')) + .default(''); + +export const optionalUrl = z + .string() + .trim() + .url('Enter a valid URL.') + .or(z.literal('')) + .default(''); + +export const moneyGbp = z.coerce + .number({ error: 'Enter an amount in GBP.' }) + .min(0, 'Amount cannot be negative.') + .default(0); + +export const positiveInt = (label: string) => + z.coerce + .number({ error: `${label} is required.` }) + .int() + .min(1, `${label} must be at least 1.`); + +export const booleanString = z.enum(['true', 'false']).default('false'); diff --git a/src/routes/dashboard/addresses/+page.server.ts b/src/routes/dashboard/addresses/+page.server.ts new file mode 100644 index 0000000..e0f44aa --- /dev/null +++ b/src/routes/dashboard/addresses/+page.server.ts @@ -0,0 +1,48 @@ +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 { addresses, clients } from '$lib/server/db/schema'; +import { archiveSchema } from '$lib/schemas/shared.schema'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadOptions() { + const clientRows = await db + .select({ id: clients.id, name: clients.name }) + .from(clients) + .where(isNull(clients.archivedAt)) + .orderBy(asc(clients.name)); + + return { + clients: clientRows.map((client) => ({ label: client.name, value: client.id })) + }; +} + +export const load: PageServerLoad = async () => { + const records = await db + .select() + .from(addresses) + .where(isNull(addresses.archivedAt)) + .orderBy(asc(addresses.label)); + + return { + records, + options: await loadOptions(), + archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' }) + }; +}; + +export const actions: Actions = { + archive: async (event) => { + const form = await superValidate(event, zod4(archiveSchema), { id: 'addresses-archive' }); + + if (!form.valid) return message(form, 'Address id is required.', { status: 400 }); + + await db + .update(addresses) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where(eq(addresses.id, form.data.id)); + + return message(form, 'Address archived.'); + } +}; diff --git a/src/routes/dashboard/addresses/+page.svelte b/src/routes/dashboard/addresses/+page.svelte new file mode 100644 index 0000000..4f0e203 --- /dev/null +++ b/src/routes/dashboard/addresses/+page.svelte @@ -0,0 +1,97 @@ + + + + Addresses | Clearity + + +
+
+
+

Addresses

+

Track postal and billing addresses for clients.

+
+
+ + +
+ + (archivingId = null)} +/> diff --git a/src/routes/dashboard/addresses/addresses-table.svelte b/src/routes/dashboard/addresses/addresses-table.svelte new file mode 100644 index 0000000..9874acb --- /dev/null +++ b/src/routes/dashboard/addresses/addresses-table.svelte @@ -0,0 +1,66 @@ + + +{#if data.records.length > 0} +
+ + + + Client + Label + Address + City + Postcode + Primary + Actions + + + + {#each data.records as record (record.id)} + + {relationLabel(record.clientId, 'clients')} + {formatValue(record.label)} + {formatValue(record.line1)} + {formatValue(record.city)} + {formatValue(record.postcode)} + {record.isPrimary ? 'Yes' : 'No'} + + + {#snippet child({ props })}{/snippet} + + openArchive(record)} + >Archive + + + + + {/each} + + +
+{:else} +
+ No addresses have been created yet. +
+{/if} diff --git a/src/routes/dashboard/addresses/archive-address-dialog.svelte b/src/routes/dashboard/addresses/archive-address-dialog.svelte new file mode 100644 index 0000000..3a10450 --- /dev/null +++ b/src/routes/dashboard/addresses/archive-address-dialog.svelte @@ -0,0 +1,40 @@ + + + !open && onClose()}> + + + Archive address? + This will hide {recordName(archivingRecord)} from active addresses. The record will remain in + the database. + + + Cancel + {#if archivingRecord} +
+ + Archive address +
+ {/if} +
+
+
diff --git a/src/routes/dashboard/admins/+page.server.ts b/src/routes/dashboard/admins/+page.server.ts new file mode 100644 index 0000000..c2ab1b7 --- /dev/null +++ b/src/routes/dashboard/admins/+page.server.ts @@ -0,0 +1,144 @@ +import { hashPassword } from 'better-auth/crypto'; +import { asc, eq } from 'drizzle-orm'; +import { message, superValidate } from 'sveltekit-superforms/server'; +import { zod4 } from 'sveltekit-superforms/adapters'; +import { db } from '$lib/server/db'; +import { account, user } from '$lib/server/db/schema'; +import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin'; +import type { Actions, PageServerLoad } from './$types'; + +function now() { + return new Date(); +} + +export const load: PageServerLoad = async ({ locals }) => { + const users = await db + .select({ + id: user.id, + name: user.name, + email: user.email, + emailVerified: user.emailVerified, + createdAt: user.createdAt, + updatedAt: user.updatedAt + }) + .from(user) + .orderBy(asc(user.name), asc(user.email)); + + return { + currentUserId: locals.user?.id, + users, + createForm: await superValidate(zod4(createAdminSchema), { id: 'create-admin' }), + editForm: await superValidate(zod4(editAdminSchema), { id: 'edit-admin' }), + deleteForm: await superValidate(zod4(deleteAdminSchema), { id: 'delete-admin' }) + }; +}; + +export const actions: Actions = { + create: async (event) => { + const form = await superValidate(event, zod4(createAdminSchema), { id: 'create-admin' }); + + if (!form.valid) { + return message(form, 'Check the highlighted fields.', { status: 400 }); + } + + const userId = crypto.randomUUID(); + const accountId = crypto.randomUUID(); + const createdAt = now(); + const passwordHash = await hashPassword(form.data.password); + + try { + await db.insert(user).values({ + id: userId, + name: form.data.name, + email: form.data.email, + emailVerified: true, + createdAt, + updatedAt: createdAt + }); + + await db.insert(account).values({ + id: accountId, + accountId: userId, + providerId: 'credential', + userId, + password: passwordHash, + createdAt, + updatedAt: createdAt + }); + } catch { + return message(form, 'Unable to create admin. Check whether that email already exists.', { + status: 400 + }); + } + + return message(form, 'Admin created.'); + }, + + edit: async (event) => { + const form = await superValidate(event, zod4(editAdminSchema), { id: 'edit-admin' }); + + if (!form.valid) { + return message(form, 'Check the highlighted fields.', { status: 400 }); + } + + if (form.data.id === event.locals.user?.id) { + return message(form, 'You cannot edit your own admin account here.', { status: 403 }); + } + + try { + await db + .update(user) + .set({ + name: form.data.name, + email: form.data.email, + updatedAt: now() + }) + .where(eq(user.id, form.data.id)); + + if (form.data.password) { + const passwordHash = await hashPassword(form.data.password); + const updated = await db + .update(account) + .set({ + password: passwordHash, + updatedAt: now() + }) + .where(eq(account.userId, form.data.id)); + + if (updated.rowsAffected === 0) { + await db.insert(account).values({ + id: crypto.randomUUID(), + accountId: form.data.id, + providerId: 'credential', + userId: form.data.id, + password: passwordHash, + createdAt: now(), + updatedAt: now() + }); + } + } + } catch { + return message(form, 'Unable to update admin. Check whether that email already exists.', { + status: 400 + }); + } + + return message(form, 'Admin updated.'); + }, + + delete: async (event) => { + const form = await superValidate(event, zod4(deleteAdminSchema), { id: 'delete-admin' }); + + if (!form.valid) { + return message(form, 'Admin id is required.', { status: 400 }); + } + + if (form.data.id === event.locals.user?.id) { + return message(form, 'You cannot delete your own admin account.', { status: 403 }); + } + + await db.delete(user).where(eq(user.id, form.data.id)); + + return message(form, 'Admin deleted.'); + } +}; diff --git a/src/routes/dashboard/admins/+page.svelte b/src/routes/dashboard/admins/+page.svelte new file mode 100644 index 0000000..f78a480 --- /dev/null +++ b/src/routes/dashboard/admins/+page.svelte @@ -0,0 +1,130 @@ + + + + Admins | Clearity + + +
+
+
+

Admins

+

+ Manage Better Auth users with administrator access. +

+
+ + +
+ + +
+ + (editingUserId = null)} +/> + + (deletingUserId = null)} +/> diff --git a/src/routes/dashboard/admins/admins-table.svelte b/src/routes/dashboard/admins/admins-table.svelte new file mode 100644 index 0000000..67d1c8c --- /dev/null +++ b/src/routes/dashboard/admins/admins-table.svelte @@ -0,0 +1,76 @@ + + +
+ + + + Name + Email + Status + Created + + Actions + + + + + {#each data.users as user (user.id)} + + {user.name} + {user.email} + + {user.emailVerified ? 'Verified' : 'Unverified'} + + {formatDate(user.createdAt)} + + {#if user.id !== data.currentUserId} + + + {#snippet child({ props })} + + {/snippet} + + + openEdit(user)}>Edit + openDelete(user)}> + Delete + + + + {/if} + + + {/each} + + +
+ +{#if data.users.length === 0} +
+ No admin users have been created yet. +
+{/if} diff --git a/src/routes/dashboard/admins/create-admin-dialog.svelte b/src/routes/dashboard/admins/create-admin-dialog.svelte new file mode 100644 index 0000000..fc78d6d --- /dev/null +++ b/src/routes/dashboard/admins/create-admin-dialog.svelte @@ -0,0 +1,85 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + Create admin + Add a manually managed admin account. + +
+ + + + {#snippet children({ props })} + Name + + {/snippet} + + + + + + + + {#snippet children({ props })} + Email + + {/snippet} + + + + + + + + {#snippet children({ props })} + Password + + {/snippet} + + + + + + + {#snippet child({ props })} + + {/snippet} + + + +
+
+
diff --git a/src/routes/dashboard/admins/delete-admin-dialog.svelte b/src/routes/dashboard/admins/delete-admin-dialog.svelte new file mode 100644 index 0000000..13a7831 --- /dev/null +++ b/src/routes/dashboard/admins/delete-admin-dialog.svelte @@ -0,0 +1,35 @@ + + + !open && onClose()}> + + + Delete admin? + + This will permanently delete {deletingUser?.name ?? 'this admin'} and revoke their sessions. + + + + Cancel + {#if deletingUser} +
+ + Delete admin +
+ {/if} +
+
+
diff --git a/src/routes/dashboard/admins/edit-admin-dialog.svelte b/src/routes/dashboard/admins/edit-admin-dialog.svelte new file mode 100644 index 0000000..d49dc78 --- /dev/null +++ b/src/routes/dashboard/admins/edit-admin-dialog.svelte @@ -0,0 +1,82 @@ + + + !open && onClose()}> + + + Edit admin + Update account details or set a new password. + + {#if editingUser} +
+ + + + + {#snippet children({ props })} + Name + + {/snippet} + + + + + + + + {#snippet children({ props })} + Email + + {/snippet} + + + + + + + + {#snippet children({ props })} + New password + + {/snippet} + + + + + + + {#snippet child({ props })} + + {/snippet} + + + +
+ {/if} +
+
diff --git a/src/routes/dashboard/bookings/+page.server.ts b/src/routes/dashboard/bookings/+page.server.ts new file mode 100644 index 0000000..836dfca --- /dev/null +++ b/src/routes/dashboard/bookings/+page.server.ts @@ -0,0 +1,65 @@ +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 { bookings, clients, rooms, services } from '$lib/server/db/schema'; +import { archiveSchema } from '$lib/schemas/shared.schema'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadOptions() { + const [clientRows, roomRows, serviceRows] = await Promise.all([ + db + .select({ id: clients.id, name: clients.name }) + .from(clients) + .where(isNull(clients.archivedAt)) + .orderBy(asc(clients.name)), + db + .select({ id: rooms.id, name: rooms.name }) + .from(rooms) + .where(isNull(rooms.archivedAt)) + .orderBy(asc(rooms.name)), + db + .select({ id: services.id, name: services.name }) + .from(services) + .where(isNull(services.archivedAt)) + .orderBy(asc(services.name)) + ]); + + return { + clients: clientRows.map((client) => ({ label: client.name, value: client.id })), + rooms: roomRows.map((room) => ({ label: room.name, value: room.id })), + services: [ + { label: 'No service', value: '' }, + ...serviceRows.map((service) => ({ label: service.name, value: service.id })) + ] + }; +} + +export const load: PageServerLoad = async () => { + const records = await db + .select() + .from(bookings) + .where(isNull(bookings.archivedAt)) + .orderBy(asc(bookings.startsAt)); + + return { + records, + options: await loadOptions(), + archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' }) + }; +}; + +export const actions: Actions = { + archive: async (event) => { + const form = await superValidate(event, zod4(archiveSchema), { id: 'bookings-archive' }); + + if (!form.valid) return message(form, 'Booking id is required.', { status: 400 }); + + await db + .update(bookings) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where(eq(bookings.id, form.data.id)); + + return message(form, 'Booking archived.'); + } +}; diff --git a/src/routes/dashboard/bookings/+page.svelte b/src/routes/dashboard/bookings/+page.svelte new file mode 100644 index 0000000..06d831d --- /dev/null +++ b/src/routes/dashboard/bookings/+page.svelte @@ -0,0 +1,111 @@ + + + + Bookings | Clearity + + +
+
+
+

Bookings

+

+ Track client room bookings and service reservations. +

+
+
+ + +
+ + (archivingId = null)} +/> diff --git a/src/routes/dashboard/bookings/archive-booking-dialog.svelte b/src/routes/dashboard/bookings/archive-booking-dialog.svelte new file mode 100644 index 0000000..b09edb8 --- /dev/null +++ b/src/routes/dashboard/bookings/archive-booking-dialog.svelte @@ -0,0 +1,40 @@ + + + !open && onClose()}> + + + Archive booking? + This will hide {recordName(archivingRecord)} from active bookings. The record will remain in + the database. + + + Cancel + {#if archivingRecord} +
+ + Archive booking +
+ {/if} +
+
+
diff --git a/src/routes/dashboard/bookings/bookings-table.svelte b/src/routes/dashboard/bookings/bookings-table.svelte new file mode 100644 index 0000000..1158eb1 --- /dev/null +++ b/src/routes/dashboard/bookings/bookings-table.svelte @@ -0,0 +1,75 @@ + + +{#if data.records.length > 0} +
+ + + + Client + Room + Starts + Ends + Status + Actions + + + + {#each data.records as record (record.id)} + + {relationLabel(record.clientId, 'clients')} + {relationLabel(record.roomId, 'rooms')} + {formatDate(record.startsAt, true)} + {formatDate(record.endsAt, true)} + {formatValue(record.status)} + + + {#snippet child({ props })}{/snippet} + + openArchive(record)} + >Archive + + + + + {/each} + + +
+{:else} +
+ No bookings have been created yet. +
+{/if} diff --git a/src/routes/dashboard/clients/+page.server.ts b/src/routes/dashboard/clients/+page.server.ts new file mode 100644 index 0000000..714f636 --- /dev/null +++ b/src/routes/dashboard/clients/+page.server.ts @@ -0,0 +1,130 @@ +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 { addresses, clients, contacts } from '$lib/server/db/schema'; +import { archiveSchema } from '$lib/schemas/shared.schema'; +import { clientEditSchema, clientOnboardingCreateSchema } from '$lib/schemas/clients.schema'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const records = await db + .select() + .from(clients) + .where(isNull(clients.archivedAt)) + .orderBy(asc(clients.name)); + + return { + records: records.map((record) => ({ + ...record, + formValues: { + id: record.id, + name: record.name ?? '', + type: record.type ?? '', + status: record.status ?? '', + website: record.website ?? '', + notes: record.notes ?? '' + } + })), + createForm: await superValidate(zod4(clientOnboardingCreateSchema), { id: 'clients-create' }), + editForm: await superValidate(zod4(clientEditSchema), { id: 'clients-edit' }), + archiveForm: await superValidate(zod4(archiveSchema), { id: 'clients-archive' }) + }; +}; + +export const actions: Actions = { + create: async (event) => { + const form = await superValidate(event, zod4(clientOnboardingCreateSchema), { + id: 'clients-create' + }); + + if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); + + try { + await db.transaction(async (tx) => { + const clientId = crypto.randomUUID(); + const now = new Date(); + + await tx.insert(clients).values({ + id: clientId, + name: form.data.name, + type: form.data.type, + status: form.data.status, + website: form.data.website || null, + notes: form.data.notes || null, + updatedAt: now, + createdAt: now + }); + + await tx.insert(contacts).values({ + id: crypto.randomUUID(), + clientId, + name: form.data.primaryContactName, + role: form.data.primaryContactRole || null, + email: form.data.primaryContactEmail || null, + phone: form.data.primaryContactPhone || null, + isPrimary: true, + notes: form.data.primaryContactNotes || null, + createdAt: now, + updatedAt: now + }); + + await tx.insert(addresses).values({ + id: crypto.randomUUID(), + clientId, + label: form.data.primaryAddressLabel, + line1: form.data.primaryAddressLine1, + line2: form.data.primaryAddressLine2 || null, + city: form.data.primaryAddressCity, + region: form.data.primaryAddressRegion || null, + postcode: form.data.primaryAddressPostcode, + country: form.data.primaryAddressCountry, + isPrimary: true, + createdAt: now, + updatedAt: now + }); + }); + } catch { + return message(form, 'Unable to create client.', { status: 400 }); + } + + return message(form, 'Client created.'); + }, + + edit: async (event) => { + const form = await superValidate(event, zod4(clientEditSchema), { id: 'clients-edit' }); + + if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); + + try { + await db + .update(clients) + .set({ + name: form.data.name, + type: form.data.type, + status: form.data.status, + website: form.data.website || null, + notes: form.data.notes || null, + updatedAt: new Date() + }) + .where(eq(clients.id, form.data.id)); + } catch { + return message(form, 'Unable to update client.', { status: 400 }); + } + + return message(form, 'Client updated.'); + }, + + archive: async (event) => { + const form = await superValidate(event, zod4(archiveSchema), { id: 'clients-archive' }); + + if (!form.valid) return message(form, 'Client id is required.', { status: 400 }); + + await db + .update(clients) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where(eq(clients.id, form.data.id)); + + return message(form, 'Client archived.'); + } +}; diff --git a/src/routes/dashboard/clients/+page.svelte b/src/routes/dashboard/clients/+page.svelte new file mode 100644 index 0000000..818f4f1 --- /dev/null +++ b/src/routes/dashboard/clients/+page.svelte @@ -0,0 +1,139 @@ + + + + Clients | Clearity + + +
+
+
+

Clients

+

+ Manage businesses, sports organisations, and individual clients. +

+
+ + +
+ + +
+ + (editingId = null)} +/> + + (archivingId = null)} +/> diff --git a/src/routes/dashboard/clients/[id]/+layout.server.ts b/src/routes/dashboard/clients/[id]/+layout.server.ts new file mode 100644 index 0000000..fcc1f61 --- /dev/null +++ b/src/routes/dashboard/clients/[id]/+layout.server.ts @@ -0,0 +1,113 @@ +import { error } from '@sveltejs/kit'; +import { and, asc, eq, isNull } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { + addresses, + bookings, + clients, + contacts, + contracts, + invoices, + rooms, + services +} from '$lib/server/db/schema'; +import { clientEditSchema } from '$lib/schemas/clients.schema'; +import type { LayoutServerLoad } from './$types'; +import { superValidate } from 'sveltekit-superforms/server'; +import { zod4 } from 'sveltekit-superforms/adapters'; + +const clientTypes = ['business', 'sport', 'individual'] as const; +const clientStatuses = ['active', 'prospect', 'paused'] as const; + +function formValues(row: typeof clients.$inferSelect) { + return { + id: row.id, + name: row.name ?? '', + type: clientTypes.find((type) => type === row.type), + status: clientStatuses.find((status) => status === row.status), + website: row.website ?? '', + notes: row.notes ?? '' + }; +} + +async function loadOptions() { + const [clientRows, roomRows, serviceRows] = await Promise.all([ + db + .select({ id: clients.id, name: clients.name }) + .from(clients) + .where(isNull(clients.archivedAt)) + .orderBy(asc(clients.name)), + db + .select({ id: rooms.id, name: rooms.name }) + .from(rooms) + .where(isNull(rooms.archivedAt)) + .orderBy(asc(rooms.name)), + db + .select({ id: services.id, name: services.name }) + .from(services) + .where(isNull(services.archivedAt)) + .orderBy(asc(services.name)) + ]); + + return { + clients: clientRows.map((client) => ({ label: client.name, value: client.id })), + rooms: roomRows.map((room) => ({ label: room.name, value: room.id })), + services: [ + { label: 'No service', value: '' }, + ...serviceRows.map((service) => ({ label: service.name, value: service.id })) + ] + }; +} + +export const load: LayoutServerLoad = async ({ params }) => { + const [client] = await db + .select() + .from(clients) + .where(and(eq(clients.id, params.id), isNull(clients.archivedAt))) + .limit(1); + + if (!client) error(404, 'Client not found'); + + const [addressRows, contactRows, invoiceRows, contractRows, bookingRows, options] = + await Promise.all([ + db + .select() + .from(addresses) + .where(and(eq(addresses.clientId, params.id), isNull(addresses.archivedAt))) + .orderBy(asc(addresses.label)), + db + .select() + .from(contacts) + .where(and(eq(contacts.clientId, params.id), isNull(contacts.archivedAt))) + .orderBy(asc(contacts.name)), + db + .select() + .from(invoices) + .where(and(eq(invoices.clientId, params.id), isNull(invoices.archivedAt))) + .orderBy(asc(invoices.invoiceNumber)), + db + .select() + .from(contracts) + .where(and(eq(contracts.clientId, params.id), isNull(contracts.archivedAt))) + .orderBy(asc(contracts.title)), + db + .select() + .from(bookings) + .where(and(eq(bookings.clientId, params.id), isNull(bookings.archivedAt))) + .orderBy(asc(bookings.startsAt)), + loadOptions() + ]); + + return { + client, + editForm: await superValidate(formValues(client), zod4(clientEditSchema), { + id: 'clients-edit' + }), + options, + addresses: addressRows, + contacts: contactRows, + invoices: invoiceRows, + contracts: contractRows, + bookings: bookingRows + }; +}; diff --git a/src/routes/dashboard/clients/[id]/+layout.svelte b/src/routes/dashboard/clients/[id]/+layout.svelte new file mode 100644 index 0000000..f0ae8cc --- /dev/null +++ b/src/routes/dashboard/clients/[id]/+layout.svelte @@ -0,0 +1,220 @@ + + + + {data.client.name} | Clearity + + +
+
+
+
+

{data.client.name}

+

+ Client workspace for contacts, addresses, invoices, contracts, and bookings. +

+
+ + + {#snippet child({ props })} + + {/snippet} + + + + Edit client + Update this client record. + +
+ + + + + {#snippet children({ props })} + Name + + {/snippet} + + + + + + + + {#snippet children({ props })} + Type + + Not set + Business + Sport + Individual + + {/snippet} + + + + + + + + {#snippet children({ props })} + Status + + Not set + Active + Prospect + Paused + + {/snippet} + + + + + + + + {#snippet children({ props })} + Website + + {/snippet} + + + + + + + + {#snippet children({ props })} + Notes +