feat: add client workspace management

This commit is contained in:
2026-06-05 23:25:36 +01:00
parent 930e83344c
commit 631a5112f4
72 changed files with 6423 additions and 0 deletions
@@ -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.');
}
};
@@ -0,0 +1,97 @@
<script lang="ts">
import AddressesTable from './addresses-table.svelte';
import ArchiveAddressDialog from './archive-address-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'addresses-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'addresses-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this address'
);
}
</script>
<svelte:head>
<title>Addresses | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Addresses</h1>
<p class="text-sm text-muted-foreground">Track postal and billing addresses for clients.</p>
</div>
</div>
<AddressesTable {data} {openArchive} {formatValue} {relationLabel} {recordName} />
</div>
<ArchiveAddressDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,66 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openArchive,
formatValue,
relationLabel,
recordName
}: { data: any; openArchive: any; formatValue: any; relationLabel: any; recordName: any } =
$props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Label</Table.Head>
<Table.Head>Address</Table.Head>
<Table.Head>City</Table.Head>
<Table.Head>Postcode</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatValue(record.label)}</Table.Cell>
<Table.Cell class="">{formatValue(record.line1)}</Table.Cell>
<Table.Cell class="">{formatValue(record.city)}</Table.Cell>
<Table.Cell class="">{formatValue(record.postcode)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No addresses have been created yet.
</div>
{/if}
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive address?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active addresses. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive address</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
+144
View File
@@ -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.');
}
};
+130
View File
@@ -0,0 +1,130 @@
<script lang="ts">
import CreateAdminDialog from './create-admin-dialog.svelte';
import DeleteAdminDialog from './delete-admin-dialog.svelte';
import AdminsTable from './admins-table.svelte';
import EditAdminDialog from './edit-admin-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingUserId = $state<string | null>(null);
let deletingUserId = $state<string | null>(null);
const editingUser = $derived(data.users.find((user) => user.id === editingUserId));
const deletingUser = $derived(data.users.find((user) => user.id === deletingUserId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(createAdminSchema),
onUpdated: ({ form }) => handleFormToast(form, 'create-admin', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'create-admin' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(editAdminSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'edit-admin', () => (editingUserId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'edit-admin' })
});
// svelte-ignore state_referenced_locally
const deleteForm = superForm(data.deleteForm, {
validators: zod4Client(deleteAdminSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'delete-admin', () => (deletingUserId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'delete-admin' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: deleteData, enhance: enhanceDelete } = deleteForm;
function formatDate(value: Date) {
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
}).format(value);
}
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') {
return value[0];
}
}
return 'Check the highlighted fields.';
}
function openEdit(user: PageData['users'][number]) {
editForm.reset({
data: {
id: user.id,
name: user.name,
email: user.email,
password: ''
}
});
editingUserId = user.id;
}
function openDelete(user: PageData['users'][number]) {
deleteForm.reset({
data: {
id: user.id
}
});
deletingUserId = user.id;
}
</script>
<svelte:head>
<title>Admins | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Admins</h1>
<p class="text-sm text-muted-foreground">
Manage Better Auth users with administrator access.
</p>
</div>
<CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<AdminsTable {data} {openEdit} {openDelete} {formatDate} />
</div>
<EditAdminDialog
{editingUser}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingUserId = null)}
/>
<DeleteAdminDialog
{deletingUser}
{deleteData}
{enhanceDelete}
onClose={() => (deletingUserId = null)}
/>
@@ -0,0 +1,76 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openDelete,
formatDate
}: {
data: any;
openEdit: any;
openDelete: any;
formatDate: any;
} = $props();
</script>
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Created</Table.Head>
<Table.Head class="w-12">
<span class="sr-only">Actions</span>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.users as user (user.id)}
<Table.Row>
<Table.Cell class="font-medium">{user.name}</Table.Cell>
<Table.Cell>{user.email}</Table.Cell>
<Table.Cell>
{user.emailVerified ? 'Verified' : 'Unverified'}
</Table.Cell>
<Table.Cell>{formatDate(user.createdAt)}</Table.Cell>
<Table.Cell>
{#if user.id !== data.currentUserId}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${user.name}`}
{...props}
>
<EllipsisVerticalIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(user)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openDelete(user)}>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if data.users.length === 0}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No admin users have been created yet.
</div>
{/if}
@@ -0,0 +1,85 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
let {
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}>
<PlusIcon data-icon="inline-start" />
Create admin
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Create admin</Dialog.Title>
<Dialog.Description>Add a manually managed admin account.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="name">
<Field.Field>
<Control id="create-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} autocomplete="name" bind:value={$createData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="email">
<Field.Field>
<Control id="create-email">
{#snippet children({ props })}
<Field.Label>Email</Field.Label>
<Input {...props} type="email" autocomplete="email" bind:value={$createData.email} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="password">
<Field.Field>
<Control id="create-password">
{#snippet children({ props })}
<Field.Label>Password</Field.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
bind:value={$createData.password}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<Button type="submit">Create admin</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,35 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
deletingUser,
deleteData,
enhanceDelete,
onClose
}: {
deletingUser: any;
deleteData: any;
enhanceDelete: any;
onClose: () => void;
} = $props();
</script>
<AlertDialog.Root open={!!deletingUser} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete admin?</AlertDialog.Title>
<AlertDialog.Description>
This will permanently delete {deletingUser?.name ?? 'this admin'} and revoke their sessions.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if deletingUser}
<form method="POST" action="?/delete" use:enhanceDelete>
<input type="hidden" name="id" value={$deleteData.id} />
<AlertDialog.Action type="submit" variant="destructive">Delete admin</AlertDialog.Action>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,82 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
let {
editingUser,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingUser: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingUser} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Edit admin</Dialog.Title>
<Dialog.Description>Update account details or set a new password.</Dialog.Description>
</Dialog.Header>
{#if editingUser}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} autocomplete="name" bind:value={$editData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="email">
<Field.Field>
<Control id="edit-email">
{#snippet children({ props })}
<Field.Label>Email</Field.Label>
<Input {...props} type="email" autocomplete="email" bind:value={$editData.email} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="password">
<Field.Field>
<Control id="edit-password">
{#snippet children({ props })}
<Field.Label>New password</Field.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
placeholder="Leave blank to keep current password"
bind:value={$editData.password}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,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.');
}
};
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import BookingsTable from './bookings-table.svelte';
import ArchiveBookingDialog from './archive-booking-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'bookings-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'bookings-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients' | 'rooms' | 'services') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this booking'
);
}
</script>
<svelte:head>
<title>Bookings | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Bookings</h1>
<p class="text-sm text-muted-foreground">
Track client room bookings and service reservations.
</p>
</div>
</div>
<BookingsTable {data} {openArchive} {formatValue} {formatDate} {relationLabel} {recordName} />
</div>
<ArchiveBookingDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive booking?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active bookings. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive booking</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,75 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openArchive,
formatValue,
formatDate,
relationLabel,
recordName
}: {
data: any;
openArchive: any;
formatValue: any;
formatDate: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Room</Table.Head>
<Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{relationLabel(record.roomId, 'rooms')}</Table.Cell>
<Table.Cell class="">{formatDate(record.startsAt, true)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endsAt, true)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No bookings have been created yet.
</div>
{/if}
@@ -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.');
}
};
+139
View File
@@ -0,0 +1,139 @@
<script lang="ts">
import CreateClientDialog from './create-client-dialog.svelte';
import ClientsTable from './clients-table.svelte';
import EditClientDialog from './edit-client-dialog.svelte';
import ArchiveClientDialog from './archive-client-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { clientEditSchema, clientOnboardingCreateSchema } from '$lib/schemas/clients.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let createStep = $state(0);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(clientOnboardingCreateSchema),
onUpdated: ({ form }) =>
handleFormToast(form, 'clients-create', () => {
createOpen = false;
createStep = 0;
}),
onError: ({ result }) => toast.error(result.error.message, { id: 'clients-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(clientEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'clients-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'clients-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'clients-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'clients-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this client');
}
</script>
<svelte:head>
<title>Clients | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Clients</h1>
<p class="text-sm text-muted-foreground">
Manage businesses, sports organisations, and individual clients.
</p>
</div>
<CreateClientDialog
bind:open={createOpen}
bind:createStep
{createForm}
{createData}
{enhanceCreate}
/>
</div>
<ClientsTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
</div>
<EditClientDialog
{editingRecord}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveClientDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -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
};
};
@@ -0,0 +1,220 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { clientEditSchema } from '$lib/schemas/clients.schema';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import type { LayoutData } from './$types';
let {
data,
children
}: {
data: LayoutData;
children: import('svelte').Snippet;
} = $props();
let editOpen = $state(false);
const tabs = [
{ value: 'addresses', label: 'Addresses' },
{ value: 'contacts', label: 'Contacts' },
{ value: 'invoices', label: 'Invoices' },
{ value: 'contracts', label: 'Contracts' },
{ value: 'bookings', label: 'Bookings' }
] as const;
type TabValue = (typeof tabs)[number]['value'];
let activeTab = $derived.by<TabValue>(() => {
const segment = page.url.pathname.split('/').filter(Boolean).at(-1);
return tabs.find((tab) => tab.value === segment)?.value ?? 'addresses';
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(clientEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'clients-edit', () => (editOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'clients-edit' })
});
const { form: editData, enhance: enhanceEdit } = editForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function navigateToTab(tab: TabValue) {
goto(resolve(`/dashboard/clients/[id]/${tab}`, { id: data.client.id }));
}
</script>
<svelte:head>
<title>{data.client.name} | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">{data.client.name}</h1>
<p class="text-sm text-muted-foreground">
Client workspace for contacts, addresses, invoices, contracts, and bookings.
</p>
</div>
<Dialog.Root bind:open={editOpen}>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}>Edit client</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit client</Dialog.Title>
<Dialog.Description>Update this client record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="/dashboard/clients?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-client-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="type">
<Field.Field>
<Control id="edit-client-type">
{#snippet children({ props })}
<Field.Label>Type</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.type}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="business">Business</NativeSelect.Option>
<NativeSelect.Option value="sport">Sport</NativeSelect.Option>
<NativeSelect.Option value="individual">Individual</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-client-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="prospect">Prospect</NativeSelect.Option>
<NativeSelect.Option value="paused">Paused</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="website">
<Field.Field>
<Control id="edit-client-website">
{#snippet children({ props })}
<Field.Label>Website</Field.Label>
<Input
{...props}
type="url"
placeholder="https://example.com"
bind:value={$editData.website}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-client-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
<div class="grid gap-3 rounded-lg border p-4 sm:grid-cols-3">
<div>
<p class="text-xs font-medium text-muted-foreground uppercase">Type</p>
<p class="text-sm capitalize">{data.client.type}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground uppercase">Website</p>
<p class="text-sm">{data.client.website ?? 'Not set'}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground uppercase">Status</p>
<p class="text-sm capitalize">{data.client.status}</p>
</div>
</div>
<Tabs.Root value={activeTab} class="w-full">
<Tabs.List class="flex h-auto flex-wrap justify-start">
{#each tabs as tab (tab.value)}
<Tabs.Trigger
value={tab.value}
onclick={() => navigateToTab(tab.value)}
aria-label={`View ${tab.label.toLowerCase()}`}
>
{tab.label}
</Tabs.Trigger>
{/each}
</Tabs.List>
<div class="mt-4">
{@render children()}
</div>
</Tabs.Root>
</div>
@@ -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 }));
};
@@ -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.');
}
};
@@ -0,0 +1,133 @@
<script lang="ts">
import CreateAddressDialog from './create-address-dialog.svelte';
import AddressesTable from './addresses-table.svelte';
import EditAddressDialog from './edit-address-dialog.svelte';
import ArchiveAddressDialog from './archive-address-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { addressCreateSchema, addressEditSchema } from '$lib/schemas/addresses.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(addressCreateSchema),
onUpdated: ({ form }) => handleFormToast(form, 'addresses-create', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'addresses-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(addressEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'addresses-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'addresses-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'addresses-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'addresses-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this address'
);
}
</script>
<svelte:head>
<title>Addresses | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Addresses</h1>
<p class="text-sm text-muted-foreground">Track postal and billing addresses for clients.</p>
</div>
<CreateAddressDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<AddressesTable {data} {openEdit} {openArchive} {formatValue} {relationLabel} {recordName} />
</div>
<EditAddressDialog
{editingRecord}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveAddressDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,74 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openArchive,
formatValue,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Label</Table.Head>
<Table.Head>Address</Table.Head>
<Table.Head>City</Table.Head>
<Table.Head>Postcode</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatValue(record.label)}</Table.Cell>
<Table.Cell class="">{formatValue(record.line1)}</Table.Cell>
<Table.Cell class="">{formatValue(record.city)}</Table.Cell>
<Table.Cell class="">{formatValue(record.postcode)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No addresses have been created yet.
</div>
{/if}
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive address?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active addresses. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive address</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,136 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let {
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create address</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create address</Dialog.Title>
<Dialog.Description>Add a new address record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="label">
<Field.Field>
<Control id="create-label">
{#snippet children({ props })}
<Field.Label>Label</Field.Label>
<Input {...props} type="text" bind:value={$createData.label} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="line1">
<Field.Field>
<Control id="create-line1">
{#snippet children({ props })}
<Field.Label>Address line 1</Field.Label>
<Input {...props} type="text" bind:value={$createData.line1} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="line2">
<Field.Field>
<Control id="create-line2">
{#snippet children({ props })}
<Field.Label>Address line 2</Field.Label>
<Input {...props} type="text" bind:value={$createData.line2} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="city">
<Field.Field>
<Control id="create-city">
{#snippet children({ props })}
<Field.Label>City</Field.Label>
<Input {...props} type="text" bind:value={$createData.city} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="region">
<Field.Field>
<Control id="create-region">
{#snippet children({ props })}
<Field.Label>Region / county</Field.Label>
<Input {...props} type="text" bind:value={$createData.region} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="postcode">
<Field.Field>
<Control id="create-postcode">
{#snippet children({ props })}
<Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$createData.postcode} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="country">
<Field.Field>
<Control id="create-country">
{#snippet children({ props })}
<Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$createData.country} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="isPrimary">
<Field.Field>
<Control id="create-isPrimary">
{#snippet children({ props })}
<Field.Label>Primary address</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Create address</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,135 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let {
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit address</Dialog.Title>
<Dialog.Description>Update this address record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="label">
<Field.Field>
<Control id="edit-label">
{#snippet children({ props })}
<Field.Label>Label</Field.Label>
<Input {...props} type="text" bind:value={$editData.label} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="line1">
<Field.Field>
<Control id="edit-line1">
{#snippet children({ props })}
<Field.Label>Address line 1</Field.Label>
<Input {...props} type="text" bind:value={$editData.line1} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="line2">
<Field.Field>
<Control id="edit-line2">
{#snippet children({ props })}
<Field.Label>Address line 2</Field.Label>
<Input {...props} type="text" bind:value={$editData.line2} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="city">
<Field.Field>
<Control id="edit-city">
{#snippet children({ props })}
<Field.Label>City</Field.Label>
<Input {...props} type="text" bind:value={$editData.city} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="region">
<Field.Field>
<Control id="edit-region">
{#snippet children({ props })}
<Field.Label>Region / county</Field.Label>
<Input {...props} type="text" bind:value={$editData.region} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="postcode">
<Field.Field>
<Control id="edit-postcode">
{#snippet children({ props })}
<Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$editData.postcode} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="country">
<Field.Field>
<Control id="edit-country">
{#snippet children({ props })}
<Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$editData.country} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="isPrimary">
<Field.Field>
<Control id="edit-isPrimary">
{#snippet children({ props })}
<Field.Label>Primary address</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,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.');
}
};
@@ -0,0 +1,156 @@
<script lang="ts">
import CreateBookingDialog from './create-booking-dialog.svelte';
import BookingsTable from './bookings-table.svelte';
import EditBookingDialog from './edit-booking-dialog.svelte';
import ArchiveBookingDialog from './archive-booking-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(bookingCreateSchema),
onUpdated: ({ form }) => handleFormToast(form, 'bookings-create', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'bookings-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(bookingEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'bookings-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'bookings-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'bookings-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'bookings-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients' | 'rooms' | 'services') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this booking'
);
}
</script>
<svelte:head>
<title>Bookings | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Bookings</h1>
<p class="text-sm text-muted-foreground">
Track client room bookings and service reservations.
</p>
</div>
<CreateBookingDialog bind:open={createOpen} {data} {createForm} {createData} {enhanceCreate} />
</div>
<BookingsTable
{data}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{relationLabel}
{recordName}
/>
</div>
<EditBookingDialog
{editingRecord}
{data}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveBookingDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive booking?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active bookings. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive booking</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,78 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openArchive,
formatValue,
formatDate,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
formatDate: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Room</Table.Head>
<Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{relationLabel(record.roomId, 'rooms')}</Table.Cell>
<Table.Cell class="">{formatDate(record.startsAt, true)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endsAt, true)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No bookings have been created yet.
</div>
{/if}
@@ -0,0 +1,128 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
data,
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
data: any;
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create booking</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create booking</Dialog.Title>
<Dialog.Description>Add a new booking record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="roomId">
<Field.Field>
<Control id="create-roomId">
{#snippet children({ props })}
<Field.Label>Room</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.roomId}>
{#each data.options.rooms as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="serviceId">
<Field.Field>
<Control id="create-serviceId">
{#snippet children({ props })}
<Field.Label>Service</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.serviceId}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
{#each data.options.services as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="startsAt">
<Field.Field>
<Control id="create-startsAt">
{#snippet children({ props })}
<Field.Label>Starts at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$createData.startsAt} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="endsAt">
<Field.Field>
<Control id="create-endsAt">
{#snippet children({ props })}
<Field.Label>Ends at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$createData.endsAt} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="status">
<Field.Field>
<Control id="create-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="booked">Booked</NativeSelect.Option>
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option>
<NativeSelect.Option value="completed">Completed</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Create booking</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,127 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
data,
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
data: any;
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit booking</Dialog.Title>
<Dialog.Description>Update this booking record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="roomId">
<Field.Field>
<Control id="edit-roomId">
{#snippet children({ props })}
<Field.Label>Room</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.roomId}>
{#each data.options.rooms as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="serviceId">
<Field.Field>
<Control id="edit-serviceId">
{#snippet children({ props })}
<Field.Label>Service</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.serviceId}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
{#each data.options.services as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="startsAt">
<Field.Field>
<Control id="edit-startsAt">
{#snippet children({ props })}
<Field.Label>Starts at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$editData.startsAt} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="endsAt">
<Field.Field>
<Control id="edit-endsAt">
{#snippet children({ props })}
<Field.Label>Ends at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$editData.endsAt} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="booked">Booked</NativeSelect.Option>
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option>
<NativeSelect.Option value="completed">Completed</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,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.');
}
};
@@ -0,0 +1,133 @@
<script lang="ts">
import CreateContactDialog from './create-contact-dialog.svelte';
import ContactsTable from './contacts-table.svelte';
import EditContactDialog from './edit-contact-dialog.svelte';
import ArchiveContactDialog from './archive-contact-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { contactCreateSchema, contactEditSchema } from '$lib/schemas/contacts.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(contactCreateSchema),
onUpdated: ({ form }) => handleFormToast(form, 'contacts-create', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contacts-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(contactEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'contacts-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contacts-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'contacts-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contacts-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this contact'
);
}
</script>
<svelte:head>
<title>Contacts | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Contacts</h1>
<p class="text-sm text-muted-foreground">Manage client contacts and decision makers.</p>
</div>
<CreateContactDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ContactsTable {data} {openEdit} {openArchive} {formatValue} {relationLabel} {recordName} />
</div>
<EditContactDialog
{editingRecord}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveContactDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive contact?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active contacts. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive contact</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,74 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openArchive,
formatValue,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Phone</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatValue(record.name)}</Table.Cell>
<Table.Cell class="">{formatValue(record.role)}</Table.Cell>
<Table.Cell class="">{formatValue(record.email)}</Table.Cell>
<Table.Cell class="">{formatValue(record.phone)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No contacts have been created yet.
</div>
{/if}
@@ -0,0 +1,115 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create contact</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create contact</Dialog.Title>
<Dialog.Description>Add a new contact record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="name">
<Field.Field>
<Control id="create-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="role">
<Field.Field>
<Control id="create-role">
{#snippet children({ props })}
<Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$createData.role} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="email">
<Field.Field>
<Control id="create-email">
{#snippet children({ props })}
<Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$createData.email} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="phone">
<Field.Field>
<Control id="create-phone">
{#snippet children({ props })}
<Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$createData.phone} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="isPrimary">
<Field.Field>
<Control id="create-isPrimary">
{#snippet children({ props })}
<Field.Label>Primary contact</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Create contact</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,114 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit contact</Dialog.Title>
<Dialog.Description>Update this contact record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="role">
<Field.Field>
<Control id="edit-role">
{#snippet children({ props })}
<Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$editData.role} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="email">
<Field.Field>
<Control id="edit-email">
{#snippet children({ props })}
<Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$editData.email} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="phone">
<Field.Field>
<Control id="edit-phone">
{#snippet children({ props })}
<Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$editData.phone} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="isPrimary">
<Field.Field>
<Control id="edit-isPrimary">
{#snippet children({ props })}
<Field.Label>Primary contact</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,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.');
}
};
@@ -0,0 +1,159 @@
<script lang="ts">
import CreateContractDialog from './create-contract-dialog.svelte';
import ContractsTable from './contracts-table.svelte';
import EditContractDialog from './edit-contract-dialog.svelte';
import ArchiveContractDialog from './archive-contract-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(contractCreateSchema),
onUpdated: ({ form }) => handleFormToast(form, 'contracts-create', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(contractEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'contracts-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this contract'
);
}
</script>
<svelte:head>
<title>Contracts | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Contracts</h1>
<p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p>
</div>
<CreateContractDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ContractsTable
{data}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<EditContractDialog
{editingRecord}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveContractDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive contract?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active contracts. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive contract</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,82 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Value</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.startDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No contracts have been created yet.
</div>
{/if}
@@ -0,0 +1,114 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create contract</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create contract</Dialog.Title>
<Dialog.Description>Add a new contract record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="title">
<Field.Field>
<Control id="create-title">
{#snippet children({ props })}
<Field.Label>Title</Field.Label>
<Input {...props} type="text" bind:value={$createData.title} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="startDate">
<Field.Field>
<Control id="create-startDate">
{#snippet children({ props })}
<Field.Label>Start date</Field.Label>
<Input {...props} type="date" bind:value={$createData.startDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="endDate">
<Field.Field>
<Control id="create-endDate">
{#snippet children({ props })}
<Field.Label>End date</Field.Label>
<Input {...props} type="date" bind:value={$createData.endDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="status">
<Field.Field>
<Control id="create-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="expired">Expired</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={createForm}
data={createData}
name="valueGbp"
label="Value"
id="create-valueGbp"
/>
<FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Create contract</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,113 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit contract</Dialog.Title>
<Dialog.Description>Update this contract record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="title">
<Field.Field>
<Control id="edit-title">
{#snippet children({ props })}
<Field.Label>Title</Field.Label>
<Input {...props} type="text" bind:value={$editData.title} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="startDate">
<Field.Field>
<Control id="edit-startDate">
{#snippet children({ props })}
<Field.Label>Start date</Field.Label>
<Input {...props} type="date" bind:value={$editData.startDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="endDate">
<Field.Field>
<Control id="edit-endDate">
{#snippet children({ props })}
<Field.Label>End date</Field.Label>
<Input {...props} type="date" bind:value={$editData.endDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="expired">Expired</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={editForm}
data={editData}
name="valueGbp"
label="Value"
id="edit-valueGbp"
/>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,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.');
}
};
@@ -0,0 +1,159 @@
<script lang="ts">
import CreateInvoiceDialog from './create-invoice-dialog.svelte';
import InvoicesTable from './invoices-table.svelte';
import EditInvoiceDialog from './edit-invoice-dialog.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(invoiceCreateSchema),
onUpdated: ({ form }) => handleFormToast(form, 'invoices-create', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(invoiceEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
);
}
</script>
<svelte:head>
<title>Invoices | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
<p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p>
</div>
<CreateInvoiceDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<InvoicesTable
{data}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<EditInvoiceDialog
{editingRecord}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive invoice?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active invoices. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive invoice</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,129 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create invoice</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create invoice</Dialog.Title>
<Dialog.Description>Add a new invoice record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="invoiceNumber">
<Field.Field>
<Control id="create-invoiceNumber">
{#snippet children({ props })}
<Field.Label>Invoice number</Field.Label>
<Input {...props} type="text" bind:value={$createData.invoiceNumber} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="issueDate">
<Field.Field>
<Control id="create-issueDate">
{#snippet children({ props })}
<Field.Label>Issue date</Field.Label>
<Input {...props} type="date" bind:value={$createData.issueDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="dueDate">
<Field.Field>
<Control id="create-dueDate">
{#snippet children({ props })}
<Field.Label>Due date</Field.Label>
<Input {...props} type="date" bind:value={$createData.dueDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="status">
<Field.Field>
<Control id="create-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="sent">Sent</NativeSelect.Option>
<NativeSelect.Option value="paid">Paid</NativeSelect.Option>
<NativeSelect.Option value="overdue">Overdue</NativeSelect.Option>
<NativeSelect.Option value="void">Void</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={createForm}
data={createData}
name="subtotalGbp"
label="Subtotal"
id="create-subtotalGbp"
/>
<MoneyField
form={createForm}
data={createData}
name="taxGbp"
label="Tax"
id="create-taxGbp"
/>
<MoneyField
form={createForm}
data={createData}
name="totalGbp"
label="Total"
id="create-totalGbp"
/>
<FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Create invoice</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,122 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit invoice</Dialog.Title>
<Dialog.Description>Update this invoice record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="invoiceNumber">
<Field.Field>
<Control id="edit-invoiceNumber">
{#snippet children({ props })}
<Field.Label>Invoice number</Field.Label>
<Input {...props} type="text" bind:value={$editData.invoiceNumber} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="issueDate">
<Field.Field>
<Control id="edit-issueDate">
{#snippet children({ props })}
<Field.Label>Issue date</Field.Label>
<Input {...props} type="date" bind:value={$editData.issueDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="dueDate">
<Field.Field>
<Control id="edit-dueDate">
{#snippet children({ props })}
<Field.Label>Due date</Field.Label>
<Input {...props} type="date" bind:value={$editData.dueDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="sent">Sent</NativeSelect.Option>
<NativeSelect.Option value="paid">Paid</NativeSelect.Option>
<NativeSelect.Option value="overdue">Overdue</NativeSelect.Option>
<NativeSelect.Option value="void">Void</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={editForm}
data={editData}
name="subtotalGbp"
label="Subtotal"
id="edit-subtotalGbp"
/>
<MoneyField form={editForm} data={editData} name="taxGbp" label="Tax" id="edit-taxGbp" />
<MoneyField
form={editForm}
data={editData}
name="totalGbp"
label="Total"
id="edit-totalGbp"
/>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,82 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Invoice #</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No invoices have been created yet.
</div>
{/if}
@@ -0,0 +1,39 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive client?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active clients. The record will remain in the
database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive">Archive client</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,86 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openEdit,
openArchive,
formatValue,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium"
><a
class="inline-flex items-center gap-2 underline-offset-4 hover:text-primary hover:underline"
href={resolve('/dashboard/clients/[id]', { id: record.id })}
>{formatValue(record.name)}<ExternalLinkIcon class="size-3.5" /></a
></Table.Cell
>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.type)}</Badge
></Table.Cell
>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item
onclick={() => goto(resolve('/dashboard/clients/[id]', { id: record.id }))}
>View</DropdownMenu.Item
>
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No clients have been created yet.
</div>
{/if}
@@ -0,0 +1,290 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import * as Stepper from '$lib/components/ui/stepper/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
open = $bindable(false),
createStep = $bindable(0),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createStep: number;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
const createSteps = [
{ title: 'Client', description: 'Business details' },
{ title: 'Contact', description: 'Primary contact' },
{ title: 'Address', description: 'Primary address' }
] as const;
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create client</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create client</Dialog.Title>
<Dialog.Description>Add a new client record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-5" use:enhanceCreate>
<Stepper.Root bind:value={createStep}>
{#each createSteps as step, index (step.title)}
<Stepper.Item step={index}>
<div class="flex flex-col items-center gap-2 text-center">
<Stepper.Indicator step={index} />
<div class="grid gap-1">
<Stepper.Title>{step.title}</Stepper.Title>
<Stepper.Description>{step.description}</Stepper.Description>
</div>
</div>
{#if index < createSteps.length - 1}
<Stepper.Separator />
{/if}
</Stepper.Item>
{/each}
</Stepper.Root>
<div class={['grid gap-2', createStep !== 0 && 'hidden']}>
<FormField form={createForm} name="name">
<Field.Field>
<Control id="create-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="type">
<Field.Field>
<Control id="create-type">
{#snippet children({ props })}
<Field.Label>Type</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.type}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="business">Business</NativeSelect.Option>
<NativeSelect.Option value="sport">Sport</NativeSelect.Option>
<NativeSelect.Option value="individual">Individual</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="status">
<Field.Field>
<Control id="create-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="prospect">Prospect</NativeSelect.Option>
<NativeSelect.Option value="paused">Paused</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="website">
<Field.Field>
<Control id="create-website">
{#snippet children({ props })}
<Field.Label>Website</Field.Label>
<Input
{...props}
type="url"
placeholder="https://example.com"
bind:value={$createData.website}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
<div class={['grid gap-2', createStep !== 1 && 'hidden']}>
<FormField form={createForm} name="primaryContactName">
<Field.Field>
<Control id="create-primary-contact-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactName} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryContactRole">
<Field.Field>
<Control id="create-primary-contact-role">
{#snippet children({ props })}
<Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactRole} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryContactEmail">
<Field.Field>
<Control id="create-primary-contact-email">
{#snippet children({ props })}
<Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$createData.primaryContactEmail} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryContactPhone">
<Field.Field>
<Control id="create-primary-contact-phone">
{#snippet children({ props })}
<Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactPhone} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryContactNotes">
<Field.Field>
<Control id="create-primary-contact-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.primaryContactNotes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
<div class={['grid gap-2', createStep !== 2 && 'hidden']}>
<FormField form={createForm} name="primaryAddressLabel">
<Field.Field>
<Control id="create-primary-address-label">
{#snippet children({ props })}
<Field.Label>Label</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLabel} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressLine1">
<Field.Field>
<Control id="create-primary-address-line-1">
{#snippet children({ props })}
<Field.Label>Address line 1</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine1} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressLine2">
<Field.Field>
<Control id="create-primary-address-line-2">
{#snippet children({ props })}
<Field.Label>Address line 2</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine2} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-2 sm:grid-cols-2">
<FormField form={createForm} name="primaryAddressCity">
<Field.Field>
<Control id="create-primary-address-city">
{#snippet children({ props })}
<Field.Label>City</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressCity} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressRegion">
<Field.Field>
<Control id="create-primary-address-region">
{#snippet children({ props })}
<Field.Label>Region</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressRegion} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<FormField form={createForm} name="primaryAddressPostcode">
<Field.Field>
<Control id="create-primary-address-postcode">
{#snippet children({ props })}
<Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressPostcode} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressCountry">
<Field.Field>
<Control id="create-primary-address-country">
{#snippet children({ props })}
<Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressCountry} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
</div>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
{#if createStep > 0}
<Button type="button" variant="outline" onclick={() => (createStep -= 1)}>Back</Button>
{/if}
{#if createStep < createSteps.length - 1}
<Button type="button" onclick={() => (createStep += 1)}>Next step</Button>
{:else}
<Button type="submit">Create client</Button>
{/if}
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,114 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit client</Dialog.Title>
<Dialog.Description>Update this client record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="type">
<Field.Field>
<Control id="edit-type">
{#snippet children({ props })}
<Field.Label>Type</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.type}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="business">Business</NativeSelect.Option>
<NativeSelect.Option value="sport">Sport</NativeSelect.Option>
<NativeSelect.Option value="individual">Individual</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="prospect">Prospect</NativeSelect.Option>
<NativeSelect.Option value="paused">Paused</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="website">
<Field.Field>
<Control id="edit-website">
{#snippet children({ props })}
<Field.Label>Website</Field.Label>
<Input
{...props}
type="url"
placeholder="https://example.com"
bind:value={$editData.website}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,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.');
}
};
@@ -0,0 +1,97 @@
<script lang="ts">
import ContactsTable from './contacts-table.svelte';
import ArchiveContactDialog from './archive-contact-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'contacts-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contacts-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this contact'
);
}
</script>
<svelte:head>
<title>Contacts | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Contacts</h1>
<p class="text-sm text-muted-foreground">Manage client contacts and decision makers.</p>
</div>
</div>
<ContactsTable {data} {openArchive} {formatValue} {relationLabel} {recordName} />
</div>
<ArchiveContactDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive contact?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active contacts. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive contact</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,66 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openArchive,
formatValue,
relationLabel,
recordName
}: { data: any; openArchive: any; formatValue: any; relationLabel: any; recordName: any } =
$props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Phone</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatValue(record.name)}</Table.Cell>
<Table.Cell class="">{formatValue(record.role)}</Table.Cell>
<Table.Cell class="">{formatValue(record.email)}</Table.Cell>
<Table.Cell class="">{formatValue(record.phone)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No contacts have been created yet.
</div>
{/if}
@@ -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.');
}
};
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import ContractsTable from './contracts-table.svelte';
import ArchiveContractDialog from './archive-contract-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this contract'
);
}
</script>
<svelte:head>
<title>Contracts | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Contracts</h1>
<p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p>
</div>
</div>
<ContractsTable
{data}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<ArchiveContractDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive contract?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active contracts. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive contract</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,79 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Value</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.startDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No contracts have been created yet.
</div>
{/if}
@@ -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.');
}
};
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
import InvoicesTable from './invoices-table.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
);
}
</script>
<svelte:head>
<title>Invoices | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
<p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p>
</div>
</div>
<InvoicesTable
{data}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive invoice?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active invoices. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive invoice</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -0,0 +1,79 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Invoice #</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No invoices have been created yet.
</div>
{/if}