update: split workspace pages into reusable components

This commit is contained in:
2026-06-24 23:31:04 +01:00
parent 5654d3d570
commit cac9d0e04d
27 changed files with 4701 additions and 734 deletions
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE `contract_rooms` (
`contract_id` text NOT NULL,
`room_id` text NOT NULL,
PRIMARY KEY(`contract_id`, `room_id`),
FOREIGN KEY (`contract_id`) REFERENCES `contracts`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE INDEX `contract_rooms_room_id_idx` ON `contract_rooms` (`room_id`);--> statement-breakpoint
INSERT INTO `contract_rooms`("contract_id", "room_id") SELECT "id", "room_id" FROM `contracts` WHERE "room_id" IS NOT NULL;--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_contracts` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`client_id` text NOT NULL,
`service_id` text,
`license_fee_gbp` real DEFAULT 0 NOT NULL,
`deposit_gbp` real DEFAULT 0 NOT NULL,
`start_date` text NOT NULL,
`end_date` text NOT NULL,
`billed_to` text,
`status` text DEFAULT 'draft' NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`service_id`) REFERENCES `services`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
INSERT INTO `__new_contracts`("id", "organization_id", "client_id", "service_id", "license_fee_gbp", "deposit_gbp", "start_date", "end_date", "billed_to", "status", "created_at", "updated_at", "archived_at") SELECT "id", "organization_id", "client_id", "service_id", "license_fee_gbp", "deposit_gbp", "start_date", "end_date", "billed_to", "status", "created_at", "updated_at", "archived_at" FROM `contracts`;--> statement-breakpoint
DROP TABLE `contracts`;--> statement-breakpoint
ALTER TABLE `__new_contracts` RENAME TO `contracts`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `contracts_organization_id_idx` ON `contracts` (`organization_id`);--> statement-breakpoint
CREATE INDEX `contracts_client_id_idx` ON `contracts` (`client_id`);--> statement-breakpoint
CREATE INDEX `contracts_service_id_idx` ON `contracts` (`service_id`);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `invoices` DROP COLUMN `status`;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -99,6 +99,20 @@
"when": 1782313131621, "when": 1782313131621,
"tag": "0013_flippant_jamie_braddock", "tag": "0013_flippant_jamie_braddock",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1782337886282,
"tag": "0014_fixed_sunfire",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1782338642144,
"tag": "0015_fuzzy_vampiro",
"breakpoints": true
} }
] ]
} }
-1
View File
@@ -13,7 +13,6 @@ export const contactCreateSchema = z.object({
role: optionalText(120), role: optionalText(120),
email: optionalEmail, email: optionalEmail,
phone: optionalText(80), phone: optionalText(80),
isPrimary: booleanString,
receivesInvoices: booleanString, receivesInvoices: booleanString,
receivesContracts: booleanString receivesContracts: booleanString
}); });
+4 -5
View File
@@ -6,19 +6,18 @@ export const contractStatuses = ['draft', 'active', 'expired', 'void'] as const;
export const contractCreateSchema = z export const contractCreateSchema = z
.object({ .object({
clientId: requiredText('Client'), clientId: requiredText('Client'),
roomId: optionalText(80), roomIds: z.array(z.string().uuid()).default([]),
serviceId: optionalText(80), serviceId: optionalText(80),
licenseFeeGbp: moneyGbp, licenseFeeGbp: moneyGbp,
depositGbp: moneyGbp, depositGbp: moneyGbp,
startDate: requiredText('Start date', 40), startDate: requiredText('Start date', 40),
endDate: requiredText('End date', 40), endDate: requiredText('End date', 40)
notes: optionalText(1000)
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (!data.roomId && !data.serviceId) { if (data.roomIds.length === 0 && !data.serviceId) {
ctx.addIssue({ ctx.addIssue({
code: 'custom', code: 'custom',
path: ['roomId'], path: ['roomIds'],
message: 'Select a room or service.' message: 'Select a room or service.'
}); });
} }
+1 -4
View File
@@ -1,16 +1,13 @@
import { z } from 'zod'; import { z } from 'zod';
import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema'; import { moneyGbp, optionalText, requiredText } from './shared.schema';
export const invoiceCreateSchema = z.object({ export const invoiceCreateSchema = z.object({
clientId: requiredText('Client'), clientId: requiredText('Client'),
invoiceNumber: requiredText('Invoice number', 80), invoiceNumber: requiredText('Invoice number', 80),
issueDate: requiredText('Issue date', 40), issueDate: requiredText('Issue date', 40),
dueDate: requiredText('Due date', 40), dueDate: requiredText('Due date', 40),
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'void']).default('draft'),
subtotalGbp: moneyGbp, subtotalGbp: moneyGbp,
taxGbp: moneyGbp, taxGbp: moneyGbp,
totalGbp: moneyGbp, totalGbp: moneyGbp,
notes: optionalText(1000) notes: optionalText(1000)
}); });
export const invoiceEditSchema = invoiceCreateSchema.extend(idSchema.shape);
+17 -4
View File
@@ -1,4 +1,4 @@
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { index, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema'; import { clients } from './clients.schema';
import { organizations } from './organizations.schema'; import { organizations } from './organizations.schema';
import { rooms } from './rooms.schema'; import { rooms } from './rooms.schema';
@@ -17,7 +17,6 @@ export const contracts = sqliteTable(
clientId: text('client_id') clientId: text('client_id')
.notNull() .notNull()
.references(() => clients.id, { onDelete: 'restrict' }), .references(() => clients.id, { onDelete: 'restrict' }),
roomId: text('room_id').references(() => rooms.id, { onDelete: 'restrict' }),
serviceId: text('service_id').references(() => services.id, { onDelete: 'restrict' }), serviceId: text('service_id').references(() => services.id, { onDelete: 'restrict' }),
licenseFeeGbp: real('license_fee_gbp').notNull().default(0), licenseFeeGbp: real('license_fee_gbp').notNull().default(0),
depositGbp: real('deposit_gbp').notNull().default(0), depositGbp: real('deposit_gbp').notNull().default(0),
@@ -25,13 +24,27 @@ export const contracts = sqliteTable(
endDate: text('end_date').notNull(), endDate: text('end_date').notNull(),
billedTo: text('billed_to'), billedTo: text('billed_to'),
status: text('status').notNull().default('draft'), status: text('status').notNull().default('draft'),
notes: text('notes'),
...timestamps ...timestamps
}, },
(table) => [ (table) => [
index('contracts_organization_id_idx').on(table.organizationId), index('contracts_organization_id_idx').on(table.organizationId),
index('contracts_client_id_idx').on(table.clientId), index('contracts_client_id_idx').on(table.clientId),
index('contracts_room_id_idx').on(table.roomId),
index('contracts_service_id_idx').on(table.serviceId) index('contracts_service_id_idx').on(table.serviceId)
] ]
); );
export const contractRooms = sqliteTable(
'contract_rooms',
{
contractId: text('contract_id')
.notNull()
.references(() => contracts.id, { onDelete: 'cascade' }),
roomId: text('room_id')
.notNull()
.references(() => rooms.id, { onDelete: 'restrict' })
},
(table) => [
primaryKey({ columns: [table.contractId, table.roomId] }),
index('contract_rooms_room_id_idx').on(table.roomId)
]
);
-1
View File
@@ -18,7 +18,6 @@ export const invoices = sqliteTable(
invoiceNumber: text('invoice_number').notNull().unique(), invoiceNumber: text('invoice_number').notNull().unique(),
issueDate: text('issue_date').notNull(), issueDate: text('issue_date').notNull(),
dueDate: text('due_date').notNull(), dueDate: text('due_date').notNull(),
status: text('status').notNull().default('draft'),
subtotalGbp: real('subtotal_gbp').notNull().default(0), subtotalGbp: real('subtotal_gbp').notNull().default(0),
taxGbp: real('tax_gbp').notNull().default(0), taxGbp: real('tax_gbp').notNull().default(0),
totalGbp: real('total_gbp').notNull().default(0), totalGbp: real('total_gbp').notNull().default(0),
+26 -27
View File
@@ -1,20 +1,18 @@
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { and, asc, desc, eq, gte, isNull, like, lte, ne } from 'drizzle-orm'; import { and, asc, desc, eq, gte, inArray, isNull, like, lte, ne } from 'drizzle-orm';
import { createSearchParamsSchema, validateSearchParams } from 'runed/kit'; 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 { db } from '$lib/server/db';
import { import {
bookings, bookings,
clients, clients,
contracts, contracts,
contractRooms,
invoiceLines, invoiceLines,
invoices, invoices,
rooms, rooms,
services services
} from '$lib/server/db/schema'; } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
type BillingLine = { type BillingLine = {
@@ -301,7 +299,6 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
id: contracts.id, id: contracts.id,
clientId: contracts.clientId, clientId: contracts.clientId,
clientName: clients.name, clientName: clients.name,
roomName: rooms.name,
serviceName: services.name, serviceName: services.name,
licenseFeeGbp: contracts.licenseFeeGbp, licenseFeeGbp: contracts.licenseFeeGbp,
startDate: contracts.startDate, startDate: contracts.startDate,
@@ -309,7 +306,6 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
}) })
.from(contracts) .from(contracts)
.innerJoin(clients, eq(contracts.clientId, clients.id)) .innerJoin(clients, eq(contracts.clientId, clients.id))
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id)) .leftJoin(services, eq(contracts.serviceId, services.id))
.where( .where(
and( and(
@@ -321,6 +317,25 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
) )
) )
.orderBy(asc(contracts.startDate)); .orderBy(asc(contracts.startDate));
const contractIds = contractRows.map((contract) => contract.id);
const contractRoomLinks =
contractIds.length > 0
? await db
.select({
contractId: contractRooms.contractId,
roomName: rooms.name
})
.from(contractRooms)
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
.where(inArray(contractRooms.contractId, contractIds))
.orderBy(asc(rooms.name))
: [];
const roomNamesByContract = new Map<string, string[]>();
for (const link of contractRoomLinks) {
const existing = roomNamesByContract.get(link.contractId) ?? [];
existing.push(link.roomName);
roomNamesByContract.set(link.contractId, existing);
}
const contractCutoff = parseDateOnly(cutoffs.contractsCutoff); const contractCutoff = parseDateOnly(cutoffs.contractsCutoff);
@@ -343,7 +358,10 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
const monthDays = daysInclusive(startOfMonth(month), endOfMonth(month)); const monthDays = daysInclusive(startOfMonth(month), endOfMonth(month));
const billedDays = daysInclusive(periodStartDate, periodEndDate); const billedDays = daysInclusive(periodStartDate, periodEndDate);
const quantity = roundQuantity(billedDays / monthDays); const quantity = roundQuantity(billedDays / monthDays);
const labelParts = [contract.roomName, contract.serviceName].filter(Boolean); const labelParts = [
...(roomNamesByContract.get(contract.id) ?? []),
contract.serviceName
].filter(Boolean);
const line = { const line = {
id: '', id: '',
clientId: contract.clientId, clientId: contract.clientId,
@@ -407,11 +425,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
return { return {
records, records,
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate), billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate)
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
}; };
}; };
@@ -458,7 +472,6 @@ export const actions: Actions = {
invoiceNumber, invoiceNumber,
issueDate, issueDate,
dueDate, dueDate,
status: 'draft',
subtotalGbp: client.totalGbp, subtotalGbp: client.totalGbp,
taxGbp: 0, taxGbp: 0,
totalGbp: client.totalGbp, totalGbp: client.totalGbp,
@@ -508,19 +521,5 @@ export const actions: Actions = {
return { return {
message: `Billing run complete. Created ${groupedClients.length} invoice${groupedClients.length === 1 ? '' : 's'}.` 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.');
} }
}; };
+1 -76
View File
@@ -1,51 +1,9 @@
<script lang="ts"> <script lang="ts">
import InvoicesTable from './invoices-table.svelte'; import InvoicesTable from './invoices-table.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import RunBillingDialog from './run-billing-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'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); 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) { function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set'; if (value === null || value === undefined || value === '') return 'Not set';
return String(value); return String(value);
@@ -72,23 +30,6 @@
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}) ...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date); }).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> </script>
<svelte:head> <svelte:head>
@@ -108,21 +49,5 @@
/> />
</div> </div>
<InvoicesTable <InvoicesTable {data} {formatValue} {formatDate} {formatMoney} {relationLabel} />
{data}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div> </div>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -1,40 +0,0 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive invoice?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active invoices. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive invoice</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,26 +1,18 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
let { let {
data, data,
openArchive,
formatValue, formatValue,
formatDate, formatDate,
formatMoney, formatMoney,
relationLabel, relationLabel
recordName
}: { }: {
data: any; data: any;
openArchive: any;
formatValue: any; formatValue: any;
formatDate: any; formatDate: any;
formatMoney: any; formatMoney: any;
relationLabel: any; relationLabel: any;
recordName: any;
} = $props(); } = $props();
</script> </script>
@@ -33,9 +25,7 @@
<Table.Head>Client</Table.Head> <Table.Head>Client</Table.Head>
<Table.Head>Issued</Table.Head> <Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head> <Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Total</Table.Head> <Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -45,28 +35,7 @@
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell> <Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell> <Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
@@ -44,7 +44,6 @@ export const load: PageServerLoad = async ({ locals, params }) => {
role: record.role ?? '', role: record.role ?? '',
email: record.email ?? '', email: record.email ?? '',
phone: record.phone ?? '', phone: record.phone ?? '',
isPrimary: record.isPrimary ? 'true' : 'false',
receivesInvoices: record.receivesInvoices ? 'true' : 'false', receivesInvoices: record.receivesInvoices ? 'true' : 'false',
receivesContracts: record.receivesContracts ? 'true' : 'false' receivesContracts: record.receivesContracts ? 'true' : 'false'
} }
@@ -85,7 +84,7 @@ export const actions: Actions = {
role: form.data.role || null, role: form.data.role || null,
email: form.data.email || null, email: form.data.email || null,
phone: form.data.phone || null, phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true', isPrimary: false,
receivesInvoices: form.data.receivesInvoices === 'true', receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true', receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date(), updatedAt: new Date(),
@@ -115,7 +114,6 @@ export const actions: Actions = {
role: form.data.role || null, role: form.data.role || null,
email: form.data.email || null, email: form.data.email || null,
phone: form.data.phone || null, phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true',
receivesInvoices: form.data.receivesInvoices === 'true', receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true', receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date() updatedAt: new Date()
@@ -134,6 +132,60 @@ export const actions: Actions = {
return message(form, 'Contact updated.'); return message(form, 'Contact updated.');
}, },
makePrimary: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
id: 'contacts-primary'
});
if (!form.valid) return message(form, 'Contact id is required.', { status: 400 });
const [contact] = await db
.select({ id: contacts.id, isPrimary: contacts.isPrimary })
.from(contacts)
.where(
and(
eq(contacts.id, form.data.id),
eq(contacts.clientId, params.id),
eq(contacts.organizationId, activeOrganizationId),
isNull(contacts.archivedAt)
)
)
.limit(1);
if (!contact) return message(form, 'Choose a valid contact.', { status: 400 });
if (contact.isPrimary) return message(form, 'Contact is already primary.');
const now = new Date();
await db.transaction(async (tx) => {
await tx
.update(contacts)
.set({ isPrimary: false, updatedAt: now })
.where(
and(
eq(contacts.clientId, params.id),
eq(contacts.organizationId, activeOrganizationId),
eq(contacts.isPrimary, true),
isNull(contacts.archivedAt)
)
);
await tx
.update(contacts)
.set({ isPrimary: true, updatedAt: now })
.where(
and(
eq(contacts.id, form.data.id),
eq(contacts.clientId, params.id),
eq(contacts.organizationId, activeOrganizationId),
isNull(contacts.archivedAt)
)
);
});
return message(form, 'Primary contact updated.');
},
archive: async ({ locals, params, request }) => { archive: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals); const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(archiveSchema), { const form = await superValidate(await request.formData(), zod4(archiveSchema), {
@@ -4,6 +4,7 @@
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import { cn } from '$lib/utils.js';
let { let {
data, data,
openEdit, openEdit,
@@ -54,8 +55,21 @@
{...props}><EllipsisVerticalIcon /></Button {...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger >{/snippet}</DropdownMenu.Trigger
> >
<DropdownMenu.Content align="end" class="w-36"> <DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item> <DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
{#if !record.isPrimary}
<form method="POST" action="?/makePrimary">
<button
type="submit"
name="id"
value={record.id}
class={cn(
'flex w-full cursor-default items-center rounded-md px-1.5 py-1 text-left text-sm outline-hidden select-none',
'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground'
)}>Make Primary Contact</button
>
</form>
{/if}
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)} <DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item >Archive</DropdownMenu.Item
> >
@@ -2,7 +2,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap'; 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 FormCheckbox from '$lib/components/form-checkbox.svelte';
import * as Field from '$lib/components/ui/field/index.js'; import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
@@ -77,26 +76,6 @@
<Field.Error><FieldErrors /></Field.Error> <Field.Error><FieldErrors /></Field.Error>
</Field.Field> </Field.Field>
</FormField> </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"> <div class="grid gap-3 py-1">
<FormCheckbox <FormCheckbox
id="create-receives-invoices" id="create-receives-invoices"
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap'; 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 FormCheckbox from '$lib/components/form-checkbox.svelte';
import * as Field from '$lib/components/ui/field/index.js'; import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
@@ -75,26 +74,6 @@
<Field.Error><FieldErrors /></Field.Error> <Field.Error><FieldErrors /></Field.Error>
</Field.Field> </Field.Field>
</FormField> </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"> <div class="grid gap-3 py-1">
<FormCheckbox <FormCheckbox
id="edit-receives-invoices" id="edit-receives-invoices"
@@ -2,7 +2,7 @@ import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { message, setError, superValidate } from 'sveltekit-superforms/server'; import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { contracts, rooms, services } from '$lib/server/db/schema'; import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import { import {
@@ -39,7 +39,6 @@ async function loadOptions(organizationId: string) {
return { return {
rooms: [ rooms: [
{ label: 'No room', value: '' },
...roomRows.map((room) => ({ ...roomRows.map((room) => ({
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`, label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
value: room.id, value: room.id,
@@ -63,11 +62,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
const records = await db const records = await db
.select({ .select({
contract: contracts, contract: contracts,
roomName: rooms.name,
serviceName: services.name serviceName: services.name
}) })
.from(contracts) .from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id)) .leftJoin(services, eq(contracts.serviceId, services.id))
.where( .where(
and( and(
@@ -77,29 +74,57 @@ export const load: PageServerLoad = async ({ locals, params }) => {
) )
) )
.orderBy(asc(contracts.startDate)); .orderBy(asc(contracts.startDate));
const contractIds = records.map((record) => record.contract.id);
const roomLinks =
contractIds.length > 0
? await db
.select({
contractId: contractRooms.contractId,
roomId: rooms.id,
roomName: rooms.name
})
.from(contractRooms)
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
.where(inArray(contractRooms.contractId, contractIds))
.orderBy(asc(rooms.name))
: [];
const roomsByContract = new Map<string, { id: string; name: string }[]>();
for (const link of roomLinks) {
const existing = roomsByContract.get(link.contractId) ?? [];
existing.push({ id: link.roomId, name: link.roomName });
roomsByContract.set(link.contractId, existing);
}
return { return {
records: records.map((record) => ({ records: records.map((record) => {
const linkedRooms = roomsByContract.get(record.contract.id) ?? [];
return {
...record.contract, ...record.contract,
roomName: record.roomName, roomName: linkedRooms.map((room) => room.name).join(', '),
roomNames: linkedRooms.map((room) => room.name),
roomIds: linkedRooms.map((room) => room.id),
serviceName: record.serviceName, serviceName: record.serviceName,
formValues: { formValues: {
id: record.contract.id, id: record.contract.id,
clientId: record.contract.clientId ?? '', clientId: record.contract.clientId ?? '',
roomId: record.contract.roomId ?? '', roomIds: linkedRooms.map((room) => room.id),
serviceId: record.contract.serviceId ?? '', serviceId: record.contract.serviceId ?? '',
licenseFeeGbp: record.contract.licenseFeeGbp, licenseFeeGbp: record.contract.licenseFeeGbp,
depositGbp: record.contract.depositGbp, depositGbp: record.contract.depositGbp,
startDate: record.contract.startDate ?? '', startDate: record.contract.startDate ?? '',
endDate: record.contract.endDate ?? '', endDate: record.contract.endDate ?? ''
notes: record.contract.notes ?? ''
} }
})), };
}),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), { createForm: await superValidate(
{ clientId: params.id, roomIds: [] },
zod4(contractCreateSchema),
{
id: 'contracts-create', id: 'contracts-create',
errors: false errors: false
}), }
),
editForm: await superValidate(zod4(contractEditSchema), { editForm: await superValidate(zod4(contractEditSchema), {
id: 'contracts-edit', id: 'contracts-edit',
errors: false errors: false
@@ -117,26 +142,26 @@ export const load: PageServerLoad = async ({ locals, params }) => {
async function selectionError( async function selectionError(
organizationId: string, organizationId: string,
roomId: string, roomIds: string[],
serviceId: string serviceId: string
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> { ): Promise<{ field: 'roomIds' | 'serviceId'; message: string } | null> {
if (roomId) { if (roomIds.length > 0) {
const [room] = await db const uniqueRoomIds = [...new Set(roomIds)];
const validRooms = await db
.select({ id: rooms.id }) .select({ id: rooms.id })
.from(rooms) .from(rooms)
.where( .where(
and( and(
eq(rooms.id, roomId), inArray(rooms.id, uniqueRoomIds),
eq(rooms.organizationId, organizationId), eq(rooms.organizationId, organizationId),
inArray(rooms.type, ['private_office', 'coworking_desk']), inArray(rooms.type, ['private_office', 'coworking_desk']),
isNull(rooms.archivedAt) isNull(rooms.archivedAt)
) )
) );
.limit(1);
if (!room) { if (validRooms.length !== uniqueRoomIds.length) {
return { return {
field: 'roomId', field: 'roomIds',
message: 'Select a valid private office or coworking desk.' message: 'Select a valid private office or coworking desk.'
}; };
} }
@@ -173,27 +198,43 @@ export const actions: Actions = {
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const invalidSelection = await selectionError( const invalidSelection = await selectionError(
activeOrganizationId, activeOrganizationId,
form.data.roomId, form.data.roomIds,
form.data.serviceId form.data.serviceId
); );
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message); if (invalidSelection) {
if (invalidSelection.field === 'serviceId') {
return setError(form, invalidSelection.field, invalidSelection.message);
}
return message(form, invalidSelection.message, { status: 400 });
}
try { try {
await db.insert(contracts).values({ const contractId = crypto.randomUUID();
id: crypto.randomUUID(), await db.transaction(async (tx) => {
await tx.insert(contracts).values({
id: contractId,
organizationId: activeOrganizationId, organizationId: activeOrganizationId,
clientId: form.data.clientId, clientId: form.data.clientId,
roomId: form.data.roomId || null,
serviceId: form.data.serviceId || null, serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp, licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp, depositGbp: form.data.depositGbp,
startDate: form.data.startDate, startDate: form.data.startDate,
endDate: form.data.endDate, endDate: form.data.endDate,
status: 'draft', status: 'draft',
notes: form.data.notes || null,
updatedAt: new Date(), updatedAt: new Date(),
createdAt: new Date() createdAt: new Date()
}); });
const uniqueRoomIds = [...new Set(form.data.roomIds)];
if (uniqueRoomIds.length > 0) {
await tx.insert(contractRooms).values(
uniqueRoomIds.map((roomId) => ({
contractId,
roomId
}))
);
}
});
} catch { } catch {
return message(form, 'Unable to create contract.', { status: 400 }); return message(form, 'Unable to create contract.', { status: 400 });
} }
@@ -212,23 +253,27 @@ export const actions: Actions = {
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const invalidSelection = await selectionError( const invalidSelection = await selectionError(
activeOrganizationId, activeOrganizationId,
form.data.roomId, form.data.roomIds,
form.data.serviceId form.data.serviceId
); );
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message); if (invalidSelection) {
if (invalidSelection.field === 'serviceId') {
return setError(form, invalidSelection.field, invalidSelection.message);
}
return message(form, invalidSelection.message, { status: 400 });
}
try { try {
await db await db.transaction(async (tx) => {
await tx
.update(contracts) .update(contracts)
.set({ .set({
clientId: form.data.clientId, clientId: form.data.clientId,
roomId: form.data.roomId || null,
serviceId: form.data.serviceId || null, serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp, licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp, depositGbp: form.data.depositGbp,
startDate: form.data.startDate, startDate: form.data.startDate,
endDate: form.data.endDate, endDate: form.data.endDate,
notes: form.data.notes || null,
updatedAt: new Date() updatedAt: new Date()
}) })
.where( .where(
@@ -238,6 +283,18 @@ export const actions: Actions = {
eq(contracts.organizationId, activeOrganizationId) eq(contracts.organizationId, activeOrganizationId)
) )
); );
await tx.delete(contractRooms).where(eq(contractRooms.contractId, form.data.id));
const uniqueRoomIds = [...new Set(form.data.roomIds)];
if (uniqueRoomIds.length > 0) {
await tx.insert(contractRooms).values(
uniqueRoomIds.map((roomId) => ({
contractId: form.data.id,
roomId
}))
);
}
});
} catch { } catch {
return message(form, 'Unable to update contract.', { status: 400 }); return message(form, 'Unable to update contract.', { status: 400 });
} }
@@ -1,11 +1,16 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import CalendarIcon from '@lucide/svelte/icons/calendar';
import { parseDate, type DateValue } from '@internationalized/date';
import { Field as FormField, Control, FieldErrors } from 'formsnap'; import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormCombobox from '$lib/components/form-combobox.svelte'; import FormCombobox from '$lib/components/form-combobox.svelte';
import MoneyField from '$lib/components/money-field.svelte'; import MoneyField from '$lib/components/money-field.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Calendar from '$lib/components/ui/calendar/index.js';
import * as Field from '$lib/components/ui/field/index.js'; import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import * as Popover from '$lib/components/ui/popover/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js'; import { cn } from '$lib/utils.js';
import RoomMultiCombobox from './room-multi-combobox.svelte';
let { let {
form, form,
@@ -27,33 +32,74 @@
prefix: string; prefix: string;
} = $props(); } = $props();
let startDateOpen = $state(false);
let endDateOpen = $state(false);
function roundMoney(value: number) { function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100; return Math.round((value + Number.EPSILON) * 100) / 100;
} }
function updatePricing(roomId: string, serviceId: string) { function updatePricing(roomIds: string[], serviceId: string) {
const room = options.rooms.find((option) => option.value === roomId); const selectedRooms = options.rooms.filter((option) => roomIds.includes(option.value));
const service = options.services.find((option) => option.value === serviceId); const service = options.services.find((option) => option.value === serviceId);
const roomLicenseFee = selectedRooms.reduce(
(total, room) => total + (room.licenseFeeGbp ?? 0),
0
);
const roomDeposit = selectedRooms.reduce((total, room) => total + (room.depositGbp ?? 0), 0);
$data.licenseFeeGbp = roundMoney((room?.licenseFeeGbp ?? 0) + (service?.licenseFeeGbp ?? 0)); $data.licenseFeeGbp = roundMoney(roomLicenseFee + (service?.licenseFeeGbp ?? 0));
$data.depositGbp = roundMoney(room?.depositGbp ?? 0); $data.depositGbp = roundMoney(roomDeposit);
}
function dateValue(value: string) {
if (!value) return undefined;
try {
return parseDate(value);
} catch {
return undefined;
}
}
function formatDate(value: string) {
const parsed = dateValue(value);
if (!parsed) return 'Select date';
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
timeZone: 'UTC'
}).format(new Date(`${parsed.toString()}T00:00:00.000Z`));
}
function changeStartDate(value: DateValue | undefined) {
if (!value) return;
$data.startDate = value.toString();
startDateOpen = false;
}
function changeEndDate(value: DateValue | undefined) {
if (!value) return;
$data.endDate = value.toString();
endDateOpen = false;
} }
</script> </script>
<FormField {form} name="roomId"> <FormField {form} name="roomIds">
<Field.Field> <Field.Field>
<Control id={`${prefix}-roomId`}> <Control id={`${prefix}-roomIds`}>
{#snippet children({ props })} {#snippet children({ props })}
<Field.Label>Room</Field.Label> <Field.Label>Rooms</Field.Label>
<FormCombobox <RoomMultiCombobox
name="roomId" name="roomIds"
bind:value={$data.roomId} bind:value={$data.roomIds}
options={options.rooms} options={options.rooms}
placeholder="Select a private office or coworking desk" placeholder="Select private offices or coworking desks"
searchPlaceholder="Search rooms..." searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms." emptyMessage="No matching rooms."
triggerProps={props} triggerProps={props}
onValueChange={(roomId) => updatePricing(roomId, $data.serviceId)} onValueChange={(roomIds) => updatePricing(roomIds, $data.serviceId)}
/> />
{/snippet} {/snippet}
</Control> </Control>
@@ -74,7 +120,7 @@
searchPlaceholder="Search services..." searchPlaceholder="Search services..."
emptyMessage="No matching services." emptyMessage="No matching services."
triggerProps={props} triggerProps={props}
onValueChange={(serviceId) => updatePricing($data.roomId, serviceId)} onValueChange={(serviceId) => updatePricing($data.roomIds, serviceId)}
/> />
{/snippet} {/snippet}
</Control> </Control>
@@ -89,7 +135,7 @@
name="licenseFeeGbp" name="licenseFeeGbp"
label="License fee" label="License fee"
id={`${prefix}-licenseFeeGbp`} id={`${prefix}-licenseFeeGbp`}
description="Private office monthly price plus the selected service. You can overwrite this amount." description="Selected room monthly prices plus the selected service. You can overwrite this amount."
/> />
<MoneyField <MoneyField
{form} {form}
@@ -107,7 +153,33 @@
<Control id={`${prefix}-startDate`}> <Control id={`${prefix}-startDate`}>
{#snippet children({ props })} {#snippet children({ props })}
<Field.Label>Start date</Field.Label> <Field.Label>Start date</Field.Label>
<Input {...props} type="date" bind:value={$data.startDate} /> <Popover.Root bind:open={startDateOpen}>
<Popover.Trigger>
{#snippet child({ props: triggerProps })}
<Button
{...triggerProps}
{...props}
type="button"
variant="outline"
class={cn(
'w-full justify-start font-normal',
!$data.startDate && 'text-muted-foreground'
)}
>
<CalendarIcon data-icon="inline-start" />{formatDate($data.startDate)}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-auto p-0">
<Calendar.Calendar
type="single"
value={dateValue($data.startDate)}
captionLayout="dropdown"
onValueChange={changeStartDate}
/>
</Popover.Content>
</Popover.Root>
<input type="hidden" name="startDate" value={$data.startDate} />
{/snippet} {/snippet}
</Control> </Control>
<Field.Error><FieldErrors /></Field.Error> <Field.Error><FieldErrors /></Field.Error>
@@ -118,22 +190,36 @@
<Control id={`${prefix}-endDate`}> <Control id={`${prefix}-endDate`}>
{#snippet children({ props })} {#snippet children({ props })}
<Field.Label>End date</Field.Label> <Field.Label>End date</Field.Label>
<Input {...props} type="date" bind:value={$data.endDate} /> <Popover.Root bind:open={endDateOpen}>
<Popover.Trigger>
{#snippet child({ props: triggerProps })}
<Button
{...triggerProps}
{...props}
type="button"
variant="outline"
class={cn(
'w-full justify-start font-normal',
!$data.endDate && 'text-muted-foreground'
)}
>
<CalendarIcon data-icon="inline-start" />{formatDate($data.endDate)}
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-auto p-0">
<Calendar.Calendar
type="single"
value={dateValue($data.endDate)}
captionLayout="dropdown"
onValueChange={changeEndDate}
/>
</Popover.Content>
</Popover.Root>
<input type="hidden" name="endDate" value={$data.endDate} />
{/snippet} {/snippet}
</Control> </Control>
<Field.Error><FieldErrors /></Field.Error> <Field.Error><FieldErrors /></Field.Error>
</Field.Field> </Field.Field>
</FormField> </FormField>
</div> </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>
@@ -0,0 +1,95 @@
<script lang="ts">
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import type { ComponentProps } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import { cn } from '$lib/utils.js';
type Option = {
value: string;
label: string;
};
let {
name,
value = $bindable([]),
options,
placeholder = 'Select rooms',
searchPlaceholder = 'Search rooms...',
emptyMessage = 'No matching rooms.',
triggerProps,
onValueChange
}: {
name: string;
value: string[];
options: readonly Option[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
triggerProps?: ComponentProps<typeof Button>;
onValueChange?: (value: string[]) => void;
} = $props();
let open = $state(false);
const selectedLabels = $derived(
options.filter((option) => value.includes(option.value)).map((option) => option.label)
);
const selectedLabel = $derived.by(() => {
if (selectedLabels.length === 0) return placeholder;
if (selectedLabels.length <= 2) return selectedLabels.join(', ');
return `${selectedLabels.length} rooms selected`;
});
function toggleRoom(roomId: string) {
value = value.includes(roomId) ? value.filter((id) => id !== roomId) : [...value, roomId];
onValueChange?.(value);
}
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
{...triggerProps}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
class={cn(
'w-full justify-between overflow-hidden font-normal',
value.length === 0 && 'text-muted-foreground',
triggerProps?.class
)}
>
<span class="truncate">{selectedLabel}</span>
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder={searchPlaceholder} />
<Command.List>
<Command.Empty>{emptyMessage}</Command.Empty>
<Command.Group>
{#each options as option (option.value)}
<Command.Item
value={option.value}
keywords={[option.label]}
data-checked={value.includes(option.value)}
onSelect={() => toggleRoom(option.value)}
>
{option.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
{#each value as roomId (roomId)}
<input type="hidden" {name} value={roomId} />
{/each}
@@ -1,12 +1,8 @@
import { and, asc, eq, isNull } from 'drizzle-orm'; 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 { db } from '$lib/server/db';
import { invoices, clients } from '$lib/server/db/schema'; import { invoices, clients } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema'; import type { PageServerLoad } from './$types';
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) { async function loadOptions(organizationId: string) {
const clientRows = await db const clientRows = await db
@@ -36,89 +32,8 @@ export const load: PageServerLoad = async ({ locals, params }) => {
return { return {
records: records.map((record) => ({ records: records.map((record) => ({
...record, ...record
formValues: {
id: record.id,
clientId: record.clientId ?? '',
invoiceNumber: record.invoiceNumber ?? '',
issueDate: record.issueDate ?? '',
dueDate: record.dueDate ?? '',
status: record.status ?? '',
subtotalGbp: record.subtotalGbp ?? '',
taxGbp: record.taxGbp ?? '',
totalGbp: record.totalGbp ?? '',
notes: record.notes ?? ''
}
})), })),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId)
editForm: await superValidate(zod4(invoiceEditSchema), {
id: 'invoices-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
}; };
}; };
export const actions: Actions = {
edit: 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(invoiceEditSchema), { id: 'invoices-edit' });
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
try {
await db
.update(invoices)
.set({
clientId: form.data.clientId,
invoiceNumber: form.data.invoiceNumber,
issueDate: form.data.issueDate,
dueDate: form.data.dueDate,
status: form.data.status,
subtotalGbp: form.data.subtotalGbp,
taxGbp: form.data.taxGbp,
totalGbp: form.data.totalGbp,
notes: form.data.notes || null,
updatedAt: new Date()
})
.where(
and(
eq(invoices.id, form.data.id),
eq(invoices.clientId, params.id),
eq(invoices.organizationId, activeOrganizationId)
)
);
} catch {
return message(form, 'Unable to update invoice.', { status: 400 });
}
return message(form, 'Invoice updated.');
},
archive: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
id: 'invoices-archive'
});
if (!form.valid) return message(form, 'Invoice id is required.', { status: 400 });
await db
.update(invoices)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(
and(
eq(invoices.id, form.data.id),
eq(invoices.clientId, params.id),
eq(invoices.organizationId, activeOrganizationId)
)
);
return message(form, 'Invoice archived.');
}
};
@@ -1,62 +1,8 @@
<script lang="ts"> <script lang="ts">
import InvoicesTable from './invoices-table.svelte'; import InvoicesTable from './invoices-table.svelte';
import EditInvoiceDialog from './edit-invoice-dialog.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
import type { PageData } from './$types'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
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 editForm = superForm(data.editForm, {
validators: zod4Client(invoiceEditSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-edit', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-edit' })
});
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
});
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) { function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set'; if (value === null || value === undefined || value === '') return 'Not set';
return String(value); return String(value);
@@ -78,28 +24,6 @@
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}) ...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date); }).format(date);
} }
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
editingId = record.id;
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -110,33 +34,9 @@
<div> <div>
<div> <div>
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1> <h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
<p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p> <p class="text-sm text-muted-foreground">Review issued invoice records.</p>
</div> </div>
</div> </div>
<InvoicesTable <InvoicesTable {data} {formatValue} {formatDate} {formatMoney} />
{data}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{recordName}
/>
</div> </div>
<EditInvoiceDialog
{editingRecord}
{editForm}
{editData}
{enhanceEdit}
onClose={() => (editingId = null)}
/>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -1,40 +0,0 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive invoice?</AlertDialog.Title>
<AlertDialog.Description
>This will hide {recordName(archivingRecord)} from active invoices. The record will remain in
the database.</AlertDialog.Description
>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
{#if archivingRecord}
<form method="POST" action="?/archive" use:enhanceArchive>
<input type="hidden" name="id" value={$archiveData.id} />
<AlertDialog.Action type="submit" variant="destructive"
>Archive invoice</AlertDialog.Action
>
</form>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,127 +0,0 @@
<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 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 { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingRecord: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit invoice</Dialog.Title>
<Dialog.Description>Update this invoice record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="invoiceNumber">
<Field.Field>
<Control id="edit-invoiceNumber">
{#snippet children({ props })}
<Field.Label>Invoice number</Field.Label>
<Input {...props} type="text" bind:value={$editData.invoiceNumber} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="issueDate">
<Field.Field>
<Control id="edit-issueDate">
{#snippet children({ props })}
<Field.Label>Issue date</Field.Label>
<Input {...props} type="date" bind:value={$editData.issueDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="dueDate">
<Field.Field>
<Control id="edit-dueDate">
{#snippet children({ props })}
<Field.Label>Due date</Field.Label>
<Input {...props} type="date" bind:value={$editData.dueDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })}
<Field.Label>Status</Field.Label>
<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}
name="subtotalGbp"
label="Subtotal"
id="edit-subtotalGbp"
/>
<MoneyField form={editForm} data={editData} name="taxGbp" label="Tax" id="edit-taxGbp" />
<MoneyField
form={editForm}
data={editData}
name="totalGbp"
label="Total"
id="edit-totalGbp"
/>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -1,26 +1,16 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
let { let {
data, data,
openEdit,
openArchive,
formatValue, formatValue,
formatDate, formatDate,
formatMoney, formatMoney
recordName
}: { }: {
data: any; data: any;
openEdit: any;
openArchive: any;
formatValue: any; formatValue: any;
formatDate: any; formatDate: any;
formatMoney: any; formatMoney: any;
recordName: any;
} = $props(); } = $props();
</script> </script>
@@ -32,9 +22,7 @@
<Table.Head>Invoice #</Table.Head> <Table.Head>Invoice #</Table.Head>
<Table.Head>Issued</Table.Head> <Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head> <Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Total</Table.Head> <Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -43,29 +31,7 @@
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell> <Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell> <Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row> </Table.Row>
{/each} {/each}
</Table.Body> </Table.Body>
+22 -5
View File
@@ -1,8 +1,8 @@
import { and, asc, eq, isNull } from 'drizzle-orm'; import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server'; import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { contracts, clients, rooms, services } from '$lib/server/db/schema'; import { contracts, contractRooms, clients, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -24,19 +24,36 @@ export const load: PageServerLoad = async ({ locals }) => {
const records = await db const records = await db
.select({ .select({
contract: contracts, contract: contracts,
roomName: rooms.name,
serviceName: services.name serviceName: services.name
}) })
.from(contracts) .from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id)) .leftJoin(services, eq(contracts.serviceId, services.id))
.where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt))) .where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt)))
.orderBy(asc(contracts.startDate)); .orderBy(asc(contracts.startDate));
const contractIds = records.map((record) => record.contract.id);
const roomLinks =
contractIds.length > 0
? await db
.select({
contractId: contractRooms.contractId,
roomName: rooms.name
})
.from(contractRooms)
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
.where(inArray(contractRooms.contractId, contractIds))
.orderBy(asc(rooms.name))
: [];
const roomNamesByContract = new Map<string, string[]>();
for (const link of roomLinks) {
const existing = roomNamesByContract.get(link.contractId) ?? [];
existing.push(link.roomName);
roomNamesByContract.set(link.contractId, existing);
}
return { return {
records: records.map((record) => ({ records: records.map((record) => ({
...record.contract, ...record.contract,
roomName: record.roomName, roomName: (roomNamesByContract.get(record.contract.id) ?? []).join(', '),
serviceName: record.serviceName serviceName: record.serviceName
})), })),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),