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
+26 -27
View File
@@ -1,20 +1,18 @@
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 { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db';
import {
bookings,
clients,
contracts,
contractRooms,
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 = {
@@ -301,7 +299,6 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
id: contracts.id,
clientId: contracts.clientId,
clientName: clients.name,
roomName: rooms.name,
serviceName: services.name,
licenseFeeGbp: contracts.licenseFeeGbp,
startDate: contracts.startDate,
@@ -309,7 +306,6 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
})
.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(
@@ -321,6 +317,25 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
)
)
.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);
@@ -343,7 +358,10 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
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 labelParts = [
...(roomNamesByContract.get(contract.id) ?? []),
contract.serviceName
].filter(Boolean);
const line = {
id: '',
clientId: contract.clientId,
@@ -407,11 +425,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
return {
records,
options: await loadOptions(activeOrganizationId),
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate)
};
};
@@ -458,7 +472,6 @@ export const actions: Actions = {
invoiceNumber,
issueDate,
dueDate,
status: 'draft',
subtotalGbp: client.totalGbp,
taxGbp: 0,
totalGbp: client.totalGbp,
@@ -508,19 +521,5 @@ export const actions: Actions = {
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.');
}
};
+1 -76
View File
@@ -1,51 +1,9 @@
<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);
@@ -72,23 +30,6 @@
...(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>
@@ -108,21 +49,5 @@
/>
</div>
<InvoicesTable
{data}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
<InvoicesTable {data} {formatValue} {formatDate} {formatMoney} {relationLabel} />
</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">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
relationLabel
}: {
data: any;
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -33,9 +25,7 @@
<Table.Head>Client</Table.Head>
<Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -45,28 +35,7 @@
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
>{#snippet child({ props })}<Button
variant="ghost"
size="icon-sm"
aria-label={`Open actions for ${recordName(record)}`}
{...props}><EllipsisVerticalIcon /></Button
>{/snippet}</DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="w-36">
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
>Archive</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>