feat: add billing and organization workflows
This commit is contained in:
@@ -13,19 +13,18 @@ import {
|
||||
} from '$lib/server/db/schema';
|
||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||
import { clientEditSchema } from '$lib/schemas/clients.schema';
|
||||
import { industries } from '$lib/constants/industries';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
|
||||
const clientTypes = ['business', 'sport', 'individual'] as const;
|
||||
const clientStatuses = ['active', 'prospect', 'paused'] as const;
|
||||
|
||||
function formValues(row: typeof clients.$inferSelect) {
|
||||
const industry = industries.find((value) => value === row.industry);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name ?? '',
|
||||
type: clientTypes.find((type) => type === row.type),
|
||||
status: clientStatuses.find((status) => status === row.status),
|
||||
industry: industry ?? ('' as const),
|
||||
website: row.website ?? '',
|
||||
notes: row.notes ?? ''
|
||||
};
|
||||
@@ -88,7 +87,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||
isNull(addresses.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(addresses.label)),
|
||||
.orderBy(asc(addresses.type)),
|
||||
db
|
||||
.select()
|
||||
.from(contacts)
|
||||
@@ -121,7 +120,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||
isNull(contracts.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(contracts.title)),
|
||||
.orderBy(asc(contracts.startDate)),
|
||||
db
|
||||
.select()
|
||||
.from(bookings)
|
||||
@@ -139,7 +138,8 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||
return {
|
||||
client,
|
||||
editForm: await superValidate(formValues(client), zod4(clientEditSchema), {
|
||||
id: 'clients-edit'
|
||||
id: 'clients-edit',
|
||||
errors: false
|
||||
}),
|
||||
options,
|
||||
addresses: addressRows,
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import { industryLabel, industryOptions } from '$lib/constants/industries';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { zod4Client } from 'sveltekit-superforms/adapters';
|
||||
import { clientEditSchema } from '$lib/schemas/clients.schema';
|
||||
import { handleFormToast } from '$lib/form-feedback';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import type { LayoutData } from './$types';
|
||||
@@ -50,6 +51,28 @@
|
||||
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function navigateToTab(tab: TabValue) {
|
||||
goto(resolve(`/dashboard/clients/[id]/${tab}`, { id: data.client.id }));
|
||||
}
|
||||
@@ -81,66 +104,60 @@
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="/dashboard/clients?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<Form.Field form={editForm} name="name">
|
||||
<Form.Control id="edit-client-name">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Name</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.name} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="type">
|
||||
<Form.Control id="edit-client-type">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Type</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.type}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="business">Business</NativeSelect.Option>
|
||||
<NativeSelect.Option value="sport">Sport</NativeSelect.Option>
|
||||
<NativeSelect.Option value="individual">Individual</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="status">
|
||||
<Form.Control id="edit-client-status">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Status</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="active">Active</NativeSelect.Option>
|
||||
<NativeSelect.Option value="prospect">Prospect</NativeSelect.Option>
|
||||
<NativeSelect.Option value="paused">Paused</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="website">
|
||||
<Form.Control id="edit-client-website">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Website</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
bind:value={$editData.website}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="notes">
|
||||
<Form.Control id="edit-client-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={editForm} name="name">
|
||||
<Field.Field>
|
||||
<Control id="edit-client-name">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Name</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.name} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="industry">
|
||||
<Field.Field>
|
||||
<Control id="edit-client-industry">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Industry</Field.Label>
|
||||
<FormSelect
|
||||
name="industry"
|
||||
bind:value={$editData.industry}
|
||||
options={industryOptions}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="website">
|
||||
<Field.Field>
|
||||
<Control id="edit-client-website">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Website</Field.Label>
|
||||
<Input
|
||||
{...props}
|
||||
type="url"
|
||||
placeholder="https://example.com"
|
||||
bind:value={$editData.website}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="notes">
|
||||
<Field.Field>
|
||||
<Control id="edit-client-notes">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Notes</Field.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
@@ -154,18 +171,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 rounded-lg border p-4 sm:grid-cols-3">
|
||||
<div class="grid gap-3 rounded-lg border p-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase">Type</p>
|
||||
<p class="text-sm capitalize">{data.client.type}</p>
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase">Industry</p>
|
||||
<p class="text-sm">{industryLabel(data.client.industry)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase">Website</p>
|
||||
<p class="text-sm">{data.client.website ?? 'Not set'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase">Status</p>
|
||||
<p class="text-sm capitalize">{data.client.status}</p>
|
||||
<div class="sm:col-span-2">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase">Notes</p>
|
||||
<p class="text-sm whitespace-pre-wrap">{data.client.notes || 'Not set'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
isNull(addresses.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(addresses.label));
|
||||
.orderBy(asc(addresses.type));
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
@@ -40,22 +40,29 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
formValues: {
|
||||
id: record.id,
|
||||
clientId: record.clientId ?? '',
|
||||
label: record.label ?? '',
|
||||
type: record.type ?? 'primary',
|
||||
line1: record.line1 ?? '',
|
||||
line2: record.line2 ?? '',
|
||||
line3: record.line3 ?? '',
|
||||
city: record.city ?? '',
|
||||
region: record.region ?? '',
|
||||
postcode: record.postcode ?? '',
|
||||
country: record.country ?? '',
|
||||
isPrimary: record.isPrimary ? 'true' : 'false'
|
||||
country: record.country ?? ''
|
||||
}
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
createForm: await superValidate({ clientId: params.id }, zod4(addressCreateSchema), {
|
||||
id: 'addresses-create'
|
||||
id: 'addresses-create',
|
||||
errors: false
|
||||
}),
|
||||
editForm: await superValidate(zod4(addressEditSchema), { id: 'addresses-edit' }),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' })
|
||||
editForm: await superValidate(zod4(addressEditSchema), {
|
||||
id: 'addresses-edit',
|
||||
errors: false
|
||||
}),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
||||
id: 'addresses-archive',
|
||||
errors: false
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
@@ -75,14 +82,14 @@ export const actions: Actions = {
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: activeOrganizationId,
|
||||
clientId: form.data.clientId,
|
||||
label: form.data.label,
|
||||
type: form.data.type,
|
||||
line1: form.data.line1,
|
||||
line2: form.data.line2 || null,
|
||||
line3: form.data.line3 || null,
|
||||
city: form.data.city,
|
||||
region: form.data.region || null,
|
||||
postcode: form.data.postcode,
|
||||
country: form.data.country,
|
||||
isPrimary: form.data.isPrimary === 'true',
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
});
|
||||
@@ -106,14 +113,14 @@ export const actions: Actions = {
|
||||
.update(addresses)
|
||||
.set({
|
||||
clientId: form.data.clientId,
|
||||
label: form.data.label,
|
||||
type: form.data.type,
|
||||
line1: form.data.line1,
|
||||
line2: form.data.line2 || null,
|
||||
line3: form.data.line3 || null,
|
||||
city: form.data.city,
|
||||
region: form.data.region || null,
|
||||
postcode: form.data.postcode,
|
||||
country: form.data.country,
|
||||
isPrimary: form.data.isPrimary === 'true',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { createListSearch } from '$lib/list-search.svelte';
|
||||
import ListSearch from '$lib/components/list-search.svelte';
|
||||
import CreateAddressDialog from './create-address-dialog.svelte';
|
||||
import AddressesTable from './addresses-table.svelte';
|
||||
import EditAddressDialog from './edit-address-dialog.svelte';
|
||||
@@ -9,20 +7,11 @@
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { zod4Client } from 'sveltekit-superforms/adapters';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import {
|
||||
formatValue,
|
||||
recordName as getRecordName,
|
||||
relationLabel as getRelationLabel
|
||||
} from '$lib/record-utils';
|
||||
import { handleFormToast } from '$lib/form-feedback';
|
||||
import { addressCreateSchema, addressEditSchema } from '$lib/schemas/addresses.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const listSearch = createListSearch(() => data.records);
|
||||
const listData = $derived({ ...data, records: listSearch.filtered });
|
||||
|
||||
let createOpen = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let archivingId = $state<string | null>(null);
|
||||
@@ -55,8 +44,31 @@
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
|
||||
function relationLabel(value: unknown, optionsKey: 'clients') {
|
||||
return getRelationLabel(data.options, value, optionsKey);
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function openEdit(record: RecordRow) {
|
||||
@@ -70,7 +82,15 @@
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
return getRecordName(record, 'this address');
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(
|
||||
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this address'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,21 +108,7 @@
|
||||
<CreateAddressDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
|
||||
</div>
|
||||
|
||||
<ListSearch
|
||||
bind:value={listSearch.params.q}
|
||||
placeholder="Search addresses..."
|
||||
totalCount={data.records.length}
|
||||
resultCount={listSearch.filtered.length}
|
||||
/>
|
||||
|
||||
<AddressesTable
|
||||
data={listData}
|
||||
{openEdit}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
{relationLabel}
|
||||
{recordName}
|
||||
/>
|
||||
<AddressesTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
|
||||
</div>
|
||||
|
||||
<EditAddressDialog
|
||||
|
||||
@@ -9,14 +9,12 @@
|
||||
openEdit,
|
||||
openArchive,
|
||||
formatValue,
|
||||
relationLabel,
|
||||
recordName
|
||||
}: {
|
||||
data: any;
|
||||
openEdit: any;
|
||||
openArchive: any;
|
||||
formatValue: any;
|
||||
relationLabel: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -26,24 +24,20 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Client</Table.Head>
|
||||
<Table.Head>Label</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>Address</Table.Head>
|
||||
<Table.Head>City</Table.Head>
|
||||
<Table.Head>Postcode</Table.Head>
|
||||
<Table.Head>Primary</Table.Head>
|
||||
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each data.records as record (record.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.label)}</Table.Cell>
|
||||
<Table.Cell class="capitalize">{formatValue(record.type)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.line1)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.city)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.postcode)}</Table.Cell>
|
||||
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
let {
|
||||
open = $bindable(false),
|
||||
createForm,
|
||||
@@ -31,82 +32,103 @@
|
||||
<Dialog.Description>Add a new address record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
||||
<Form.Field form={createForm} name="label">
|
||||
<Form.Control id="create-label">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Label</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.label} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="line1">
|
||||
<Form.Control id="create-line1">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Address line 1</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.line1} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="line2">
|
||||
<Form.Control id="create-line2">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Address line 2</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.line2} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="city">
|
||||
<Form.Control id="create-city">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>City</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.city} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="region">
|
||||
<Form.Control id="create-region">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Region / county</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.region} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="postcode">
|
||||
<Form.Control id="create-postcode">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Postcode</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.postcode} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="country">
|
||||
<Form.Control id="create-country">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Country</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.country} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="isPrimary">
|
||||
<Form.Control id="create-isPrimary">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Primary address</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.isPrimary}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="false">No</NativeSelect.Option>
|
||||
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={createForm} name="type">
|
||||
<Field.Field>
|
||||
<Control id="create-type">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Type</Field.Label>
|
||||
<FormSelect
|
||||
name="type"
|
||||
bind:value={$createData.type}
|
||||
options={[
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'invoicing', label: 'Invoicing' },
|
||||
{ value: 'contract', label: 'Contract' }
|
||||
]}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="line1">
|
||||
<Field.Field>
|
||||
<Control id="create-line1">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Address line 1</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.line1} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="line2">
|
||||
<Field.Field>
|
||||
<Control id="create-line2">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Address line 2</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.line2} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="line3">
|
||||
<Field.Field>
|
||||
<Control id="create-line3">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Address line 3</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.line3} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="city">
|
||||
<Field.Field>
|
||||
<Control id="create-city">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>City</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.city} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="region">
|
||||
<Field.Field>
|
||||
<Control id="create-region">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Region / county</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.region} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="postcode">
|
||||
<Field.Field>
|
||||
<Control id="create-postcode">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Postcode</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.postcode} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="country">
|
||||
<Field.Field>
|
||||
<Control id="create-country">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Country</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.country} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
let {
|
||||
editingRecord,
|
||||
editForm,
|
||||
@@ -29,82 +30,103 @@
|
||||
{#if editingRecord}
|
||||
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<Form.Field form={editForm} name="label">
|
||||
<Form.Control id="edit-label">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Label</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.label} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="line1">
|
||||
<Form.Control id="edit-line1">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Address line 1</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.line1} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="line2">
|
||||
<Form.Control id="edit-line2">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Address line 2</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.line2} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="city">
|
||||
<Form.Control id="edit-city">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>City</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.city} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="region">
|
||||
<Form.Control id="edit-region">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Region / county</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.region} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="postcode">
|
||||
<Form.Control id="edit-postcode">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Postcode</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.postcode} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="country">
|
||||
<Form.Control id="edit-country">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Country</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.country} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="isPrimary">
|
||||
<Form.Control id="edit-isPrimary">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Primary address</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.isPrimary}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="false">No</NativeSelect.Option>
|
||||
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={editForm} name="type">
|
||||
<Field.Field>
|
||||
<Control id="edit-type">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Type</Field.Label>
|
||||
<FormSelect
|
||||
name="type"
|
||||
bind:value={$editData.type}
|
||||
options={[
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
{ value: 'invoicing', label: 'Invoicing' },
|
||||
{ value: 'contract', label: 'Contract' }
|
||||
]}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="line1">
|
||||
<Field.Field>
|
||||
<Control id="edit-line1">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Address line 1</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.line1} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="line2">
|
||||
<Field.Field>
|
||||
<Control id="edit-line2">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Address line 2</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.line2} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="line3">
|
||||
<Field.Field>
|
||||
<Control id="edit-line3">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Address line 3</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.line3} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="city">
|
||||
<Field.Field>
|
||||
<Control id="edit-city">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>City</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.city} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="region">
|
||||
<Field.Field>
|
||||
<Control id="edit-region">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Region / county</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.region} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="postcode">
|
||||
<Field.Field>
|
||||
<Control id="edit-postcode">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Postcode</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.postcode} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="country">
|
||||
<Field.Field>
|
||||
<Control id="edit-country">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Country</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.country} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -67,10 +67,17 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
createForm: await superValidate({ clientId: params.id }, zod4(bookingCreateSchema), {
|
||||
id: 'bookings-create'
|
||||
id: 'bookings-create',
|
||||
errors: false
|
||||
}),
|
||||
editForm: await superValidate(zod4(bookingEditSchema), { id: 'bookings-edit' }),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' })
|
||||
editForm: await superValidate(zod4(bookingEditSchema), {
|
||||
id: 'bookings-edit',
|
||||
errors: false
|
||||
}),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
||||
id: 'bookings-archive',
|
||||
errors: false
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { createListSearch } from '$lib/list-search.svelte';
|
||||
import ListSearch from '$lib/components/list-search.svelte';
|
||||
import CreateBookingDialog from './create-booking-dialog.svelte';
|
||||
import BookingsTable from './bookings-table.svelte';
|
||||
import EditBookingDialog from './edit-booking-dialog.svelte';
|
||||
@@ -9,21 +7,11 @@
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { zod4Client } from 'sveltekit-superforms/adapters';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import {
|
||||
formatValue,
|
||||
formatDate,
|
||||
recordName as getRecordName,
|
||||
relationLabel as getRelationLabel
|
||||
} from '$lib/record-utils';
|
||||
import { handleFormToast } from '$lib/form-feedback';
|
||||
import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const listSearch = createListSearch(() => data.records);
|
||||
const listData = $derived({ ...data, records: listSearch.filtered });
|
||||
|
||||
let createOpen = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let archivingId = $state<string | null>(null);
|
||||
@@ -56,8 +44,48 @@
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
|
||||
function relationLabel(value: unknown, optionsKey: 'clients' | 'rooms' | 'services') {
|
||||
return getRelationLabel(data.options, value, optionsKey);
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function relationLabel(value: unknown, optionsKey: 'rooms' | 'services') {
|
||||
if (typeof value !== 'string') return 'Not set';
|
||||
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, withTime = false) {
|
||||
if (!value || typeof value !== 'string') return 'Not set';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function openEdit(record: RecordRow) {
|
||||
@@ -71,7 +99,15 @@
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
return getRecordName(record, 'this booking');
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(
|
||||
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this booking'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -91,15 +127,8 @@
|
||||
<CreateBookingDialog bind:open={createOpen} {data} {createForm} {createData} {enhanceCreate} />
|
||||
</div>
|
||||
|
||||
<ListSearch
|
||||
bind:value={listSearch.params.q}
|
||||
placeholder="Search bookings..."
|
||||
totalCount={data.records.length}
|
||||
resultCount={listSearch.filtered.length}
|
||||
/>
|
||||
|
||||
<BookingsTable
|
||||
data={listData}
|
||||
{data}
|
||||
{openEdit}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Client</Table.Head>
|
||||
<Table.Head>Room</Table.Head>
|
||||
<Table.Head>Starts</Table.Head>
|
||||
<Table.Head>Ends</Table.Head>
|
||||
@@ -40,7 +39,6 @@
|
||||
<Table.Body>
|
||||
{#each data.records as record (record.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||
<Table.Cell class="">{relationLabel(record.roomId, 'rooms')}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.startsAt, true)}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.endsAt, true)}</Table.Cell>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormCombobox from '$lib/components/form-combobox.svelte';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
data,
|
||||
@@ -34,75 +36,99 @@
|
||||
<Dialog.Description>Add a new booking record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
||||
<Form.Field form={createForm} name="roomId">
|
||||
<Form.Control id="create-roomId">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Room</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.roomId}>
|
||||
{#each data.options.rooms as option (option.value)}
|
||||
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
|
||||
{/each}
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="serviceId">
|
||||
<Form.Control id="create-serviceId">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Service</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.serviceId}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
{#each data.options.services as option (option.value)}
|
||||
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
|
||||
{/each}
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="startsAt">
|
||||
<Form.Control id="create-startsAt">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Starts at</Form.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$createData.startsAt} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="endsAt">
|
||||
<Form.Control id="create-endsAt">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Ends at</Form.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$createData.endsAt} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="status">
|
||||
<Form.Control id="create-status">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Status</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="booked">Booked</NativeSelect.Option>
|
||||
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option>
|
||||
<NativeSelect.Option value="completed">Completed</NativeSelect.Option>
|
||||
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="notes">
|
||||
<Form.Control id="create-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$createData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={createForm} name="roomId">
|
||||
<Field.Field>
|
||||
<Control id="create-roomId">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Room</Field.Label>
|
||||
<FormCombobox
|
||||
name="roomId"
|
||||
bind:value={$createData.roomId}
|
||||
options={data.options.rooms}
|
||||
placeholder="Select a room"
|
||||
searchPlaceholder="Search rooms..."
|
||||
emptyMessage="No matching rooms."
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="serviceId">
|
||||
<Field.Field>
|
||||
<Control id="create-serviceId">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Service</Field.Label>
|
||||
<FormCombobox
|
||||
name="serviceId"
|
||||
bind:value={$createData.serviceId}
|
||||
options={data.options.services}
|
||||
placeholder="Select a service"
|
||||
searchPlaceholder="Search services..."
|
||||
emptyMessage="No matching services."
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="startsAt">
|
||||
<Field.Field>
|
||||
<Control id="create-startsAt">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Starts at</Field.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$createData.startsAt} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="endsAt">
|
||||
<Field.Field>
|
||||
<Control id="create-endsAt">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Ends at</Field.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$createData.endsAt} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="status">
|
||||
<Field.Field>
|
||||
<Control id="create-status">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Status</Field.Label>
|
||||
<FormSelect
|
||||
name="status"
|
||||
bind:value={$createData.status}
|
||||
options={[
|
||||
{ value: '', label: 'Not set' },
|
||||
{ value: 'booked', label: 'Booked' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
]}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="notes">
|
||||
<Field.Field>
|
||||
<Control id="create-notes">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Notes</Field.Label>
|
||||
<Textarea {...props} bind:value={$createData.notes} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormCombobox from '$lib/components/form-combobox.svelte';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
data,
|
||||
@@ -32,75 +34,99 @@
|
||||
{#if editingRecord}
|
||||
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<Form.Field form={editForm} name="roomId">
|
||||
<Form.Control id="edit-roomId">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Room</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.roomId}>
|
||||
{#each data.options.rooms as option (option.value)}
|
||||
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
|
||||
{/each}
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="serviceId">
|
||||
<Form.Control id="edit-serviceId">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Service</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.serviceId}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
{#each data.options.services as option (option.value)}
|
||||
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
|
||||
{/each}
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="startsAt">
|
||||
<Form.Control id="edit-startsAt">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Starts at</Form.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$editData.startsAt} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="endsAt">
|
||||
<Form.Control id="edit-endsAt">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Ends at</Form.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$editData.endsAt} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="status">
|
||||
<Form.Control id="edit-status">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Status</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="booked">Booked</NativeSelect.Option>
|
||||
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option>
|
||||
<NativeSelect.Option value="completed">Completed</NativeSelect.Option>
|
||||
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="notes">
|
||||
<Form.Control id="edit-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={editForm} name="roomId">
|
||||
<Field.Field>
|
||||
<Control id="edit-roomId">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Room</Field.Label>
|
||||
<FormCombobox
|
||||
name="roomId"
|
||||
bind:value={$editData.roomId}
|
||||
options={data.options.rooms}
|
||||
placeholder="Select a room"
|
||||
searchPlaceholder="Search rooms..."
|
||||
emptyMessage="No matching rooms."
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="serviceId">
|
||||
<Field.Field>
|
||||
<Control id="edit-serviceId">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Service</Field.Label>
|
||||
<FormCombobox
|
||||
name="serviceId"
|
||||
bind:value={$editData.serviceId}
|
||||
options={data.options.services}
|
||||
placeholder="Select a service"
|
||||
searchPlaceholder="Search services..."
|
||||
emptyMessage="No matching services."
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="startsAt">
|
||||
<Field.Field>
|
||||
<Control id="edit-startsAt">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Starts at</Field.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$editData.startsAt} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="endsAt">
|
||||
<Field.Field>
|
||||
<Control id="edit-endsAt">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Ends at</Field.Label>
|
||||
<Input {...props} type="datetime-local" bind:value={$editData.endsAt} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="status">
|
||||
<Field.Field>
|
||||
<Control id="edit-status">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Status</Field.Label>
|
||||
<FormSelect
|
||||
name="status"
|
||||
bind:value={$editData.status}
|
||||
options={[
|
||||
{ value: '', label: 'Not set' },
|
||||
{ value: 'booked', label: 'Booked' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
]}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="notes">
|
||||
<Field.Field>
|
||||
<Control id="edit-notes">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Notes</Field.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -45,15 +45,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
email: record.email ?? '',
|
||||
phone: record.phone ?? '',
|
||||
isPrimary: record.isPrimary ? 'true' : 'false',
|
||||
notes: record.notes ?? ''
|
||||
receivesInvoices: record.receivesInvoices ? 'true' : 'false',
|
||||
receivesContracts: record.receivesContracts ? 'true' : 'false'
|
||||
}
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
createForm: await superValidate({ clientId: params.id }, zod4(contactCreateSchema), {
|
||||
id: 'contacts-create'
|
||||
id: 'contacts-create',
|
||||
errors: false
|
||||
}),
|
||||
editForm: await superValidate(zod4(contactEditSchema), { id: 'contacts-edit' }),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' })
|
||||
editForm: await superValidate(zod4(contactEditSchema), {
|
||||
id: 'contacts-edit',
|
||||
errors: false
|
||||
}),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
||||
id: 'contacts-archive',
|
||||
errors: false
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
@@ -78,7 +86,8 @@ export const actions: Actions = {
|
||||
email: form.data.email || null,
|
||||
phone: form.data.phone || null,
|
||||
isPrimary: form.data.isPrimary === 'true',
|
||||
notes: form.data.notes || null,
|
||||
receivesInvoices: form.data.receivesInvoices === 'true',
|
||||
receivesContracts: form.data.receivesContracts === 'true',
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
});
|
||||
@@ -107,7 +116,8 @@ export const actions: Actions = {
|
||||
email: form.data.email || null,
|
||||
phone: form.data.phone || null,
|
||||
isPrimary: form.data.isPrimary === 'true',
|
||||
notes: form.data.notes || null,
|
||||
receivesInvoices: form.data.receivesInvoices === 'true',
|
||||
receivesContracts: form.data.receivesContracts === 'true',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { createListSearch } from '$lib/list-search.svelte';
|
||||
import ListSearch from '$lib/components/list-search.svelte';
|
||||
import CreateContactDialog from './create-contact-dialog.svelte';
|
||||
import ContactsTable from './contacts-table.svelte';
|
||||
import EditContactDialog from './edit-contact-dialog.svelte';
|
||||
@@ -9,20 +7,11 @@
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { zod4Client } from 'sveltekit-superforms/adapters';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import {
|
||||
formatValue,
|
||||
recordName as getRecordName,
|
||||
relationLabel as getRelationLabel
|
||||
} from '$lib/record-utils';
|
||||
import { handleFormToast } from '$lib/form-feedback';
|
||||
import { contactCreateSchema, contactEditSchema } from '$lib/schemas/contacts.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const listSearch = createListSearch(() => data.records);
|
||||
const listData = $derived({ ...data, records: listSearch.filtered });
|
||||
|
||||
let createOpen = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let archivingId = $state<string | null>(null);
|
||||
@@ -55,8 +44,31 @@
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
|
||||
function relationLabel(value: unknown, optionsKey: 'clients') {
|
||||
return getRelationLabel(data.options, value, optionsKey);
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function openEdit(record: RecordRow) {
|
||||
@@ -70,7 +82,15 @@
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
return getRecordName(record, 'this contact');
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(
|
||||
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this contact'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -88,21 +108,7 @@
|
||||
<CreateContactDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
|
||||
</div>
|
||||
|
||||
<ListSearch
|
||||
bind:value={listSearch.params.q}
|
||||
placeholder="Search contacts..."
|
||||
totalCount={data.records.length}
|
||||
resultCount={listSearch.filtered.length}
|
||||
/>
|
||||
|
||||
<ContactsTable
|
||||
data={listData}
|
||||
{openEdit}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
{relationLabel}
|
||||
{recordName}
|
||||
/>
|
||||
<ContactsTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
|
||||
</div>
|
||||
|
||||
<EditContactDialog
|
||||
|
||||
@@ -9,14 +9,12 @@
|
||||
openEdit,
|
||||
openArchive,
|
||||
formatValue,
|
||||
relationLabel,
|
||||
recordName
|
||||
}: {
|
||||
data: any;
|
||||
openEdit: any;
|
||||
openArchive: any;
|
||||
formatValue: any;
|
||||
relationLabel: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -26,24 +24,26 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Client</Table.Head>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Role</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Phone</Table.Head>
|
||||
<Table.Head>Primary</Table.Head>
|
||||
<Table.Head>Invoices</Table.Head>
|
||||
<Table.Head>Contracts</Table.Head>
|
||||
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each data.records as record (record.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.name)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.role)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.email)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.phone)}</Table.Cell>
|
||||
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
|
||||
<Table.Cell>{record.receivesInvoices ? 'Yes' : 'No'}</Table.Cell>
|
||||
<Table.Cell>{record.receivesContracts ? 'Yes' : 'No'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import FormCheckbox from '$lib/components/form-checkbox.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
open = $bindable(false),
|
||||
createForm,
|
||||
@@ -32,64 +33,86 @@
|
||||
<Dialog.Description>Add a new contact record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
||||
<Form.Field form={createForm} name="name">
|
||||
<Form.Control id="create-name">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Name</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.name} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="role">
|
||||
<Form.Control id="create-role">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Role</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.role} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="email">
|
||||
<Form.Control id="create-email">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Email</Form.Label>
|
||||
<Input {...props} type="email" bind:value={$createData.email} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="phone">
|
||||
<Form.Control id="create-phone">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Phone</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.phone} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="isPrimary">
|
||||
<Form.Control id="create-isPrimary">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Primary contact</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.isPrimary}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="false">No</NativeSelect.Option>
|
||||
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="notes">
|
||||
<Form.Control id="create-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$createData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={createForm} name="name">
|
||||
<Field.Field>
|
||||
<Control id="create-name">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Name</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.name} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="role">
|
||||
<Field.Field>
|
||||
<Control id="create-role">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Role</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.role} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="email">
|
||||
<Field.Field>
|
||||
<Control id="create-email">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Email</Field.Label>
|
||||
<Input {...props} type="email" bind:value={$createData.email} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="phone">
|
||||
<Field.Field>
|
||||
<Control id="create-phone">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Phone</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.phone} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="isPrimary">
|
||||
<Field.Field>
|
||||
<Control id="create-isPrimary">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Primary contact</Field.Label>
|
||||
<FormSelect
|
||||
name="isPrimary"
|
||||
bind:value={$createData.isPrimary}
|
||||
options={[
|
||||
{ value: '', label: 'Not set' },
|
||||
{ value: 'false', label: 'No' },
|
||||
{ value: 'true', label: 'Yes' }
|
||||
]}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<div class="grid gap-3 py-1">
|
||||
<FormCheckbox
|
||||
id="create-receives-invoices"
|
||||
name="receivesInvoices"
|
||||
label="Receives invoices"
|
||||
description="Send invoices raised for this client to this contact."
|
||||
bind:value={$createData.receivesInvoices}
|
||||
/>
|
||||
<FormCheckbox
|
||||
id="create-receives-contracts"
|
||||
name="receivesContracts"
|
||||
label="Receives contracts"
|
||||
description="Send contract documents for this client to this contact."
|
||||
bind:value={$createData.receivesContracts}
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import FormCheckbox from '$lib/components/form-checkbox.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
editingRecord,
|
||||
editForm,
|
||||
@@ -30,64 +31,86 @@
|
||||
{#if editingRecord}
|
||||
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<Form.Field form={editForm} name="name">
|
||||
<Form.Control id="edit-name">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Name</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.name} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="role">
|
||||
<Form.Control id="edit-role">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Role</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.role} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="email">
|
||||
<Form.Control id="edit-email">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Email</Form.Label>
|
||||
<Input {...props} type="email" bind:value={$editData.email} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="phone">
|
||||
<Form.Control id="edit-phone">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Phone</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.phone} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="isPrimary">
|
||||
<Form.Control id="edit-isPrimary">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Primary contact</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.isPrimary}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="false">No</NativeSelect.Option>
|
||||
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="notes">
|
||||
<Form.Control id="edit-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={editForm} name="name">
|
||||
<Field.Field>
|
||||
<Control id="edit-name">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Name</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.name} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="role">
|
||||
<Field.Field>
|
||||
<Control id="edit-role">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Role</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.role} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="email">
|
||||
<Field.Field>
|
||||
<Control id="edit-email">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Email</Field.Label>
|
||||
<Input {...props} type="email" bind:value={$editData.email} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="phone">
|
||||
<Field.Field>
|
||||
<Control id="edit-phone">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Phone</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.phone} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="isPrimary">
|
||||
<Field.Field>
|
||||
<Control id="edit-isPrimary">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Primary contact</Field.Label>
|
||||
<FormSelect
|
||||
name="isPrimary"
|
||||
bind:value={$editData.isPrimary}
|
||||
options={[
|
||||
{ value: '', label: 'Not set' },
|
||||
{ value: 'false', label: 'No' },
|
||||
{ value: 'true', label: 'Yes' }
|
||||
]}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<div class="grid gap-3 py-1">
|
||||
<FormCheckbox
|
||||
id="edit-receives-invoices"
|
||||
name="receivesInvoices"
|
||||
label="Receives invoices"
|
||||
description="Send invoices raised for this client to this contact."
|
||||
bind:value={$editData.receivesInvoices}
|
||||
/>
|
||||
<FormCheckbox
|
||||
id="edit-receives-contracts"
|
||||
name="receivesContracts"
|
||||
label="Receives contracts"
|
||||
description="Send contract documents for this client to this contact."
|
||||
bind:value={$editData.receivesContracts}
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -1,30 +1,74 @@
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
import { message, superValidate } from 'sveltekit-superforms/server';
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { message, setError, superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import { db } from '$lib/server/db';
|
||||
import { contracts, clients } from '$lib/server/db/schema';
|
||||
import { contracts, rooms, services } from '$lib/server/db/schema';
|
||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
|
||||
import {
|
||||
contractCreateSchema,
|
||||
contractEditSchema,
|
||||
contractTransitionSchema
|
||||
} from '$lib/schemas/contracts.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function loadOptions(organizationId: string) {
|
||||
const clientRows = await db
|
||||
.select({ id: clients.id, name: clients.name })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt)))
|
||||
.orderBy(asc(clients.name));
|
||||
const [roomRows, serviceRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: rooms.id,
|
||||
name: rooms.name,
|
||||
type: rooms.type,
|
||||
pricePerMonthGbp: rooms.pricePerMonthGbp
|
||||
})
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.organizationId, organizationId),
|
||||
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
||||
isNull(rooms.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(rooms.name)),
|
||||
db
|
||||
.select({ id: services.id, name: services.name, priceGbp: services.priceGbp })
|
||||
.from(services)
|
||||
.where(and(eq(services.organizationId, organizationId), isNull(services.archivedAt)))
|
||||
.orderBy(asc(services.name))
|
||||
]);
|
||||
|
||||
return {
|
||||
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
|
||||
rooms: [
|
||||
{ label: 'No room', value: '' },
|
||||
...roomRows.map((room) => ({
|
||||
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
|
||||
value: room.id,
|
||||
licenseFeeGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) : 0,
|
||||
depositGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) * 2 : 0
|
||||
}))
|
||||
],
|
||||
services: [
|
||||
{ label: 'No service', value: '' },
|
||||
...serviceRows.map((service) => ({
|
||||
label: service.name,
|
||||
value: service.id,
|
||||
licenseFeeGbp: service.priceGbp
|
||||
}))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const records = await db
|
||||
.select()
|
||||
.select({
|
||||
contract: contracts,
|
||||
roomName: rooms.name,
|
||||
serviceName: services.name
|
||||
})
|
||||
.from(contracts)
|
||||
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
|
||||
.leftJoin(services, eq(contracts.serviceId, services.id))
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.clientId, params.id),
|
||||
@@ -32,31 +76,91 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
isNull(contracts.archivedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(contracts.title));
|
||||
.orderBy(asc(contracts.startDate));
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
...record,
|
||||
...record.contract,
|
||||
roomName: record.roomName,
|
||||
serviceName: record.serviceName,
|
||||
formValues: {
|
||||
id: record.id,
|
||||
clientId: record.clientId ?? '',
|
||||
title: record.title ?? '',
|
||||
startDate: record.startDate ?? '',
|
||||
endDate: record.endDate ?? '',
|
||||
status: record.status ?? '',
|
||||
valueGbp: record.valueGbp ?? '',
|
||||
notes: record.notes ?? ''
|
||||
id: record.contract.id,
|
||||
clientId: record.contract.clientId ?? '',
|
||||
roomId: record.contract.roomId ?? '',
|
||||
serviceId: record.contract.serviceId ?? '',
|
||||
licenseFeeGbp: record.contract.licenseFeeGbp,
|
||||
depositGbp: record.contract.depositGbp,
|
||||
startDate: record.contract.startDate ?? '',
|
||||
endDate: record.contract.endDate ?? '',
|
||||
notes: record.contract.notes ?? ''
|
||||
}
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), {
|
||||
id: 'contracts-create'
|
||||
id: 'contracts-create',
|
||||
errors: false
|
||||
}),
|
||||
editForm: await superValidate(zod4(contractEditSchema), { id: 'contracts-edit' }),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
|
||||
editForm: await superValidate(zod4(contractEditSchema), {
|
||||
id: 'contracts-edit',
|
||||
errors: false
|
||||
}),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
||||
id: 'contracts-archive',
|
||||
errors: false
|
||||
}),
|
||||
transitionForm: await superValidate(zod4(contractTransitionSchema), {
|
||||
id: 'contracts-transition',
|
||||
errors: false
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
async function selectionError(
|
||||
organizationId: string,
|
||||
roomId: string,
|
||||
serviceId: string
|
||||
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> {
|
||||
if (roomId) {
|
||||
const [room] = await db
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, roomId),
|
||||
eq(rooms.organizationId, organizationId),
|
||||
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
||||
isNull(rooms.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!room) {
|
||||
return {
|
||||
field: 'roomId',
|
||||
message: 'Select a valid private office or coworking desk.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceId) {
|
||||
const [service] = await db
|
||||
.select({ id: services.id })
|
||||
.from(services)
|
||||
.where(
|
||||
and(
|
||||
eq(services.id, serviceId),
|
||||
eq(services.organizationId, organizationId),
|
||||
isNull(services.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!service) return { field: 'serviceId', message: 'Select a valid service.' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
@@ -67,17 +171,25 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
const invalidSelection = await selectionError(
|
||||
activeOrganizationId,
|
||||
form.data.roomId,
|
||||
form.data.serviceId
|
||||
);
|
||||
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
|
||||
|
||||
try {
|
||||
await db.insert(contracts).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: activeOrganizationId,
|
||||
clientId: form.data.clientId,
|
||||
title: form.data.title,
|
||||
roomId: form.data.roomId || null,
|
||||
serviceId: form.data.serviceId || null,
|
||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||
depositGbp: form.data.depositGbp,
|
||||
startDate: form.data.startDate,
|
||||
endDate: form.data.endDate || null,
|
||||
status: form.data.status,
|
||||
valueGbp: form.data.valueGbp,
|
||||
endDate: form.data.endDate,
|
||||
status: 'draft',
|
||||
notes: form.data.notes || null,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
@@ -98,17 +210,24 @@ export const actions: Actions = {
|
||||
});
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
const invalidSelection = await selectionError(
|
||||
activeOrganizationId,
|
||||
form.data.roomId,
|
||||
form.data.serviceId
|
||||
);
|
||||
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(contracts)
|
||||
.set({
|
||||
clientId: form.data.clientId,
|
||||
title: form.data.title,
|
||||
roomId: form.data.roomId || null,
|
||||
serviceId: form.data.serviceId || null,
|
||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||
depositGbp: form.data.depositGbp,
|
||||
startDate: form.data.startDate,
|
||||
endDate: form.data.endDate || null,
|
||||
status: form.data.status,
|
||||
valueGbp: form.data.valueGbp,
|
||||
endDate: form.data.endDate,
|
||||
notes: form.data.notes || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
@@ -126,6 +245,50 @@ export const actions: Actions = {
|
||||
return message(form, 'Contract updated.');
|
||||
},
|
||||
|
||||
transition: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const form = await superValidate(await request.formData(), zod4(contractTransitionSchema), {
|
||||
id: 'contracts-transition'
|
||||
});
|
||||
|
||||
if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 });
|
||||
|
||||
const [contract] = await db
|
||||
.select({ status: contracts.status })
|
||||
.from(contracts)
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.id, form.data.id),
|
||||
eq(contracts.clientId, params.id),
|
||||
eq(contracts.organizationId, activeOrganizationId),
|
||||
isNull(contracts.archivedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const allowedTransitions: Record<string, readonly string[]> = {
|
||||
draft: ['active', 'void'],
|
||||
active: ['expired']
|
||||
};
|
||||
|
||||
if (!contract || !allowedTransitions[contract.status]?.includes(form.data.targetStatus)) {
|
||||
return message(form, 'That contract status change is not allowed.', { status: 400 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(contracts)
|
||||
.set({ status: form.data.targetStatus, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(contracts.id, form.data.id),
|
||||
eq(contracts.clientId, params.id),
|
||||
eq(contracts.organizationId, activeOrganizationId)
|
||||
)
|
||||
);
|
||||
|
||||
return message(form, `Contract marked ${form.data.targetStatus}.`);
|
||||
},
|
||||
|
||||
archive: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { createListSearch } from '$lib/list-search.svelte';
|
||||
import ListSearch from '$lib/components/list-search.svelte';
|
||||
import CreateContractDialog from './create-contract-dialog.svelte';
|
||||
import ContractsTable from './contracts-table.svelte';
|
||||
import EditContractDialog from './edit-contract-dialog.svelte';
|
||||
@@ -10,21 +8,14 @@
|
||||
import { zod4Client } from 'sveltekit-superforms/adapters';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import {
|
||||
formatValue,
|
||||
formatMoney,
|
||||
formatDate,
|
||||
recordName as getRecordName,
|
||||
relationLabel as getRelationLabel
|
||||
} from '$lib/record-utils';
|
||||
import { handleFormToast } from '$lib/form-feedback';
|
||||
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
|
||||
contractCreateSchema,
|
||||
contractEditSchema,
|
||||
contractTransitionSchema
|
||||
} from '$lib/schemas/contracts.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const listSearch = createListSearch(() => data.records);
|
||||
const listData = $derived({ ...data, records: listSearch.filtered });
|
||||
|
||||
let createOpen = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let archivingId = $state<string | null>(null);
|
||||
@@ -52,17 +43,67 @@
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-archive' })
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
const transitionForm = superForm(data.transitionForm, {
|
||||
validators: zod4Client(contractTransitionSchema),
|
||||
resetForm: false,
|
||||
onUpdated: ({ form }) =>
|
||||
handleFormToast(form, 'contracts-transition', () => (editingId = null)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-transition' })
|
||||
});
|
||||
|
||||
const { form: createData, enhance: enhanceCreate } = createForm;
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
const { enhance: enhanceTransition } = transitionForm;
|
||||
|
||||
function relationLabel(value: unknown, optionsKey: 'clients') {
|
||||
return getRelationLabel(data.options, value, optionsKey);
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, withTime = false) {
|
||||
if (!value || typeof value !== 'string') return 'Not set';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function openEdit(record: RecordRow) {
|
||||
editForm.reset({ data: record.formValues as never });
|
||||
transitionForm.reset({
|
||||
data: {
|
||||
id: record.id,
|
||||
targetStatus: record.status === 'active' ? 'expired' : 'active'
|
||||
}
|
||||
});
|
||||
editingId = record.id;
|
||||
}
|
||||
|
||||
@@ -72,7 +113,7 @@
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
return getRecordName(record, 'this contract');
|
||||
return String(record?.roomName ?? record?.serviceName ?? 'this contract');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -87,33 +128,25 @@
|
||||
<p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p>
|
||||
</div>
|
||||
|
||||
<CreateContractDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
|
||||
<CreateContractDialog
|
||||
bind:open={createOpen}
|
||||
options={data.options}
|
||||
{createForm}
|
||||
{createData}
|
||||
{enhanceCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ListSearch
|
||||
bind:value={listSearch.params.q}
|
||||
placeholder="Search contracts..."
|
||||
totalCount={data.records.length}
|
||||
resultCount={listSearch.filtered.length}
|
||||
/>
|
||||
|
||||
<ContractsTable
|
||||
data={listData}
|
||||
{openEdit}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
{formatDate}
|
||||
{formatMoney}
|
||||
{relationLabel}
|
||||
{recordName}
|
||||
/>
|
||||
<ContractsTable {data} {openEdit} {openArchive} {formatValue} {formatDate} {recordName} />
|
||||
</div>
|
||||
|
||||
<EditContractDialog
|
||||
{editingRecord}
|
||||
options={data.options}
|
||||
{editForm}
|
||||
{editData}
|
||||
{enhanceEdit}
|
||||
{enhanceTransition}
|
||||
onClose={() => (editingId = null)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormCombobox from '$lib/components/form-combobox.svelte';
|
||||
import MoneyField from '$lib/components/money-field.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
|
||||
let {
|
||||
form,
|
||||
data,
|
||||
options,
|
||||
prefix
|
||||
}: {
|
||||
form: any;
|
||||
data: any;
|
||||
options: {
|
||||
rooms: readonly {
|
||||
value: string;
|
||||
label: string;
|
||||
licenseFeeGbp?: number;
|
||||
depositGbp?: number;
|
||||
}[];
|
||||
services: readonly { value: string; label: string; licenseFeeGbp?: number }[];
|
||||
};
|
||||
prefix: string;
|
||||
} = $props();
|
||||
|
||||
function roundMoney(value: number) {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function updatePricing(roomId: string, serviceId: string) {
|
||||
const room = options.rooms.find((option) => option.value === roomId);
|
||||
const service = options.services.find((option) => option.value === serviceId);
|
||||
|
||||
$data.licenseFeeGbp = roundMoney((room?.licenseFeeGbp ?? 0) + (service?.licenseFeeGbp ?? 0));
|
||||
$data.depositGbp = roundMoney(room?.depositGbp ?? 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<FormField {form} name="roomId">
|
||||
<Field.Field>
|
||||
<Control id={`${prefix}-roomId`}>
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Room</Field.Label>
|
||||
<FormCombobox
|
||||
name="roomId"
|
||||
bind:value={$data.roomId}
|
||||
options={options.rooms}
|
||||
placeholder="Select a private office or coworking desk"
|
||||
searchPlaceholder="Search rooms..."
|
||||
emptyMessage="No matching rooms."
|
||||
triggerProps={props}
|
||||
onValueChange={(roomId) => updatePricing(roomId, $data.serviceId)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField {form} name="serviceId">
|
||||
<Field.Field>
|
||||
<Control id={`${prefix}-serviceId`}>
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Service</Field.Label>
|
||||
<FormCombobox
|
||||
name="serviceId"
|
||||
bind:value={$data.serviceId}
|
||||
options={options.services}
|
||||
placeholder="Select a service"
|
||||
searchPlaceholder="Search services..."
|
||||
emptyMessage="No matching services."
|
||||
triggerProps={props}
|
||||
onValueChange={(serviceId) => updatePricing($data.roomId, serviceId)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<MoneyField
|
||||
{form}
|
||||
{data}
|
||||
name="licenseFeeGbp"
|
||||
label="License fee"
|
||||
id={`${prefix}-licenseFeeGbp`}
|
||||
description="Private office monthly price plus the selected service. You can overwrite this amount."
|
||||
/>
|
||||
<MoneyField
|
||||
{form}
|
||||
{data}
|
||||
name="depositGbp"
|
||||
label="Deposit"
|
||||
id={`${prefix}-depositGbp`}
|
||||
description="Defaults to two months of private office fees. Coworking desks and services require no deposit."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<FormField {form} name="startDate">
|
||||
<Field.Field>
|
||||
<Control id={`${prefix}-startDate`}>
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Start date</Field.Label>
|
||||
<Input {...props} type="date" bind:value={$data.startDate} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField {form} name="endDate">
|
||||
<Field.Field>
|
||||
<Control id={`${prefix}-endDate`}>
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>End date</Field.Label>
|
||||
<Input {...props} type="date" bind:value={$data.endDate} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField {form} name="notes">
|
||||
<Field.Field>
|
||||
<Control id={`${prefix}-notes`}>
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Notes</Field.Label>
|
||||
<Textarea {...props} bind:value={$data.notes} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
@@ -11,8 +11,6 @@
|
||||
openArchive,
|
||||
formatValue,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
relationLabel,
|
||||
recordName
|
||||
}: {
|
||||
data: any;
|
||||
@@ -20,8 +18,6 @@
|
||||
openArchive: any;
|
||||
formatValue: any;
|
||||
formatDate: any;
|
||||
formatMoney: any;
|
||||
relationLabel: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -31,27 +27,25 @@
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head>Client</Table.Head>
|
||||
<Table.Head>Room</Table.Head>
|
||||
<Table.Head>Service</Table.Head>
|
||||
<Table.Head>Starts</Table.Head>
|
||||
<Table.Head>Ends</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Value</Table.Head>
|
||||
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each data.records as record (record.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell>
|
||||
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||
<Table.Cell class="font-medium">{formatValue(record.roomName)}</Table.Cell>
|
||||
<Table.Cell>{formatValue(record.serviceName)}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.startDate)}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
|
||||
<Table.Cell class=""
|
||||
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
|
||||
></Table.Cell
|
||||
>
|
||||
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import MoneyField from '$lib/components/money-field.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import ContractFormFields from './contract-form-fields.svelte';
|
||||
let {
|
||||
open = $bindable(false),
|
||||
options,
|
||||
createForm,
|
||||
createData,
|
||||
enhanceCreate
|
||||
}: {
|
||||
open: boolean;
|
||||
options: any;
|
||||
createForm: any;
|
||||
createData: any;
|
||||
enhanceCreate: any;
|
||||
@@ -33,64 +31,7 @@
|
||||
<Dialog.Description>Add a new contract record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
||||
<Form.Field form={createForm} name="title">
|
||||
<Form.Control id="create-title">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.title} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="startDate">
|
||||
<Form.Control id="create-startDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Start date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$createData.startDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="endDate">
|
||||
<Form.Control id="create-endDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>End date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$createData.endDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="status">
|
||||
<Form.Control id="create-status">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Status</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
|
||||
<NativeSelect.Option value="active">Active</NativeSelect.Option>
|
||||
<NativeSelect.Option value="expired">Expired</NativeSelect.Option>
|
||||
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<MoneyField
|
||||
form={createForm}
|
||||
data={createData}
|
||||
name="valueGbp"
|
||||
label="Value"
|
||||
id="create-valueGbp"
|
||||
/>
|
||||
<Form.Field form={createForm} name="notes">
|
||||
<Form.Control id="create-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$createData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<ContractFormFields form={createForm} data={createData} {options} prefix="create" />
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import MoneyField from '$lib/components/money-field.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import ContractFormFields from './contract-form-fields.svelte';
|
||||
let {
|
||||
editingRecord,
|
||||
options,
|
||||
editForm,
|
||||
editData,
|
||||
enhanceEdit,
|
||||
enhanceTransition,
|
||||
onClose
|
||||
}: {
|
||||
editingRecord: any;
|
||||
options: any;
|
||||
editForm: any;
|
||||
editData: any;
|
||||
enhanceEdit: any;
|
||||
enhanceTransition: any;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -25,78 +26,49 @@
|
||||
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit contract</Dialog.Title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Dialog.Title>Edit contract</Dialog.Title>
|
||||
{#if editingRecord}
|
||||
<Badge variant="secondary" class="capitalize">{editingRecord.status}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<Dialog.Description>Update this contract record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if editingRecord}
|
||||
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<form id="edit-contract-form" method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<Form.Field form={editForm} name="title">
|
||||
<Form.Control id="edit-title">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.title} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="startDate">
|
||||
<Form.Control id="edit-startDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Start date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$editData.startDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="endDate">
|
||||
<Form.Control id="edit-endDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>End date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$editData.endDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="status">
|
||||
<Form.Control id="edit-status">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Status</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
|
||||
<NativeSelect.Option value="active">Active</NativeSelect.Option>
|
||||
<NativeSelect.Option value="expired">Expired</NativeSelect.Option>
|
||||
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<MoneyField
|
||||
form={editForm}
|
||||
data={editData}
|
||||
name="valueGbp"
|
||||
label="Value"
|
||||
id="edit-valueGbp"
|
||||
/>
|
||||
<Form.Field form={editForm} name="notes">
|
||||
<Form.Control id="edit-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Dialog.Footer>
|
||||
<ContractFormFields form={editForm} data={editData} {options} prefix="edit" />
|
||||
</form>
|
||||
|
||||
<Dialog.Footer class="flex-row items-center justify-between sm:justify-between">
|
||||
{#if editingRecord.status === 'draft' || editingRecord.status === 'active'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/transition"
|
||||
class="flex flex-wrap gap-2"
|
||||
use:enhanceTransition
|
||||
>
|
||||
<input type="hidden" name="id" value={editingRecord.id} />
|
||||
{#if editingRecord.status === 'draft'}
|
||||
<Button type="submit" name="targetStatus" value="void" variant="destructive"
|
||||
>Void contract</Button
|
||||
>
|
||||
<Button type="submit" name="targetStatus" value="active">Activate contract</Button>
|
||||
{:else}
|
||||
<Button type="submit" name="targetStatus" value="expired">Mark as expired</Button>
|
||||
{/if}
|
||||
</form>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
>{/snippet}</Dialog.Close
|
||||
>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
<Button type="submit" form="edit-contract-form">Save changes</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { db } from '$lib/server/db';
|
||||
import { invoices, clients } from '$lib/server/db/schema';
|
||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.schema';
|
||||
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function loadOptions(organizationId: string) {
|
||||
@@ -51,48 +51,18 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
}
|
||||
})),
|
||||
options: await loadOptions(activeOrganizationId),
|
||||
createForm: await superValidate({ clientId: params.id }, zod4(invoiceCreateSchema), {
|
||||
id: 'invoices-create'
|
||||
editForm: await superValidate(zod4(invoiceEditSchema), {
|
||||
id: 'invoices-edit',
|
||||
errors: false
|
||||
}),
|
||||
editForm: await superValidate(zod4(invoiceEditSchema), { id: 'invoices-edit' }),
|
||||
archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' })
|
||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
||||
id: 'invoices-archive',
|
||||
errors: false
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const formData = await request.formData();
|
||||
formData.set('clientId', params.id);
|
||||
const form = await superValidate(formData, zod4(invoiceCreateSchema), {
|
||||
id: 'invoices-create'
|
||||
});
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
|
||||
try {
|
||||
await db.insert(invoices).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: activeOrganizationId,
|
||||
clientId: form.data.clientId,
|
||||
invoiceNumber: form.data.invoiceNumber,
|
||||
issueDate: form.data.issueDate,
|
||||
dueDate: form.data.dueDate,
|
||||
status: form.data.status,
|
||||
subtotalGbp: form.data.subtotalGbp,
|
||||
taxGbp: form.data.taxGbp,
|
||||
totalGbp: form.data.totalGbp,
|
||||
notes: form.data.notes || null,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
});
|
||||
} catch {
|
||||
return message(form, 'Unable to create invoice.', { status: 400 });
|
||||
}
|
||||
|
||||
return message(form, 'Invoice created.');
|
||||
},
|
||||
|
||||
edit: async ({ locals, params, request }) => {
|
||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||
const formData = await request.formData();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { createListSearch } from '$lib/list-search.svelte';
|
||||
import ListSearch from '$lib/components/list-search.svelte';
|
||||
import CreateInvoiceDialog from './create-invoice-dialog.svelte';
|
||||
import InvoicesTable from './invoices-table.svelte';
|
||||
import EditInvoiceDialog from './edit-invoice-dialog.svelte';
|
||||
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
|
||||
@@ -9,35 +6,17 @@
|
||||
import { superForm } from 'sveltekit-superforms';
|
||||
import { zod4Client } from 'sveltekit-superforms/adapters';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import {
|
||||
formatValue,
|
||||
formatMoney,
|
||||
formatDate,
|
||||
recordName as getRecordName,
|
||||
relationLabel as getRelationLabel
|
||||
} from '$lib/record-utils';
|
||||
import { handleFormToast } from '$lib/form-feedback';
|
||||
import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.schema';
|
||||
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const listSearch = createListSearch(() => data.records);
|
||||
const listData = $derived({ ...data, records: listSearch.filtered });
|
||||
|
||||
let createOpen = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let archivingId = $state<string | null>(null);
|
||||
|
||||
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
|
||||
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const createForm = superForm(data.createForm, {
|
||||
validators: zod4Client(invoiceCreateSchema),
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'invoices-create', () => (createOpen = false)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-create' })
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
const editForm = superForm(data.editForm, {
|
||||
validators: zod4Client(invoiceEditSchema),
|
||||
@@ -53,12 +32,51 @@
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
|
||||
});
|
||||
|
||||
const { form: createData, enhance: enhanceCreate } = createForm;
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
|
||||
function relationLabel(value: unknown, optionsKey: 'clients') {
|
||||
return getRelationLabel(data.options, value, optionsKey);
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
const amount = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
|
||||
}
|
||||
|
||||
function formatDate(value: unknown, withTime = false) {
|
||||
if (!value || typeof value !== 'string') return 'Not set';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function openEdit(record: RecordRow) {
|
||||
@@ -72,7 +90,15 @@
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
return getRecordName(record, 'this invoice');
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(
|
||||
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,30 +107,20 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
|
||||
<p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p>
|
||||
</div>
|
||||
|
||||
<CreateInvoiceDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
|
||||
</div>
|
||||
|
||||
<ListSearch
|
||||
bind:value={listSearch.params.q}
|
||||
placeholder="Search invoices..."
|
||||
totalCount={data.records.length}
|
||||
resultCount={listSearch.filtered.length}
|
||||
/>
|
||||
|
||||
<InvoicesTable
|
||||
data={listData}
|
||||
{data}
|
||||
{openEdit}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
{formatDate}
|
||||
{formatMoney}
|
||||
{relationLabel}
|
||||
{recordName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import MoneyField from '$lib/components/money-field.svelte';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
open = $bindable(false),
|
||||
createForm,
|
||||
createData,
|
||||
enhanceCreate
|
||||
}: {
|
||||
open: boolean;
|
||||
createForm: any;
|
||||
createData: any;
|
||||
enhanceCreate: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props}><PlusIcon data-icon="inline-start" />Create invoice</Button>
|
||||
{/snippet}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create invoice</Dialog.Title>
|
||||
<Dialog.Description>Add a new invoice record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
|
||||
<Form.Field form={createForm} name="invoiceNumber">
|
||||
<Form.Control id="create-invoiceNumber">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Invoice number</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.invoiceNumber} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="issueDate">
|
||||
<Form.Control id="create-issueDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Issue date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$createData.issueDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="dueDate">
|
||||
<Form.Control id="create-dueDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Due date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$createData.dueDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={createForm} name="status">
|
||||
<Form.Control id="create-status">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Status</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
|
||||
<NativeSelect.Option value="sent">Sent</NativeSelect.Option>
|
||||
<NativeSelect.Option value="paid">Paid</NativeSelect.Option>
|
||||
<NativeSelect.Option value="overdue">Overdue</NativeSelect.Option>
|
||||
<NativeSelect.Option value="void">Void</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<MoneyField
|
||||
form={createForm}
|
||||
data={createData}
|
||||
name="subtotalGbp"
|
||||
label="Subtotal"
|
||||
id="create-subtotalGbp"
|
||||
/>
|
||||
<MoneyField
|
||||
form={createForm}
|
||||
data={createData}
|
||||
name="taxGbp"
|
||||
label="Tax"
|
||||
id="create-taxGbp"
|
||||
/>
|
||||
<MoneyField
|
||||
form={createForm}
|
||||
data={createData}
|
||||
name="totalGbp"
|
||||
label="Total"
|
||||
id="create-totalGbp"
|
||||
/>
|
||||
<Form.Field form={createForm} name="notes">
|
||||
<Form.Control id="create-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$createData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
>{/snippet}</Dialog.Close
|
||||
>
|
||||
<Button type="submit">Create invoice</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as Form from '$lib/components/ui/form/index.js';
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import FormSelect from '$lib/components/form-select.svelte';
|
||||
import MoneyField from '$lib/components/money-field.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
editingRecord,
|
||||
@@ -31,49 +32,62 @@
|
||||
{#if editingRecord}
|
||||
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<Form.Field form={editForm} name="invoiceNumber">
|
||||
<Form.Control id="edit-invoiceNumber">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Invoice number</Form.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.invoiceNumber} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="issueDate">
|
||||
<Form.Control id="edit-issueDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Issue date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$editData.issueDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="dueDate">
|
||||
<Form.Control id="edit-dueDate">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Due date</Form.Label>
|
||||
<Input {...props} type="date" bind:value={$editData.dueDate} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={editForm} name="status">
|
||||
<Form.Control id="edit-status">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Status</Form.Label>
|
||||
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
|
||||
<NativeSelect.Option value="">Not set</NativeSelect.Option>
|
||||
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
|
||||
<NativeSelect.Option value="sent">Sent</NativeSelect.Option>
|
||||
<NativeSelect.Option value="paid">Paid</NativeSelect.Option>
|
||||
<NativeSelect.Option value="overdue">Overdue</NativeSelect.Option>
|
||||
<NativeSelect.Option value="void">Void</NativeSelect.Option>
|
||||
</NativeSelect.Root>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={editForm} name="invoiceNumber">
|
||||
<Field.Field>
|
||||
<Control id="edit-invoiceNumber">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Invoice number</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.invoiceNumber} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="issueDate">
|
||||
<Field.Field>
|
||||
<Control id="edit-issueDate">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Issue date</Field.Label>
|
||||
<Input {...props} type="date" bind:value={$editData.issueDate} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="dueDate">
|
||||
<Field.Field>
|
||||
<Control id="edit-dueDate">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Due date</Field.Label>
|
||||
<Input {...props} type="date" bind:value={$editData.dueDate} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="status">
|
||||
<Field.Field>
|
||||
<Control id="edit-status">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Status</Field.Label>
|
||||
<FormSelect
|
||||
name="status"
|
||||
bind:value={$editData.status}
|
||||
options={[
|
||||
{ value: '', label: 'Not set' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'sent', label: 'Sent' },
|
||||
{ value: 'paid', label: 'Paid' },
|
||||
{ value: 'overdue', label: 'Overdue' },
|
||||
{ value: 'void', label: 'Void' }
|
||||
]}
|
||||
triggerProps={props}
|
||||
/>
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<MoneyField
|
||||
form={editForm}
|
||||
data={editData}
|
||||
@@ -89,15 +103,17 @@
|
||||
label="Total"
|
||||
id="edit-totalGbp"
|
||||
/>
|
||||
<Form.Field form={editForm} name="notes">
|
||||
<Form.Control id="edit-notes">
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>Notes</Form.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<FormField form={editForm} name="notes">
|
||||
<Field.Field>
|
||||
<Control id="edit-notes">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Notes</Field.Label>
|
||||
<Textarea {...props} bind:value={$editData.notes} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
formatValue,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
relationLabel,
|
||||
recordName
|
||||
}: {
|
||||
data: any;
|
||||
@@ -21,7 +20,6 @@
|
||||
formatValue: any;
|
||||
formatDate: any;
|
||||
formatMoney: any;
|
||||
relationLabel: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -32,7 +30,6 @@
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Invoice #</Table.Head>
|
||||
<Table.Head>Client</Table.Head>
|
||||
<Table.Head>Issued</Table.Head>
|
||||
<Table.Head>Due</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
@@ -44,7 +41,6 @@
|
||||
{#each data.records as record (record.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
||||
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
||||
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
||||
<Table.Cell class=""
|
||||
|
||||
Reference in New Issue
Block a user