update: split workspace pages into reusable components
This commit is contained in:
@@ -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,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>
|
||||
|
||||
@@ -44,7 +44,6 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
role: record.role ?? '',
|
||||
email: record.email ?? '',
|
||||
phone: record.phone ?? '',
|
||||
isPrimary: record.isPrimary ? 'true' : 'false',
|
||||
receivesInvoices: record.receivesInvoices ? 'true' : 'false',
|
||||
receivesContracts: record.receivesContracts ? 'true' : 'false'
|
||||
}
|
||||
@@ -85,7 +84,7 @@ export const actions: Actions = {
|
||||
role: form.data.role || null,
|
||||
email: form.data.email || null,
|
||||
phone: form.data.phone || null,
|
||||
isPrimary: form.data.isPrimary === 'true',
|
||||
isPrimary: false,
|
||||
receivesInvoices: form.data.receivesInvoices === 'true',
|
||||
receivesContracts: form.data.receivesContracts === 'true',
|
||||
updatedAt: new Date(),
|
||||
@@ -115,7 +114,6 @@ export const actions: Actions = {
|
||||
role: form.data.role || null,
|
||||
email: form.data.email || null,
|
||||
phone: form.data.phone || null,
|
||||
isPrimary: form.data.isPrimary === 'true',
|
||||
receivesInvoices: form.data.receivesInvoices === 'true',
|
||||
receivesContracts: form.data.receivesContracts === 'true',
|
||||
updatedAt: new Date()
|
||||
@@ -134,6 +132,60 @@ export const actions: Actions = {
|
||||
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 }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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 { cn } from '$lib/utils.js';
|
||||
let {
|
||||
data,
|
||||
openEdit,
|
||||
@@ -54,8 +55,21 @@
|
||||
{...props}><EllipsisVerticalIcon /></Button
|
||||
>{/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>
|
||||
{#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)}
|
||||
>Archive</DropdownMenu.Item
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
/* 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 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';
|
||||
@@ -77,26 +76,6 @@
|
||||
<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"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<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 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';
|
||||
@@ -75,26 +74,6 @@
|
||||
<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"
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, rooms, services } from '$lib/server/db/schema';
|
||||
import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema';
|
||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import {
|
||||
@@ -39,7 +39,6 @@ async function loadOptions(organizationId: string) {
|
||||
|
||||
return {
|
||||
rooms: [
|
||||
{ label: 'No room', value: '' },
|
||||
...roomRows.map((room) => ({
|
||||
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
|
||||
value: room.id,
|
||||
@@ -63,11 +62,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const records = await db
|
||||
.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(
|
||||
@@ -77,29 +74,57 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
)
|
||||
)
|
||||
.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 {
|
||||
records: records.map((record) => ({
|
||||
...record.contract,
|
||||
roomName: record.roomName,
|
||||
serviceName: record.serviceName,
|
||||
formValues: {
|
||||
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',
|
||||
errors: false
|
||||
records: records.map((record) => {
|
||||
const linkedRooms = roomsByContract.get(record.contract.id) ?? [];
|
||||
return {
|
||||
...record.contract,
|
||||
roomName: linkedRooms.map((room) => room.name).join(', '),
|
||||
roomNames: linkedRooms.map((room) => room.name),
|
||||
roomIds: linkedRooms.map((room) => room.id),
|
||||
serviceName: record.serviceName,
|
||||
formValues: {
|
||||
id: record.contract.id,
|
||||
clientId: record.contract.clientId ?? '',
|
||||
roomIds: linkedRooms.map((room) => room.id),
|
||||
serviceId: record.contract.serviceId ?? '',
|
||||
licenseFeeGbp: record.contract.licenseFeeGbp,
|
||||
depositGbp: record.contract.depositGbp,
|
||||
startDate: record.contract.startDate ?? '',
|
||||
endDate: record.contract.endDate ?? ''
|
||||
}
|
||||
};
|
||||
}),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
createForm: await superValidate(
|
||||
{ clientId: params.id, roomIds: [] },
|
||||
zod4(contractCreateSchema),
|
||||
{
|
||||
id: 'contracts-create',
|
||||
errors: false
|
||||
}
|
||||
),
|
||||
editForm: await superValidate(zod4(contractEditSchema), {
|
||||
id: 'contracts-edit',
|
||||
errors: false
|
||||
@@ -117,26 +142,26 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
|
||||
async function selectionError(
|
||||
organizationId: string,
|
||||
roomId: string,
|
||||
roomIds: string[],
|
||||
serviceId: string
|
||||
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> {
|
||||
if (roomId) {
|
||||
const [room] = await db
|
||||
): Promise<{ field: 'roomIds' | 'serviceId'; message: string } | null> {
|
||||
if (roomIds.length > 0) {
|
||||
const uniqueRoomIds = [...new Set(roomIds)];
|
||||
const validRooms = await db
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, roomId),
|
||||
inArray(rooms.id, uniqueRoomIds),
|
||||
eq(rooms.organizationId, organizationId),
|
||||
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
||||
isNull(rooms.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
if (!room) {
|
||||
if (validRooms.length !== uniqueRoomIds.length) {
|
||||
return {
|
||||
field: 'roomId',
|
||||
field: 'roomIds',
|
||||
message: 'Select a valid private office or coworking desk.'
|
||||
};
|
||||
}
|
||||
@@ -173,26 +198,42 @@ 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.roomIds,
|
||||
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 {
|
||||
await db.insert(contracts).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: activeOrganizationId,
|
||||
clientId: form.data.clientId,
|
||||
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,
|
||||
status: 'draft',
|
||||
notes: form.data.notes || null,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
const contractId = crypto.randomUUID();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(contracts).values({
|
||||
id: contractId,
|
||||
organizationId: activeOrganizationId,
|
||||
clientId: form.data.clientId,
|
||||
serviceId: form.data.serviceId || null,
|
||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||
depositGbp: form.data.depositGbp,
|
||||
startDate: form.data.startDate,
|
||||
endDate: form.data.endDate,
|
||||
status: 'draft',
|
||||
updatedAt: 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 {
|
||||
return message(form, 'Unable to create contract.', { status: 400 });
|
||||
@@ -212,32 +253,48 @@ 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.roomIds,
|
||||
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 {
|
||||
await db
|
||||
.update(contracts)
|
||||
.set({
|
||||
clientId: form.data.clientId,
|
||||
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,
|
||||
notes: form.data.notes || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.id, form.data.id),
|
||||
eq(contracts.clientId, params.id),
|
||||
eq(contracts.organizationId, activeOrganizationId)
|
||||
)
|
||||
);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(contracts)
|
||||
.set({
|
||||
clientId: form.data.clientId,
|
||||
serviceId: form.data.serviceId || null,
|
||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||
depositGbp: form.data.depositGbp,
|
||||
startDate: form.data.startDate,
|
||||
endDate: form.data.endDate,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.id, form.data.id),
|
||||
eq(contracts.clientId, params.id),
|
||||
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 {
|
||||
return message(form, 'Unable to update contract.', { status: 400 });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
/* 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 FormCombobox from '$lib/components/form-combobox.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 { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RoomMultiCombobox from './room-multi-combobox.svelte';
|
||||
|
||||
let {
|
||||
form,
|
||||
@@ -27,33 +32,74 @@
|
||||
prefix: string;
|
||||
} = $props();
|
||||
|
||||
let startDateOpen = $state(false);
|
||||
let endDateOpen = $state(false);
|
||||
|
||||
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);
|
||||
function updatePricing(roomIds: string[], serviceId: string) {
|
||||
const selectedRooms = options.rooms.filter((option) => roomIds.includes(option.value));
|
||||
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.depositGbp = roundMoney(room?.depositGbp ?? 0);
|
||||
$data.licenseFeeGbp = roundMoney(roomLicenseFee + (service?.licenseFeeGbp ?? 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>
|
||||
|
||||
<FormField {form} name="roomId">
|
||||
<FormField {form} name="roomIds">
|
||||
<Field.Field>
|
||||
<Control id={`${prefix}-roomId`}>
|
||||
<Control id={`${prefix}-roomIds`}>
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Room</Field.Label>
|
||||
<FormCombobox
|
||||
name="roomId"
|
||||
bind:value={$data.roomId}
|
||||
<Field.Label>Rooms</Field.Label>
|
||||
<RoomMultiCombobox
|
||||
name="roomIds"
|
||||
bind:value={$data.roomIds}
|
||||
options={options.rooms}
|
||||
placeholder="Select a private office or coworking desk"
|
||||
placeholder="Select private offices or coworking desks"
|
||||
searchPlaceholder="Search rooms..."
|
||||
emptyMessage="No matching rooms."
|
||||
triggerProps={props}
|
||||
onValueChange={(roomId) => updatePricing(roomId, $data.serviceId)}
|
||||
onValueChange={(roomIds) => updatePricing(roomIds, $data.serviceId)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
@@ -74,7 +120,7 @@
|
||||
searchPlaceholder="Search services..."
|
||||
emptyMessage="No matching services."
|
||||
triggerProps={props}
|
||||
onValueChange={(serviceId) => updatePricing($data.roomId, serviceId)}
|
||||
onValueChange={(serviceId) => updatePricing($data.roomIds, serviceId)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
@@ -89,7 +135,7 @@
|
||||
name="licenseFeeGbp"
|
||||
label="License fee"
|
||||
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
|
||||
{form}
|
||||
@@ -107,7 +153,33 @@
|
||||
<Control id={`${prefix}-startDate`}>
|
||||
{#snippet children({ props })}
|
||||
<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}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
@@ -118,22 +190,36 @@
|
||||
<Control id={`${prefix}-endDate`}>
|
||||
{#snippet children({ props })}
|
||||
<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}
|
||||
</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>
|
||||
|
||||
@@ -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 { 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 { invoiceEditSchema } from '$lib/schemas/invoices.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
async function loadOptions(organizationId: string) {
|
||||
const clientRows = await db
|
||||
@@ -36,89 +32,8 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
...record,
|
||||
formValues: {
|
||||
id: record.id,
|
||||
clientId: record.clientId ?? '',
|
||||
invoiceNumber: record.invoiceNumber ?? '',
|
||||
issueDate: record.issueDate ?? '',
|
||||
dueDate: record.dueDate ?? '',
|
||||
status: record.status ?? '',
|
||||
subtotalGbp: record.subtotalGbp ?? '',
|
||||
taxGbp: record.taxGbp ?? '',
|
||||
totalGbp: record.totalGbp ?? '',
|
||||
notes: record.notes ?? ''
|
||||
}
|
||||
...record
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
editForm: await superValidate(zod4(invoiceEditSchema), {
|
||||
id: 'invoices-edit',
|
||||
errors: false
|
||||
}),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
||||
id: 'invoices-archive',
|
||||
errors: false
|
||||
})
|
||||
options: await loadOptions(activeOrganizationId)
|
||||
};
|
||||
};
|
||||
|
||||
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">
|
||||
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';
|
||||
type RecordRow = PageData['records'][number];
|
||||
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) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
@@ -78,28 +24,6 @@
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function openEdit(record: RecordRow) {
|
||||
editForm.reset({ data: record.formValues as never });
|
||||
editingId = record.id;
|
||||
}
|
||||
|
||||
function openArchive(record: RecordRow) {
|
||||
archiveForm.reset({ data: { id: record.id } });
|
||||
archivingId = record.id;
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(
|
||||
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -110,33 +34,9 @@
|
||||
<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>
|
||||
<p class="text-sm text-muted-foreground">Review issued invoice records.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InvoicesTable
|
||||
{data}
|
||||
{openEdit}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
{formatDate}
|
||||
{formatMoney}
|
||||
{recordName}
|
||||
/>
|
||||
<InvoicesTable {data} {formatValue} {formatDate} {formatMoney} />
|
||||
</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">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
let {
|
||||
data,
|
||||
openEdit,
|
||||
openArchive,
|
||||
formatValue,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
recordName
|
||||
formatMoney
|
||||
}: {
|
||||
data: any;
|
||||
openEdit: any;
|
||||
openArchive: any;
|
||||
formatValue: any;
|
||||
formatDate: any;
|
||||
formatMoney: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -32,9 +22,7 @@
|
||||
<Table.Head>Invoice #</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>
|
||||
@@ -43,29 +31,7 @@
|
||||
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
||||
<Table.Cell class=""
|
||||
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
>{#snippet child({ props })}<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${recordName(record)}`}
|
||||
{...props}><EllipsisVerticalIcon /></Button
|
||||
>{/snippet}</DropdownMenu.Trigger
|
||||
>
|
||||
<DropdownMenu.Content align="end" class="w-36">
|
||||
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
|
||||
>Archive</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
@@ -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 { zod4 } from 'sveltekit-superforms/adapters';
|
||||
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 { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -24,19 +24,36 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const records = await db
|
||||
.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.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 {
|
||||
records: records.map((record) => ({
|
||||
...record.contract,
|
||||
roomName: record.roomName,
|
||||
roomName: (roomNamesByContract.get(record.contract.id) ?? []).join(', '),
|
||||
serviceName: record.serviceName
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
|
||||
Reference in New Issue
Block a user