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}
+
+ {/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.
+
+
+
+
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}
+
+ {/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}
+
+ {/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}
+
+ {/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.
+
+
+
+
+
+
+
+
+
+
Type
+
{data.client.type}
+
+
+
Website
+
{data.client.website ?? 'Not set'}
+
+
+
Status
+
{data.client.status}
+
+
+
+
+
+ {#each tabs as tab (tab.value)}
+ navigateToTab(tab.value)}
+ aria-label={`View ${tab.label.toLowerCase()}`}
+ >
+ {tab.label}
+
+ {/each}
+
+
+ {@render children()}
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/+page.server.ts b/src/routes/dashboard/clients/[id]/+page.server.ts
new file mode 100644
index 0000000..5513380
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/+page.server.ts
@@ -0,0 +1,7 @@
+import { redirect } from '@sveltejs/kit';
+import { resolve } from '$app/paths';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = ({ params }) => {
+ redirect(307, resolve('/dashboard/clients/[id]/addresses', { id: params.id }));
+};
diff --git a/src/routes/dashboard/clients/[id]/addresses/+page.server.ts b/src/routes/dashboard/clients/[id]/addresses/+page.server.ts
new file mode 100644
index 0000000..fc7ef0a
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/addresses/+page.server.ts
@@ -0,0 +1,130 @@
+import { and, 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 { addressCreateSchema, addressEditSchema } from '$lib/schemas/addresses.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 ({ params }) => {
+ const records = await db
+ .select()
+ .from(addresses)
+ .where(and(eq(addresses.clientId, params.id), isNull(addresses.archivedAt)))
+ .orderBy(asc(addresses.label));
+
+ return {
+ records: records.map((record) => ({
+ ...record,
+ formValues: {
+ id: record.id,
+ clientId: record.clientId ?? '',
+ label: record.label ?? '',
+ line1: record.line1 ?? '',
+ line2: record.line2 ?? '',
+ city: record.city ?? '',
+ region: record.region ?? '',
+ postcode: record.postcode ?? '',
+ country: record.country ?? '',
+ isPrimary: record.isPrimary ? 'true' : 'false'
+ }
+ })),
+ options: await loadOptions(),
+ createForm: await superValidate({ clientId: params.id }, zod4(addressCreateSchema), {
+ id: 'addresses-create'
+ }),
+ editForm: await superValidate(zod4(addressEditSchema), { id: 'addresses-edit' }),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' })
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(addressCreateSchema), {
+ id: 'addresses-create'
+ });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db.insert(addresses).values({
+ id: crypto.randomUUID(),
+ clientId: form.data.clientId,
+ label: form.data.label,
+ line1: form.data.line1,
+ line2: form.data.line2 || null,
+ city: form.data.city,
+ region: form.data.region || null,
+ postcode: form.data.postcode,
+ country: form.data.country,
+ isPrimary: form.data.isPrimary === 'true',
+ updatedAt: new Date(),
+ createdAt: new Date()
+ });
+ } catch {
+ return message(form, 'Unable to create address.', { status: 400 });
+ }
+
+ return message(form, 'Address created.');
+ },
+
+ edit: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(addressEditSchema), { id: 'addresses-edit' });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db
+ .update(addresses)
+ .set({
+ clientId: form.data.clientId,
+ label: form.data.label,
+ line1: form.data.line1,
+ line2: form.data.line2 || null,
+ city: form.data.city,
+ region: form.data.region || null,
+ postcode: form.data.postcode,
+ country: form.data.country,
+ isPrimary: form.data.isPrimary === 'true',
+ updatedAt: new Date()
+ })
+ .where(and(eq(addresses.id, form.data.id), eq(addresses.clientId, params.id)));
+ } catch {
+ return message(form, 'Unable to update address.', { status: 400 });
+ }
+
+ return message(form, 'Address updated.');
+ },
+
+ archive: async ({ params, request }) => {
+ const form = await superValidate(await request.formData(), 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(and(eq(addresses.id, form.data.id), eq(addresses.clientId, params.id)));
+
+ return message(form, 'Address archived.');
+ }
+};
diff --git a/src/routes/dashboard/clients/[id]/addresses/+page.svelte b/src/routes/dashboard/clients/[id]/addresses/+page.svelte
new file mode 100644
index 0000000..e37f9df
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/addresses/+page.svelte
@@ -0,0 +1,133 @@
+
+
+
+ Addresses | Clearity
+
+
+
+
+
+
Addresses
+
Track postal and billing addresses for clients.
+
+
+
+
+
+
+
+
+ (editingId = null)}
+/>
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/clients/[id]/addresses/addresses-table.svelte b/src/routes/dashboard/clients/[id]/addresses/addresses-table.svelte
new file mode 100644
index 0000000..a114fc5
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/addresses/addresses-table.svelte
@@ -0,0 +1,74 @@
+
+
+{#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}
+
+ openEdit(record)}>Edit
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No addresses have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/clients/[id]/addresses/archive-address-dialog.svelte b/src/routes/dashboard/clients/[id]/addresses/archive-address-dialog.svelte
new file mode 100644
index 0000000..3a10450
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/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}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/addresses/create-address-dialog.svelte b/src/routes/dashboard/clients/[id]/addresses/create-address-dialog.svelte
new file mode 100644
index 0000000..d5d7f6d
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/addresses/create-address-dialog.svelte
@@ -0,0 +1,136 @@
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ Create address
+ Add a new address record.
+
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/addresses/edit-address-dialog.svelte b/src/routes/dashboard/clients/[id]/addresses/edit-address-dialog.svelte
new file mode 100644
index 0000000..3fa046a
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/addresses/edit-address-dialog.svelte
@@ -0,0 +1,135 @@
+
+
+ !open && onClose()}>
+
+
+ Edit address
+ Update this address record.
+
+ {#if editingRecord}
+
+ {/if}
+
+
diff --git a/src/routes/dashboard/clients/[id]/bookings/+page.server.ts b/src/routes/dashboard/clients/[id]/bookings/+page.server.ts
new file mode 100644
index 0000000..3c7574a
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/bookings/+page.server.ts
@@ -0,0 +1,143 @@
+import { and, 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 { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.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 ({ params }) => {
+ const records = await db
+ .select()
+ .from(bookings)
+ .where(and(eq(bookings.clientId, params.id), isNull(bookings.archivedAt)))
+ .orderBy(asc(bookings.startsAt));
+
+ return {
+ records: records.map((record) => ({
+ ...record,
+ formValues: {
+ id: record.id,
+ clientId: record.clientId ?? '',
+ roomId: record.roomId ?? '',
+ serviceId: record.serviceId ?? '',
+ startsAt: record.startsAt ?? '',
+ endsAt: record.endsAt ?? '',
+ status: record.status ?? '',
+ notes: record.notes ?? ''
+ }
+ })),
+ options: await loadOptions(),
+ createForm: await superValidate({ clientId: params.id }, zod4(bookingCreateSchema), {
+ id: 'bookings-create'
+ }),
+ editForm: await superValidate(zod4(bookingEditSchema), { id: 'bookings-edit' }),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' })
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(bookingCreateSchema), {
+ id: 'bookings-create'
+ });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db.insert(bookings).values({
+ id: crypto.randomUUID(),
+ clientId: form.data.clientId,
+ roomId: form.data.roomId,
+ serviceId: form.data.serviceId || null,
+ startsAt: form.data.startsAt,
+ endsAt: form.data.endsAt,
+ status: form.data.status,
+ notes: form.data.notes || null,
+ updatedAt: new Date(),
+ createdAt: new Date()
+ });
+ } catch {
+ return message(form, 'Unable to create booking.', { status: 400 });
+ }
+
+ return message(form, 'Booking created.');
+ },
+
+ edit: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(bookingEditSchema), {
+ id: 'bookings-edit'
+ });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db
+ .update(bookings)
+ .set({
+ clientId: form.data.clientId,
+ roomId: form.data.roomId,
+ serviceId: form.data.serviceId || null,
+ startsAt: form.data.startsAt,
+ endsAt: form.data.endsAt,
+ status: form.data.status,
+ notes: form.data.notes || null,
+ updatedAt: new Date()
+ })
+ .where(and(eq(bookings.id, form.data.id), eq(bookings.clientId, params.id)));
+ } catch {
+ return message(form, 'Unable to update booking.', { status: 400 });
+ }
+
+ return message(form, 'Booking updated.');
+ },
+
+ archive: async ({ params, request }) => {
+ const form = await superValidate(await request.formData(), 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(and(eq(bookings.id, form.data.id), eq(bookings.clientId, params.id)));
+
+ return message(form, 'Booking archived.');
+ }
+};
diff --git a/src/routes/dashboard/clients/[id]/bookings/+page.svelte b/src/routes/dashboard/clients/[id]/bookings/+page.svelte
new file mode 100644
index 0000000..5087184
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/bookings/+page.svelte
@@ -0,0 +1,156 @@
+
+
+
+ Bookings | Clearity
+
+
+
+
+
+
Bookings
+
+ Track client room bookings and service reservations.
+
+
+
+
+
+
+
+
+
+ (editingId = null)}
+/>
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/clients/[id]/bookings/archive-booking-dialog.svelte b/src/routes/dashboard/clients/[id]/bookings/archive-booking-dialog.svelte
new file mode 100644
index 0000000..b09edb8
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/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}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/bookings/bookings-table.svelte b/src/routes/dashboard/clients/[id]/bookings/bookings-table.svelte
new file mode 100644
index 0000000..b33d7c3
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/bookings/bookings-table.svelte
@@ -0,0 +1,78 @@
+
+
+{#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}
+
+ openEdit(record)}>Edit
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No bookings have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/clients/[id]/bookings/create-booking-dialog.svelte b/src/routes/dashboard/clients/[id]/bookings/create-booking-dialog.svelte
new file mode 100644
index 0000000..47b4722
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/bookings/create-booking-dialog.svelte
@@ -0,0 +1,128 @@
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ Create booking
+ Add a new booking record.
+
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/bookings/edit-booking-dialog.svelte b/src/routes/dashboard/clients/[id]/bookings/edit-booking-dialog.svelte
new file mode 100644
index 0000000..3a644e9
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/bookings/edit-booking-dialog.svelte
@@ -0,0 +1,127 @@
+
+
+ !open && onClose()}>
+
+
+ Edit booking
+ Update this booking record.
+
+ {#if editingRecord}
+
+ {/if}
+
+
diff --git a/src/routes/dashboard/clients/[id]/contacts/+page.server.ts b/src/routes/dashboard/clients/[id]/contacts/+page.server.ts
new file mode 100644
index 0000000..03c4d83
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contacts/+page.server.ts
@@ -0,0 +1,124 @@
+import { and, 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 { contacts, clients } from '$lib/server/db/schema';
+import { archiveSchema } from '$lib/schemas/shared.schema';
+import { contactCreateSchema, contactEditSchema } from '$lib/schemas/contacts.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 ({ params }) => {
+ const records = await db
+ .select()
+ .from(contacts)
+ .where(and(eq(contacts.clientId, params.id), isNull(contacts.archivedAt)))
+ .orderBy(asc(contacts.name));
+
+ return {
+ records: records.map((record) => ({
+ ...record,
+ formValues: {
+ id: record.id,
+ clientId: record.clientId ?? '',
+ name: record.name ?? '',
+ role: record.role ?? '',
+ email: record.email ?? '',
+ phone: record.phone ?? '',
+ isPrimary: record.isPrimary ? 'true' : 'false',
+ notes: record.notes ?? ''
+ }
+ })),
+ options: await loadOptions(),
+ createForm: await superValidate({ clientId: params.id }, zod4(contactCreateSchema), {
+ id: 'contacts-create'
+ }),
+ editForm: await superValidate(zod4(contactEditSchema), { id: 'contacts-edit' }),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' })
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(contactCreateSchema), {
+ id: 'contacts-create'
+ });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db.insert(contacts).values({
+ id: crypto.randomUUID(),
+ clientId: form.data.clientId,
+ name: form.data.name,
+ role: form.data.role || null,
+ email: form.data.email || null,
+ phone: form.data.phone || null,
+ isPrimary: form.data.isPrimary === 'true',
+ notes: form.data.notes || null,
+ updatedAt: new Date(),
+ createdAt: new Date()
+ });
+ } catch {
+ return message(form, 'Unable to create contact.', { status: 400 });
+ }
+
+ return message(form, 'Contact created.');
+ },
+
+ edit: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(contactEditSchema), { id: 'contacts-edit' });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db
+ .update(contacts)
+ .set({
+ clientId: form.data.clientId,
+ name: form.data.name,
+ role: form.data.role || null,
+ email: form.data.email || null,
+ phone: form.data.phone || null,
+ isPrimary: form.data.isPrimary === 'true',
+ notes: form.data.notes || null,
+ updatedAt: new Date()
+ })
+ .where(and(eq(contacts.id, form.data.id), eq(contacts.clientId, params.id)));
+ } catch {
+ return message(form, 'Unable to update contact.', { status: 400 });
+ }
+
+ return message(form, 'Contact updated.');
+ },
+
+ archive: async ({ params, request }) => {
+ const form = await superValidate(await request.formData(), zod4(archiveSchema), {
+ id: 'contacts-archive'
+ });
+
+ if (!form.valid) return message(form, 'Contact id is required.', { status: 400 });
+
+ await db
+ .update(contacts)
+ .set({ archivedAt: new Date(), updatedAt: new Date() })
+ .where(and(eq(contacts.id, form.data.id), eq(contacts.clientId, params.id)));
+
+ return message(form, 'Contact archived.');
+ }
+};
diff --git a/src/routes/dashboard/clients/[id]/contacts/+page.svelte b/src/routes/dashboard/clients/[id]/contacts/+page.svelte
new file mode 100644
index 0000000..979d1d1
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contacts/+page.svelte
@@ -0,0 +1,133 @@
+
+
+
+ Contacts | Clearity
+
+
+
+
+
+
Contacts
+
Manage client contacts and decision makers.
+
+
+
+
+
+
+
+
+ (editingId = null)}
+/>
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/clients/[id]/contacts/archive-contact-dialog.svelte b/src/routes/dashboard/clients/[id]/contacts/archive-contact-dialog.svelte
new file mode 100644
index 0000000..9f679b5
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contacts/archive-contact-dialog.svelte
@@ -0,0 +1,40 @@
+
+
+ !open && onClose()}>
+
+
+ Archive contact?
+ This will hide {recordName(archivingRecord)} from active contacts. The record will remain in
+ the database.
+
+
+ Cancel
+ {#if archivingRecord}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/contacts/contacts-table.svelte b/src/routes/dashboard/clients/[id]/contacts/contacts-table.svelte
new file mode 100644
index 0000000..6f316c7
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contacts/contacts-table.svelte
@@ -0,0 +1,74 @@
+
+
+{#if data.records.length > 0}
+
+
+
+
+ Client
+ Name
+ Role
+ Email
+ Phone
+ Primary
+ Actions
+
+
+
+ {#each data.records as record (record.id)}
+
+ {relationLabel(record.clientId, 'clients')}
+ {formatValue(record.name)}
+ {formatValue(record.role)}
+ {formatValue(record.email)}
+ {formatValue(record.phone)}
+ {record.isPrimary ? 'Yes' : 'No'}
+
+
+ {#snippet child({ props })}{/snippet}
+
+ openEdit(record)}>Edit
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No contacts have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/clients/[id]/contacts/create-contact-dialog.svelte b/src/routes/dashboard/clients/[id]/contacts/create-contact-dialog.svelte
new file mode 100644
index 0000000..343d0ac
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contacts/create-contact-dialog.svelte
@@ -0,0 +1,115 @@
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ Create contact
+ Add a new contact record.
+
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/contacts/edit-contact-dialog.svelte b/src/routes/dashboard/clients/[id]/contacts/edit-contact-dialog.svelte
new file mode 100644
index 0000000..979d4a3
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contacts/edit-contact-dialog.svelte
@@ -0,0 +1,114 @@
+
+
+ !open && onClose()}>
+
+
+ Edit contact
+ Update this contact record.
+
+ {#if editingRecord}
+
+ {/if}
+
+
diff --git a/src/routes/dashboard/clients/[id]/contracts/+page.server.ts b/src/routes/dashboard/clients/[id]/contracts/+page.server.ts
new file mode 100644
index 0000000..e9dd191
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contracts/+page.server.ts
@@ -0,0 +1,126 @@
+import { and, 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 { contracts, clients } from '$lib/server/db/schema';
+import { archiveSchema } from '$lib/schemas/shared.schema';
+import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.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 ({ params }) => {
+ const records = await db
+ .select()
+ .from(contracts)
+ .where(and(eq(contracts.clientId, params.id), isNull(contracts.archivedAt)))
+ .orderBy(asc(contracts.title));
+
+ return {
+ records: records.map((record) => ({
+ ...record,
+ formValues: {
+ id: record.id,
+ clientId: record.clientId ?? '',
+ title: record.title ?? '',
+ startDate: record.startDate ?? '',
+ endDate: record.endDate ?? '',
+ status: record.status ?? '',
+ valueGbp: record.valueGbp ?? '',
+ notes: record.notes ?? ''
+ }
+ })),
+ options: await loadOptions(),
+ createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), {
+ id: 'contracts-create'
+ }),
+ editForm: await superValidate(zod4(contractEditSchema), { id: 'contracts-edit' }),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(contractCreateSchema), {
+ id: 'contracts-create'
+ });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db.insert(contracts).values({
+ id: crypto.randomUUID(),
+ clientId: form.data.clientId,
+ title: form.data.title,
+ startDate: form.data.startDate,
+ endDate: form.data.endDate || null,
+ status: form.data.status,
+ valueGbp: form.data.valueGbp,
+ notes: form.data.notes || null,
+ updatedAt: new Date(),
+ createdAt: new Date()
+ });
+ } catch {
+ return message(form, 'Unable to create contract.', { status: 400 });
+ }
+
+ return message(form, 'Contract created.');
+ },
+
+ edit: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(contractEditSchema), {
+ id: 'contracts-edit'
+ });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db
+ .update(contracts)
+ .set({
+ clientId: form.data.clientId,
+ title: form.data.title,
+ startDate: form.data.startDate,
+ endDate: form.data.endDate || null,
+ status: form.data.status,
+ valueGbp: form.data.valueGbp,
+ notes: form.data.notes || null,
+ updatedAt: new Date()
+ })
+ .where(and(eq(contracts.id, form.data.id), eq(contracts.clientId, params.id)));
+ } catch {
+ return message(form, 'Unable to update contract.', { status: 400 });
+ }
+
+ return message(form, 'Contract updated.');
+ },
+
+ archive: async ({ params, request }) => {
+ const form = await superValidate(await request.formData(), zod4(archiveSchema), {
+ id: 'contracts-archive'
+ });
+
+ if (!form.valid) return message(form, 'Contract id is required.', { status: 400 });
+
+ await db
+ .update(contracts)
+ .set({ archivedAt: new Date(), updatedAt: new Date() })
+ .where(and(eq(contracts.id, form.data.id), eq(contracts.clientId, params.id)));
+
+ return message(form, 'Contract archived.');
+ }
+};
diff --git a/src/routes/dashboard/clients/[id]/contracts/+page.svelte b/src/routes/dashboard/clients/[id]/contracts/+page.svelte
new file mode 100644
index 0000000..d93ca82
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contracts/+page.svelte
@@ -0,0 +1,159 @@
+
+
+
+ Contracts | Clearity
+
+
+
+
+
+
Contracts
+
Manage client agreements and commercial terms.
+
+
+
+
+
+
+
+
+ (editingId = null)}
+/>
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/clients/[id]/contracts/archive-contract-dialog.svelte b/src/routes/dashboard/clients/[id]/contracts/archive-contract-dialog.svelte
new file mode 100644
index 0000000..66d0e08
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contracts/archive-contract-dialog.svelte
@@ -0,0 +1,40 @@
+
+
+ !open && onClose()}>
+
+
+ Archive contract?
+ This will hide {recordName(archivingRecord)} from active contracts. The record will remain in
+ the database.
+
+
+ Cancel
+ {#if archivingRecord}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/contracts/contracts-table.svelte b/src/routes/dashboard/clients/[id]/contracts/contracts-table.svelte
new file mode 100644
index 0000000..71a2a19
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contracts/contracts-table.svelte
@@ -0,0 +1,82 @@
+
+
+{#if data.records.length > 0}
+
+
+
+
+ Title
+ Client
+ Starts
+ Ends
+ Status
+ Value
+ Actions
+
+
+
+ {#each data.records as record (record.id)}
+
+ {formatValue(record.title)}
+ {relationLabel(record.clientId, 'clients')}
+ {formatDate(record.startDate)}
+ {formatDate(record.endDate)}
+ {formatValue(record.status)}
+ {formatMoney(record.valueGbp)}
+
+
+ {#snippet child({ props })}{/snippet}
+
+ openEdit(record)}>Edit
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No contracts have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/clients/[id]/contracts/create-contract-dialog.svelte b/src/routes/dashboard/clients/[id]/contracts/create-contract-dialog.svelte
new file mode 100644
index 0000000..b83a70e
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contracts/create-contract-dialog.svelte
@@ -0,0 +1,114 @@
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ Create contract
+ Add a new contract record.
+
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/contracts/edit-contract-dialog.svelte b/src/routes/dashboard/clients/[id]/contracts/edit-contract-dialog.svelte
new file mode 100644
index 0000000..f4782dd
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/contracts/edit-contract-dialog.svelte
@@ -0,0 +1,113 @@
+
+
+ !open && onClose()}>
+
+
+ Edit contract
+ Update this contract record.
+
+ {#if editingRecord}
+
+ {/if}
+
+
diff --git a/src/routes/dashboard/clients/[id]/invoices/+page.server.ts b/src/routes/dashboard/clients/[id]/invoices/+page.server.ts
new file mode 100644
index 0000000..ce9c5cb
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/invoices/+page.server.ts
@@ -0,0 +1,130 @@
+import { and, 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 { invoices, clients } from '$lib/server/db/schema';
+import { archiveSchema } from '$lib/schemas/shared.schema';
+import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.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 ({ params }) => {
+ const records = await db
+ .select()
+ .from(invoices)
+ .where(and(eq(invoices.clientId, params.id), isNull(invoices.archivedAt)))
+ .orderBy(asc(invoices.invoiceNumber));
+
+ return {
+ records: records.map((record) => ({
+ ...record,
+ formValues: {
+ id: record.id,
+ clientId: record.clientId ?? '',
+ invoiceNumber: record.invoiceNumber ?? '',
+ issueDate: record.issueDate ?? '',
+ dueDate: record.dueDate ?? '',
+ status: record.status ?? '',
+ subtotalGbp: record.subtotalGbp ?? '',
+ taxGbp: record.taxGbp ?? '',
+ totalGbp: record.totalGbp ?? '',
+ notes: record.notes ?? ''
+ }
+ })),
+ options: await loadOptions(),
+ createForm: await superValidate({ clientId: params.id }, zod4(invoiceCreateSchema), {
+ id: 'invoices-create'
+ }),
+ editForm: await superValidate(zod4(invoiceEditSchema), { id: 'invoices-edit' }),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' })
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(invoiceCreateSchema), {
+ id: 'invoices-create'
+ });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db.insert(invoices).values({
+ id: crypto.randomUUID(),
+ clientId: form.data.clientId,
+ invoiceNumber: form.data.invoiceNumber,
+ issueDate: form.data.issueDate,
+ dueDate: form.data.dueDate,
+ status: form.data.status,
+ subtotalGbp: form.data.subtotalGbp,
+ taxGbp: form.data.taxGbp,
+ totalGbp: form.data.totalGbp,
+ notes: form.data.notes || null,
+ updatedAt: new Date(),
+ createdAt: new Date()
+ });
+ } catch {
+ return message(form, 'Unable to create invoice.', { status: 400 });
+ }
+
+ return message(form, 'Invoice created.');
+ },
+
+ edit: async ({ params, request }) => {
+ const formData = await request.formData();
+ formData.set('clientId', params.id);
+ const form = await superValidate(formData, zod4(invoiceEditSchema), { id: 'invoices-edit' });
+
+ if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
+
+ try {
+ await db
+ .update(invoices)
+ .set({
+ clientId: form.data.clientId,
+ invoiceNumber: form.data.invoiceNumber,
+ issueDate: form.data.issueDate,
+ dueDate: form.data.dueDate,
+ status: form.data.status,
+ subtotalGbp: form.data.subtotalGbp,
+ taxGbp: form.data.taxGbp,
+ totalGbp: form.data.totalGbp,
+ notes: form.data.notes || null,
+ updatedAt: new Date()
+ })
+ .where(and(eq(invoices.id, form.data.id), eq(invoices.clientId, params.id)));
+ } catch {
+ return message(form, 'Unable to update invoice.', { status: 400 });
+ }
+
+ return message(form, 'Invoice updated.');
+ },
+
+ archive: async ({ params, request }) => {
+ const form = await superValidate(await request.formData(), zod4(archiveSchema), {
+ id: 'invoices-archive'
+ });
+
+ if (!form.valid) return message(form, 'Invoice id is required.', { status: 400 });
+
+ await db
+ .update(invoices)
+ .set({ archivedAt: new Date(), updatedAt: new Date() })
+ .where(and(eq(invoices.id, form.data.id), eq(invoices.clientId, params.id)));
+
+ return message(form, 'Invoice archived.');
+ }
+};
diff --git a/src/routes/dashboard/clients/[id]/invoices/+page.svelte b/src/routes/dashboard/clients/[id]/invoices/+page.svelte
new file mode 100644
index 0000000..930d0c8
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/invoices/+page.svelte
@@ -0,0 +1,159 @@
+
+
+
+ Invoices | Clearity
+
+
+
+
+
+
Invoices
+
Manage invoice records and payment status.
+
+
+
+
+
+
+
+
+ (editingId = null)}
+/>
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/clients/[id]/invoices/archive-invoice-dialog.svelte b/src/routes/dashboard/clients/[id]/invoices/archive-invoice-dialog.svelte
new file mode 100644
index 0000000..4eb0f86
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/invoices/archive-invoice-dialog.svelte
@@ -0,0 +1,40 @@
+
+
+ !open && onClose()}>
+
+
+ Archive invoice?
+ This will hide {recordName(archivingRecord)} from active invoices. The record will remain in
+ the database.
+
+
+ Cancel
+ {#if archivingRecord}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/invoices/create-invoice-dialog.svelte b/src/routes/dashboard/clients/[id]/invoices/create-invoice-dialog.svelte
new file mode 100644
index 0000000..0f2ad86
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/invoices/create-invoice-dialog.svelte
@@ -0,0 +1,129 @@
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ Create invoice
+ Add a new invoice record.
+
+
+
+
diff --git a/src/routes/dashboard/clients/[id]/invoices/edit-invoice-dialog.svelte b/src/routes/dashboard/clients/[id]/invoices/edit-invoice-dialog.svelte
new file mode 100644
index 0000000..96130a9
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/invoices/edit-invoice-dialog.svelte
@@ -0,0 +1,122 @@
+
+
+ !open && onClose()}>
+
+
+ Edit invoice
+ Update this invoice record.
+
+ {#if editingRecord}
+
+ {/if}
+
+
diff --git a/src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte b/src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte
new file mode 100644
index 0000000..4e46465
--- /dev/null
+++ b/src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte
@@ -0,0 +1,82 @@
+
+
+{#if data.records.length > 0}
+
+
+
+
+ Invoice #
+ Client
+ Issued
+ Due
+ Status
+ Total
+ Actions
+
+
+
+ {#each data.records as record (record.id)}
+
+ {formatValue(record.invoiceNumber)}
+ {relationLabel(record.clientId, 'clients')}
+ {formatDate(record.issueDate)}
+ {formatDate(record.dueDate)}
+ {formatValue(record.status)}
+ {formatMoney(record.totalGbp)}
+
+
+ {#snippet child({ props })}{/snippet}
+
+ openEdit(record)}>Edit
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No invoices have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/clients/archive-client-dialog.svelte b/src/routes/dashboard/clients/archive-client-dialog.svelte
new file mode 100644
index 0000000..95bad88
--- /dev/null
+++ b/src/routes/dashboard/clients/archive-client-dialog.svelte
@@ -0,0 +1,39 @@
+
+
+ !open && onClose()}>
+
+
+ Archive client?
+ This will hide {recordName(archivingRecord)} from active clients. The record will remain in the
+ database.
+
+
+ Cancel
+ {#if archivingRecord}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/clients/clients-table.svelte b/src/routes/dashboard/clients/clients-table.svelte
new file mode 100644
index 0000000..49575b3
--- /dev/null
+++ b/src/routes/dashboard/clients/clients-table.svelte
@@ -0,0 +1,86 @@
+
+
+{#if data.records.length > 0}
+
+
+
+
+ Name
+ Type
+ Status
+ Actions
+
+
+
+ {#each data.records as record (record.id)}
+
+ {formatValue(record.name)}
+ {formatValue(record.type)}
+ {formatValue(record.status)}
+
+
+ {#snippet child({ props })}{/snippet}
+
+ goto(resolve('/dashboard/clients/[id]', { id: record.id }))}
+ >View
+ openEdit(record)}>Edit
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No clients have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/clients/create-client-dialog.svelte b/src/routes/dashboard/clients/create-client-dialog.svelte
new file mode 100644
index 0000000..0ec7a16
--- /dev/null
+++ b/src/routes/dashboard/clients/create-client-dialog.svelte
@@ -0,0 +1,290 @@
+
+
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+ Create client
+ Add a new client record.
+
+
+
+
diff --git a/src/routes/dashboard/clients/edit-client-dialog.svelte b/src/routes/dashboard/clients/edit-client-dialog.svelte
new file mode 100644
index 0000000..b3ebb4d
--- /dev/null
+++ b/src/routes/dashboard/clients/edit-client-dialog.svelte
@@ -0,0 +1,114 @@
+
+
+ !open && onClose()}>
+
+
+ Edit client
+ Update this client record.
+
+ {#if editingRecord}
+
+ {/if}
+
+
diff --git a/src/routes/dashboard/contacts/+page.server.ts b/src/routes/dashboard/contacts/+page.server.ts
new file mode 100644
index 0000000..6aa04c6
--- /dev/null
+++ b/src/routes/dashboard/contacts/+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 { contacts, 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(contacts)
+ .where(isNull(contacts.archivedAt))
+ .orderBy(asc(contacts.name));
+
+ return {
+ records,
+ options: await loadOptions(),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' })
+ };
+};
+
+export const actions: Actions = {
+ archive: async (event) => {
+ const form = await superValidate(event, zod4(archiveSchema), { id: 'contacts-archive' });
+
+ if (!form.valid) return message(form, 'Contact id is required.', { status: 400 });
+
+ await db
+ .update(contacts)
+ .set({ archivedAt: new Date(), updatedAt: new Date() })
+ .where(eq(contacts.id, form.data.id));
+
+ return message(form, 'Contact archived.');
+ }
+};
diff --git a/src/routes/dashboard/contacts/+page.svelte b/src/routes/dashboard/contacts/+page.svelte
new file mode 100644
index 0000000..ea0b780
--- /dev/null
+++ b/src/routes/dashboard/contacts/+page.svelte
@@ -0,0 +1,97 @@
+
+
+
+ Contacts | Clearity
+
+
+
+
+
+
Contacts
+
Manage client contacts and decision makers.
+
+
+
+
+
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/contacts/archive-contact-dialog.svelte b/src/routes/dashboard/contacts/archive-contact-dialog.svelte
new file mode 100644
index 0000000..9f679b5
--- /dev/null
+++ b/src/routes/dashboard/contacts/archive-contact-dialog.svelte
@@ -0,0 +1,40 @@
+
+
+ !open && onClose()}>
+
+
+ Archive contact?
+ This will hide {recordName(archivingRecord)} from active contacts. The record will remain in
+ the database.
+
+
+ Cancel
+ {#if archivingRecord}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/contacts/contacts-table.svelte b/src/routes/dashboard/contacts/contacts-table.svelte
new file mode 100644
index 0000000..c25e89e
--- /dev/null
+++ b/src/routes/dashboard/contacts/contacts-table.svelte
@@ -0,0 +1,66 @@
+
+
+{#if data.records.length > 0}
+
+
+
+
+ Client
+ Name
+ Role
+ Email
+ Phone
+ Primary
+ Actions
+
+
+
+ {#each data.records as record (record.id)}
+
+ {relationLabel(record.clientId, 'clients')}
+ {formatValue(record.name)}
+ {formatValue(record.role)}
+ {formatValue(record.email)}
+ {formatValue(record.phone)}
+ {record.isPrimary ? 'Yes' : 'No'}
+
+
+ {#snippet child({ props })}{/snippet}
+
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No contacts have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/contracts/+page.server.ts b/src/routes/dashboard/contracts/+page.server.ts
new file mode 100644
index 0000000..2d45db9
--- /dev/null
+++ b/src/routes/dashboard/contracts/+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 { contracts, 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(contracts)
+ .where(isNull(contracts.archivedAt))
+ .orderBy(asc(contracts.title));
+
+ return {
+ records,
+ options: await loadOptions(),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
+ };
+};
+
+export const actions: Actions = {
+ archive: async (event) => {
+ const form = await superValidate(event, zod4(archiveSchema), { id: 'contracts-archive' });
+
+ if (!form.valid) return message(form, 'Contract id is required.', { status: 400 });
+
+ await db
+ .update(contracts)
+ .set({ archivedAt: new Date(), updatedAt: new Date() })
+ .where(eq(contracts.id, form.data.id));
+
+ return message(form, 'Contract archived.');
+ }
+};
diff --git a/src/routes/dashboard/contracts/+page.svelte b/src/routes/dashboard/contracts/+page.svelte
new file mode 100644
index 0000000..1881979
--- /dev/null
+++ b/src/routes/dashboard/contracts/+page.svelte
@@ -0,0 +1,122 @@
+
+
+
+ Contracts | Clearity
+
+
+
+
+
+
Contracts
+
Manage client agreements and commercial terms.
+
+
+
+
+
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/contracts/archive-contract-dialog.svelte b/src/routes/dashboard/contracts/archive-contract-dialog.svelte
new file mode 100644
index 0000000..66d0e08
--- /dev/null
+++ b/src/routes/dashboard/contracts/archive-contract-dialog.svelte
@@ -0,0 +1,40 @@
+
+
+ !open && onClose()}>
+
+
+ Archive contract?
+ This will hide {recordName(archivingRecord)} from active contracts. The record will remain in
+ the database.
+
+
+ Cancel
+ {#if archivingRecord}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/contracts/contracts-table.svelte b/src/routes/dashboard/contracts/contracts-table.svelte
new file mode 100644
index 0000000..096ce98
--- /dev/null
+++ b/src/routes/dashboard/contracts/contracts-table.svelte
@@ -0,0 +1,79 @@
+
+
+{#if data.records.length > 0}
+
+
+
+
+ Title
+ Client
+ Starts
+ Ends
+ Status
+ Value
+ Actions
+
+
+
+ {#each data.records as record (record.id)}
+
+ {formatValue(record.title)}
+ {relationLabel(record.clientId, 'clients')}
+ {formatDate(record.startDate)}
+ {formatDate(record.endDate)}
+ {formatValue(record.status)}
+ {formatMoney(record.valueGbp)}
+
+
+ {#snippet child({ props })}{/snippet}
+
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No contracts have been created yet.
+
+{/if}
diff --git a/src/routes/dashboard/invoices/+page.server.ts b/src/routes/dashboard/invoices/+page.server.ts
new file mode 100644
index 0000000..4e7b793
--- /dev/null
+++ b/src/routes/dashboard/invoices/+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 { invoices, 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(invoices)
+ .where(isNull(invoices.archivedAt))
+ .orderBy(asc(invoices.invoiceNumber));
+
+ return {
+ records,
+ options: await loadOptions(),
+ archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' })
+ };
+};
+
+export const actions: Actions = {
+ archive: async (event) => {
+ const form = await superValidate(event, zod4(archiveSchema), { id: 'invoices-archive' });
+
+ if (!form.valid) return message(form, 'Invoice id is required.', { status: 400 });
+
+ await db
+ .update(invoices)
+ .set({ archivedAt: new Date(), updatedAt: new Date() })
+ .where(eq(invoices.id, form.data.id));
+
+ return message(form, 'Invoice archived.');
+ }
+};
diff --git a/src/routes/dashboard/invoices/+page.svelte b/src/routes/dashboard/invoices/+page.svelte
new file mode 100644
index 0000000..e1e4b63
--- /dev/null
+++ b/src/routes/dashboard/invoices/+page.svelte
@@ -0,0 +1,122 @@
+
+
+
+ Invoices | Clearity
+
+
+
+
+
+
Invoices
+
Manage invoice records and payment status.
+
+
+
+
+
+
+ (archivingId = null)}
+/>
diff --git a/src/routes/dashboard/invoices/archive-invoice-dialog.svelte b/src/routes/dashboard/invoices/archive-invoice-dialog.svelte
new file mode 100644
index 0000000..4eb0f86
--- /dev/null
+++ b/src/routes/dashboard/invoices/archive-invoice-dialog.svelte
@@ -0,0 +1,40 @@
+
+
+ !open && onClose()}>
+
+
+ Archive invoice?
+ This will hide {recordName(archivingRecord)} from active invoices. The record will remain in
+ the database.
+
+
+ Cancel
+ {#if archivingRecord}
+
+ {/if}
+
+
+
diff --git a/src/routes/dashboard/invoices/invoices-table.svelte b/src/routes/dashboard/invoices/invoices-table.svelte
new file mode 100644
index 0000000..0bfa8ae
--- /dev/null
+++ b/src/routes/dashboard/invoices/invoices-table.svelte
@@ -0,0 +1,79 @@
+
+
+{#if data.records.length > 0}
+
+
+
+
+ Invoice #
+ Client
+ Issued
+ Due
+ Status
+ Total
+ Actions
+
+
+
+ {#each data.records as record (record.id)}
+
+ {formatValue(record.invoiceNumber)}
+ {relationLabel(record.clientId, 'clients')}
+ {formatDate(record.issueDate)}
+ {formatDate(record.dueDate)}
+ {formatValue(record.status)}
+ {formatMoney(record.totalGbp)}
+
+
+ {#snippet child({ props })}{/snippet}
+
+ openArchive(record)}
+ >Archive
+
+
+
+
+ {/each}
+
+
+
+{:else}
+
+ No invoices have been created yet.
+
+{/if}