feat: add billing and organization workflows

This commit is contained in:
2026-06-24 13:53:52 +01:00
parent 4fd81c923b
commit 44bfb083f9
97 changed files with 10966 additions and 2044 deletions
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import HomeIcon from '@lucide/svelte/icons/home';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button/index.js';
const status = $derived(page.status);
const message = $derived(page.error?.message ?? 'Something went wrong.');
const currentPath = $derived(page.url.pathname);
const title = $derived(status === 404 ? message : 'Something went wrong');
const description = $derived(
status === 404
? 'This item may be archived, deleted, or unavailable in the active organization.'
: 'The dashboard could not finish loading this view.'
);
</script>
<svelte:head>
<title>{status} | Clearity</title>
</svelte:head>
<div class="flex min-h-[calc(100vh-8rem)] items-start justify-center pt-12">
<div class="grid w-full max-w-md gap-5 text-center">
<div class="grid gap-2">
<p class="text-sm font-medium text-muted-foreground">{status}</p>
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
<p class="text-sm text-muted-foreground">{description}</p>
</div>
<div class="flex justify-center gap-2">
<Button href={resolve('/dashboard')} variant="outline">
<HomeIcon data-icon="inline-start" />
Dashboard
</Button>
<Button href={currentPath}>
<RefreshCwIcon data-icon="inline-start" />
Retry
</Button>
</div>
</div>
</div>
+3
View File
@@ -14,6 +14,8 @@
const segmentLabels: Record<string, string> = {
dashboard: 'Dashboard',
organization: 'Organization',
details: 'Details',
admins: 'Admins',
clients: 'Clients',
addresses: 'Addresses',
@@ -21,6 +23,7 @@
bookings: 'Bookings',
rooms: 'Rooms',
services: 'Services',
billing: 'Billing',
invoices: 'Invoices',
contracts: 'Contracts'
};
@@ -25,12 +25,15 @@ export const load: PageServerLoad = async ({ locals }) => {
.select()
.from(addresses)
.where(and(eq(addresses.organizationId, activeOrganizationId), isNull(addresses.archivedAt)))
.orderBy(asc(addresses.label));
.orderBy(asc(addresses.type));
return {
records,
options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' })
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'addresses-archive',
errors: false
})
};
};
@@ -20,11 +20,10 @@
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Label</Table.Head>
<Table.Head>Type</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>
@@ -32,11 +31,10 @@
{#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="capitalize">{formatValue(record.type)}</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
@@ -1,91 +0,0 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/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 user
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Create user</Dialog.Title>
<Dialog.Description>Add a manually managed Better Auth user.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="name">
<Form.Control id="create-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} autocomplete="name" bind:value={$createData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="email">
<Form.Control id="create-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" autocomplete="email" bind:value={$createData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="password">
<Form.Control id="create-password">
{#snippet children({ props })}
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
bind:value={$createData.password}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="role">
<Form.Control id="create-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<NativeSelect.Root {...props} bind:value={$createData.role} class="w-full">
<NativeSelect.Option value="admin">Admin</NativeSelect.Option>
<NativeSelect.Option value="user">User</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<Button type="submit">Create user</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -1,88 +0,0 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/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 {
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 user</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} />
<Form.Field form={editForm} name="name">
<Form.Control id="edit-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} autocomplete="name" bind:value={$editData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="email">
<Form.Control id="edit-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" autocomplete="email" bind:value={$editData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="password">
<Form.Control id="edit-password">
{#snippet children({ props })}
<Form.Label>New password</Form.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
placeholder="Leave blank to keep current password"
bind:value={$editData.password}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="role">
<Form.Control id="edit-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<NativeSelect.Root {...props} bind:value={$editData.role} class="w-full">
<NativeSelect.Option value="admin">Admin</NativeSelect.Option>
<NativeSelect.Option value="user">User</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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,526 @@
import { fail } from '@sveltejs/kit';
import { and, asc, desc, eq, gte, isNull, like, lte, ne } from 'drizzle-orm';
import { createSearchParamsSchema, validateSearchParams } from 'runed/kit';
import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db';
import {
bookings,
clients,
contracts,
invoiceLines,
invoices,
rooms,
services
} from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types';
type BillingLine = {
id: string;
clientId: string;
clientName: string;
sourceType: 'booking-room' | 'booking-service' | 'contract';
sourceId: string;
periodStart: string;
periodEnd: string;
description: string;
quantity: number;
unitPriceGbp: number;
totalGbp: number;
};
type BillingClient = {
clientId: string;
clientName: string;
totalGbp: number;
lineCount: number;
lines: BillingLine[];
};
const dayMs = 24 * 60 * 60 * 1000;
const billingTermDays = 12;
async function loadOptions(organizationId: string) {
const clientRows = await db
.select({ id: clients.id, name: clients.name })
.from(clients)
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt)))
.orderBy(asc(clients.name));
return {
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
};
}
function toDateOnly(date: Date) {
return date.toISOString().slice(0, 10);
}
function parseDateOnly(value: string) {
const [year, month, day] = value.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
function addMonths(date: Date, months: number) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1));
}
function endOfMonth(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0));
}
function startOfMonth(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
}
function minDate(a: Date, b: Date) {
return a.getTime() <= b.getTime() ? a : b;
}
function maxDate(a: Date, b: Date) {
return a.getTime() >= b.getTime() ? a : b;
}
function daysInclusive(start: Date, end: Date) {
return Math.floor((end.getTime() - start.getTime()) / dayMs) + 1;
}
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function roundQuantity(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function monthLabel(date: Date) {
return new Intl.DateTimeFormat('en-GB', {
month: 'short',
year: 'numeric',
timeZone: 'UTC'
}).format(date);
}
function defaultInvoiceDate(today = new Date()) {
return toDateOnly(new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 2)));
}
function billingSearchParamsSchema() {
return createSearchParamsSchema({
billingDate: {
type: 'date',
default: parseDateOnly(defaultInvoiceDate()),
dateFormat: 'date'
}
});
}
function isDateOnly(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return toDateOnly(parseDateOnly(value)) === value;
}
function normalizeInvoiceDate(value: FormDataEntryValue | string | null) {
if (typeof value !== 'string' || !isDateOnly(value)) return defaultInvoiceDate();
return value;
}
function invoiceDateFromUrl(url: URL) {
const { data } = validateSearchParams(url, billingSearchParamsSchema());
return toDateOnly(data.billingDate);
}
function billingCutoffs(invoiceDate: string) {
const selectedDate = parseDateOnly(invoiceDate);
const thisMonth = new Date(
Date.UTC(selectedDate.getUTCFullYear(), selectedDate.getUTCMonth(), 1)
);
const contractsCutoff = endOfMonth(addMonths(thisMonth, 1));
const servicesCutoff = endOfMonth(addMonths(thisMonth, -1));
return {
contractsCutoff: toDateOnly(contractsCutoff),
servicesCutoff: toDateOnly(servicesCutoff)
};
}
function billingDueDate(invoiceDate: string) {
const selectedDate = parseDateOnly(invoiceDate);
return toDateOnly(new Date(selectedDate.getTime() + billingTermDays * dayMs));
}
function sourceKey(line: {
sourceType: string;
sourceId: string;
periodStart: string;
periodEnd: string;
}) {
return `${line.sourceType}:${line.sourceId}:${line.periodStart}:${line.periodEnd}`;
}
function lineId(line: Pick<BillingLine, 'sourceType' | 'sourceId' | 'periodStart' | 'periodEnd'>) {
return sourceKey(line);
}
function groupBillingLines(lines: BillingLine[]) {
const clientsById = new Map<string, BillingClient>();
for (const line of lines) {
const client = clientsById.get(line.clientId) ?? {
clientId: line.clientId,
clientName: line.clientName,
totalGbp: 0,
lineCount: 0,
lines: []
};
client.lines.push(line);
client.lineCount += 1;
client.totalGbp = roundMoney(client.totalGbp + line.totalGbp);
clientsById.set(line.clientId, client);
}
return [...clientsById.values()].sort((a, b) => a.clientName.localeCompare(b.clientName));
}
function bookingPrice(row: {
startsAt: string;
endsAt: string;
internalPricePerHourGbp: number | null;
externalPricePerHourGbp: number | null;
internalPricePerDayGbp: number | null;
externalPricePerDayGbp: number | null;
}) {
const startsAt = new Date(row.startsAt);
const endsAt = new Date(row.endsAt);
const durationHours = Math.max((endsAt.getTime() - startsAt.getTime()) / (60 * 60 * 1000), 0);
const dayRate = row.externalPricePerDayGbp ?? row.internalPricePerDayGbp ?? 0;
const hourRate = row.externalPricePerHourGbp ?? row.internalPricePerHourGbp ?? 0;
if (dayRate > 0 && durationHours >= 7) {
const quantity = Math.max(1, Math.ceil(durationHours / 24));
return { quantity, unitPriceGbp: dayRate, totalGbp: roundMoney(quantity * dayRate) };
}
const quantity = roundQuantity(durationHours);
return { quantity, unitPriceGbp: hourRate, totalGbp: roundMoney(quantity * hourRate) };
}
async function buildBillingPreview(organizationId: string, invoiceDate: string) {
const cutoffs = billingCutoffs(invoiceDate);
const existingLines = await db
.select({
sourceType: invoiceLines.sourceType,
sourceId: invoiceLines.sourceId,
periodStart: invoiceLines.periodStart,
periodEnd: invoiceLines.periodEnd
})
.from(invoiceLines)
.where(and(eq(invoiceLines.organizationId, organizationId), isNull(invoiceLines.archivedAt)));
const billedSources = new Set(existingLines.map(sourceKey));
const lines: BillingLine[] = [];
const bookingRows = await db
.select({
id: bookings.id,
clientId: bookings.clientId,
clientName: clients.name,
roomName: rooms.name,
roomType: rooms.type,
startsAt: bookings.startsAt,
endsAt: bookings.endsAt,
serviceId: bookings.serviceId,
serviceName: services.name,
servicePriceGbp: services.priceGbp,
internalPricePerHourGbp: rooms.internalPricePerHourGbp,
externalPricePerHourGbp: rooms.externalPricePerHourGbp,
internalPricePerDayGbp: rooms.internalPricePerDayGbp,
externalPricePerDayGbp: rooms.externalPricePerDayGbp
})
.from(bookings)
.innerJoin(clients, eq(bookings.clientId, clients.id))
.innerJoin(rooms, eq(bookings.roomId, rooms.id))
.leftJoin(services, eq(bookings.serviceId, services.id))
.where(
and(
eq(bookings.organizationId, organizationId),
lte(bookings.startsAt, `${cutoffs.servicesCutoff}T23:59`),
ne(bookings.status, 'cancelled'),
isNull(bookings.archivedAt)
)
)
.orderBy(asc(bookings.startsAt));
for (const booking of bookingRows) {
const periodStart = booking.startsAt.slice(0, 10);
const periodEnd = booking.endsAt.slice(0, 10);
if (booking.roomType === 'meeting_room') {
const price = bookingPrice(booking);
const line = {
id: '',
clientId: booking.clientId,
clientName: booking.clientName,
sourceType: 'booking-room' as const,
sourceId: booking.id,
periodStart,
periodEnd,
description: `Meeting room: ${booking.roomName} (${periodStart})`,
...price
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
if (booking.serviceId && booking.serviceName) {
const line = {
id: '',
clientId: booking.clientId,
clientName: booking.clientName,
sourceType: 'booking-service' as const,
sourceId: booking.id,
periodStart,
periodEnd,
description: `Service: ${booking.serviceName} (${periodStart})`,
quantity: 1,
unitPriceGbp: booking.servicePriceGbp ?? 0,
totalGbp: roundMoney(booking.servicePriceGbp ?? 0)
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
}
const contractRows = await db
.select({
id: contracts.id,
clientId: contracts.clientId,
clientName: clients.name,
roomName: rooms.name,
serviceName: services.name,
licenseFeeGbp: contracts.licenseFeeGbp,
startDate: contracts.startDate,
endDate: contracts.endDate
})
.from(contracts)
.innerJoin(clients, eq(contracts.clientId, clients.id))
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(
and(
eq(contracts.organizationId, organizationId),
eq(contracts.status, 'active'),
lte(contracts.startDate, cutoffs.contractsCutoff),
gte(contracts.endDate, '1900-01-01'),
isNull(contracts.archivedAt)
)
)
.orderBy(asc(contracts.startDate));
const contractCutoff = parseDateOnly(cutoffs.contractsCutoff);
for (const contract of contractRows) {
const contractStart = parseDateOnly(contract.startDate);
const contractEnd = minDate(parseDateOnly(contract.endDate), contractCutoff);
if (contractEnd.getTime() < contractStart.getTime() || contract.licenseFeeGbp <= 0) continue;
for (
let month = startOfMonth(contractStart);
month.getTime() <= contractEnd.getTime();
month = addMonths(month, 1)
) {
const periodStartDate = maxDate(month, contractStart);
const periodEndDate = minDate(endOfMonth(month), contractEnd);
if (periodEndDate.getTime() < periodStartDate.getTime()) continue;
const periodStart = toDateOnly(periodStartDate);
const periodEnd = toDateOnly(periodEndDate);
const monthDays = daysInclusive(startOfMonth(month), endOfMonth(month));
const billedDays = daysInclusive(periodStartDate, periodEndDate);
const quantity = roundQuantity(billedDays / monthDays);
const labelParts = [contract.roomName, contract.serviceName].filter(Boolean);
const line = {
id: '',
clientId: contract.clientId,
clientName: contract.clientName,
sourceType: 'contract' as const,
sourceId: contract.id,
periodStart,
periodEnd,
description: `Contract: ${labelParts.join(' + ') || 'monthly fee'} (${monthLabel(month)})`,
quantity,
unitPriceGbp: contract.licenseFeeGbp,
totalGbp: roundMoney(quantity * contract.licenseFeeGbp)
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
}
lines.sort(
(a, b) => a.clientName.localeCompare(b.clientName) || a.periodStart.localeCompare(b.periodStart)
);
return {
...cutoffs,
invoiceDate,
dueDate: billingDueDate(invoiceDate),
lines,
clients: groupBillingLines(lines),
totalGbp: roundMoney(lines.reduce((total, line) => total + line.totalGbp, 0))
};
}
function invoiceNumberPrefix(invoiceDate: string) {
const date = parseDateOnly(invoiceDate);
return `INV-${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
}
async function latestInvoiceSequence(organizationId: string, prefix: string) {
const [latest] = await db
.select({ invoiceNumber: invoices.invoiceNumber })
.from(invoices)
.where(
and(eq(invoices.organizationId, organizationId), like(invoices.invoiceNumber, `${prefix}-%`))
)
.orderBy(desc(invoices.invoiceNumber))
.limit(1);
return latest?.invoiceNumber ? Number(latest.invoiceNumber.split('-').at(-1) ?? 0) : 0;
}
export const load: PageServerLoad = async ({ locals, url }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const invoiceDate = invoiceDateFromUrl(url);
const records = await db
.select()
.from(invoices)
.where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt)))
.orderBy(asc(invoices.invoiceNumber));
return {
records,
options: await loadOptions(activeOrganizationId),
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
};
};
export const actions: Actions = {
runBilling: async ({ locals, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const formData = await request.formData();
const invoiceDate = normalizeInvoiceDate(formData.get('billingDate'));
let includedLineIds: unknown;
try {
includedLineIds = JSON.parse(String(formData.get('includedLineIds') ?? '[]'));
} catch {
return fail(400, { message: 'Choose valid billing lines.' });
}
if (!Array.isArray(includedLineIds) || includedLineIds.some((id) => typeof id !== 'string')) {
return fail(400, { message: 'Choose valid billing lines.' });
}
const includedIds = new Set(includedLineIds);
const preview = await buildBillingPreview(activeOrganizationId, invoiceDate);
const includedLines = preview.lines.filter((line) => includedIds.has(line.id));
if (includedLines.length === 0) return fail(400, { message: 'There is nothing to bill.' });
const groupedClients = groupBillingLines(includedLines);
const issueDate = invoiceDate;
const dueDate = billingDueDate(invoiceDate);
const invoicePrefix = invoiceNumberPrefix(invoiceDate);
const latestSequence = await latestInvoiceSequence(activeOrganizationId, invoicePrefix);
await db.transaction(async (tx) => {
let invoiceOffset = 0;
for (const client of groupedClients) {
const invoiceId = crypto.randomUUID();
const invoiceNumber = `${invoicePrefix}-${String(latestSequence + invoiceOffset + 1).padStart(3, '0')}`;
invoiceOffset += 1;
await tx.insert(invoices).values({
id: invoiceId,
organizationId: activeOrganizationId,
clientId: client.clientId,
invoiceNumber,
issueDate,
dueDate,
status: 'draft',
subtotalGbp: client.totalGbp,
taxGbp: 0,
totalGbp: client.totalGbp,
notes: `Generated from billing run. Contract billing through ${preview.contractsCutoff}; services through ${preview.servicesCutoff}.`,
createdAt: new Date(),
updatedAt: new Date()
});
await tx.insert(invoiceLines).values(
client.lines.map((line) => ({
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
invoiceId,
clientId: client.clientId,
sourceType: line.sourceType,
sourceId: line.sourceId,
periodStart: line.periodStart,
periodEnd: line.periodEnd,
description: line.description,
quantity: line.quantity,
unitPriceGbp: line.unitPriceGbp,
totalGbp: line.totalGbp,
createdAt: new Date(),
updatedAt: new Date()
}))
);
}
const contractBilledTo = new Map<string, string>();
for (const line of includedLines) {
if (line.sourceType !== 'contract') continue;
const current = contractBilledTo.get(line.sourceId);
if (!current || line.periodEnd > current)
contractBilledTo.set(line.sourceId, line.periodEnd);
}
for (const [contractId, billedTo] of contractBilledTo) {
await tx
.update(contracts)
.set({ billedTo, updatedAt: new Date() })
.where(
and(eq(contracts.id, contractId), eq(contracts.organizationId, activeOrganizationId))
);
}
});
return {
message: `Billing run complete. Created ${groupedClients.length} invoice${groupedClients.length === 1 ? '' : 's'}.`
};
},
archive: async (event) => {
const { activeOrganizationId } = await loadOrganizationContext(event.locals);
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(and(eq(invoices.id, form.data.id), eq(invoices.organizationId, activeOrganizationId)));
return message(form, 'Invoice archived.');
}
};
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import InvoicesTable from './invoices-table.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import RunBillingDialog from './run-billing-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>Billing | 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">Billing</h1>
<p class="text-sm text-muted-foreground">Run billing and manage generated invoices.</p>
</div>
<RunBillingDialog
preview={data.billingPreview}
{formatMoney}
formatDate={(value) => formatDate(value)}
/>
</div>
<InvoicesTable
{data}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,359 @@
<script lang="ts">
import CalendarIcon from '@lucide/svelte/icons/calendar';
import ReceiptTextIcon from '@lucide/svelte/icons/receipt-text';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { enhance } from '$app/forms';
import { parseDate, type DateValue } from '@internationalized/date';
import type { SubmitFunction } from '@sveltejs/kit';
import { createSearchParamsSchema, useSearchParams } from 'runed/kit';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button/index.js';
import * as Calendar from '$lib/components/ui/calendar/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import * as Table from '$lib/components/ui/table/index.js';
type BillingLine = {
id: string;
clientId: string;
clientName: string;
sourceType: string;
periodStart: string;
periodEnd: string;
description: string;
quantity: number;
unitPriceGbp: number;
totalGbp: number;
};
type BillingClient = {
clientId: string;
clientName: string;
totalGbp: number;
lineCount: number;
lines: BillingLine[];
};
let {
preview,
formatMoney,
formatDate
}: {
preview: {
contractsCutoff: string;
servicesCutoff: string;
invoiceDate: string;
dueDate: string;
lines: BillingLine[];
clients: BillingClient[];
totalGbp: number;
};
formatMoney: (value: unknown) => string;
formatDate: (value: unknown) => string;
} = $props();
const searchParamsSchema = createSearchParamsSchema({
billingDate: {
type: 'date',
default: defaultInvoiceDate(),
dateFormat: 'date'
}
});
const searchParams = useSearchParams(searchParamsSchema, { pushHistory: false, noScroll: true });
let open = $state(false);
let datePickerOpen = $state(false);
let detailOpen = $state(false);
let selectedClientId = $state<string | null>(null);
let excludedClientIds = $state<string[]>([]);
let excludedLineIds = $state<string[]>([]);
const invoiceDateString = $derived(dateToString(searchParams.billingDate));
const invoiceDateValue = $derived(dateValueFromString(invoiceDateString));
const includedLineIds = $derived.by(() =>
preview.lines
.filter(
(line) => !excludedClientIds.includes(line.clientId) && !excludedLineIds.includes(line.id)
)
.map((line) => line.id)
);
const includedClients = $derived.by(() => {
return preview.clients
.map((client) => {
if (excludedClientIds.includes(client.clientId)) return null;
const lines = client.lines.filter((line) => !excludedLineIds.includes(line.id));
const totalGbp = roundMoney(lines.reduce((total, line) => total + line.totalGbp, 0));
return {
...client,
lines,
lineCount: lines.length,
totalGbp
};
})
.filter((client): client is BillingClient => !!client && client.lineCount > 0);
});
const includedTotal = $derived(
roundMoney(includedClients.reduce((total, client) => total + client.totalGbp, 0))
);
const selectedClient = $derived(
includedClients.find((client) => client.clientId === selectedClientId)
);
const enhanceBilling: SubmitFunction<{ message?: string }, { message?: string }> = () => {
return async ({ result, update }) => {
if (result.type === 'success') {
toast.success(String(result.data?.message ?? 'Billing run complete.'), {
id: 'run-billing'
});
open = false;
detailOpen = false;
selectedClientId = null;
excludedClientIds = [];
excludedLineIds = [];
await update();
return;
}
if (result.type === 'failure') {
toast.error(String(result.data?.message ?? 'Unable to complete billing run.'), {
id: 'run-billing'
});
return;
}
if (result.type === 'error') {
toast.error(result.error.message, { id: 'run-billing' });
}
};
};
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function dateFromString(value: string) {
return new Date(`${value}T00:00:00.000Z`);
}
function defaultInvoiceDate() {
const today = new Date();
return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 2));
}
function dateToString(value: Date) {
return value.toISOString().slice(0, 10);
}
function dateValueFromString(value: string) {
try {
return parseDate(value);
} catch {
return parseDate(preview.invoiceDate);
}
}
function changeInvoiceDate(value: DateValue | undefined) {
if (!value) return;
selectedClientId = null;
detailOpen = false;
excludedClientIds = [];
excludedLineIds = [];
searchParams.billingDate = dateFromString(value.toString());
datePickerOpen = false;
}
function openClient(clientId: string) {
selectedClientId = clientId;
detailOpen = true;
}
function removeClient(clientId: string) {
if (!excludedClientIds.includes(clientId)) excludedClientIds = [...excludedClientIds, clientId];
if (selectedClientId === clientId) {
selectedClientId = null;
detailOpen = false;
}
}
function removeLine(lineId: string) {
if (!excludedLineIds.includes(lineId)) excludedLineIds = [...excludedLineIds, lineId];
}
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><ReceiptTextIcon data-icon="inline-start" />Run billing</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>Run billing</Dialog.Title>
<Dialog.Description>
Contracts through {formatDate(preview.contractsCutoff)}. Bookings and services through {formatDate(
preview.servicesCutoff
)}.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="grid gap-1">
<div class="text-sm font-medium">Invoicing date</div>
<Popover.Root bind:open={datePickerOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}
><CalendarIcon data-icon="inline-start" />{formatDate(invoiceDateString)}</Button
>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-auto p-0">
<Calendar.Calendar
type="single"
value={invoiceDateValue}
captionLayout="dropdown"
onValueChange={changeInvoiceDate}
/>
</Popover.Content>
</Popover.Root>
<div class="text-xs text-muted-foreground">Due {formatDate(preview.dueDate)}</div>
</div>
<div class="text-right text-sm">
<div class="text-muted-foreground">
{includedClients.length} client{includedClients.length === 1 ? '' : 's'} · {includedLineIds.length}
line{includedLineIds.length === 1 ? '' : 's'}
</div>
<div class="font-medium">{formatMoney(includedTotal)}</div>
</div>
</div>
<div class="sr-only" aria-live="polite">
<div class="text-muted-foreground">
{includedClients.length} client{includedClients.length === 1 ? '' : 's'} · {includedLineIds.length}
line{includedLineIds.length === 1 ? '' : 's'}
</div>
<div class="font-medium">{formatMoney(includedTotal)}</div>
</div>
{#if includedClients.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Lines</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Remove</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each includedClients as client (client.clientId)}
<Table.Row class="cursor-pointer" onclick={() => openClient(client.clientId)}>
<Table.Cell class="font-medium">{client.clientName}</Table.Cell>
<Table.Cell>{client.lineCount}</Table.Cell>
<Table.Cell>{formatMoney(client.totalGbp)}</Table.Cell>
<Table.Cell>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${client.clientName} from billing run`}
onclick={(event) => {
event.stopPropagation();
removeClient(client.clientId);
}}><Trash2Icon /></Button
>
</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 billable clients are currently selected.
</div>
{/if}
</div>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<form method="POST" action="?/runBilling" use:enhance={enhanceBilling}>
<input type="hidden" name="billingDate" value={invoiceDateString} />
<input type="hidden" name="includedLineIds" value={JSON.stringify(includedLineIds)} />
<Button type="submit" disabled={includedLineIds.length === 0}>Complete billing run</Button>
</form>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={detailOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>{selectedClient?.clientName ?? 'Billing details'}</Dialog.Title>
<Dialog.Description>
{selectedClient?.lineCount ?? 0} line{selectedClient?.lineCount === 1 ? '' : 's'} · {formatMoney(
selectedClient?.totalGbp ?? 0
)}
</Dialog.Description>
</Dialog.Header>
{#if selectedClient}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Description</Table.Head>
<Table.Head>Period</Table.Head>
<Table.Head>Qty</Table.Head>
<Table.Head>Unit</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Remove</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each selectedClient.lines as line (line.id)}
<Table.Row>
<Table.Cell class="font-medium">{line.description}</Table.Cell>
<Table.Cell
>{formatDate(line.periodStart)} - {formatDate(line.periodEnd)}</Table.Cell
>
<Table.Cell>{line.quantity}</Table.Cell>
<Table.Cell>{formatMoney(line.unitPriceGbp)}</Table.Cell>
<Table.Cell>{formatMoney(line.totalGbp)}</Table.Cell>
<Table.Cell>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${line.description}`}
onclick={() => removeLine(line.id)}><Trash2Icon /></Button
>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Done</Button>
{/snippet}
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -47,7 +47,10 @@ export const load: PageServerLoad = async ({ locals }) => {
return {
records,
options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' })
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'bookings-archive',
errors: false
})
};
};
+48 -18
View File
@@ -11,26 +11,57 @@ import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.select({
client: clients,
primaryContactName: contacts.name,
primaryContactEmail: contacts.email,
primaryAddressLine1: addresses.line1
})
.from(clients)
.leftJoin(
contacts,
and(
eq(contacts.clientId, clients.id),
eq(contacts.organizationId, activeOrganizationId),
eq(contacts.isPrimary, true),
isNull(contacts.archivedAt)
)
)
.leftJoin(
addresses,
and(
eq(addresses.clientId, clients.id),
eq(addresses.organizationId, activeOrganizationId),
eq(addresses.type, 'primary'),
isNull(addresses.archivedAt)
)
)
.where(and(eq(clients.organizationId, activeOrganizationId), isNull(clients.archivedAt)))
.orderBy(asc(clients.name));
return {
records: records.map((record) => ({
...record,
...record.client,
primaryContactName: record.primaryContactName,
primaryContactEmail: record.primaryContactEmail,
primaryAddressLine1: record.primaryAddressLine1,
formValues: {
id: record.id,
name: record.name ?? '',
type: record.type ?? '',
status: record.status ?? '',
website: record.website ?? '',
notes: record.notes ?? ''
id: record.client.id,
name: record.client.name ?? '',
industry: record.client.industry ?? '',
website: record.client.website ?? '',
notes: record.client.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' })
createForm: await superValidate(zod4(clientOnboardingCreateSchema), {
id: 'clients-create',
errors: false
}),
editForm: await superValidate(zod4(clientEditSchema), { id: 'clients-edit', errors: false }),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'clients-archive',
errors: false
})
};
};
@@ -52,8 +83,7 @@ export const actions: Actions = {
id: clientId,
organizationId: activeOrganizationId,
name: form.data.name,
type: form.data.type,
status: form.data.status,
industry: form.data.industry || null,
website: form.data.website || null,
notes: form.data.notes || null,
updatedAt: now,
@@ -69,7 +99,8 @@ export const actions: Actions = {
email: form.data.primaryContactEmail || null,
phone: form.data.primaryContactPhone || null,
isPrimary: true,
notes: form.data.primaryContactNotes || null,
receivesInvoices: form.data.primaryContactReceivesInvoices === 'true',
receivesContracts: form.data.primaryContactReceivesContracts === 'true',
createdAt: now,
updatedAt: now
});
@@ -78,14 +109,14 @@ export const actions: Actions = {
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId,
label: form.data.primaryAddressLabel,
type: form.data.primaryAddressType,
line1: form.data.primaryAddressLine1,
line2: form.data.primaryAddressLine2 || null,
line3: form.data.primaryAddressLine3 || null,
city: form.data.primaryAddressCity,
region: form.data.primaryAddressRegion || null,
postcode: form.data.primaryAddressPostcode,
country: form.data.primaryAddressCountry,
isPrimary: true,
createdAt: now,
updatedAt: now
});
@@ -108,8 +139,7 @@ export const actions: Actions = {
.update(clients)
.set({
name: form.data.name,
type: form.data.type,
status: form.data.status,
industry: form.data.industry || null,
website: form.data.website || null,
notes: form.data.notes || null,
updatedAt: new Date()
+1 -3
View File
@@ -79,9 +79,7 @@
<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>
<p class="text-sm text-muted-foreground">Manage client details, contacts, and addresses.</p>
</div>
<CreateClientDialog
@@ -13,19 +13,18 @@ import {
} from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { clientEditSchema } from '$lib/schemas/clients.schema';
import { industries } from '$lib/constants/industries';
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) {
const industry = industries.find((value) => value === row.industry);
return {
id: row.id,
name: row.name ?? '',
type: clientTypes.find((type) => type === row.type),
status: clientStatuses.find((status) => status === row.status),
industry: industry ?? ('' as const),
website: row.website ?? '',
notes: row.notes ?? ''
};
@@ -88,7 +87,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
isNull(addresses.archivedAt)
)
)
.orderBy(asc(addresses.label)),
.orderBy(asc(addresses.type)),
db
.select()
.from(contacts)
@@ -121,7 +120,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
isNull(contracts.archivedAt)
)
)
.orderBy(asc(contracts.title)),
.orderBy(asc(contracts.startDate)),
db
.select()
.from(bookings)
@@ -139,7 +138,8 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
return {
client,
editForm: await superValidate(formValues(client), zod4(clientEditSchema), {
id: 'clients-edit'
id: 'clients-edit',
errors: false
}),
options,
addresses: addressRows,
@@ -2,16 +2,17 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import { industryLabel, industryOptions } from '$lib/constants/industries';
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 { handleFormToast } from '$lib/form-feedback';
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';
@@ -50,6 +51,28 @@
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 }));
}
@@ -81,66 +104,60 @@
</Dialog.Header>
<form method="POST" action="/dashboard/clients?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name">
<Form.Control id="edit-client-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="type">
<Form.Control id="edit-client-type">
{#snippet children({ props })}
<Form.Label>Type</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-client-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="website">
<Form.Control id="edit-client-website">
{#snippet children({ props })}
<Form.Label>Website</Form.Label>
<Input
{...props}
type="url"
placeholder="https://example.com"
bind:value={$editData.website}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-client-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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="industry">
<Field.Field>
<Control id="edit-client-industry">
{#snippet children({ props })}
<Field.Label>Industry</Field.Label>
<FormSelect
name="industry"
bind:value={$editData.industry}
options={industryOptions}
triggerProps={props}
/>
{/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
@@ -154,18 +171,18 @@
</div>
</div>
<div class="grid gap-3 rounded-lg border p-4 sm:grid-cols-3">
<div class="grid gap-3 rounded-lg border p-4 sm:grid-cols-2">
<div>
<p class="text-xs font-medium text-muted-foreground uppercase">Type</p>
<p class="text-sm capitalize">{data.client.type}</p>
<p class="text-xs font-medium text-muted-foreground uppercase">Industry</p>
<p class="text-sm">{industryLabel(data.client.industry)}</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 class="sm:col-span-2">
<p class="text-xs font-medium text-muted-foreground uppercase">Notes</p>
<p class="text-sm whitespace-pre-wrap">{data.client.notes || 'Not set'}</p>
</div>
</div>
@@ -32,7 +32,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
isNull(addresses.archivedAt)
)
)
.orderBy(asc(addresses.label));
.orderBy(asc(addresses.type));
return {
records: records.map((record) => ({
@@ -40,22 +40,29 @@ export const load: PageServerLoad = async ({ locals, params }) => {
formValues: {
id: record.id,
clientId: record.clientId ?? '',
label: record.label ?? '',
type: record.type ?? 'primary',
line1: record.line1 ?? '',
line2: record.line2 ?? '',
line3: record.line3 ?? '',
city: record.city ?? '',
region: record.region ?? '',
postcode: record.postcode ?? '',
country: record.country ?? '',
isPrimary: record.isPrimary ? 'true' : 'false'
country: record.country ?? ''
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(addressCreateSchema), {
id: 'addresses-create'
id: 'addresses-create',
errors: false
}),
editForm: await superValidate(zod4(addressEditSchema), { id: 'addresses-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' })
editForm: await superValidate(zod4(addressEditSchema), {
id: 'addresses-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'addresses-archive',
errors: false
})
};
};
@@ -75,14 +82,14 @@ export const actions: Actions = {
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId: form.data.clientId,
label: form.data.label,
type: form.data.type,
line1: form.data.line1,
line2: form.data.line2 || null,
line3: form.data.line3 || 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()
});
@@ -106,14 +113,14 @@ export const actions: Actions = {
.update(addresses)
.set({
clientId: form.data.clientId,
label: form.data.label,
type: form.data.type,
line1: form.data.line1,
line2: form.data.line2 || null,
line3: form.data.line3 || 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(
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateAddressDialog from './create-address-dialog.svelte';
import AddressesTable from './addresses-table.svelte';
import EditAddressDialog from './edit-address-dialog.svelte';
@@ -9,20 +7,11 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { addressCreateSchema, addressEditSchema } from '$lib/schemas/addresses.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -55,8 +44,31 @@
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
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) {
@@ -70,7 +82,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this address');
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>
@@ -88,21 +108,7 @@
<CreateAddressDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search addresses..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<AddressesTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{relationLabel}
{recordName}
/>
<AddressesTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
</div>
<EditAddressDialog
@@ -9,14 +9,12 @@
openEdit,
openArchive,
formatValue,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -26,24 +24,20 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Label</Table.Head>
<Table.Head>Type</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="capitalize">{formatValue(record.type)}</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
@@ -1,11 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.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';
let {
open = $bindable(false),
createForm,
@@ -31,82 +32,103 @@
<Dialog.Description>Add a new address record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="label">
<Form.Control id="create-label">
{#snippet children({ props })}
<Form.Label>Label</Form.Label>
<Input {...props} type="text" bind:value={$createData.label} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="line1">
<Form.Control id="create-line1">
{#snippet children({ props })}
<Form.Label>Address line 1</Form.Label>
<Input {...props} type="text" bind:value={$createData.line1} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="line2">
<Form.Control id="create-line2">
{#snippet children({ props })}
<Form.Label>Address line 2</Form.Label>
<Input {...props} type="text" bind:value={$createData.line2} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="city">
<Form.Control id="create-city">
{#snippet children({ props })}
<Form.Label>City</Form.Label>
<Input {...props} type="text" bind:value={$createData.city} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="region">
<Form.Control id="create-region">
{#snippet children({ props })}
<Form.Label>Region / county</Form.Label>
<Input {...props} type="text" bind:value={$createData.region} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="postcode">
<Form.Control id="create-postcode">
{#snippet children({ props })}
<Form.Label>Postcode</Form.Label>
<Input {...props} type="text" bind:value={$createData.postcode} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="country">
<Form.Control id="create-country">
{#snippet children({ props })}
<Form.Label>Country</Form.Label>
<Input {...props} type="text" bind:value={$createData.country} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="isPrimary">
<Form.Control id="create-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary address</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<FormField form={createForm} name="type">
<Field.Field>
<Control id="create-type">
{#snippet children({ props })}
<Field.Label>Type</Field.Label>
<FormSelect
name="type"
bind:value={$createData.type}
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'invoicing', label: 'Invoicing' },
{ value: 'contract', label: 'Contract' }
]}
triggerProps={props}
/>
{/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="line3">
<Field.Field>
<Control id="create-line3">
{#snippet children({ props })}
<Field.Label>Address line 3</Field.Label>
<Input {...props} type="text" bind:value={$createData.line3} />
{/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>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,10 +1,11 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.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';
let {
editingRecord,
editForm,
@@ -29,82 +30,103 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="label">
<Form.Control id="edit-label">
{#snippet children({ props })}
<Form.Label>Label</Form.Label>
<Input {...props} type="text" bind:value={$editData.label} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="line1">
<Form.Control id="edit-line1">
{#snippet children({ props })}
<Form.Label>Address line 1</Form.Label>
<Input {...props} type="text" bind:value={$editData.line1} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="line2">
<Form.Control id="edit-line2">
{#snippet children({ props })}
<Form.Label>Address line 2</Form.Label>
<Input {...props} type="text" bind:value={$editData.line2} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="city">
<Form.Control id="edit-city">
{#snippet children({ props })}
<Form.Label>City</Form.Label>
<Input {...props} type="text" bind:value={$editData.city} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="region">
<Form.Control id="edit-region">
{#snippet children({ props })}
<Form.Label>Region / county</Form.Label>
<Input {...props} type="text" bind:value={$editData.region} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="postcode">
<Form.Control id="edit-postcode">
{#snippet children({ props })}
<Form.Label>Postcode</Form.Label>
<Input {...props} type="text" bind:value={$editData.postcode} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="country">
<Form.Control id="edit-country">
{#snippet children({ props })}
<Form.Label>Country</Form.Label>
<Input {...props} type="text" bind:value={$editData.country} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="isPrimary">
<Form.Control id="edit-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary address</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<FormField form={editForm} name="type">
<Field.Field>
<Control id="edit-type">
{#snippet children({ props })}
<Field.Label>Type</Field.Label>
<FormSelect
name="type"
bind:value={$editData.type}
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'invoicing', label: 'Invoicing' },
{ value: 'contract', label: 'Contract' }
]}
triggerProps={props}
/>
{/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="line3">
<Field.Field>
<Control id="edit-line3">
{#snippet children({ props })}
<Field.Label>Address line 3</Field.Label>
<Input {...props} type="text" bind:value={$editData.line3} />
{/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>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -67,10 +67,17 @@ export const load: PageServerLoad = async ({ locals, params }) => {
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(bookingCreateSchema), {
id: 'bookings-create'
id: 'bookings-create',
errors: false
}),
editForm: await superValidate(zod4(bookingEditSchema), { id: 'bookings-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' })
editForm: await superValidate(zod4(bookingEditSchema), {
id: 'bookings-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'bookings-archive',
errors: false
})
};
};
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateBookingDialog from './create-booking-dialog.svelte';
import BookingsTable from './bookings-table.svelte';
import EditBookingDialog from './edit-booking-dialog.svelte';
@@ -9,21 +7,11 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -56,8 +44,48 @@
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients' | 'rooms' | 'services') {
return getRelationLabel(data.options, value, optionsKey);
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: '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) {
@@ -71,7 +99,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this booking');
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>
@@ -91,15 +127,8 @@
<CreateBookingDialog bind:open={createOpen} {data} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search bookings..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<BookingsTable
data={listData}
{data}
{openEdit}
{openArchive}
{formatValue}
@@ -29,7 +29,6 @@
<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>
@@ -40,7 +39,6 @@
<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>
@@ -1,11 +1,13 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormCombobox from '$lib/components/form-combobox.svelte';
import FormSelect from '$lib/components/form-select.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 {
data,
@@ -34,75 +36,99 @@
<Dialog.Description>Add a new booking record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="roomId">
<Form.Control id="create-roomId">
{#snippet children({ props })}
<Form.Label>Room</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="serviceId">
<Form.Control id="create-serviceId">
{#snippet children({ props })}
<Form.Label>Service</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="startsAt">
<Form.Control id="create-startsAt">
{#snippet children({ props })}
<Form.Label>Starts at</Form.Label>
<Input {...props} type="datetime-local" bind:value={$createData.startsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="endsAt">
<Form.Control id="create-endsAt">
{#snippet children({ props })}
<Form.Label>Ends at</Form.Label>
<Input {...props} type="datetime-local" bind:value={$createData.endsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<FormField form={createForm} name="roomId">
<Field.Field>
<Control id="create-roomId">
{#snippet children({ props })}
<Field.Label>Room</Field.Label>
<FormCombobox
name="roomId"
bind:value={$createData.roomId}
options={data.options.rooms}
placeholder="Select a room"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
/>
{/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>
<FormCombobox
name="serviceId"
bind:value={$createData.serviceId}
options={data.options.services}
placeholder="Select a service"
searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
/>
{/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>
<FormSelect
name="status"
bind:value={$createData.status}
options={[
{ value: '', label: 'Not set' },
{ value: 'booked', label: 'Booked' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' }
]}
triggerProps={props}
/>
{/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
@@ -1,10 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormCombobox from '$lib/components/form-combobox.svelte';
import FormSelect from '$lib/components/form-select.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 {
data,
@@ -32,75 +34,99 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="roomId">
<Form.Control id="edit-roomId">
{#snippet children({ props })}
<Form.Label>Room</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="serviceId">
<Form.Control id="edit-serviceId">
{#snippet children({ props })}
<Form.Label>Service</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="startsAt">
<Form.Control id="edit-startsAt">
{#snippet children({ props })}
<Form.Label>Starts at</Form.Label>
<Input {...props} type="datetime-local" bind:value={$editData.startsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="endsAt">
<Form.Control id="edit-endsAt">
{#snippet children({ props })}
<Form.Label>Ends at</Form.Label>
<Input {...props} type="datetime-local" bind:value={$editData.endsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<FormField form={editForm} name="roomId">
<Field.Field>
<Control id="edit-roomId">
{#snippet children({ props })}
<Field.Label>Room</Field.Label>
<FormCombobox
name="roomId"
bind:value={$editData.roomId}
options={data.options.rooms}
placeholder="Select a room"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
/>
{/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>
<FormCombobox
name="serviceId"
bind:value={$editData.serviceId}
options={data.options.services}
placeholder="Select a service"
searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
/>
{/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>
<FormSelect
name="status"
bind:value={$editData.status}
options={[
{ value: '', label: 'Not set' },
{ value: 'booked', label: 'Booked' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' }
]}
triggerProps={props}
/>
{/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
@@ -45,15 +45,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
email: record.email ?? '',
phone: record.phone ?? '',
isPrimary: record.isPrimary ? 'true' : 'false',
notes: record.notes ?? ''
receivesInvoices: record.receivesInvoices ? 'true' : 'false',
receivesContracts: record.receivesContracts ? 'true' : 'false'
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(contactCreateSchema), {
id: 'contacts-create'
id: 'contacts-create',
errors: false
}),
editForm: await superValidate(zod4(contactEditSchema), { id: 'contacts-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' })
editForm: await superValidate(zod4(contactEditSchema), {
id: 'contacts-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contacts-archive',
errors: false
})
};
};
@@ -78,7 +86,8 @@ export const actions: Actions = {
email: form.data.email || null,
phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true',
notes: form.data.notes || null,
receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date(),
createdAt: new Date()
});
@@ -107,7 +116,8 @@ export const actions: Actions = {
email: form.data.email || null,
phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true',
notes: form.data.notes || null,
receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date()
})
.where(
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateContactDialog from './create-contact-dialog.svelte';
import ContactsTable from './contacts-table.svelte';
import EditContactDialog from './edit-contact-dialog.svelte';
@@ -9,20 +7,11 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { contactCreateSchema, contactEditSchema } from '$lib/schemas/contacts.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -55,8 +44,31 @@
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
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) {
@@ -70,7 +82,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contact');
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>
@@ -88,21 +108,7 @@
<CreateContactDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search contacts..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<ContactsTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{relationLabel}
{recordName}
/>
<ContactsTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
</div>
<EditContactDialog
@@ -9,14 +9,12 @@
openEdit,
openArchive,
formatValue,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -26,24 +24,26 @@
<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>Invoices</Table.Head>
<Table.Head>Contracts</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>{record.receivesInvoices ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>{record.receivesContracts ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,12 +1,13 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import FormCheckbox from '$lib/components/form-checkbox.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,
@@ -32,64 +33,86 @@
<Dialog.Description>Add a new contact record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="name">
<Form.Control id="create-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$createData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="role">
<Form.Control id="create-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<Input {...props} type="text" bind:value={$createData.role} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="email">
<Form.Control id="create-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" bind:value={$createData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="phone">
<Form.Control id="create-phone">
{#snippet children({ props })}
<Form.Label>Phone</Form.Label>
<Input {...props} type="text" bind:value={$createData.phone} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="isPrimary">
<Form.Control id="create-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary contact</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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>
<FormSelect
name="isPrimary"
bind:value={$createData.isPrimary}
options={[
{ value: '', label: 'Not set' },
{ value: 'false', label: 'No' },
{ value: 'true', label: 'Yes' }
]}
triggerProps={props}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-3 py-1">
<FormCheckbox
id="create-receives-invoices"
name="receivesInvoices"
label="Receives invoices"
description="Send invoices raised for this client to this contact."
bind:value={$createData.receivesInvoices}
/>
<FormCheckbox
id="create-receives-contracts"
name="receivesContracts"
label="Receives contracts"
description="Send contract documents for this client to this contact."
bind:value={$createData.receivesContracts}
/>
</div>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,11 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import FormCheckbox from '$lib/components/form-checkbox.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,
@@ -30,64 +31,86 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name">
<Form.Control id="edit-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="role">
<Form.Control id="edit-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<Input {...props} type="text" bind:value={$editData.role} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="email">
<Form.Control id="edit-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" bind:value={$editData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="phone">
<Form.Control id="edit-phone">
{#snippet children({ props })}
<Form.Label>Phone</Form.Label>
<Input {...props} type="text" bind:value={$editData.phone} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="isPrimary">
<Form.Control id="edit-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary contact</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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>
<FormSelect
name="isPrimary"
bind:value={$editData.isPrimary}
options={[
{ value: '', label: 'Not set' },
{ value: 'false', label: 'No' },
{ value: 'true', label: 'Yes' }
]}
triggerProps={props}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-3 py-1">
<FormCheckbox
id="edit-receives-invoices"
name="receivesInvoices"
label="Receives invoices"
description="Send invoices raised for this client to this contact."
bind:value={$editData.receivesInvoices}
/>
<FormCheckbox
id="edit-receives-contracts"
name="receivesContracts"
label="Receives contracts"
description="Send contract documents for this client to this contact."
bind:value={$editData.receivesContracts}
/>
</div>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,30 +1,74 @@
import { and, asc, eq, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server';
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { message, setError, 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 { contracts, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
import {
contractCreateSchema,
contractEditSchema,
contractTransitionSchema
} from '$lib/schemas/contracts.schema';
import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) {
const clientRows = await db
.select({ id: clients.id, name: clients.name })
.from(clients)
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt)))
.orderBy(asc(clients.name));
const [roomRows, serviceRows] = await Promise.all([
db
.select({
id: rooms.id,
name: rooms.name,
type: rooms.type,
pricePerMonthGbp: rooms.pricePerMonthGbp
})
.from(rooms)
.where(
and(
eq(rooms.organizationId, organizationId),
inArray(rooms.type, ['private_office', 'coworking_desk']),
isNull(rooms.archivedAt)
)
)
.orderBy(asc(rooms.name)),
db
.select({ id: services.id, name: services.name, priceGbp: services.priceGbp })
.from(services)
.where(and(eq(services.organizationId, organizationId), isNull(services.archivedAt)))
.orderBy(asc(services.name))
]);
return {
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
rooms: [
{ label: 'No room', value: '' },
...roomRows.map((room) => ({
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
value: room.id,
licenseFeeGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) : 0,
depositGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) * 2 : 0
}))
],
services: [
{ label: 'No service', value: '' },
...serviceRows.map((service) => ({
label: service.name,
value: service.id,
licenseFeeGbp: service.priceGbp
}))
]
};
}
export const load: PageServerLoad = async ({ locals, params }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.select({
contract: contracts,
roomName: rooms.name,
serviceName: services.name
})
.from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(
and(
eq(contracts.clientId, params.id),
@@ -32,31 +76,91 @@ export const load: PageServerLoad = async ({ locals, params }) => {
isNull(contracts.archivedAt)
)
)
.orderBy(asc(contracts.title));
.orderBy(asc(contracts.startDate));
return {
records: records.map((record) => ({
...record,
...record.contract,
roomName: record.roomName,
serviceName: record.serviceName,
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 ?? ''
id: record.contract.id,
clientId: record.contract.clientId ?? '',
roomId: record.contract.roomId ?? '',
serviceId: record.contract.serviceId ?? '',
licenseFeeGbp: record.contract.licenseFeeGbp,
depositGbp: record.contract.depositGbp,
startDate: record.contract.startDate ?? '',
endDate: record.contract.endDate ?? '',
notes: record.contract.notes ?? ''
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), {
id: 'contracts-create'
id: 'contracts-create',
errors: false
}),
editForm: await superValidate(zod4(contractEditSchema), { id: 'contracts-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
editForm: await superValidate(zod4(contractEditSchema), {
id: 'contracts-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contracts-archive',
errors: false
}),
transitionForm: await superValidate(zod4(contractTransitionSchema), {
id: 'contracts-transition',
errors: false
})
};
};
async function selectionError(
organizationId: string,
roomId: string,
serviceId: string
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> {
if (roomId) {
const [room] = await db
.select({ id: rooms.id })
.from(rooms)
.where(
and(
eq(rooms.id, roomId),
eq(rooms.organizationId, organizationId),
inArray(rooms.type, ['private_office', 'coworking_desk']),
isNull(rooms.archivedAt)
)
)
.limit(1);
if (!room) {
return {
field: 'roomId',
message: 'Select a valid private office or coworking desk.'
};
}
}
if (serviceId) {
const [service] = await db
.select({ id: services.id })
.from(services)
.where(
and(
eq(services.id, serviceId),
eq(services.organizationId, organizationId),
isNull(services.archivedAt)
)
)
.limit(1);
if (!service) return { field: 'serviceId', message: 'Select a valid service.' };
}
return null;
}
export const actions: Actions = {
create: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
@@ -67,17 +171,25 @@ export const actions: Actions = {
});
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const invalidSelection = await selectionError(
activeOrganizationId,
form.data.roomId,
form.data.serviceId
);
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
try {
await db.insert(contracts).values({
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId: form.data.clientId,
title: form.data.title,
roomId: form.data.roomId || null,
serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp,
startDate: form.data.startDate,
endDate: form.data.endDate || null,
status: form.data.status,
valueGbp: form.data.valueGbp,
endDate: form.data.endDate,
status: 'draft',
notes: form.data.notes || null,
updatedAt: new Date(),
createdAt: new Date()
@@ -98,17 +210,24 @@ export const actions: Actions = {
});
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const invalidSelection = await selectionError(
activeOrganizationId,
form.data.roomId,
form.data.serviceId
);
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
try {
await db
.update(contracts)
.set({
clientId: form.data.clientId,
title: form.data.title,
roomId: form.data.roomId || null,
serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp,
startDate: form.data.startDate,
endDate: form.data.endDate || null,
status: form.data.status,
valueGbp: form.data.valueGbp,
endDate: form.data.endDate,
notes: form.data.notes || null,
updatedAt: new Date()
})
@@ -126,6 +245,50 @@ export const actions: Actions = {
return message(form, 'Contract updated.');
},
transition: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(contractTransitionSchema), {
id: 'contracts-transition'
});
if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 });
const [contract] = await db
.select({ status: contracts.status })
.from(contracts)
.where(
and(
eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId),
isNull(contracts.archivedAt)
)
)
.limit(1);
const allowedTransitions: Record<string, readonly string[]> = {
draft: ['active', 'void'],
active: ['expired']
};
if (!contract || !allowedTransitions[contract.status]?.includes(form.data.targetStatus)) {
return message(form, 'That contract status change is not allowed.', { status: 400 });
}
await db
.update(contracts)
.set({ status: form.data.targetStatus, updatedAt: new Date() })
.where(
and(
eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId)
)
);
return message(form, `Contract marked ${form.data.targetStatus}.`);
},
archive: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateContractDialog from './create-contract-dialog.svelte';
import ContractsTable from './contracts-table.svelte';
import EditContractDialog from './edit-contract-dialog.svelte';
@@ -10,21 +8,14 @@
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
formatMoney,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
contractCreateSchema,
contractEditSchema,
contractTransitionSchema
} from '$lib/schemas/contracts.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -52,17 +43,67 @@
onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-archive' })
});
// svelte-ignore state_referenced_locally
const transitionForm = superForm(data.transitionForm, {
validators: zod4Client(contractTransitionSchema),
resetForm: false,
onUpdated: ({ form }) =>
handleFormToast(form, 'contracts-transition', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-transition' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
const { enhance: enhanceTransition } = transitionForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
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 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 });
transitionForm.reset({
data: {
id: record.id,
targetStatus: record.status === 'active' ? 'expired' : 'active'
}
});
editingId = record.id;
}
@@ -72,7 +113,7 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contract');
return String(record?.roomName ?? record?.serviceName ?? 'this contract');
}
</script>
@@ -87,33 +128,25 @@
<p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p>
</div>
<CreateContractDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
<CreateContractDialog
bind:open={createOpen}
options={data.options}
{createForm}
{createData}
{enhanceCreate}
/>
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search contracts..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<ContractsTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
<ContractsTable {data} {openEdit} {openArchive} {formatValue} {formatDate} {recordName} />
</div>
<EditContractDialog
{editingRecord}
options={data.options}
{editForm}
{editData}
{enhanceEdit}
{enhanceTransition}
onClose={() => (editingId = null)}
/>
@@ -0,0 +1,139 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormCombobox from '$lib/components/form-combobox.svelte';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
form,
data,
options,
prefix
}: {
form: any;
data: any;
options: {
rooms: readonly {
value: string;
label: string;
licenseFeeGbp?: number;
depositGbp?: number;
}[];
services: readonly { value: string; label: string; licenseFeeGbp?: number }[];
};
prefix: string;
} = $props();
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function updatePricing(roomId: string, serviceId: string) {
const room = options.rooms.find((option) => option.value === roomId);
const service = options.services.find((option) => option.value === serviceId);
$data.licenseFeeGbp = roundMoney((room?.licenseFeeGbp ?? 0) + (service?.licenseFeeGbp ?? 0));
$data.depositGbp = roundMoney(room?.depositGbp ?? 0);
}
</script>
<FormField {form} name="roomId">
<Field.Field>
<Control id={`${prefix}-roomId`}>
{#snippet children({ props })}
<Field.Label>Room</Field.Label>
<FormCombobox
name="roomId"
bind:value={$data.roomId}
options={options.rooms}
placeholder="Select a private office or coworking desk"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
onValueChange={(roomId) => updatePricing(roomId, $data.serviceId)}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField {form} name="serviceId">
<Field.Field>
<Control id={`${prefix}-serviceId`}>
{#snippet children({ props })}
<Field.Label>Service</Field.Label>
<FormCombobox
name="serviceId"
bind:value={$data.serviceId}
options={options.services}
placeholder="Select a service"
searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
onValueChange={(serviceId) => updatePricing($data.roomId, serviceId)}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-2 sm:grid-cols-2">
<MoneyField
{form}
{data}
name="licenseFeeGbp"
label="License fee"
id={`${prefix}-licenseFeeGbp`}
description="Private office monthly price plus the selected service. You can overwrite this amount."
/>
<MoneyField
{form}
{data}
name="depositGbp"
label="Deposit"
id={`${prefix}-depositGbp`}
description="Defaults to two months of private office fees. Coworking desks and services require no deposit."
/>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<FormField {form} name="startDate">
<Field.Field>
<Control id={`${prefix}-startDate`}>
{#snippet children({ props })}
<Field.Label>Start date</Field.Label>
<Input {...props} type="date" bind:value={$data.startDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField {form} name="endDate">
<Field.Field>
<Control id={`${prefix}-endDate`}>
{#snippet children({ props })}
<Field.Label>End date</Field.Label>
<Input {...props} type="date" bind:value={$data.endDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
<FormField {form} name="notes">
<Field.Field>
<Control id={`${prefix}-notes`}>
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$data.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
@@ -11,8 +11,6 @@
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
@@ -20,8 +18,6 @@
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -31,27 +27,25 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Room</Table.Head>
<Table.Head>Service</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="font-medium">{formatValue(record.roomName)}</Table.Cell>
<Table.Cell>{formatValue(record.serviceName)}</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
@@ -1,20 +1,18 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import MoneyField from '$lib/components/money-field.svelte';
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';
import ContractFormFields from './contract-form-fields.svelte';
let {
open = $bindable(false),
options,
createForm,
createData,
enhanceCreate
}: {
open: boolean;
options: any;
createForm: any;
createData: any;
enhanceCreate: any;
@@ -33,64 +31,7 @@
<Dialog.Description>Add a new contract record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="title">
<Form.Control id="create-title">
{#snippet children({ props })}
<Form.Label>Title</Form.Label>
<Input {...props} type="text" bind:value={$createData.title} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="startDate">
<Form.Control id="create-startDate">
{#snippet children({ props })}
<Form.Label>Start date</Form.Label>
<Input {...props} type="date" bind:value={$createData.startDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="endDate">
<Form.Control id="create-endDate">
{#snippet children({ props })}
<Form.Label>End date</Form.Label>
<Input {...props} type="date" bind:value={$createData.endDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<MoneyField
form={createForm}
data={createData}
name="valueGbp"
label="Value"
id="create-valueGbp"
/>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<ContractFormFields form={createForm} data={createData} {options} prefix="create" />
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,23 +1,24 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import MoneyField from '$lib/components/money-field.svelte';
import { Badge } from '$lib/components/ui/badge/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';
import ContractFormFields from './contract-form-fields.svelte';
let {
editingRecord,
options,
editForm,
editData,
enhanceEdit,
enhanceTransition,
onClose
}: {
editingRecord: any;
options: any;
editForm: any;
editData: any;
enhanceEdit: any;
enhanceTransition: any;
onClose: () => void;
} = $props();
</script>
@@ -25,78 +26,49 @@
<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>
<div class="flex items-center gap-2">
<Dialog.Title>Edit contract</Dialog.Title>
{#if editingRecord}
<Badge variant="secondary" class="capitalize">{editingRecord.status}</Badge>
{/if}
</div>
<Dialog.Description>Update this contract record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<form id="edit-contract-form" method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="title">
<Form.Control id="edit-title">
{#snippet children({ props })}
<Form.Label>Title</Form.Label>
<Input {...props} type="text" bind:value={$editData.title} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="startDate">
<Form.Control id="edit-startDate">
{#snippet children({ props })}
<Form.Label>Start date</Form.Label>
<Input {...props} type="date" bind:value={$editData.startDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="endDate">
<Form.Control id="edit-endDate">
{#snippet children({ props })}
<Form.Label>End date</Form.Label>
<Input {...props} type="date" bind:value={$editData.endDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<MoneyField
form={editForm}
data={editData}
name="valueGbp"
label="Value"
id="edit-valueGbp"
/>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Dialog.Footer>
<ContractFormFields form={editForm} data={editData} {options} prefix="edit" />
</form>
<Dialog.Footer class="flex-row items-center justify-between sm:justify-between">
{#if editingRecord.status === 'draft' || editingRecord.status === 'active'}
<form
method="POST"
action="?/transition"
class="flex flex-wrap gap-2"
use:enhanceTransition
>
<input type="hidden" name="id" value={editingRecord.id} />
{#if editingRecord.status === 'draft'}
<Button type="submit" name="targetStatus" value="void" variant="destructive"
>Void contract</Button
>
<Button type="submit" name="targetStatus" value="active">Activate contract</Button>
{:else}
<Button type="submit" name="targetStatus" value="expired">Mark as expired</Button>
{/if}
</form>
{:else}
<div></div>
{/if}
<div class="flex gap-2">
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
<Button type="submit" form="edit-contract-form">Save changes</Button>
</div>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -5,7 +5,7 @@ import { db } from '$lib/server/db';
import { invoices, clients } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.schema';
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) {
@@ -51,48 +51,18 @@ export const load: PageServerLoad = async ({ locals, params }) => {
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(invoiceCreateSchema), {
id: 'invoices-create'
editForm: await superValidate(zod4(invoiceEditSchema), {
id: 'invoices-edit',
errors: false
}),
editForm: await superValidate(zod4(invoiceEditSchema), { id: 'invoices-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' })
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
};
};
export const actions: Actions = {
create: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
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(),
organizationId: activeOrganizationId,
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 ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const formData = await request.formData();
@@ -1,7 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
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';
@@ -9,35 +6,17 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
formatMoney,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.schema';
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
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),
@@ -53,12 +32,51 @@
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 relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function 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) {
@@ -72,7 +90,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this invoice');
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>
@@ -81,30 +107,20 @@
</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>
<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>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search invoices..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<InvoicesTable
data={listData}
{data}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
@@ -1,118 +0,0 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import MoneyField from '$lib/components/money-field.svelte';
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>
<Form.Field form={createForm} name="invoiceNumber">
<Form.Control id="create-invoiceNumber">
{#snippet children({ props })}
<Form.Label>Invoice number</Form.Label>
<Input {...props} type="text" bind:value={$createData.invoiceNumber} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="issueDate">
<Form.Control id="create-issueDate">
{#snippet children({ props })}
<Form.Label>Issue date</Form.Label>
<Input {...props} type="date" bind:value={$createData.issueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="dueDate">
<Form.Control id="create-dueDate">
{#snippet children({ props })}
<Form.Label>Due date</Form.Label>
<Input {...props} type="date" bind:value={$createData.dueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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"
/>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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>
@@ -1,11 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
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,
@@ -31,49 +32,62 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="invoiceNumber">
<Form.Control id="edit-invoiceNumber">
{#snippet children({ props })}
<Form.Label>Invoice number</Form.Label>
<Input {...props} type="text" bind:value={$editData.invoiceNumber} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="issueDate">
<Form.Control id="edit-issueDate">
{#snippet children({ props })}
<Form.Label>Issue date</Form.Label>
<Input {...props} type="date" bind:value={$editData.issueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="dueDate">
<Form.Control id="edit-dueDate">
{#snippet children({ props })}
<Form.Label>Due date</Form.Label>
<Input {...props} type="date" bind:value={$editData.dueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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>
<FormSelect
name="status"
bind:value={$editData.status}
options={[
{ value: '', label: 'Not set' },
{ value: 'draft', label: 'Draft' },
{ value: 'sent', label: 'Sent' },
{ value: 'paid', label: 'Paid' },
{ value: 'overdue', label: 'Overdue' },
{ value: 'void', label: 'Void' }
]}
triggerProps={props}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={editForm}
data={editData}
@@ -89,15 +103,17 @@
label="Total"
id="edit-totalGbp"
/>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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
@@ -12,7 +12,6 @@
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
@@ -21,7 +20,6 @@
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -32,7 +30,6 @@
<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>
@@ -44,7 +41,6 @@
{#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=""
@@ -4,7 +4,6 @@
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';
@@ -29,8 +28,9 @@
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Primary contact</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Primary address</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
@@ -44,14 +44,9 @@
>{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>{formatValue(record.primaryContactName)}</Table.Cell>
<Table.Cell>{formatValue(record.primaryContactEmail)}</Table.Cell>
<Table.Cell>{formatValue(record.primaryAddressLine1)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,11 +1,14 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import FormCheckbox from '$lib/components/form-checkbox.svelte';
import { industryOptions } from '$lib/constants/industries';
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 {
@@ -22,9 +25,9 @@
enhanceCreate: any;
} = $props();
const createSteps = [
{ title: 'Client', description: 'Business details' },
{ title: 'Contact', description: 'Primary contact' },
{ title: 'Address', description: 'Primary address' }
{ title: 'Client', description: 'Client details' },
{ title: 'Address', description: 'Primary address' },
{ title: 'Contact', description: 'Primary contact' }
] as const;
</script>
@@ -58,181 +61,224 @@
</Stepper.Root>
<div class={['grid gap-2', createStep !== 0 && 'hidden']}>
<Form.Field form={createForm} name="name">
<Form.Control id="create-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$createData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="type">
<Form.Control id="create-type">
{#snippet children({ props })}
<Form.Label>Type</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="website">
<Form.Control id="create-website">
{#snippet children({ props })}
<Form.Label>Website</Form.Label>
<Input
{...props}
type="url"
placeholder="https://example.com"
bind:value={$createData.website}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<div class={['grid gap-2', createStep !== 1 && 'hidden']}>
<Form.Field form={createForm} name="primaryContactName">
<Form.Control id="create-primary-contact-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactName} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactRole">
<Form.Control id="create-primary-contact-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactRole} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactEmail">
<Form.Control id="create-primary-contact-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" bind:value={$createData.primaryContactEmail} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactPhone">
<Form.Control id="create-primary-contact-phone">
{#snippet children({ props })}
<Form.Label>Phone</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactPhone} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactNotes">
<Form.Control id="create-primary-contact-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.primaryContactNotes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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="industry">
<Field.Field>
<Control id="create-industry">
{#snippet children({ props })}
<Field.Label>Industry</Field.Label>
<FormSelect
name="industry"
bind:value={$createData.industry}
options={industryOptions}
triggerProps={props}
/>
{/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 !== 2 && 'hidden']}>
<Form.Field form={createForm} name="primaryAddressLabel">
<Form.Control id="create-primary-address-label">
{#snippet children({ props })}
<Form.Label>Label</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLabel} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressLine1">
<Form.Control id="create-primary-address-line-1">
{#snippet children({ props })}
<Form.Label>Address line 1</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine1} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressLine2">
<Form.Control id="create-primary-address-line-2">
{#snippet children({ props })}
<Form.Label>Address line 2</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine2} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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>
<div class="grid gap-3 py-1">
<FormCheckbox
id="create-primary-contact-receives-invoices"
name="primaryContactReceivesInvoices"
label="Receives invoices"
description="Send invoices raised for this client to this contact."
bind:value={$createData.primaryContactReceivesInvoices}
/>
<FormCheckbox
id="create-primary-contact-receives-contracts"
name="primaryContactReceivesContracts"
label="Receives contracts"
description="Send contract documents for this client to this contact."
bind:value={$createData.primaryContactReceivesContracts}
/>
</div>
</div>
<div class={['grid gap-2', createStep !== 1 && 'hidden']}>
<FormField form={createForm} name="primaryAddressType">
<Field.Field>
<Control id="create-primary-address-type">
{#snippet children({ props })}
<Field.Label>Type</Field.Label>
<FormSelect
name="primaryAddressType"
bind:value={$createData.primaryAddressType}
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'invoicing', label: 'Invoicing' },
{ value: 'contract', label: 'Contract' }
]}
triggerProps={props}
/>
{/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>
<FormField form={createForm} name="primaryAddressLine3">
<Field.Field>
<Control id="create-primary-address-line-3">
{#snippet children({ props })}
<Field.Label>Address line 3</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine3} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-2 sm:grid-cols-2">
<Form.Field form={createForm} name="primaryAddressCity">
<Form.Control id="create-primary-address-city">
{#snippet children({ props })}
<Form.Label>City</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressCity} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressRegion">
<Form.Control id="create-primary-address-region">
{#snippet children({ props })}
<Form.Label>Region</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressRegion} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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">
<Form.Field form={createForm} name="primaryAddressPostcode">
<Form.Control id="create-primary-address-postcode">
{#snippet children({ props })}
<Form.Label>Postcode</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressPostcode} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressCountry">
<Form.Control id="create-primary-address-country">
{#snippet children({ props })}
<Form.Label>Country</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressCountry} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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>
@@ -1,10 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import { industryOptions } from '$lib/constants/industries';
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,
@@ -30,66 +32,60 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name">
<Form.Control id="edit-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="type">
<Form.Control id="edit-type">
{#snippet children({ props })}
<Form.Label>Type</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
{#snippet children({ props })}
<Form.Label>Status</Form.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}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="website">
<Form.Control id="edit-website">
{#snippet children({ props })}
<Form.Label>Website</Form.Label>
<Input
{...props}
type="url"
placeholder="https://example.com"
bind:value={$editData.website}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<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="industry">
<Field.Field>
<Control id="edit-industry">
{#snippet children({ props })}
<Field.Label>Industry</Field.Label>
<FormSelect
name="industry"
bind:value={$editData.industry}
options={industryOptions}
triggerProps={props}
/>
{/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
@@ -30,7 +30,10 @@ export const load: PageServerLoad = async ({ locals }) => {
return {
records,
options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' })
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contacts-archive',
errors: false
})
};
};
@@ -25,6 +25,8 @@
<Table.Head>Email</Table.Head>
<Table.Head>Phone</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head>Invoices</Table.Head>
<Table.Head>Contracts</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
@@ -37,6 +39,8 @@
<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>{record.receivesInvoices ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>{record.receivesContracts ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
+18 -5
View File
@@ -2,7 +2,7 @@ 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 { contracts, clients, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types';
@@ -22,15 +22,28 @@ async function loadOptions(organizationId: string) {
export const load: PageServerLoad = async ({ locals }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.select({
contract: contracts,
roomName: rooms.name,
serviceName: services.name
})
.from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt)))
.orderBy(asc(contracts.title));
.orderBy(asc(contracts.startDate));
return {
records,
records: records.map((record) => ({
...record.contract,
roomName: record.roomName,
serviceName: record.serviceName
})),
options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contracts-archive',
errors: false
})
};
};
+43 -31
View File
@@ -1,27 +1,14 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
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 {
formatValue,
formatMoney,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
@@ -36,8 +23,48 @@
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') {
return getRelationLabel(data.options, value, optionsKey);
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) {
@@ -46,7 +73,7 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contract');
return String(record?.roomName ?? record?.serviceName ?? 'this contract');
}
</script>
@@ -62,22 +89,7 @@
</div>
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search contracts..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<ContractsTable
data={listData}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
<ContractsTable {data} {openArchive} {formatValue} {formatDate} {relationLabel} {recordName} />
</div>
<ArchiveContractDialog
@@ -10,7 +10,6 @@
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
@@ -18,7 +17,6 @@
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
@@ -29,19 +27,20 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Room</Table.Head>
<Table.Head>Service</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="font-medium">{formatValue(record.roomName)}</Table.Cell>
<Table.Cell>{formatValue(record.serviceName)}</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>
@@ -49,7 +48,6 @@
><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
@@ -1,51 +0,0 @@
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 { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) {
const clientRows = await db
.select({ id: clients.id, name: clients.name })
.from(clients)
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt)))
.orderBy(asc(clients.name));
return {
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
};
}
export const load: PageServerLoad = async ({ locals }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.from(invoices)
.where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt)))
.orderBy(asc(invoices.invoiceNumber));
return {
records,
options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' })
};
};
export const actions: Actions = {
archive: async (event) => {
const { activeOrganizationId } = await loadOrganizationContext(event.locals);
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(and(eq(invoices.id, form.data.id), eq(invoices.organizationId, activeOrganizationId)));
return message(form, 'Invoice archived.');
}
};
@@ -1,89 +0,0 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
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 {
formatValue,
formatMoney,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
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 relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, '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>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search invoices..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<InvoicesTable
data={listData}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ url }) => {
redirect(308, `/dashboard/billing${url.search}`);
};
@@ -0,0 +1,71 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import OrganizationEditDialog from './organization-edit-dialog.svelte';
import type { LayoutData } from './$types';
let {
data,
children
}: {
data: LayoutData;
children: import('svelte').Snippet;
} = $props();
let editOpen = $state(false);
const tabs = [
{ value: 'details', label: 'Details' },
{ value: 'admins', label: 'Admins' }
] as const;
type TabValue = (typeof tabs)[number]['value'];
const activeTab = $derived.by<TabValue>(() => {
const segment = page.url.pathname.split('/').filter(Boolean).at(-1);
return tabs.find((tab) => tab.value === segment)?.value ?? 'details';
});
function navigateToTab(tab: TabValue) {
goto(resolve(`/dashboard/organization/${tab}`));
}
</script>
<svelte:head>
<title>{data.activeOrganization.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.activeOrganization.name}</h1>
<p class="text-sm text-muted-foreground">
Manage organization details, invoice payment information, and administrators.
</p>
</div>
<OrganizationEditDialog organization={data.activeOrganization} bind:open={editOpen} />
</div>
</div>
<Tabs.Root
value={activeTab}
onValueChange={(value) => {
if (value !== activeTab) navigateToTab(value as TabValue);
}}
class="w-full"
>
<Tabs.List class="flex h-auto flex-wrap justify-start">
{#each tabs as tab (tab.value)}
<Tabs.Trigger value={tab.value} aria-label={`View organization ${tab.label.toLowerCase()}`}>
{tab.label}
</Tabs.Trigger>
{/each}
</Tabs.List>
<div class="mt-4">
{@render children()}
</div>
</Tabs.Root>
</div>
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(307, '/dashboard/organization/details');
};
@@ -25,9 +25,15 @@ export const load: PageServerLoad = async ({ locals }) => {
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' })
createForm: await superValidate(zod4(createAdminSchema), {
id: 'create-admin',
errors: false
}),
editForm: await superValidate(zod4(editAdminSchema), { id: 'edit-admin', errors: false }),
deleteForm: await superValidate(zod4(deleteAdminSchema), {
id: 'delete-admin',
errors: false
})
};
};
@@ -1,21 +1,15 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
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 { formatDate } from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import AdminsTable from './admins-table.svelte';
import CreateAdminDialog from './create-admin-dialog.svelte';
import DeleteAdminDialog from './delete-admin-dialog.svelte';
import EditAdminDialog from './edit-admin-dialog.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.users);
const listData = $derived({ ...data, users: listSearch.filtered });
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingUserId = $state<string | null>(null);
@@ -23,12 +17,14 @@
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),
@@ -36,6 +32,7 @@
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),
@@ -48,6 +45,38 @@
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: {
@@ -71,28 +100,19 @@
}
</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 and admin roles.</p>
<h2 class="text-xl font-semibold tracking-tight">Administrators</h2>
<p class="text-sm text-muted-foreground">
Manage users who can access and administer Clearity.
</p>
</div>
<CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search admins..."
totalCount={data.users.length}
resultCount={listSearch.filtered.length}
/>
<AdminsTable data={listData} {openEdit} {openDelete} {formatDate} />
<AdminsTable {data} {openEdit} {openDelete} {formatDate} />
</div>
<EditAdminDialog
@@ -0,0 +1,105 @@
<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 FormSelect from '$lib/components/form-select.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';
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 user
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Create user</Dialog.Title>
<Dialog.Description>Add a manually managed Better Auth user.</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>
<FormField form={createForm} name="role">
<Field.Field>
<Control id="create-role">
{#snippet children({ props })}
<Field.Label>Role</Field.Label>
<FormSelect
name="role"
bind:value={$createData.role}
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' }
]}
triggerProps={props}
/>
{/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 user</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,102 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.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';
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 user</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>
<FormField form={editForm} name="role">
<Field.Field>
<Control id="edit-role">
{#snippet children({ props })}
<Field.Label>Role</Field.Label>
<FormSelect
name="role"
bind:value={$editData.role}
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' }
]}
triggerProps={props}
/>
{/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,57 @@
<script lang="ts">
import LandmarkIcon from '@lucide/svelte/icons/landmark';
import MapPinIcon from '@lucide/svelte/icons/map-pin';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const organization = $derived(data.activeOrganization);
const detailGroups = $derived([
{
title: 'Organization details',
icon: MapPinIcon,
items: [
{ label: 'Name', value: organization.name },
{ label: 'Workspace', value: organization.workspace },
{ label: 'Address', value: organization.addressLine1 },
{ label: 'Address line 2', value: organization.addressLine2 },
{ label: 'City', value: organization.city },
{ label: 'Region', value: organization.region },
{ label: 'Postcode', value: organization.postcode },
{ label: 'Country', value: organization.country }
]
},
{
title: 'Bank details',
icon: LandmarkIcon,
items: [
{ label: 'Bank name', value: organization.bankName },
{ label: 'Account name', value: organization.bankAccountName },
{ label: 'Account number', value: organization.bankAccountNumber },
{ label: 'Sort code', value: organization.bankSortCode },
{ label: 'IBAN', value: organization.bankIban },
{ label: 'SWIFT/BIC', value: organization.bankSwift }
]
}
]);
</script>
<div class="grid gap-4 lg:grid-cols-2">
{#each detailGroups as group (group.title)}
<section class="rounded-lg border">
<div class="flex items-center gap-2 border-b px-4 py-3">
<group.icon class="size-4 text-muted-foreground" />
<h2 class="font-medium">{group.title}</h2>
</div>
<dl class="grid gap-x-6 gap-y-4 p-4 sm:grid-cols-2">
{#each group.items as item (item.label)}
<div>
<dt class="text-xs font-medium text-muted-foreground uppercase">{item.label}</dt>
<dd class="mt-1 text-sm">{item.value || 'Not set'}</dd>
</div>
{/each}
</dl>
</section>
{/each}
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import OrganizationFormFields from '$lib/components/organization-form-fields.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
type Organization = {
id: string;
name: string;
workspace: string;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
region?: string | null;
postcode?: string | null;
country?: string | null;
bankName?: string | null;
bankAccountName?: string | null;
bankAccountNumber?: string | null;
bankSortCode?: string | null;
bankIban?: string | null;
bankSwift?: string | null;
};
let {
organization,
open = $bindable(false)
}: {
organization: Organization;
open: boolean;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}>Edit organization</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit organization</Dialog.Title>
<Dialog.Description>
Update organization, address, and invoice payment details.
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="/dashboard/organizations/current" class="grid gap-5">
<input type="hidden" name="id" value={organization.id} />
<input type="hidden" name="redirectTo" value="/dashboard/organization/details" />
<OrganizationFormFields prefix="organization-edit" {organization} />
<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>
+3 -3
View File
@@ -33,9 +33,9 @@ export const load: PageServerLoad = async ({ locals }) => {
pricePerMonthGbp: record.pricePerMonthGbp ?? 0
}
})),
createForm: await superValidate(zod4(roomCreateSchema), { id: 'rooms-create' }),
editForm: await superValidate(zod4(roomEditSchema), { id: 'rooms-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'rooms-archive' })
createForm: await superValidate(zod4(roomCreateSchema), { id: 'rooms-create', errors: false }),
editForm: await superValidate(zod4(roomEditSchema), { id: 'rooms-edit', errors: false }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'rooms-archive', errors: false })
};
};
@@ -28,9 +28,15 @@ export const load: PageServerLoad = async ({ locals }) => {
description: record.description ?? ''
}
})),
createForm: await superValidate(zod4(serviceCreateSchema), { id: 'services-create' }),
editForm: await superValidate(zod4(serviceEditSchema), { id: 'services-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'services-archive' })
createForm: await superValidate(zod4(serviceCreateSchema), {
id: 'services-create',
errors: false
}),
editForm: await superValidate(zod4(serviceEditSchema), { id: 'services-edit', errors: false }),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'services-archive',
errors: false
})
};
};