feat: add billing and organization workflows

This commit is contained in:
2026-06-24 13:53:52 +01:00
parent 4fd81c923b
commit 44bfb083f9
97 changed files with 10966 additions and 2044 deletions
@@ -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>