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
+28
View File
@@ -0,0 +1,28 @@
# Clearity
Clearity is a workspace management system offering a comprehensive multi-occupancy management solution for office, lab and workspace portfolio management, and billing in a single, centralised platform.
This project is based inspired by Clarity Core by RA, for which more information can be found on their website here: https://ra-is.co.uk/clarity-core-workspace-management-system/. However, that application is based on an outdated architecture.
## Tech Stack
- Svelte
- SvelteKit
- Drizzle w/ libSQL
- [better-auth for authenticaton](https://better-auth.com/docs/introduction)
- [shadcn-svelte for UI components](https://www.shadcn-svelte.com/docs)
- [runed for helper functions](https://runed.dev/docs) (e.g. useUrlSearchParams)
## Deployment
Target for deployment is Cloudflare using wrangler.jsonc and the wrangler cli.
- Cloudflare Workers
- Cloudflare D1
- Cloudflare R2
## Semantics
Break large +page.svelte files into several constituent-component-files.svelte using kebab case naming conventions and stored in the same directory as the +page.svelte file unless it's used across several pages.
Keep abstractions as mimimal as possible to help ensure this project is human-readable.
+9
View File
@@ -0,0 +1,9 @@
ALTER TABLE `clients` ADD `industry` text;--> statement-breakpoint
ALTER TABLE `clients` DROP COLUMN `type`;--> statement-breakpoint
ALTER TABLE `clients` DROP COLUMN `status`;--> statement-breakpoint
ALTER TABLE `addresses` ADD `type` text DEFAULT 'primary' NOT NULL;--> statement-breakpoint
ALTER TABLE `addresses` ADD `line_3` text;--> statement-breakpoint
ALTER TABLE `addresses` DROP COLUMN `label`;--> statement-breakpoint
ALTER TABLE `addresses` DROP COLUMN `is_primary`;--> statement-breakpoint
ALTER TABLE `contacts` ADD `receives_invoices` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `contacts` ADD `receives_contracts` integer DEFAULT false NOT NULL;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `contacts` DROP COLUMN `notes`;
+28
View File
@@ -0,0 +1,28 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_contracts` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`client_id` text NOT NULL,
`room_id` text,
`service_id` text,
`start_date` text NOT NULL,
`end_date` text NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`notes` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`service_id`) REFERENCES `services`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
INSERT INTO `__new_contracts`("id", "organization_id", "client_id", "room_id", "service_id", "start_date", "end_date", "status", "notes", "created_at", "updated_at", "archived_at") SELECT "id", "organization_id", "client_id", NULL, NULL, "start_date", COALESCE(NULLIF("end_date", ''), "start_date"), CASE WHEN "status" = 'cancelled' THEN 'void' ELSE "status" END, "notes", "created_at", "updated_at", "archived_at" FROM `contracts`;--> statement-breakpoint
DROP TABLE `contracts`;--> statement-breakpoint
ALTER TABLE `__new_contracts` RENAME TO `contracts`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `contracts_organization_id_idx` ON `contracts` (`organization_id`);--> statement-breakpoint
CREATE INDEX `contracts_client_id_idx` ON `contracts` (`client_id`);--> statement-breakpoint
CREATE INDEX `contracts_room_id_idx` ON `contracts` (`room_id`);--> statement-breakpoint
CREATE INDEX `contracts_service_id_idx` ON `contracts` (`service_id`);
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `contracts` ADD `license_fee_gbp` real DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `contracts` ADD `deposit_gbp` real DEFAULT 0 NOT NULL;
+26
View File
@@ -0,0 +1,26 @@
ALTER TABLE `contracts` ADD `billed_to` text;--> statement-breakpoint
CREATE TABLE `invoice_lines` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`invoice_id` text NOT NULL,
`client_id` text NOT NULL,
`source_type` text NOT NULL,
`source_id` text NOT NULL,
`period_start` text NOT NULL,
`period_end` text NOT NULL,
`description` text NOT NULL,
`quantity` real DEFAULT 1 NOT NULL,
`unit_price_gbp` real DEFAULT 0 NOT NULL,
`total_gbp` real DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`archived_at` integer,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE restrict,
FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE restrict
);
--> statement-breakpoint
CREATE INDEX `invoice_lines_organization_id_idx` ON `invoice_lines` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoice_lines_invoice_id_idx` ON `invoice_lines` (`invoice_id`);--> statement-breakpoint
CREATE INDEX `invoice_lines_client_id_idx` ON `invoice_lines` (`client_id`);--> statement-breakpoint
CREATE INDEX `invoice_lines_source_idx` ON `invoice_lines` (`source_type`,`source_id`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -50,6 +50,41 @@
"when": 1780750080744, "when": 1780750080744,
"tag": "0006_cloudy_catseye", "tag": "0006_cloudy_catseye",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1781787818482,
"tag": "0007_numerous_invaders",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1781795846034,
"tag": "0008_violet_metal_master",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1781814809169,
"tag": "0009_safe_speed_demon",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1781816209764,
"tag": "0010_dazzling_hairball",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1782291600000,
"tag": "0011_billing_flow",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -2,6 +2,7 @@
"name": "clearity", "name": "clearity",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"packageManager": "pnpm@11.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+6
View File
@@ -1,3 +1,9 @@
allowBuilds:
'@prisma/client': true
better-sqlite3: true
esbuild: true
sharp: true
workerd: true
onlyBuiltDependencies: onlyBuiltDependencies:
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
- workerd - workerd
+2 -6
View File
@@ -9,10 +9,6 @@
title: 'Dashboard', title: 'Dashboard',
url: '/dashboard' url: '/dashboard'
}, },
{
title: 'Admins',
url: '/dashboard/admins'
},
{ {
title: 'Clients', title: 'Clients',
url: '/dashboard/clients' url: '/dashboard/clients'
@@ -38,8 +34,8 @@
url: '/dashboard/services' url: '/dashboard/services'
}, },
{ {
title: 'Invoices', title: 'Billing',
url: '/dashboard/invoices' url: '/dashboard/billing'
}, },
{ {
title: 'Contracts', title: 'Contracts',
+36
View File
@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { Label } from '$lib/components/ui/label/index.js';
let {
name,
value = $bindable('false'),
label,
description,
id
}: {
name: string;
value: string;
label: string;
description?: string;
id: string;
} = $props();
</script>
<input type="hidden" {name} {value} />
<Label
for={id}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg border p-3 hover:bg-accent/50 has-[[aria-checked=true]]:border-primary has-[[aria-checked=true]]:bg-accent/50"
>
<Checkbox
{id}
class="data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
bind:checked={() => value === 'true', (checked) => (value = checked ? 'true' : 'false')}
/>
<div class="grid gap-1.5 font-normal">
<p class="text-sm leading-none font-medium">{label}</p>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
</div>
</Label>
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import type { ComponentProps } from 'svelte';
import { tick } from 'svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Command from '$lib/components/ui/command/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import { cn } from '$lib/utils.js';
type Option = {
value: string;
label: string;
};
const emptyValue = '__form-combobox-empty__';
let {
name,
value = $bindable(''),
options,
placeholder = 'Select an option',
searchPlaceholder = 'Search...',
emptyMessage = 'No results found.',
triggerProps,
onValueChange
}: {
name: string;
value: string;
options: readonly Option[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
triggerProps?: ComponentProps<typeof Button>;
onValueChange?: (value: string) => void;
} = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
const selectedLabel = $derived(
options.find((option) => option.value === value)?.label ?? placeholder
);
function closeAndFocusTrigger() {
open = false;
tick().then(() => triggerRef.focus());
}
</script>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef}>
{#snippet child({ props })}
<Button
{...props}
{...triggerProps}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
class={cn(
'w-full justify-between font-normal',
!value && 'text-muted-foreground',
triggerProps?.class
)}
>
{selectedLabel}
<ChevronsUpDownIcon class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[var(--bits-popover-anchor-width)] p-0" align="start">
<Command.Root>
<Command.Input placeholder={searchPlaceholder} />
<Command.List>
<Command.Empty>{emptyMessage}</Command.Empty>
<Command.Group>
{#each options as option (option.value)}
<Command.Item
value={option.value || emptyValue}
keywords={[option.label]}
data-checked={value === option.value}
onSelect={() => {
value = option.value;
onValueChange?.(option.value);
closeAndFocusTrigger();
}}
>
{option.label}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
<input type="hidden" {name} {value} />
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import * as Select from '$lib/components/ui/select/index.js';
import { cn } from '$lib/utils.js';
type Option = {
value: string;
label: string;
};
const emptyValue = '__form-select-empty__';
let {
name,
value = $bindable(),
options,
placeholder = 'Select an option',
triggerProps
}: {
name: string;
value: string;
options: readonly Option[];
placeholder?: string;
triggerProps?: ComponentProps<typeof Select.Trigger>;
} = $props();
const selectedValue = $derived(value || emptyValue);
const selectedLabel = $derived(
options.find((option) => option.value === value)?.label ?? placeholder
);
</script>
<Select.Root
type="single"
value={selectedValue}
onValueChange={(nextValue) => {
value = nextValue === emptyValue ? '' : nextValue;
}}
>
<Select.Trigger {...triggerProps} class={cn('w-full', triggerProps?.class)}>
<span>{selectedLabel}</span>
</Select.Trigger>
<Select.Content>
{#each options as option (option.value)}
<Select.Item value={option.value || emptyValue}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<input type="hidden" {name} {value} />
+37 -25
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import GalleryVerticalEndIcon from '@lucide/svelte/icons/gallery-vertical-end'; import GalleryVerticalEndIcon from '@lucide/svelte/icons/gallery-vertical-end';
import * as Form from '$lib/components/ui/form/index.js'; import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js'; import * as Field from '$lib/components/ui/field/index.js';
import { superForm, type SuperValidated } from 'sveltekit-superforms'; import { superForm, type SuperValidated } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
@@ -9,7 +9,6 @@
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from '$lib/utils.js';
import { firstFormError } from '$lib/form-feedback';
import { loginSchema, type LoginSchema } from '$lib/schemas/auth'; import { loginSchema, type LoginSchema } from '$lib/schemas/auth';
let { let {
@@ -28,7 +27,7 @@
resetForm: false, resetForm: false,
onUpdated: ({ form }) => { onUpdated: ({ form }) => {
if (!form.valid) { if (!form.valid) {
toast.error(form.message ?? firstFormError(form.errors), { toast.error(form.message ?? firstError(form.errors), {
id: 'login-error', id: 'login-error',
position: 'top-center' position: 'top-center'
}); });
@@ -42,7 +41,17 @@
} }
}); });
const { form: formData, enhance } = loginForm; const { form: formData, enhance, tainted } = loginForm;
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.';
}
</script> </script>
<div class={cn('flex flex-col gap-6', className)} bind:this={ref} {...restProps}> <div class={cn('flex flex-col gap-6', className)} bind:this={ref} {...restProps}>
@@ -58,46 +67,49 @@
<span class="sr-only">Clearity</span> <span class="sr-only">Clearity</span>
</div> </div>
<h1 class="text-xl font-bold">Sign in to Clearity</h1> <h1 class="text-xl font-bold">Sign in to Clearity</h1>
<Field.Description>
Use the administrator account created for this workspace.
</Field.Description>
</div> </div>
<Form.Field form={loginForm} name="email"> <FormField form={loginForm} name="email">
<Form.Control id="email-{id}"> <Field.Field>
<Control id="email-{id}">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Email</Form.Label> <Field.Label>Email</Field.Label>
<Input <Input
{...props} {...props}
aria-invalid={$tainted?.email ? props['aria-invalid'] : undefined}
type="email" type="email"
autocomplete="email" autocomplete="email"
placeholder="admin@example.com" placeholder="admin@example.com"
bind:value={$formData.email} bind:value={$formData.email}
/> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> {#if $tainted?.email}
</Form.Field> <Field.Error><FieldErrors /></Field.Error>
<Form.Field form={loginForm} name="password"> {/if}
<Form.Control id="password-{id}"> </Field.Field>
</FormField>
<FormField form={loginForm} name="password">
<Field.Field>
<Control id="password-{id}">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Password</Form.Label> <Field.Label>Password</Field.Label>
<Input <Input
{...props} {...props}
aria-invalid={$tainted?.password ? props['aria-invalid'] : undefined}
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
bind:value={$formData.password} bind:value={$formData.password}
/> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> {#if $tainted?.password}
<Field.Description> <Field.Error><FieldErrors /></Field.Error>
New users are created manually by an administrator; public registration is disabled. {/if}
</Field.Description> </Field.Field>
</Form.Field> </FormField>
<Field.Field>
<Button type="submit" class="w-full">Sign in</Button> <Button type="submit" class="w-full">Sign in</Button>
</Field.Field>
</Field.Group> </Field.Group>
</form> </form>
<Field.Description class="px-6 text-center">
Business and sport centre management for clients, bookings, rooms, services, and invoices.
</Field.Description>
</div> </div>
+16 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 * as Field from '$lib/components/ui/field/index.js';
import * as InputGroup from '$lib/components/ui/input-group/index.js'; import * as InputGroup from '$lib/components/ui/input-group/index.js';
let { let {
@@ -8,20 +9,23 @@
data, data,
name, name,
label, label,
id id,
description
}: { }: {
form: any; form: any;
data: any; data: any;
name: string; name: string;
label: string; label: string;
id: string; id: string;
description?: string;
} = $props(); } = $props();
</script> </script>
<Form.Field {form} {name}> <FormField {form} {name}>
<Form.Control {id}> <Field.Field>
<Control {id}>
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>{label}</Form.Label> <Field.Label>{label}</Field.Label>
<InputGroup.Root> <InputGroup.Root>
<InputGroup.Addon> <InputGroup.Addon>
<InputGroup.Text>£</InputGroup.Text> <InputGroup.Text>£</InputGroup.Text>
@@ -37,6 +41,10 @@
/> />
</InputGroup.Root> </InputGroup.Root>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> {#if description}
</Form.Field> <Field.Description>{description}</Field.Description>
{/if}
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
@@ -0,0 +1,179 @@
<script lang="ts">
import LandmarkIcon from '@lucide/svelte/icons/landmark';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
type Organization = {
name: string;
workspace: string;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
region?: string | null;
postcode?: string | null;
country?: string | null;
bankName?: string | null;
bankAccountName?: string | null;
bankAccountNumber?: string | null;
bankSortCode?: string | null;
bankIban?: string | null;
bankSwift?: string | null;
};
let {
prefix,
organization
}: {
prefix: string;
organization?: Organization;
} = $props();
</script>
<div class="grid gap-5">
<div class="grid gap-3">
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-name`}>Name</Field.Label>
<Input
id={`${prefix}-name`}
name="name"
type="text"
value={organization?.name ?? ''}
required
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-workspace`}>Workspace</Field.Label>
<Input
id={`${prefix}-workspace`}
name="workspace"
type="text"
value={organization?.workspace ?? 'Blueprint Workspace'}
required
/>
</Field.Field>
</div>
<Field.Field>
<Field.Label for={`${prefix}-address-line-1`}>Address</Field.Label>
<Input
id={`${prefix}-address-line-1`}
name="addressLine1"
type="text"
value={organization?.addressLine1 ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-address-line-2`}>Address line 2</Field.Label>
<Input
id={`${prefix}-address-line-2`}
name="addressLine2"
type="text"
value={organization?.addressLine2 ?? ''}
/>
</Field.Field>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-city`}>City</Field.Label>
<Input id={`${prefix}-city`} name="city" type="text" value={organization?.city ?? ''} />
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-region`}>Region</Field.Label>
<Input
id={`${prefix}-region`}
name="region"
type="text"
value={organization?.region ?? ''}
/>
</Field.Field>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-postcode`}>Postcode</Field.Label>
<Input
id={`${prefix}-postcode`}
name="postcode"
type="text"
value={organization?.postcode ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-country`}>Country</Field.Label>
<Input
id={`${prefix}-country`}
name="country"
type="text"
value={organization?.country ?? 'United Kingdom'}
required
/>
</Field.Field>
</div>
</div>
<div class="grid gap-3">
<div class="flex items-center gap-2 text-sm font-medium">
<LandmarkIcon class="size-4" />
<span>Bank details</span>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-bank-name`}>Bank name</Field.Label>
<Input
id={`${prefix}-bank-name`}
name="bankName"
type="text"
value={organization?.bankName ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-bank-account-name`}>Account name</Field.Label>
<Input
id={`${prefix}-bank-account-name`}
name="bankAccountName"
type="text"
value={organization?.bankAccountName ?? ''}
/>
</Field.Field>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-bank-account-number`}>Account number</Field.Label>
<Input
id={`${prefix}-bank-account-number`}
name="bankAccountNumber"
type="text"
value={organization?.bankAccountNumber ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-bank-sort-code`}>Sort code</Field.Label>
<Input
id={`${prefix}-bank-sort-code`}
name="bankSortCode"
type="text"
value={organization?.bankSortCode ?? ''}
/>
</Field.Field>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-bank-iban`}>IBAN</Field.Label>
<Input
id={`${prefix}-bank-iban`}
name="bankIban"
type="text"
value={organization?.bankIban ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-bank-swift`}>SWIFT/BIC</Field.Label>
<Input
id={`${prefix}-bank-swift`}
name="bankSwift"
type="text"
value={organization?.bankSwift ?? ''}
/>
</Field.Field>
</div>
</div>
</div>
+19 -195
View File
@@ -2,15 +2,14 @@
import Building2Icon from '@lucide/svelte/icons/building-2'; import Building2Icon from '@lucide/svelte/icons/building-2';
import CheckIcon from '@lucide/svelte/icons/check'; import CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import LandmarkIcon from '@lucide/svelte/icons/landmark';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings'; import SettingsIcon from '@lucide/svelte/icons/settings';
import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import OrganizationFormFields from './organization-form-fields.svelte';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as Sidebar from '$lib/components/ui/sidebar/index.js';
type Organization = { type Organization = {
@@ -39,162 +38,11 @@
activeOrganization: Organization; activeOrganization: Organization;
} = $props(); } = $props();
let settingsOpen = $state(false);
let createOpen = $state(false); let createOpen = $state(false);
const redirectTo = $derived(page.url.pathname + page.url.search); const redirectTo = $derived(page.url.pathname + page.url.search);
const organizationSettingsUrl = resolve('/dashboard/organization/details');
</script> </script>
{#snippet organizationFields(prefix: string, organization?: Organization)}
<div class="grid gap-3">
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-name`}>Name</Field.Label>
<Input
id={`${prefix}-name`}
name="name"
type="text"
value={organization?.name ?? ''}
required
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-workspace`}>Workspace</Field.Label>
<Input
id={`${prefix}-workspace`}
name="workspace"
type="text"
value={organization?.workspace ?? 'Blueprint Workspace'}
required
/>
</Field.Field>
</div>
<Field.Field>
<Field.Label for={`${prefix}-address-line-1`}>Address</Field.Label>
<Input
id={`${prefix}-address-line-1`}
name="addressLine1"
type="text"
value={organization?.addressLine1 ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-address-line-2`}>Address line 2</Field.Label>
<Input
id={`${prefix}-address-line-2`}
name="addressLine2"
type="text"
value={organization?.addressLine2 ?? ''}
/>
</Field.Field>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-city`}>City</Field.Label>
<Input id={`${prefix}-city`} name="city" type="text" value={organization?.city ?? ''} />
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-region`}>Region</Field.Label>
<Input
id={`${prefix}-region`}
name="region"
type="text"
value={organization?.region ?? ''}
/>
</Field.Field>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-postcode`}>Postcode</Field.Label>
<Input
id={`${prefix}-postcode`}
name="postcode"
type="text"
value={organization?.postcode ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-country`}>Country</Field.Label>
<Input
id={`${prefix}-country`}
name="country"
type="text"
value={organization?.country ?? 'United Kingdom'}
required
/>
</Field.Field>
</div>
</div>
{/snippet}
{#snippet bankFields(prefix: string, organization?: Organization)}
<div class="grid gap-3">
<div class="flex items-center gap-2 text-sm font-medium">
<LandmarkIcon class="size-4" />
<span>Bank details</span>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-bank-name`}>Bank name</Field.Label>
<Input
id={`${prefix}-bank-name`}
name="bankName"
type="text"
value={organization?.bankName ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-bank-account-name`}>Account name</Field.Label>
<Input
id={`${prefix}-bank-account-name`}
name="bankAccountName"
type="text"
value={organization?.bankAccountName ?? ''}
/>
</Field.Field>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-bank-account-number`}>Account number</Field.Label>
<Input
id={`${prefix}-bank-account-number`}
name="bankAccountNumber"
type="text"
value={organization?.bankAccountNumber ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-bank-sort-code`}>Sort code</Field.Label>
<Input
id={`${prefix}-bank-sort-code`}
name="bankSortCode"
type="text"
value={organization?.bankSortCode ?? ''}
/>
</Field.Field>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Field.Field>
<Field.Label for={`${prefix}-bank-iban`}>IBAN</Field.Label>
<Input
id={`${prefix}-bank-iban`}
name="bankIban"
type="text"
value={organization?.bankIban ?? ''}
/>
</Field.Field>
<Field.Field>
<Field.Label for={`${prefix}-bank-swift`}>SWIFT/BIC</Field.Label>
<Input
id={`${prefix}-bank-swift`}
name="bankSwift"
type="text"
value={organization?.bankSwift ?? ''}
/>
</Field.Field>
</div>
</div>
{/snippet}
<Sidebar.Menu> <Sidebar.Menu>
<Sidebar.MenuItem> <Sidebar.MenuItem>
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -225,11 +73,12 @@
<input type="hidden" name="redirectTo" value={redirectTo} /> <input type="hidden" name="redirectTo" value={redirectTo} />
<DropdownMenu.Item <DropdownMenu.Item
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground data-[selected=true]:bg-sidebar-accent data-[selected=true]:text-sidebar-accent-foreground" class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground data-[selected=true]:bg-sidebar-accent data-[selected=true]:text-sidebar-accent-foreground"
data-organization-item
data-selected={organization.id === activeOrganization.id} data-selected={organization.id === activeOrganization.id}
> >
<button type="submit" class="contents"> <button type="submit" class="contents">
<div <div
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground group-focus/dropdown-menu-item:bg-sidebar-primary group-focus/dropdown-menu-item:text-sidebar-primary-foreground" class="relative z-10 flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground group-focus/dropdown-menu-item:bg-sidebar-primary group-focus/dropdown-menu-item:text-sidebar-primary-foreground"
> >
<Building2Icon class="size-4 !text-sidebar-primary-foreground" /> <Building2Icon class="size-4 !text-sidebar-primary-foreground" />
</div> </div>
@@ -250,13 +99,7 @@
<DropdownMenu.Item <DropdownMenu.Item
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground" class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground"
> >
<button <a href={organizationSettingsUrl} class="contents">
type="button"
class="contents"
onclick={() => {
settingsOpen = true;
}}
>
<div <div
class="flex aspect-square size-8 items-center justify-center rounded-lg border bg-background text-foreground" class="flex aspect-square size-8 items-center justify-center rounded-lg border bg-background text-foreground"
> >
@@ -264,9 +107,9 @@
</div> </div>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">Organization settings</span> <span class="truncate font-semibold">Organization settings</span>
<span class="truncate text-xs text-muted-foreground">Edit current organization</span> <span class="truncate text-xs text-muted-foreground">Details and administrators</span>
</div> </div>
</button> </a>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground" class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground"
@@ -294,34 +137,6 @@
</Sidebar.MenuItem> </Sidebar.MenuItem>
</Sidebar.Menu> </Sidebar.Menu>
<Dialog.Root bind:open={settingsOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Organization settings</Dialog.Title>
<Dialog.Description
>Update details for the active organization and invoice payments.</Dialog.Description
>
</Dialog.Header>
<form method="POST" action="/dashboard/organizations/current" class="grid gap-5">
<input type="hidden" name="id" value={activeOrganization.id} />
<input type="hidden" name="redirectTo" value={redirectTo} />
{@render organizationFields('organization-settings', activeOrganization)}
{@render bankFields('organization-settings', activeOrganization)}
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<Button type="submit">Save settings</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={createOpen}> <Dialog.Root bind:open={createOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl"> <Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header> <Dialog.Header>
@@ -334,8 +149,7 @@
<form method="POST" action="/dashboard/organizations" class="grid gap-5"> <form method="POST" action="/dashboard/organizations" class="grid gap-5">
<input type="hidden" name="redirectTo" value={redirectTo} /> <input type="hidden" name="redirectTo" value={redirectTo} />
{@render organizationFields('organization-create')} <OrganizationFormFields prefix="organization-create" />
{@render bankFields('organization-create')}
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close> <Dialog.Close>
@@ -348,3 +162,13 @@
</form> </form>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
<style>
:global([data-organization-item]:focus > button > div:first-child),
:global([data-organization-item][data-highlighted] > button > div:first-child),
:global([data-organization-item]:focus > button > div:first-child svg),
:global([data-organization-item][data-highlighted] > button > div:first-child svg) {
color: var(--sidebar-primary-foreground) !important;
stroke: var(--sidebar-primary-foreground) !important;
}
</style>
+46
View File
@@ -0,0 +1,46 @@
export const industries = [
'agriculture',
'mining-and-quarrying',
'manufacturing',
'utilities',
'construction',
'retail-and-wholesale',
'transport-and-storage',
'accommodation-and-food',
'information-and-communication',
'financial-services',
'real-estate',
'professional-and-technical',
'administration-and-support',
'public-administration-and-defence',
'education',
'health-and-social-care',
'arts-entertainment-and-recreation',
'other-services'
] as const;
export const industryOptions = [
{ value: '', label: 'Not set' },
{ value: 'agriculture', label: 'Agriculture' },
{ value: 'mining-and-quarrying', label: 'Mining and quarrying' },
{ value: 'manufacturing', label: 'Manufacturing' },
{ value: 'utilities', label: 'Utilities' },
{ value: 'construction', label: 'Construction' },
{ value: 'retail-and-wholesale', label: 'Retail and wholesale' },
{ value: 'transport-and-storage', label: 'Transport and storage' },
{ value: 'accommodation-and-food', label: 'Accommodation and food' },
{ value: 'information-and-communication', label: 'Information and communication' },
{ value: 'financial-services', label: 'Financial services' },
{ value: 'real-estate', label: 'Real estate' },
{ value: 'professional-and-technical', label: 'Professional and technical' },
{ value: 'administration-and-support', label: 'Administration and support' },
{ value: 'public-administration-and-defence', label: 'Public administration and defence' },
{ value: 'education', label: 'Education' },
{ value: 'health-and-social-care', label: 'Health and social care' },
{ value: 'arts-entertainment-and-recreation', label: 'Arts, entertainment and recreation' },
{ value: 'other-services', label: 'Other services' }
] as const;
export function industryLabel(value: string | null | undefined) {
return industryOptions.find((option) => option.value === value)?.label ?? 'Not set';
}
+4 -4
View File
@@ -1,16 +1,16 @@
import { z } from 'zod'; import { z } from 'zod';
import { booleanString, idSchema, optionalText, requiredText } from './shared.schema'; import { idSchema, optionalText, requiredText } from './shared.schema';
export const addressCreateSchema = z.object({ export const addressCreateSchema = z.object({
clientId: requiredText('Client'), clientId: requiredText('Client'),
label: requiredText('Label', 80), type: z.enum(['primary', 'invoicing', 'contract']).default('primary'),
line1: requiredText('Address line 1'), line1: requiredText('Address line 1'),
line2: optionalText(160), line2: optionalText(160),
line3: optionalText(160),
city: requiredText('City', 100), city: requiredText('City', 100),
region: optionalText(100), region: optionalText(100),
postcode: requiredText('Postcode', 24), postcode: requiredText('Postcode', 24),
country: requiredText('Country', 80).default('United Kingdom'), country: requiredText('Country', 80).default('United Kingdom')
isPrimary: booleanString
}); });
export const addressEditSchema = addressCreateSchema.extend(idSchema.shape); export const addressEditSchema = addressCreateSchema.extend(idSchema.shape);
+6 -4
View File
@@ -1,10 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
import { industries } from '$lib/constants/industries';
import { idSchema, optionalEmail, optionalText, optionalUrl, requiredText } from './shared.schema'; import { idSchema, optionalEmail, optionalText, optionalUrl, requiredText } from './shared.schema';
export const clientCreateSchema = z.object({ export const clientCreateSchema = z.object({
name: requiredText('Client name'), name: requiredText('Client name'),
type: z.enum(['business', 'sport', 'individual']).default('business'), industry: z.enum([...industries, '']).default(''),
status: z.enum(['active', 'prospect', 'paused']).default('active'),
website: optionalUrl, website: optionalUrl,
notes: optionalText(1000) notes: optionalText(1000)
}); });
@@ -16,10 +16,12 @@ export const clientOnboardingCreateSchema = clientCreateSchema.extend({
primaryContactRole: optionalText(120), primaryContactRole: optionalText(120),
primaryContactEmail: optionalEmail, primaryContactEmail: optionalEmail,
primaryContactPhone: optionalText(80), primaryContactPhone: optionalText(80),
primaryContactNotes: optionalText(1000), primaryContactReceivesInvoices: z.enum(['true', 'false']).default('false'),
primaryAddressLabel: requiredText('Address label', 80).default('Primary'), primaryContactReceivesContracts: z.enum(['true', 'false']).default('false'),
primaryAddressType: z.enum(['primary', 'invoicing', 'contract']).default('primary'),
primaryAddressLine1: requiredText('Address line 1'), primaryAddressLine1: requiredText('Address line 1'),
primaryAddressLine2: optionalText(160), primaryAddressLine2: optionalText(160),
primaryAddressLine3: optionalText(160),
primaryAddressCity: requiredText('City', 100), primaryAddressCity: requiredText('City', 100),
primaryAddressRegion: optionalText(100), primaryAddressRegion: optionalText(100),
primaryAddressPostcode: requiredText('Postcode', 24), primaryAddressPostcode: requiredText('Postcode', 24),
+2 -1
View File
@@ -14,7 +14,8 @@ export const contactCreateSchema = z.object({
email: optionalEmail, email: optionalEmail,
phone: optionalText(80), phone: optionalText(80),
isPrimary: booleanString, isPrimary: booleanString,
notes: optionalText(1000) receivesInvoices: booleanString,
receivesContracts: booleanString
}); });
export const contactEditSchema = contactCreateSchema.extend(idSchema.shape); export const contactEditSchema = contactCreateSchema.extend(idSchema.shape);
+31 -6
View File
@@ -1,14 +1,39 @@
import { z } from 'zod'; import { z } from 'zod';
import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema'; import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema';
export const contractCreateSchema = z.object({ export const contractStatuses = ['draft', 'active', 'expired', 'void'] as const;
export const contractCreateSchema = z
.object({
clientId: requiredText('Client'), clientId: requiredText('Client'),
title: requiredText('Contract title'), roomId: optionalText(80),
serviceId: optionalText(80),
licenseFeeGbp: moneyGbp,
depositGbp: moneyGbp,
startDate: requiredText('Start date', 40), startDate: requiredText('Start date', 40),
endDate: optionalText(40), endDate: requiredText('End date', 40),
status: z.enum(['draft', 'active', 'expired', 'cancelled']).default('draft'),
valueGbp: moneyGbp,
notes: optionalText(1000) notes: optionalText(1000)
})
.superRefine((data, ctx) => {
if (!data.roomId && !data.serviceId) {
ctx.addIssue({
code: 'custom',
path: ['roomId'],
message: 'Select a room or service.'
});
}
if (data.startDate && data.endDate && data.endDate < data.startDate) {
ctx.addIssue({
code: 'custom',
path: ['endDate'],
message: 'End date must be on or after the start date.'
});
}
}); });
export const contractEditSchema = contractCreateSchema.extend(idSchema.shape); export const contractEditSchema = contractCreateSchema.safeExtend(idSchema.shape);
export const contractTransitionSchema = idSchema.extend({
targetStatus: z.enum(['active', 'expired', 'void'])
});
+3 -3
View File
@@ -1,4 +1,4 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { index, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema'; import { clients } from './clients.schema';
import { organizations } from './organizations.schema'; import { organizations } from './organizations.schema';
import { timestamps } from './shared.schema'; import { timestamps } from './shared.schema';
@@ -15,14 +15,14 @@ export const addresses = sqliteTable(
clientId: text('client_id') clientId: text('client_id')
.notNull() .notNull()
.references(() => clients.id, { onDelete: 'restrict' }), .references(() => clients.id, { onDelete: 'restrict' }),
label: text('label').notNull(), type: text('type').notNull().default('primary'),
line1: text('line_1').notNull(), line1: text('line_1').notNull(),
line2: text('line_2'), line2: text('line_2'),
line3: text('line_3'),
city: text('city').notNull(), city: text('city').notNull(),
region: text('region'), region: text('region'),
postcode: text('postcode').notNull(), postcode: text('postcode').notNull(),
country: text('country').notNull().default('United Kingdom'), country: text('country').notNull().default('United Kingdom'),
isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false),
...timestamps ...timestamps
}, },
(table) => [ (table) => [
+1 -2
View File
@@ -10,8 +10,7 @@ export const clients = sqliteTable('clients', {
.notNull() .notNull()
.references(() => organizations.id, { onDelete: 'restrict' }), .references(() => organizations.id, { onDelete: 'restrict' }),
name: text('name').notNull(), name: text('name').notNull(),
type: text('type').notNull().default('business'), industry: text('industry'),
status: text('status').notNull().default('active'),
website: text('website'), website: text('website'),
notes: text('notes'), notes: text('notes'),
...timestamps ...timestamps
+2 -1
View File
@@ -20,7 +20,8 @@ export const contacts = sqliteTable(
email: text('email'), email: text('email'),
phone: text('phone'), phone: text('phone'),
isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false), isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false),
notes: text('notes'), receivesInvoices: integer('receives_invoices', { mode: 'boolean' }).notNull().default(false),
receivesContracts: integer('receives_contracts', { mode: 'boolean' }).notNull().default(false),
...timestamps ...timestamps
}, },
(table) => [ (table) => [
+11 -4
View File
@@ -1,6 +1,8 @@
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema'; import { clients } from './clients.schema';
import { organizations } from './organizations.schema'; import { organizations } from './organizations.schema';
import { rooms } from './rooms.schema';
import { services } from './services.schema';
import { timestamps } from './shared.schema'; import { timestamps } from './shared.schema';
export const contracts = sqliteTable( export const contracts = sqliteTable(
@@ -15,16 +17,21 @@ export const contracts = sqliteTable(
clientId: text('client_id') clientId: text('client_id')
.notNull() .notNull()
.references(() => clients.id, { onDelete: 'restrict' }), .references(() => clients.id, { onDelete: 'restrict' }),
title: text('title').notNull(), roomId: text('room_id').references(() => rooms.id, { onDelete: 'restrict' }),
serviceId: text('service_id').references(() => services.id, { onDelete: 'restrict' }),
licenseFeeGbp: real('license_fee_gbp').notNull().default(0),
depositGbp: real('deposit_gbp').notNull().default(0),
startDate: text('start_date').notNull(), startDate: text('start_date').notNull(),
endDate: text('end_date'), endDate: text('end_date').notNull(),
billedTo: text('billed_to'),
status: text('status').notNull().default('draft'), status: text('status').notNull().default('draft'),
valueGbp: real('value_gbp').notNull().default(0),
notes: text('notes'), notes: text('notes'),
...timestamps ...timestamps
}, },
(table) => [ (table) => [
index('contracts_organization_id_idx').on(table.organizationId), index('contracts_organization_id_idx').on(table.organizationId),
index('contracts_client_id_idx').on(table.clientId) index('contracts_client_id_idx').on(table.clientId),
index('contracts_room_id_idx').on(table.roomId),
index('contracts_service_id_idx').on(table.serviceId)
] ]
); );
+33
View File
@@ -30,3 +30,36 @@ export const invoices = sqliteTable(
index('invoices_client_id_idx').on(table.clientId) index('invoices_client_id_idx').on(table.clientId)
] ]
); );
export const invoiceLines = sqliteTable(
'invoice_lines',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
organizationId: text('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'restrict' }),
invoiceId: text('invoice_id')
.notNull()
.references(() => invoices.id, { onDelete: 'cascade' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'restrict' }),
sourceType: text('source_type').notNull(),
sourceId: text('source_id').notNull(),
periodStart: text('period_start').notNull(),
periodEnd: text('period_end').notNull(),
description: text('description').notNull(),
quantity: real('quantity').notNull().default(1),
unitPriceGbp: real('unit_price_gbp').notNull().default(0),
totalGbp: real('total_gbp').notNull().default(0),
...timestamps
},
(table) => [
index('invoice_lines_organization_id_idx').on(table.organizationId),
index('invoice_lines_invoice_id_idx').on(table.invoiceId),
index('invoice_lines_client_id_idx').on(table.clientId),
index('invoice_lines_source_idx').on(table.sourceType, table.sourceId)
]
);
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import HomeIcon from '@lucide/svelte/icons/home';
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button/index.js';
const status = $derived(page.status);
const message = $derived(page.error?.message ?? 'Something went wrong.');
const currentPath = $derived(page.url.pathname);
const title = $derived(status === 404 ? message : 'Something went wrong');
const description = $derived(
status === 404
? 'This item may be archived, deleted, or unavailable in the active organization.'
: 'The dashboard could not finish loading this view.'
);
</script>
<svelte:head>
<title>{status} | Clearity</title>
</svelte:head>
<div class="flex min-h-[calc(100vh-8rem)] items-start justify-center pt-12">
<div class="grid w-full max-w-md gap-5 text-center">
<div class="grid gap-2">
<p class="text-sm font-medium text-muted-foreground">{status}</p>
<h1 class="text-2xl font-semibold tracking-tight">{title}</h1>
<p class="text-sm text-muted-foreground">{description}</p>
</div>
<div class="flex justify-center gap-2">
<Button href={resolve('/dashboard')} variant="outline">
<HomeIcon data-icon="inline-start" />
Dashboard
</Button>
<Button href={currentPath}>
<RefreshCwIcon data-icon="inline-start" />
Retry
</Button>
</div>
</div>
</div>
+3
View File
@@ -14,6 +14,8 @@
const segmentLabels: Record<string, string> = { const segmentLabels: Record<string, string> = {
dashboard: 'Dashboard', dashboard: 'Dashboard',
organization: 'Organization',
details: 'Details',
admins: 'Admins', admins: 'Admins',
clients: 'Clients', clients: 'Clients',
addresses: 'Addresses', addresses: 'Addresses',
@@ -21,6 +23,7 @@
bookings: 'Bookings', bookings: 'Bookings',
rooms: 'Rooms', rooms: 'Rooms',
services: 'Services', services: 'Services',
billing: 'Billing',
invoices: 'Invoices', invoices: 'Invoices',
contracts: 'Contracts' contracts: 'Contracts'
}; };
@@ -25,12 +25,15 @@ export const load: PageServerLoad = async ({ locals }) => {
.select() .select()
.from(addresses) .from(addresses)
.where(and(eq(addresses.organizationId, activeOrganizationId), isNull(addresses.archivedAt))) .where(and(eq(addresses.organizationId, activeOrganizationId), isNull(addresses.archivedAt)))
.orderBy(asc(addresses.label)); .orderBy(asc(addresses.type));
return { return {
records, records,
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' }) archiveForm: await superValidate(zod4(archiveSchema), {
id: 'addresses-archive',
errors: false
})
}; };
}; };
@@ -20,11 +20,10 @@
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Client</Table.Head> <Table.Head>Client</Table.Head>
<Table.Head>Label</Table.Head> <Table.Head>Type</Table.Head>
<Table.Head>Address</Table.Head> <Table.Head>Address</Table.Head>
<Table.Head>City</Table.Head> <Table.Head>City</Table.Head>
<Table.Head>Postcode</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.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -32,11 +31,10 @@
{#each data.records as record (record.id)} {#each data.records as record (record.id)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell> <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.line1)}</Table.Cell>
<Table.Cell class="">{formatValue(record.city)}</Table.Cell> <Table.Cell class="">{formatValue(record.city)}</Table.Cell>
<Table.Cell class="">{formatValue(record.postcode)}</Table.Cell> <Table.Cell class="">{formatValue(record.postcode)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,91 +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 { 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,
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 user
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Create user</Dialog.Title>
<Dialog.Description>Add a manually managed Better Auth user.</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} autocomplete="name" bind:value={$createData.name} />
{/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" autocomplete="email" bind:value={$createData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="password">
<Form.Control id="create-password">
{#snippet children({ props })}
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
bind:value={$createData.password}
/>
{/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>
<NativeSelect.Root {...props} bind:value={$createData.role} class="w-full">
<NativeSelect.Option value="admin">Admin</NativeSelect.Option>
<NativeSelect.Option value="user">User</NativeSelect.Option>
</NativeSelect.Root>
{/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 user</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -1,88 +0,0 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/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 {
editingUser,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingUser: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingUser} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Edit user</Dialog.Title>
<Dialog.Description>Update account details or set a new password.</Dialog.Description>
</Dialog.Header>
{#if editingUser}
<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} autocomplete="name" bind:value={$editData.name} />
{/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" autocomplete="email" bind:value={$editData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="password">
<Form.Control id="edit-password">
{#snippet children({ props })}
<Form.Label>New password</Form.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
placeholder="Leave blank to keep current password"
bind:value={$editData.password}
/>
{/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>
<NativeSelect.Root {...props} bind:value={$editData.role} class="w-full">
<NativeSelect.Option value="admin">Admin</NativeSelect.Option>
<NativeSelect.Option value="user">User</NativeSelect.Option>
</NativeSelect.Root>
{/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">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,526 @@
import { fail } from '@sveltejs/kit';
import { and, asc, desc, eq, gte, isNull, like, lte, ne } from 'drizzle-orm';
import { createSearchParamsSchema, validateSearchParams } from 'runed/kit';
import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db';
import {
bookings,
clients,
contracts,
invoiceLines,
invoices,
rooms,
services
} from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types';
type BillingLine = {
id: string;
clientId: string;
clientName: string;
sourceType: 'booking-room' | 'booking-service' | 'contract';
sourceId: string;
periodStart: string;
periodEnd: string;
description: string;
quantity: number;
unitPriceGbp: number;
totalGbp: number;
};
type BillingClient = {
clientId: string;
clientName: string;
totalGbp: number;
lineCount: number;
lines: BillingLine[];
};
const dayMs = 24 * 60 * 60 * 1000;
const billingTermDays = 12;
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));
return {
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
};
}
function toDateOnly(date: Date) {
return date.toISOString().slice(0, 10);
}
function parseDateOnly(value: string) {
const [year, month, day] = value.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
function addMonths(date: Date, months: number) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1));
}
function endOfMonth(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0));
}
function startOfMonth(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
}
function minDate(a: Date, b: Date) {
return a.getTime() <= b.getTime() ? a : b;
}
function maxDate(a: Date, b: Date) {
return a.getTime() >= b.getTime() ? a : b;
}
function daysInclusive(start: Date, end: Date) {
return Math.floor((end.getTime() - start.getTime()) / dayMs) + 1;
}
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function roundQuantity(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function monthLabel(date: Date) {
return new Intl.DateTimeFormat('en-GB', {
month: 'short',
year: 'numeric',
timeZone: 'UTC'
}).format(date);
}
function defaultInvoiceDate(today = new Date()) {
return toDateOnly(new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 2)));
}
function billingSearchParamsSchema() {
return createSearchParamsSchema({
billingDate: {
type: 'date',
default: parseDateOnly(defaultInvoiceDate()),
dateFormat: 'date'
}
});
}
function isDateOnly(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return toDateOnly(parseDateOnly(value)) === value;
}
function normalizeInvoiceDate(value: FormDataEntryValue | string | null) {
if (typeof value !== 'string' || !isDateOnly(value)) return defaultInvoiceDate();
return value;
}
function invoiceDateFromUrl(url: URL) {
const { data } = validateSearchParams(url, billingSearchParamsSchema());
return toDateOnly(data.billingDate);
}
function billingCutoffs(invoiceDate: string) {
const selectedDate = parseDateOnly(invoiceDate);
const thisMonth = new Date(
Date.UTC(selectedDate.getUTCFullYear(), selectedDate.getUTCMonth(), 1)
);
const contractsCutoff = endOfMonth(addMonths(thisMonth, 1));
const servicesCutoff = endOfMonth(addMonths(thisMonth, -1));
return {
contractsCutoff: toDateOnly(contractsCutoff),
servicesCutoff: toDateOnly(servicesCutoff)
};
}
function billingDueDate(invoiceDate: string) {
const selectedDate = parseDateOnly(invoiceDate);
return toDateOnly(new Date(selectedDate.getTime() + billingTermDays * dayMs));
}
function sourceKey(line: {
sourceType: string;
sourceId: string;
periodStart: string;
periodEnd: string;
}) {
return `${line.sourceType}:${line.sourceId}:${line.periodStart}:${line.periodEnd}`;
}
function lineId(line: Pick<BillingLine, 'sourceType' | 'sourceId' | 'periodStart' | 'periodEnd'>) {
return sourceKey(line);
}
function groupBillingLines(lines: BillingLine[]) {
const clientsById = new Map<string, BillingClient>();
for (const line of lines) {
const client = clientsById.get(line.clientId) ?? {
clientId: line.clientId,
clientName: line.clientName,
totalGbp: 0,
lineCount: 0,
lines: []
};
client.lines.push(line);
client.lineCount += 1;
client.totalGbp = roundMoney(client.totalGbp + line.totalGbp);
clientsById.set(line.clientId, client);
}
return [...clientsById.values()].sort((a, b) => a.clientName.localeCompare(b.clientName));
}
function bookingPrice(row: {
startsAt: string;
endsAt: string;
internalPricePerHourGbp: number | null;
externalPricePerHourGbp: number | null;
internalPricePerDayGbp: number | null;
externalPricePerDayGbp: number | null;
}) {
const startsAt = new Date(row.startsAt);
const endsAt = new Date(row.endsAt);
const durationHours = Math.max((endsAt.getTime() - startsAt.getTime()) / (60 * 60 * 1000), 0);
const dayRate = row.externalPricePerDayGbp ?? row.internalPricePerDayGbp ?? 0;
const hourRate = row.externalPricePerHourGbp ?? row.internalPricePerHourGbp ?? 0;
if (dayRate > 0 && durationHours >= 7) {
const quantity = Math.max(1, Math.ceil(durationHours / 24));
return { quantity, unitPriceGbp: dayRate, totalGbp: roundMoney(quantity * dayRate) };
}
const quantity = roundQuantity(durationHours);
return { quantity, unitPriceGbp: hourRate, totalGbp: roundMoney(quantity * hourRate) };
}
async function buildBillingPreview(organizationId: string, invoiceDate: string) {
const cutoffs = billingCutoffs(invoiceDate);
const existingLines = await db
.select({
sourceType: invoiceLines.sourceType,
sourceId: invoiceLines.sourceId,
periodStart: invoiceLines.periodStart,
periodEnd: invoiceLines.periodEnd
})
.from(invoiceLines)
.where(and(eq(invoiceLines.organizationId, organizationId), isNull(invoiceLines.archivedAt)));
const billedSources = new Set(existingLines.map(sourceKey));
const lines: BillingLine[] = [];
const bookingRows = await db
.select({
id: bookings.id,
clientId: bookings.clientId,
clientName: clients.name,
roomName: rooms.name,
roomType: rooms.type,
startsAt: bookings.startsAt,
endsAt: bookings.endsAt,
serviceId: bookings.serviceId,
serviceName: services.name,
servicePriceGbp: services.priceGbp,
internalPricePerHourGbp: rooms.internalPricePerHourGbp,
externalPricePerHourGbp: rooms.externalPricePerHourGbp,
internalPricePerDayGbp: rooms.internalPricePerDayGbp,
externalPricePerDayGbp: rooms.externalPricePerDayGbp
})
.from(bookings)
.innerJoin(clients, eq(bookings.clientId, clients.id))
.innerJoin(rooms, eq(bookings.roomId, rooms.id))
.leftJoin(services, eq(bookings.serviceId, services.id))
.where(
and(
eq(bookings.organizationId, organizationId),
lte(bookings.startsAt, `${cutoffs.servicesCutoff}T23:59`),
ne(bookings.status, 'cancelled'),
isNull(bookings.archivedAt)
)
)
.orderBy(asc(bookings.startsAt));
for (const booking of bookingRows) {
const periodStart = booking.startsAt.slice(0, 10);
const periodEnd = booking.endsAt.slice(0, 10);
if (booking.roomType === 'meeting_room') {
const price = bookingPrice(booking);
const line = {
id: '',
clientId: booking.clientId,
clientName: booking.clientName,
sourceType: 'booking-room' as const,
sourceId: booking.id,
periodStart,
periodEnd,
description: `Meeting room: ${booking.roomName} (${periodStart})`,
...price
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
if (booking.serviceId && booking.serviceName) {
const line = {
id: '',
clientId: booking.clientId,
clientName: booking.clientName,
sourceType: 'booking-service' as const,
sourceId: booking.id,
periodStart,
periodEnd,
description: `Service: ${booking.serviceName} (${periodStart})`,
quantity: 1,
unitPriceGbp: booking.servicePriceGbp ?? 0,
totalGbp: roundMoney(booking.servicePriceGbp ?? 0)
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
}
const contractRows = await db
.select({
id: contracts.id,
clientId: contracts.clientId,
clientName: clients.name,
roomName: rooms.name,
serviceName: services.name,
licenseFeeGbp: contracts.licenseFeeGbp,
startDate: contracts.startDate,
endDate: contracts.endDate
})
.from(contracts)
.innerJoin(clients, eq(contracts.clientId, clients.id))
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(
and(
eq(contracts.organizationId, organizationId),
eq(contracts.status, 'active'),
lte(contracts.startDate, cutoffs.contractsCutoff),
gte(contracts.endDate, '1900-01-01'),
isNull(contracts.archivedAt)
)
)
.orderBy(asc(contracts.startDate));
const contractCutoff = parseDateOnly(cutoffs.contractsCutoff);
for (const contract of contractRows) {
const contractStart = parseDateOnly(contract.startDate);
const contractEnd = minDate(parseDateOnly(contract.endDate), contractCutoff);
if (contractEnd.getTime() < contractStart.getTime() || contract.licenseFeeGbp <= 0) continue;
for (
let month = startOfMonth(contractStart);
month.getTime() <= contractEnd.getTime();
month = addMonths(month, 1)
) {
const periodStartDate = maxDate(month, contractStart);
const periodEndDate = minDate(endOfMonth(month), contractEnd);
if (periodEndDate.getTime() < periodStartDate.getTime()) continue;
const periodStart = toDateOnly(periodStartDate);
const periodEnd = toDateOnly(periodEndDate);
const monthDays = daysInclusive(startOfMonth(month), endOfMonth(month));
const billedDays = daysInclusive(periodStartDate, periodEndDate);
const quantity = roundQuantity(billedDays / monthDays);
const labelParts = [contract.roomName, contract.serviceName].filter(Boolean);
const line = {
id: '',
clientId: contract.clientId,
clientName: contract.clientName,
sourceType: 'contract' as const,
sourceId: contract.id,
periodStart,
periodEnd,
description: `Contract: ${labelParts.join(' + ') || 'monthly fee'} (${monthLabel(month)})`,
quantity,
unitPriceGbp: contract.licenseFeeGbp,
totalGbp: roundMoney(quantity * contract.licenseFeeGbp)
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
}
lines.sort(
(a, b) => a.clientName.localeCompare(b.clientName) || a.periodStart.localeCompare(b.periodStart)
);
return {
...cutoffs,
invoiceDate,
dueDate: billingDueDate(invoiceDate),
lines,
clients: groupBillingLines(lines),
totalGbp: roundMoney(lines.reduce((total, line) => total + line.totalGbp, 0))
};
}
function invoiceNumberPrefix(invoiceDate: string) {
const date = parseDateOnly(invoiceDate);
return `INV-${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
}
async function latestInvoiceSequence(organizationId: string, prefix: string) {
const [latest] = await db
.select({ invoiceNumber: invoices.invoiceNumber })
.from(invoices)
.where(
and(eq(invoices.organizationId, organizationId), like(invoices.invoiceNumber, `${prefix}-%`))
)
.orderBy(desc(invoices.invoiceNumber))
.limit(1);
return latest?.invoiceNumber ? Number(latest.invoiceNumber.split('-').at(-1) ?? 0) : 0;
}
export const load: PageServerLoad = async ({ locals, url }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const invoiceDate = invoiceDateFromUrl(url);
const records = await db
.select()
.from(invoices)
.where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt)))
.orderBy(asc(invoices.invoiceNumber));
return {
records,
options: await loadOptions(activeOrganizationId),
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
};
};
export const actions: Actions = {
runBilling: async ({ locals, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const formData = await request.formData();
const invoiceDate = normalizeInvoiceDate(formData.get('billingDate'));
let includedLineIds: unknown;
try {
includedLineIds = JSON.parse(String(formData.get('includedLineIds') ?? '[]'));
} catch {
return fail(400, { message: 'Choose valid billing lines.' });
}
if (!Array.isArray(includedLineIds) || includedLineIds.some((id) => typeof id !== 'string')) {
return fail(400, { message: 'Choose valid billing lines.' });
}
const includedIds = new Set(includedLineIds);
const preview = await buildBillingPreview(activeOrganizationId, invoiceDate);
const includedLines = preview.lines.filter((line) => includedIds.has(line.id));
if (includedLines.length === 0) return fail(400, { message: 'There is nothing to bill.' });
const groupedClients = groupBillingLines(includedLines);
const issueDate = invoiceDate;
const dueDate = billingDueDate(invoiceDate);
const invoicePrefix = invoiceNumberPrefix(invoiceDate);
const latestSequence = await latestInvoiceSequence(activeOrganizationId, invoicePrefix);
await db.transaction(async (tx) => {
let invoiceOffset = 0;
for (const client of groupedClients) {
const invoiceId = crypto.randomUUID();
const invoiceNumber = `${invoicePrefix}-${String(latestSequence + invoiceOffset + 1).padStart(3, '0')}`;
invoiceOffset += 1;
await tx.insert(invoices).values({
id: invoiceId,
organizationId: activeOrganizationId,
clientId: client.clientId,
invoiceNumber,
issueDate,
dueDate,
status: 'draft',
subtotalGbp: client.totalGbp,
taxGbp: 0,
totalGbp: client.totalGbp,
notes: `Generated from billing run. Contract billing through ${preview.contractsCutoff}; services through ${preview.servicesCutoff}.`,
createdAt: new Date(),
updatedAt: new Date()
});
await tx.insert(invoiceLines).values(
client.lines.map((line) => ({
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
invoiceId,
clientId: client.clientId,
sourceType: line.sourceType,
sourceId: line.sourceId,
periodStart: line.periodStart,
periodEnd: line.periodEnd,
description: line.description,
quantity: line.quantity,
unitPriceGbp: line.unitPriceGbp,
totalGbp: line.totalGbp,
createdAt: new Date(),
updatedAt: new Date()
}))
);
}
const contractBilledTo = new Map<string, string>();
for (const line of includedLines) {
if (line.sourceType !== 'contract') continue;
const current = contractBilledTo.get(line.sourceId);
if (!current || line.periodEnd > current)
contractBilledTo.set(line.sourceId, line.periodEnd);
}
for (const [contractId, billedTo] of contractBilledTo) {
await tx
.update(contracts)
.set({ billedTo, updatedAt: new Date() })
.where(
and(eq(contracts.id, contractId), eq(contracts.organizationId, activeOrganizationId))
);
}
});
return {
message: `Billing run complete. Created ${groupedClients.length} invoice${groupedClients.length === 1 ? '' : 's'}.`
};
},
archive: async (event) => {
const { activeOrganizationId } = await loadOrganizationContext(event.locals);
const form = await superValidate(event, zod4(archiveSchema), { id: 'invoices-archive' });
if (!form.valid) return message(form, 'Invoice id is required.', { status: 400 });
await db
.update(invoices)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(and(eq(invoices.id, form.data.id), eq(invoices.organizationId, activeOrganizationId)));
return message(form, 'Invoice archived.');
}
};
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import InvoicesTable from './invoices-table.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import RunBillingDialog from './run-billing-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? 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 openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
);
}
</script>
<svelte:head>
<title>Billing | Clearity</title>
</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>
<h1 class="text-2xl font-semibold tracking-tight">Billing</h1>
<p class="text-sm text-muted-foreground">Run billing and manage generated invoices.</p>
</div>
<RunBillingDialog
preview={data.billingPreview}
{formatMoney}
formatDate={(value) => formatDate(value)}
/>
</div>
<InvoicesTable
{data}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,359 @@
<script lang="ts">
import CalendarIcon from '@lucide/svelte/icons/calendar';
import ReceiptTextIcon from '@lucide/svelte/icons/receipt-text';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { enhance } from '$app/forms';
import { parseDate, type DateValue } from '@internationalized/date';
import type { SubmitFunction } from '@sveltejs/kit';
import { createSearchParamsSchema, useSearchParams } from 'runed/kit';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button/index.js';
import * as Calendar from '$lib/components/ui/calendar/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import * as Table from '$lib/components/ui/table/index.js';
type BillingLine = {
id: string;
clientId: string;
clientName: string;
sourceType: string;
periodStart: string;
periodEnd: string;
description: string;
quantity: number;
unitPriceGbp: number;
totalGbp: number;
};
type BillingClient = {
clientId: string;
clientName: string;
totalGbp: number;
lineCount: number;
lines: BillingLine[];
};
let {
preview,
formatMoney,
formatDate
}: {
preview: {
contractsCutoff: string;
servicesCutoff: string;
invoiceDate: string;
dueDate: string;
lines: BillingLine[];
clients: BillingClient[];
totalGbp: number;
};
formatMoney: (value: unknown) => string;
formatDate: (value: unknown) => string;
} = $props();
const searchParamsSchema = createSearchParamsSchema({
billingDate: {
type: 'date',
default: defaultInvoiceDate(),
dateFormat: 'date'
}
});
const searchParams = useSearchParams(searchParamsSchema, { pushHistory: false, noScroll: true });
let open = $state(false);
let datePickerOpen = $state(false);
let detailOpen = $state(false);
let selectedClientId = $state<string | null>(null);
let excludedClientIds = $state<string[]>([]);
let excludedLineIds = $state<string[]>([]);
const invoiceDateString = $derived(dateToString(searchParams.billingDate));
const invoiceDateValue = $derived(dateValueFromString(invoiceDateString));
const includedLineIds = $derived.by(() =>
preview.lines
.filter(
(line) => !excludedClientIds.includes(line.clientId) && !excludedLineIds.includes(line.id)
)
.map((line) => line.id)
);
const includedClients = $derived.by(() => {
return preview.clients
.map((client) => {
if (excludedClientIds.includes(client.clientId)) return null;
const lines = client.lines.filter((line) => !excludedLineIds.includes(line.id));
const totalGbp = roundMoney(lines.reduce((total, line) => total + line.totalGbp, 0));
return {
...client,
lines,
lineCount: lines.length,
totalGbp
};
})
.filter((client): client is BillingClient => !!client && client.lineCount > 0);
});
const includedTotal = $derived(
roundMoney(includedClients.reduce((total, client) => total + client.totalGbp, 0))
);
const selectedClient = $derived(
includedClients.find((client) => client.clientId === selectedClientId)
);
const enhanceBilling: SubmitFunction<{ message?: string }, { message?: string }> = () => {
return async ({ result, update }) => {
if (result.type === 'success') {
toast.success(String(result.data?.message ?? 'Billing run complete.'), {
id: 'run-billing'
});
open = false;
detailOpen = false;
selectedClientId = null;
excludedClientIds = [];
excludedLineIds = [];
await update();
return;
}
if (result.type === 'failure') {
toast.error(String(result.data?.message ?? 'Unable to complete billing run.'), {
id: 'run-billing'
});
return;
}
if (result.type === 'error') {
toast.error(result.error.message, { id: 'run-billing' });
}
};
};
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function dateFromString(value: string) {
return new Date(`${value}T00:00:00.000Z`);
}
function defaultInvoiceDate() {
const today = new Date();
return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 2));
}
function dateToString(value: Date) {
return value.toISOString().slice(0, 10);
}
function dateValueFromString(value: string) {
try {
return parseDate(value);
} catch {
return parseDate(preview.invoiceDate);
}
}
function changeInvoiceDate(value: DateValue | undefined) {
if (!value) return;
selectedClientId = null;
detailOpen = false;
excludedClientIds = [];
excludedLineIds = [];
searchParams.billingDate = dateFromString(value.toString());
datePickerOpen = false;
}
function openClient(clientId: string) {
selectedClientId = clientId;
detailOpen = true;
}
function removeClient(clientId: string) {
if (!excludedClientIds.includes(clientId)) excludedClientIds = [...excludedClientIds, clientId];
if (selectedClientId === clientId) {
selectedClientId = null;
detailOpen = false;
}
}
function removeLine(lineId: string) {
if (!excludedLineIds.includes(lineId)) excludedLineIds = [...excludedLineIds, lineId];
}
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><ReceiptTextIcon data-icon="inline-start" />Run billing</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>Run billing</Dialog.Title>
<Dialog.Description>
Contracts through {formatDate(preview.contractsCutoff)}. Bookings and services through {formatDate(
preview.servicesCutoff
)}.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="grid gap-1">
<div class="text-sm font-medium">Invoicing date</div>
<Popover.Root bind:open={datePickerOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}
><CalendarIcon data-icon="inline-start" />{formatDate(invoiceDateString)}</Button
>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-auto p-0">
<Calendar.Calendar
type="single"
value={invoiceDateValue}
captionLayout="dropdown"
onValueChange={changeInvoiceDate}
/>
</Popover.Content>
</Popover.Root>
<div class="text-xs text-muted-foreground">Due {formatDate(preview.dueDate)}</div>
</div>
<div class="text-right text-sm">
<div class="text-muted-foreground">
{includedClients.length} client{includedClients.length === 1 ? '' : 's'} · {includedLineIds.length}
line{includedLineIds.length === 1 ? '' : 's'}
</div>
<div class="font-medium">{formatMoney(includedTotal)}</div>
</div>
</div>
<div class="sr-only" aria-live="polite">
<div class="text-muted-foreground">
{includedClients.length} client{includedClients.length === 1 ? '' : 's'} · {includedLineIds.length}
line{includedLineIds.length === 1 ? '' : 's'}
</div>
<div class="font-medium">{formatMoney(includedTotal)}</div>
</div>
{#if includedClients.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Lines</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Remove</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each includedClients as client (client.clientId)}
<Table.Row class="cursor-pointer" onclick={() => openClient(client.clientId)}>
<Table.Cell class="font-medium">{client.clientName}</Table.Cell>
<Table.Cell>{client.lineCount}</Table.Cell>
<Table.Cell>{formatMoney(client.totalGbp)}</Table.Cell>
<Table.Cell>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${client.clientName} from billing run`}
onclick={(event) => {
event.stopPropagation();
removeClient(client.clientId);
}}><Trash2Icon /></Button
>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No billable clients are currently selected.
</div>
{/if}
</div>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<form method="POST" action="?/runBilling" use:enhance={enhanceBilling}>
<input type="hidden" name="billingDate" value={invoiceDateString} />
<input type="hidden" name="includedLineIds" value={JSON.stringify(includedLineIds)} />
<Button type="submit" disabled={includedLineIds.length === 0}>Complete billing run</Button>
</form>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={detailOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>{selectedClient?.clientName ?? 'Billing details'}</Dialog.Title>
<Dialog.Description>
{selectedClient?.lineCount ?? 0} line{selectedClient?.lineCount === 1 ? '' : 's'} · {formatMoney(
selectedClient?.totalGbp ?? 0
)}
</Dialog.Description>
</Dialog.Header>
{#if selectedClient}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Description</Table.Head>
<Table.Head>Period</Table.Head>
<Table.Head>Qty</Table.Head>
<Table.Head>Unit</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Remove</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each selectedClient.lines as line (line.id)}
<Table.Row>
<Table.Cell class="font-medium">{line.description}</Table.Cell>
<Table.Cell
>{formatDate(line.periodStart)} - {formatDate(line.periodEnd)}</Table.Cell
>
<Table.Cell>{line.quantity}</Table.Cell>
<Table.Cell>{formatMoney(line.unitPriceGbp)}</Table.Cell>
<Table.Cell>{formatMoney(line.totalGbp)}</Table.Cell>
<Table.Cell>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${line.description}`}
onclick={() => removeLine(line.id)}><Trash2Icon /></Button
>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Done</Button>
{/snippet}
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -47,7 +47,10 @@ export const load: PageServerLoad = async ({ locals }) => {
return { return {
records, records,
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' }) archiveForm: await superValidate(zod4(archiveSchema), {
id: 'bookings-archive',
errors: false
})
}; };
}; };
+48 -18
View File
@@ -11,26 +11,57 @@ import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals); const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db const records = await db
.select() .select({
client: clients,
primaryContactName: contacts.name,
primaryContactEmail: contacts.email,
primaryAddressLine1: addresses.line1
})
.from(clients) .from(clients)
.leftJoin(
contacts,
and(
eq(contacts.clientId, clients.id),
eq(contacts.organizationId, activeOrganizationId),
eq(contacts.isPrimary, true),
isNull(contacts.archivedAt)
)
)
.leftJoin(
addresses,
and(
eq(addresses.clientId, clients.id),
eq(addresses.organizationId, activeOrganizationId),
eq(addresses.type, 'primary'),
isNull(addresses.archivedAt)
)
)
.where(and(eq(clients.organizationId, activeOrganizationId), isNull(clients.archivedAt))) .where(and(eq(clients.organizationId, activeOrganizationId), isNull(clients.archivedAt)))
.orderBy(asc(clients.name)); .orderBy(asc(clients.name));
return { return {
records: records.map((record) => ({ records: records.map((record) => ({
...record, ...record.client,
primaryContactName: record.primaryContactName,
primaryContactEmail: record.primaryContactEmail,
primaryAddressLine1: record.primaryAddressLine1,
formValues: { formValues: {
id: record.id, id: record.client.id,
name: record.name ?? '', name: record.client.name ?? '',
type: record.type ?? '', industry: record.client.industry ?? '',
status: record.status ?? '', website: record.client.website ?? '',
website: record.website ?? '', notes: record.client.notes ?? ''
notes: record.notes ?? ''
} }
})), })),
createForm: await superValidate(zod4(clientOnboardingCreateSchema), { id: 'clients-create' }), createForm: await superValidate(zod4(clientOnboardingCreateSchema), {
editForm: await superValidate(zod4(clientEditSchema), { id: 'clients-edit' }), id: 'clients-create',
archiveForm: await superValidate(zod4(archiveSchema), { id: 'clients-archive' }) errors: false
}),
editForm: await superValidate(zod4(clientEditSchema), { id: 'clients-edit', errors: false }),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'clients-archive',
errors: false
})
}; };
}; };
@@ -52,8 +83,7 @@ export const actions: Actions = {
id: clientId, id: clientId,
organizationId: activeOrganizationId, organizationId: activeOrganizationId,
name: form.data.name, name: form.data.name,
type: form.data.type, industry: form.data.industry || null,
status: form.data.status,
website: form.data.website || null, website: form.data.website || null,
notes: form.data.notes || null, notes: form.data.notes || null,
updatedAt: now, updatedAt: now,
@@ -69,7 +99,8 @@ export const actions: Actions = {
email: form.data.primaryContactEmail || null, email: form.data.primaryContactEmail || null,
phone: form.data.primaryContactPhone || null, phone: form.data.primaryContactPhone || null,
isPrimary: true, isPrimary: true,
notes: form.data.primaryContactNotes || null, receivesInvoices: form.data.primaryContactReceivesInvoices === 'true',
receivesContracts: form.data.primaryContactReceivesContracts === 'true',
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
}); });
@@ -78,14 +109,14 @@ export const actions: Actions = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
organizationId: activeOrganizationId, organizationId: activeOrganizationId,
clientId, clientId,
label: form.data.primaryAddressLabel, type: form.data.primaryAddressType,
line1: form.data.primaryAddressLine1, line1: form.data.primaryAddressLine1,
line2: form.data.primaryAddressLine2 || null, line2: form.data.primaryAddressLine2 || null,
line3: form.data.primaryAddressLine3 || null,
city: form.data.primaryAddressCity, city: form.data.primaryAddressCity,
region: form.data.primaryAddressRegion || null, region: form.data.primaryAddressRegion || null,
postcode: form.data.primaryAddressPostcode, postcode: form.data.primaryAddressPostcode,
country: form.data.primaryAddressCountry, country: form.data.primaryAddressCountry,
isPrimary: true,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
}); });
@@ -108,8 +139,7 @@ export const actions: Actions = {
.update(clients) .update(clients)
.set({ .set({
name: form.data.name, name: form.data.name,
type: form.data.type, industry: form.data.industry || null,
status: form.data.status,
website: form.data.website || null, website: form.data.website || null,
notes: form.data.notes || null, notes: form.data.notes || null,
updatedAt: new Date() updatedAt: new Date()
+1 -3
View File
@@ -79,9 +79,7 @@
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <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">Clients</h1> <h1 class="text-2xl font-semibold tracking-tight">Clients</h1>
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">Manage client details, contacts, and addresses.</p>
Manage businesses, sports organisations, and individual clients.
</p>
</div> </div>
<CreateClientDialog <CreateClientDialog
@@ -13,19 +13,18 @@ import {
} from '$lib/server/db/schema'; } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { clientEditSchema } from '$lib/schemas/clients.schema'; import { clientEditSchema } from '$lib/schemas/clients.schema';
import { industries } from '$lib/constants/industries';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { superValidate } from 'sveltekit-superforms/server'; import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; 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) { function formValues(row: typeof clients.$inferSelect) {
const industry = industries.find((value) => value === row.industry);
return { return {
id: row.id, id: row.id,
name: row.name ?? '', name: row.name ?? '',
type: clientTypes.find((type) => type === row.type), industry: industry ?? ('' as const),
status: clientStatuses.find((status) => status === row.status),
website: row.website ?? '', website: row.website ?? '',
notes: row.notes ?? '' notes: row.notes ?? ''
}; };
@@ -88,7 +87,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
isNull(addresses.archivedAt) isNull(addresses.archivedAt)
) )
) )
.orderBy(asc(addresses.label)), .orderBy(asc(addresses.type)),
db db
.select() .select()
.from(contacts) .from(contacts)
@@ -121,7 +120,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
isNull(contracts.archivedAt) isNull(contracts.archivedAt)
) )
) )
.orderBy(asc(contracts.title)), .orderBy(asc(contracts.startDate)),
db db
.select() .select()
.from(bookings) .from(bookings)
@@ -139,7 +138,8 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
return { return {
client, client,
editForm: await superValidate(formValues(client), zod4(clientEditSchema), { editForm: await superValidate(formValues(client), zod4(clientEditSchema), {
id: 'clients-edit' id: 'clients-edit',
errors: false
}), }),
options, options,
addresses: addressRows, addresses: addressRows,
@@ -2,16 +2,17 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; 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 { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { clientEditSchema } from '$lib/schemas/clients.schema'; import { clientEditSchema } from '$lib/schemas/clients.schema';
import { handleFormToast } from '$lib/form-feedback';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/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 * as Tabs from '$lib/components/ui/tabs/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
@@ -50,6 +51,28 @@
const { form: editData, enhance: enhanceEdit } = editForm; 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) { function navigateToTab(tab: TabValue) {
goto(resolve(`/dashboard/clients/[id]/${tab}`, { id: data.client.id })); goto(resolve(`/dashboard/clients/[id]/${tab}`, { id: data.client.id }));
} }
@@ -81,47 +104,38 @@
</Dialog.Header> </Dialog.Header>
<form method="POST" action="/dashboard/clients?/edit" class="grid gap-2" use:enhanceEdit> <form method="POST" action="/dashboard/clients?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} /> <input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name"> <FormField form={editForm} name="name">
<Form.Control id="edit-client-name"> <Field.Field>
<Control id="edit-client-name">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Name</Form.Label> <Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} /> <Input {...props} type="text" bind:value={$editData.name} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="type"> </FormField>
<Form.Control id="edit-client-type"> <FormField form={editForm} name="industry">
<Field.Field>
<Control id="edit-client-industry">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Type</Form.Label> <Field.Label>Industry</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.type}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="industry"
<NativeSelect.Option value="business">Business</NativeSelect.Option> bind:value={$editData.industry}
<NativeSelect.Option value="sport">Sport</NativeSelect.Option> options={industryOptions}
<NativeSelect.Option value="individual">Individual</NativeSelect.Option> triggerProps={props}
</NativeSelect.Root> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="status"> </FormField>
<Form.Control id="edit-client-status"> <FormField form={editForm} name="website">
<Field.Field>
<Control id="edit-client-website">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Status</Form.Label> <Field.Label>Website</Field.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 <Input
{...props} {...props}
type="url" type="url"
@@ -129,18 +143,21 @@
bind:value={$editData.website} bind:value={$editData.website}
/> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="notes"> </FormField>
<Form.Control id="edit-client-notes"> <FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-client-notes">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Notes</Form.Label> <Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} /> <Textarea {...props} bind:value={$editData.notes} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -154,18 +171,18 @@
</div> </div>
</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> <div>
<p class="text-xs font-medium text-muted-foreground uppercase">Type</p> <p class="text-xs font-medium text-muted-foreground uppercase">Industry</p>
<p class="text-sm capitalize">{data.client.type}</p> <p class="text-sm">{industryLabel(data.client.industry)}</p>
</div> </div>
<div> <div>
<p class="text-xs font-medium text-muted-foreground uppercase">Website</p> <p class="text-xs font-medium text-muted-foreground uppercase">Website</p>
<p class="text-sm">{data.client.website ?? 'Not set'}</p> <p class="text-sm">{data.client.website ?? 'Not set'}</p>
</div> </div>
<div> <div class="sm:col-span-2">
<p class="text-xs font-medium text-muted-foreground uppercase">Status</p> <p class="text-xs font-medium text-muted-foreground uppercase">Notes</p>
<p class="text-sm capitalize">{data.client.status}</p> <p class="text-sm whitespace-pre-wrap">{data.client.notes || 'Not set'}</p>
</div> </div>
</div> </div>
@@ -32,7 +32,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
isNull(addresses.archivedAt) isNull(addresses.archivedAt)
) )
) )
.orderBy(asc(addresses.label)); .orderBy(asc(addresses.type));
return { return {
records: records.map((record) => ({ records: records.map((record) => ({
@@ -40,22 +40,29 @@ export const load: PageServerLoad = async ({ locals, params }) => {
formValues: { formValues: {
id: record.id, id: record.id,
clientId: record.clientId ?? '', clientId: record.clientId ?? '',
label: record.label ?? '', type: record.type ?? 'primary',
line1: record.line1 ?? '', line1: record.line1 ?? '',
line2: record.line2 ?? '', line2: record.line2 ?? '',
line3: record.line3 ?? '',
city: record.city ?? '', city: record.city ?? '',
region: record.region ?? '', region: record.region ?? '',
postcode: record.postcode ?? '', postcode: record.postcode ?? '',
country: record.country ?? '', country: record.country ?? ''
isPrimary: record.isPrimary ? 'true' : 'false'
} }
})), })),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(addressCreateSchema), { createForm: await superValidate({ clientId: params.id }, zod4(addressCreateSchema), {
id: 'addresses-create' id: 'addresses-create',
errors: false
}), }),
editForm: await superValidate(zod4(addressEditSchema), { id: 'addresses-edit' }), editForm: await superValidate(zod4(addressEditSchema), {
archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' }) 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(), id: crypto.randomUUID(),
organizationId: activeOrganizationId, organizationId: activeOrganizationId,
clientId: form.data.clientId, clientId: form.data.clientId,
label: form.data.label, type: form.data.type,
line1: form.data.line1, line1: form.data.line1,
line2: form.data.line2 || null, line2: form.data.line2 || null,
line3: form.data.line3 || null,
city: form.data.city, city: form.data.city,
region: form.data.region || null, region: form.data.region || null,
postcode: form.data.postcode, postcode: form.data.postcode,
country: form.data.country, country: form.data.country,
isPrimary: form.data.isPrimary === 'true',
updatedAt: new Date(), updatedAt: new Date(),
createdAt: new Date() createdAt: new Date()
}); });
@@ -106,14 +113,14 @@ export const actions: Actions = {
.update(addresses) .update(addresses)
.set({ .set({
clientId: form.data.clientId, clientId: form.data.clientId,
label: form.data.label, type: form.data.type,
line1: form.data.line1, line1: form.data.line1,
line2: form.data.line2 || null, line2: form.data.line2 || null,
line3: form.data.line3 || null,
city: form.data.city, city: form.data.city,
region: form.data.region || null, region: form.data.region || null,
postcode: form.data.postcode, postcode: form.data.postcode,
country: form.data.country, country: form.data.country,
isPrimary: form.data.isPrimary === 'true',
updatedAt: new Date() updatedAt: new Date()
}) })
.where( .where(
@@ -1,6 +1,4 @@
<script lang="ts"> <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 CreateAddressDialog from './create-address-dialog.svelte';
import AddressesTable from './addresses-table.svelte'; import AddressesTable from './addresses-table.svelte';
import EditAddressDialog from './edit-address-dialog.svelte'; import EditAddressDialog from './edit-address-dialog.svelte';
@@ -9,20 +7,11 @@
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema'; 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 { addressCreateSchema, addressEditSchema } from '$lib/schemas/addresses.schema';
import type { PageData } from './$types'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number]; type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false); let createOpen = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null); let archivingId = $state<string | null>(null);
@@ -55,8 +44,31 @@
const { form: editData, enhance: enhanceEdit } = editForm; const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm; const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') { function handleFormToast(
return getRelationLabel(data.options, value, optionsKey); 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) { function openEdit(record: RecordRow) {
@@ -70,7 +82,15 @@
} }
function recordName(record: Partial<RecordRow> | undefined) { 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> </script>
@@ -88,21 +108,7 @@
<CreateAddressDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} /> <CreateAddressDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div> </div>
<ListSearch <AddressesTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
bind:value={listSearch.params.q}
placeholder="Search addresses..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<AddressesTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{relationLabel}
{recordName}
/>
</div> </div>
<EditAddressDialog <EditAddressDialog
@@ -9,14 +9,12 @@
openEdit, openEdit,
openArchive, openArchive,
formatValue, formatValue,
relationLabel,
recordName recordName
}: { }: {
data: any; data: any;
openEdit: any; openEdit: any;
openArchive: any; openArchive: any;
formatValue: any; formatValue: any;
relationLabel: any;
recordName: any; recordName: any;
} = $props(); } = $props();
</script> </script>
@@ -26,24 +24,20 @@
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Client</Table.Head> <Table.Head>Type</Table.Head>
<Table.Head>Label</Table.Head>
<Table.Head>Address</Table.Head> <Table.Head>Address</Table.Head>
<Table.Head>City</Table.Head> <Table.Head>City</Table.Head>
<Table.Head>Postcode</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.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each data.records as record (record.id)} {#each data.records as record (record.id)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell> <Table.Cell class="capitalize">{formatValue(record.type)}</Table.Cell>
<Table.Cell class="">{formatValue(record.label)}</Table.Cell>
<Table.Cell class="">{formatValue(record.line1)}</Table.Cell> <Table.Cell class="">{formatValue(record.line1)}</Table.Cell>
<Table.Cell class="">{formatValue(record.city)}</Table.Cell> <Table.Cell class="">{formatValue(record.city)}</Table.Cell>
<Table.Cell class="">{formatValue(record.postcode)}</Table.Cell> <Table.Cell class="">{formatValue(record.postcode)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import * 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let { let {
open = $bindable(false), open = $bindable(false),
createForm, createForm,
@@ -31,82 +32,103 @@
<Dialog.Description>Add a new address record.</Dialog.Description> <Dialog.Description>Add a new address record.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate> <form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="label"> <FormField form={createForm} name="type">
<Form.Control id="create-label"> <Field.Field>
<Control id="create-type">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Label</Form.Label> <Field.Label>Type</Field.Label>
<Input {...props} type="text" bind:value={$createData.label} /> <FormSelect
name="type"
bind:value={$createData.type}
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'invoicing', label: 'Invoicing' },
{ value: 'contract', label: 'Contract' }
]}
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="line1"> </FormField>
<Form.Control id="create-line1"> <FormField form={createForm} name="line1">
<Field.Field>
<Control id="create-line1">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Address line 1</Form.Label> <Field.Label>Address line 1</Field.Label>
<Input {...props} type="text" bind:value={$createData.line1} /> <Input {...props} type="text" bind:value={$createData.line1} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="line2"> </FormField>
<Form.Control id="create-line2"> <FormField form={createForm} name="line2">
<Field.Field>
<Control id="create-line2">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Address line 2</Form.Label> <Field.Label>Address line 2</Field.Label>
<Input {...props} type="text" bind:value={$createData.line2} /> <Input {...props} type="text" bind:value={$createData.line2} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="city"> </FormField>
<Form.Control id="create-city"> <FormField form={createForm} name="line3">
<Field.Field>
<Control id="create-line3">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>City</Form.Label> <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} /> <Input {...props} type="text" bind:value={$createData.city} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="region"> </FormField>
<Form.Control id="create-region"> <FormField form={createForm} name="region">
<Field.Field>
<Control id="create-region">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Region / county</Form.Label> <Field.Label>Region / county</Field.Label>
<Input {...props} type="text" bind:value={$createData.region} /> <Input {...props} type="text" bind:value={$createData.region} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="postcode"> </FormField>
<Form.Control id="create-postcode"> <FormField form={createForm} name="postcode">
<Field.Field>
<Control id="create-postcode">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Postcode</Form.Label> <Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$createData.postcode} /> <Input {...props} type="text" bind:value={$createData.postcode} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="country"> </FormField>
<Form.Control id="create-country"> <FormField form={createForm} name="country">
<Field.Field>
<Control id="create-country">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Country</Form.Label> <Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$createData.country} /> <Input {...props} type="text" bind:value={$createData.country} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="isPrimary"> </FormField>
<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>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let { let {
editingRecord, editingRecord,
editForm, editForm,
@@ -29,82 +30,103 @@
{#if editingRecord} {#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit> <form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} /> <input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="label"> <FormField form={editForm} name="type">
<Form.Control id="edit-label"> <Field.Field>
<Control id="edit-type">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Label</Form.Label> <Field.Label>Type</Field.Label>
<Input {...props} type="text" bind:value={$editData.label} /> <FormSelect
name="type"
bind:value={$editData.type}
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'invoicing', label: 'Invoicing' },
{ value: 'contract', label: 'Contract' }
]}
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="line1"> </FormField>
<Form.Control id="edit-line1"> <FormField form={editForm} name="line1">
<Field.Field>
<Control id="edit-line1">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Address line 1</Form.Label> <Field.Label>Address line 1</Field.Label>
<Input {...props} type="text" bind:value={$editData.line1} /> <Input {...props} type="text" bind:value={$editData.line1} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="line2"> </FormField>
<Form.Control id="edit-line2"> <FormField form={editForm} name="line2">
<Field.Field>
<Control id="edit-line2">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Address line 2</Form.Label> <Field.Label>Address line 2</Field.Label>
<Input {...props} type="text" bind:value={$editData.line2} /> <Input {...props} type="text" bind:value={$editData.line2} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="city"> </FormField>
<Form.Control id="edit-city"> <FormField form={editForm} name="line3">
<Field.Field>
<Control id="edit-line3">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>City</Form.Label> <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} /> <Input {...props} type="text" bind:value={$editData.city} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="region"> </FormField>
<Form.Control id="edit-region"> <FormField form={editForm} name="region">
<Field.Field>
<Control id="edit-region">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Region / county</Form.Label> <Field.Label>Region / county</Field.Label>
<Input {...props} type="text" bind:value={$editData.region} /> <Input {...props} type="text" bind:value={$editData.region} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="postcode"> </FormField>
<Form.Control id="edit-postcode"> <FormField form={editForm} name="postcode">
<Field.Field>
<Control id="edit-postcode">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Postcode</Form.Label> <Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$editData.postcode} /> <Input {...props} type="text" bind:value={$editData.postcode} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="country"> </FormField>
<Form.Control id="edit-country"> <FormField form={editForm} name="country">
<Field.Field>
<Control id="edit-country">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Country</Form.Label> <Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$editData.country} /> <Input {...props} type="text" bind:value={$editData.country} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="isPrimary"> </FormField>
<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>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -67,10 +67,17 @@ export const load: PageServerLoad = async ({ locals, params }) => {
})), })),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(bookingCreateSchema), { createForm: await superValidate({ clientId: params.id }, zod4(bookingCreateSchema), {
id: 'bookings-create' id: 'bookings-create',
errors: false
}), }),
editForm: await superValidate(zod4(bookingEditSchema), { id: 'bookings-edit' }), editForm: await superValidate(zod4(bookingEditSchema), {
archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' }) id: 'bookings-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'bookings-archive',
errors: false
})
}; };
}; };
@@ -1,6 +1,4 @@
<script lang="ts"> <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 CreateBookingDialog from './create-booking-dialog.svelte';
import BookingsTable from './bookings-table.svelte'; import BookingsTable from './bookings-table.svelte';
import EditBookingDialog from './edit-booking-dialog.svelte'; import EditBookingDialog from './edit-booking-dialog.svelte';
@@ -9,21 +7,11 @@
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema'; 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 { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
import type { PageData } from './$types'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number]; type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false); let createOpen = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null); let archivingId = $state<string | null>(null);
@@ -56,8 +44,48 @@
const { form: editData, enhance: enhanceEdit } = editForm; const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm; const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients' | 'rooms' | 'services') { function handleFormToast(
return getRelationLabel(data.options, value, optionsKey); 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) { function openEdit(record: RecordRow) {
@@ -71,7 +99,15 @@
} }
function recordName(record: Partial<RecordRow> | undefined) { 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> </script>
@@ -91,15 +127,8 @@
<CreateBookingDialog bind:open={createOpen} {data} {createForm} {createData} {enhanceCreate} /> <CreateBookingDialog bind:open={createOpen} {data} {createForm} {createData} {enhanceCreate} />
</div> </div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search bookings..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<BookingsTable <BookingsTable
data={listData} {data}
{openEdit} {openEdit}
{openArchive} {openArchive}
{formatValue} {formatValue}
@@ -29,7 +29,6 @@
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Room</Table.Head> <Table.Head>Room</Table.Head>
<Table.Head>Starts</Table.Head> <Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head> <Table.Head>Ends</Table.Head>
@@ -40,7 +39,6 @@
<Table.Body> <Table.Body>
{#each data.records as record (record.id)} {#each data.records as record (record.id)}
<Table.Row> <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="">{relationLabel(record.roomId, 'rooms')}</Table.Cell>
<Table.Cell class="">{formatDate(record.startsAt, true)}</Table.Cell> <Table.Cell class="">{formatDate(record.startsAt, true)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endsAt, true)}</Table.Cell> <Table.Cell class="">{formatDate(record.endsAt, true)}</Table.Cell>
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import * 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/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 { Textarea } from '$lib/components/ui/textarea/index.js';
let { let {
data, data,
@@ -34,75 +36,99 @@
<Dialog.Description>Add a new booking record.</Dialog.Description> <Dialog.Description>Add a new booking record.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate> <form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="roomId"> <FormField form={createForm} name="roomId">
<Form.Control id="create-roomId"> <Field.Field>
<Control id="create-roomId">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Room</Form.Label> <Field.Label>Room</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.roomId}> <FormCombobox
{#each data.options.rooms as option (option.value)} name="roomId"
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option> bind:value={$createData.roomId}
{/each} options={data.options.rooms}
</NativeSelect.Root> placeholder="Select a room"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="serviceId"> </FormField>
<Form.Control id="create-serviceId"> <FormField form={createForm} name="serviceId">
<Field.Field>
<Control id="create-serviceId">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Service</Form.Label> <Field.Label>Service</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.serviceId}> <FormCombobox
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="serviceId"
{#each data.options.services as option (option.value)} bind:value={$createData.serviceId}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option> options={data.options.services}
{/each} placeholder="Select a service"
</NativeSelect.Root> searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="startsAt"> </FormField>
<Form.Control id="create-startsAt"> <FormField form={createForm} name="startsAt">
<Field.Field>
<Control id="create-startsAt">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Starts at</Form.Label> <Field.Label>Starts at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$createData.startsAt} /> <Input {...props} type="datetime-local" bind:value={$createData.startsAt} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="endsAt"> </FormField>
<Form.Control id="create-endsAt"> <FormField form={createForm} name="endsAt">
<Field.Field>
<Control id="create-endsAt">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Ends at</Form.Label> <Field.Label>Ends at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$createData.endsAt} /> <Input {...props} type="datetime-local" bind:value={$createData.endsAt} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="status"> </FormField>
<Form.Control id="create-status"> <FormField form={createForm} name="status">
<Field.Field>
<Control id="create-status">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Status</Form.Label> <Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="status"
<NativeSelect.Option value="booked">Booked</NativeSelect.Option> bind:value={$createData.status}
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option> options={[
<NativeSelect.Option value="completed">Completed</NativeSelect.Option> { value: '', label: 'Not set' },
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option> { value: 'booked', label: 'Booked' },
</NativeSelect.Root> { value: 'confirmed', label: 'Confirmed' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' }
]}
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="notes"> </FormField>
<Form.Control id="create-notes"> <FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Notes</Form.Label> <Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} /> <Textarea {...props} bind:value={$createData.notes} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/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 { Textarea } from '$lib/components/ui/textarea/index.js';
let { let {
data, data,
@@ -32,75 +34,99 @@
{#if editingRecord} {#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit> <form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} /> <input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="roomId"> <FormField form={editForm} name="roomId">
<Form.Control id="edit-roomId"> <Field.Field>
<Control id="edit-roomId">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Room</Form.Label> <Field.Label>Room</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.roomId}> <FormCombobox
{#each data.options.rooms as option (option.value)} name="roomId"
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option> bind:value={$editData.roomId}
{/each} options={data.options.rooms}
</NativeSelect.Root> placeholder="Select a room"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="serviceId"> </FormField>
<Form.Control id="edit-serviceId"> <FormField form={editForm} name="serviceId">
<Field.Field>
<Control id="edit-serviceId">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Service</Form.Label> <Field.Label>Service</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.serviceId}> <FormCombobox
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="serviceId"
{#each data.options.services as option (option.value)} bind:value={$editData.serviceId}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option> options={data.options.services}
{/each} placeholder="Select a service"
</NativeSelect.Root> searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="startsAt"> </FormField>
<Form.Control id="edit-startsAt"> <FormField form={editForm} name="startsAt">
<Field.Field>
<Control id="edit-startsAt">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Starts at</Form.Label> <Field.Label>Starts at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$editData.startsAt} /> <Input {...props} type="datetime-local" bind:value={$editData.startsAt} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="endsAt"> </FormField>
<Form.Control id="edit-endsAt"> <FormField form={editForm} name="endsAt">
<Field.Field>
<Control id="edit-endsAt">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Ends at</Form.Label> <Field.Label>Ends at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$editData.endsAt} /> <Input {...props} type="datetime-local" bind:value={$editData.endsAt} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="status"> </FormField>
<Form.Control id="edit-status"> <FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Status</Form.Label> <Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="status"
<NativeSelect.Option value="booked">Booked</NativeSelect.Option> bind:value={$editData.status}
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option> options={[
<NativeSelect.Option value="completed">Completed</NativeSelect.Option> { value: '', label: 'Not set' },
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option> { value: 'booked', label: 'Booked' },
</NativeSelect.Root> { value: 'confirmed', label: 'Confirmed' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' }
]}
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="notes"> </FormField>
<Form.Control id="edit-notes"> <FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Notes</Form.Label> <Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} /> <Textarea {...props} bind:value={$editData.notes} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -45,15 +45,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
email: record.email ?? '', email: record.email ?? '',
phone: record.phone ?? '', phone: record.phone ?? '',
isPrimary: record.isPrimary ? 'true' : 'false', isPrimary: record.isPrimary ? 'true' : 'false',
notes: record.notes ?? '' receivesInvoices: record.receivesInvoices ? 'true' : 'false',
receivesContracts: record.receivesContracts ? 'true' : 'false'
} }
})), })),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(contactCreateSchema), { createForm: await superValidate({ clientId: params.id }, zod4(contactCreateSchema), {
id: 'contacts-create' id: 'contacts-create',
errors: false
}), }),
editForm: await superValidate(zod4(contactEditSchema), { id: 'contacts-edit' }), editForm: await superValidate(zod4(contactEditSchema), {
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' }) 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, email: form.data.email || null,
phone: form.data.phone || null, phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true', isPrimary: form.data.isPrimary === 'true',
notes: form.data.notes || null, receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date(), updatedAt: new Date(),
createdAt: new Date() createdAt: new Date()
}); });
@@ -107,7 +116,8 @@ export const actions: Actions = {
email: form.data.email || null, email: form.data.email || null,
phone: form.data.phone || null, phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true', isPrimary: form.data.isPrimary === 'true',
notes: form.data.notes || null, receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date() updatedAt: new Date()
}) })
.where( .where(
@@ -1,6 +1,4 @@
<script lang="ts"> <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 CreateContactDialog from './create-contact-dialog.svelte';
import ContactsTable from './contacts-table.svelte'; import ContactsTable from './contacts-table.svelte';
import EditContactDialog from './edit-contact-dialog.svelte'; import EditContactDialog from './edit-contact-dialog.svelte';
@@ -9,20 +7,11 @@
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema'; 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 { contactCreateSchema, contactEditSchema } from '$lib/schemas/contacts.schema';
import type { PageData } from './$types'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number]; type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false); let createOpen = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null); let archivingId = $state<string | null>(null);
@@ -55,8 +44,31 @@
const { form: editData, enhance: enhanceEdit } = editForm; const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm; const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') { function handleFormToast(
return getRelationLabel(data.options, value, optionsKey); 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) { function openEdit(record: RecordRow) {
@@ -70,7 +82,15 @@
} }
function recordName(record: Partial<RecordRow> | undefined) { 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> </script>
@@ -88,21 +108,7 @@
<CreateContactDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} /> <CreateContactDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div> </div>
<ListSearch <ContactsTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
bind:value={listSearch.params.q}
placeholder="Search contacts..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<ContactsTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{relationLabel}
{recordName}
/>
</div> </div>
<EditContactDialog <EditContactDialog
@@ -9,14 +9,12 @@
openEdit, openEdit,
openArchive, openArchive,
formatValue, formatValue,
relationLabel,
recordName recordName
}: { }: {
data: any; data: any;
openEdit: any; openEdit: any;
openArchive: any; openArchive: any;
formatValue: any; formatValue: any;
relationLabel: any;
recordName: any; recordName: any;
} = $props(); } = $props();
</script> </script>
@@ -26,24 +24,26 @@
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Name</Table.Head> <Table.Head>Name</Table.Head>
<Table.Head>Role</Table.Head> <Table.Head>Role</Table.Head>
<Table.Head>Email</Table.Head> <Table.Head>Email</Table.Head>
<Table.Head>Phone</Table.Head> <Table.Head>Phone</Table.Head>
<Table.Head>Primary</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.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each data.records as record (record.id)} {#each data.records as record (record.id)}
<Table.Row> <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.name)}</Table.Cell>
<Table.Cell class="">{formatValue(record.role)}</Table.Cell> <Table.Cell class="">{formatValue(record.role)}</Table.Cell>
<Table.Cell class="">{formatValue(record.email)}</Table.Cell> <Table.Cell class="">{formatValue(record.email)}</Table.Cell>
<Table.Cell class="">{formatValue(record.phone)}</Table.Cell> <Table.Cell class="">{formatValue(record.phone)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</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> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import * 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/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 { let {
open = $bindable(false), open = $bindable(false),
createForm, createForm,
@@ -32,64 +33,86 @@
<Dialog.Description>Add a new contact record.</Dialog.Description> <Dialog.Description>Add a new contact record.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate> <form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="name"> <FormField form={createForm} name="name">
<Form.Control id="create-name"> <Field.Field>
<Control id="create-name">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Name</Form.Label> <Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.name} /> <Input {...props} type="text" bind:value={$createData.name} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="role"> </FormField>
<Form.Control id="create-role"> <FormField form={createForm} name="role">
<Field.Field>
<Control id="create-role">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Role</Form.Label> <Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$createData.role} /> <Input {...props} type="text" bind:value={$createData.role} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="email"> </FormField>
<Form.Control id="create-email"> <FormField form={createForm} name="email">
<Field.Field>
<Control id="create-email">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Email</Form.Label> <Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$createData.email} /> <Input {...props} type="email" bind:value={$createData.email} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="phone"> </FormField>
<Form.Control id="create-phone"> <FormField form={createForm} name="phone">
<Field.Field>
<Control id="create-phone">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Phone</Form.Label> <Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$createData.phone} /> <Input {...props} type="text" bind:value={$createData.phone} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="isPrimary"> </FormField>
<Form.Control id="create-isPrimary"> <FormField form={createForm} name="isPrimary">
<Field.Field>
<Control id="create-isPrimary">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Primary contact</Form.Label> <Field.Label>Primary contact</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.isPrimary}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="isPrimary"
<NativeSelect.Option value="false">No</NativeSelect.Option> bind:value={$createData.isPrimary}
<NativeSelect.Option value="true">Yes</NativeSelect.Option> options={[
</NativeSelect.Root> { value: '', label: 'Not set' },
{ value: 'false', label: 'No' },
{ value: 'true', label: 'Yes' }
]}
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="notes"> </FormField>
<Form.Control id="create-notes"> <div class="grid gap-3 py-1">
{#snippet children({ props })} <FormCheckbox
<Form.Label>Notes</Form.Label> id="create-receives-invoices"
<Textarea {...props} bind:value={$createData.notes} /> name="receivesInvoices"
{/snippet} label="Receives invoices"
</Form.Control> description="Send invoices raised for this client to this contact."
<Form.FieldErrors /> bind:value={$createData.receivesInvoices}
</Form.Field> />
<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.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/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 { let {
editingRecord, editingRecord,
editForm, editForm,
@@ -30,64 +31,86 @@
{#if editingRecord} {#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit> <form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} /> <input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name"> <FormField form={editForm} name="name">
<Form.Control id="edit-name"> <Field.Field>
<Control id="edit-name">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Name</Form.Label> <Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} /> <Input {...props} type="text" bind:value={$editData.name} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="role"> </FormField>
<Form.Control id="edit-role"> <FormField form={editForm} name="role">
<Field.Field>
<Control id="edit-role">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Role</Form.Label> <Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$editData.role} /> <Input {...props} type="text" bind:value={$editData.role} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="email"> </FormField>
<Form.Control id="edit-email"> <FormField form={editForm} name="email">
<Field.Field>
<Control id="edit-email">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Email</Form.Label> <Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$editData.email} /> <Input {...props} type="email" bind:value={$editData.email} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="phone"> </FormField>
<Form.Control id="edit-phone"> <FormField form={editForm} name="phone">
<Field.Field>
<Control id="edit-phone">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Phone</Form.Label> <Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$editData.phone} /> <Input {...props} type="text" bind:value={$editData.phone} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="isPrimary"> </FormField>
<Form.Control id="edit-isPrimary"> <FormField form={editForm} name="isPrimary">
<Field.Field>
<Control id="edit-isPrimary">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Primary contact</Form.Label> <Field.Label>Primary contact</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.isPrimary}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="isPrimary"
<NativeSelect.Option value="false">No</NativeSelect.Option> bind:value={$editData.isPrimary}
<NativeSelect.Option value="true">Yes</NativeSelect.Option> options={[
</NativeSelect.Root> { value: '', label: 'Not set' },
{ value: 'false', label: 'No' },
{ value: 'true', label: 'Yes' }
]}
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="notes"> </FormField>
<Form.Control id="edit-notes"> <div class="grid gap-3 py-1">
{#snippet children({ props })} <FormCheckbox
<Form.Label>Notes</Form.Label> id="edit-receives-invoices"
<Textarea {...props} bind:value={$editData.notes} /> name="receivesInvoices"
{/snippet} label="Receives invoices"
</Form.Control> description="Send invoices raised for this client to this contact."
<Form.FieldErrors /> bind:value={$editData.receivesInvoices}
</Form.Field> />
<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.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,30 +1,74 @@
import { and, asc, eq, isNull } from 'drizzle-orm'; import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server'; import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { contracts, clients } from '$lib/server/db/schema'; import { contracts, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema'; import {
contractCreateSchema,
contractEditSchema,
contractTransitionSchema
} from '$lib/schemas/contracts.schema';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) { async function loadOptions(organizationId: string) {
const clientRows = await db const [roomRows, serviceRows] = await Promise.all([
.select({ id: clients.id, name: clients.name }) db
.from(clients) .select({
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt))) id: rooms.id,
.orderBy(asc(clients.name)); 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 { 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 }) => { export const load: PageServerLoad = async ({ locals, params }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals); const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db const records = await db
.select() .select({
contract: contracts,
roomName: rooms.name,
serviceName: services.name
})
.from(contracts) .from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where( .where(
and( and(
eq(contracts.clientId, params.id), eq(contracts.clientId, params.id),
@@ -32,31 +76,91 @@ export const load: PageServerLoad = async ({ locals, params }) => {
isNull(contracts.archivedAt) isNull(contracts.archivedAt)
) )
) )
.orderBy(asc(contracts.title)); .orderBy(asc(contracts.startDate));
return { return {
records: records.map((record) => ({ records: records.map((record) => ({
...record, ...record.contract,
roomName: record.roomName,
serviceName: record.serviceName,
formValues: { formValues: {
id: record.id, id: record.contract.id,
clientId: record.clientId ?? '', clientId: record.contract.clientId ?? '',
title: record.title ?? '', roomId: record.contract.roomId ?? '',
startDate: record.startDate ?? '', serviceId: record.contract.serviceId ?? '',
endDate: record.endDate ?? '', licenseFeeGbp: record.contract.licenseFeeGbp,
status: record.status ?? '', depositGbp: record.contract.depositGbp,
valueGbp: record.valueGbp ?? '', startDate: record.contract.startDate ?? '',
notes: record.notes ?? '' endDate: record.contract.endDate ?? '',
notes: record.contract.notes ?? ''
} }
})), })),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), { createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), {
id: 'contracts-create' id: 'contracts-create',
errors: false
}), }),
editForm: await superValidate(zod4(contractEditSchema), { id: 'contracts-edit' }), editForm: await superValidate(zod4(contractEditSchema), {
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' }) 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 = { export const actions: Actions = {
create: async ({ locals, params, request }) => { create: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals); 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 }); 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 { try {
await db.insert(contracts).values({ await db.insert(contracts).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
organizationId: activeOrganizationId, organizationId: activeOrganizationId,
clientId: form.data.clientId, 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, startDate: form.data.startDate,
endDate: form.data.endDate || null, endDate: form.data.endDate,
status: form.data.status, status: 'draft',
valueGbp: form.data.valueGbp,
notes: form.data.notes || null, notes: form.data.notes || null,
updatedAt: new Date(), updatedAt: new Date(),
createdAt: 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 }); 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 { try {
await db await db
.update(contracts) .update(contracts)
.set({ .set({
clientId: form.data.clientId, 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, startDate: form.data.startDate,
endDate: form.data.endDate || null, endDate: form.data.endDate,
status: form.data.status,
valueGbp: form.data.valueGbp,
notes: form.data.notes || null, notes: form.data.notes || null,
updatedAt: new Date() updatedAt: new Date()
}) })
@@ -126,6 +245,50 @@ export const actions: Actions = {
return message(form, 'Contract updated.'); 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 }) => { archive: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals); const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(archiveSchema), { const form = await superValidate(await request.formData(), zod4(archiveSchema), {
@@ -1,6 +1,4 @@
<script lang="ts"> <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 CreateContractDialog from './create-contract-dialog.svelte';
import ContractsTable from './contracts-table.svelte'; import ContractsTable from './contracts-table.svelte';
import EditContractDialog from './edit-contract-dialog.svelte'; import EditContractDialog from './edit-contract-dialog.svelte';
@@ -10,21 +8,14 @@
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import { import {
formatValue, contractCreateSchema,
formatMoney, contractEditSchema,
formatDate, contractTransitionSchema
recordName as getRecordName, } from '$lib/schemas/contracts.schema';
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
import type { PageData } from './$types'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number]; type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false); let createOpen = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null); let archivingId = $state<string | null>(null);
@@ -52,17 +43,67 @@
onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)), onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-archive' }) 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: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm; const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm; const { form: archiveData, enhance: enhanceArchive } = archiveForm;
const { enhance: enhanceTransition } = transitionForm;
function relationLabel(value: unknown, optionsKey: 'clients') { function handleFormToast(
return getRelationLabel(data.options, value, optionsKey); 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) { function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never }); editForm.reset({ data: record.formValues as never });
transitionForm.reset({
data: {
id: record.id,
targetStatus: record.status === 'active' ? 'expired' : 'active'
}
});
editingId = record.id; editingId = record.id;
} }
@@ -72,7 +113,7 @@
} }
function recordName(record: Partial<RecordRow> | undefined) { function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contract'); return String(record?.roomName ?? record?.serviceName ?? 'this contract');
} }
</script> </script>
@@ -87,33 +128,25 @@
<p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p> <p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p>
</div> </div>
<CreateContractDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} /> <CreateContractDialog
bind:open={createOpen}
options={data.options}
{createForm}
{createData}
{enhanceCreate}
/>
</div> </div>
<ListSearch <ContractsTable {data} {openEdit} {openArchive} {formatValue} {formatDate} {recordName} />
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}
/>
</div> </div>
<EditContractDialog <EditContractDialog
{editingRecord} {editingRecord}
options={data.options}
{editForm} {editForm}
{editData} {editData}
{enhanceEdit} {enhanceEdit}
{enhanceTransition}
onClose={() => (editingId = null)} 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, openArchive,
formatValue, formatValue,
formatDate, formatDate,
formatMoney,
relationLabel,
recordName recordName
}: { }: {
data: any; data: any;
@@ -20,8 +18,6 @@
openArchive: any; openArchive: any;
formatValue: any; formatValue: any;
formatDate: any; formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any; recordName: any;
} = $props(); } = $props();
</script> </script>
@@ -31,27 +27,25 @@
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Title</Table.Head> <Table.Head>Room</Table.Head>
<Table.Head>Client</Table.Head> <Table.Head>Service</Table.Head>
<Table.Head>Starts</Table.Head> <Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head> <Table.Head>Ends</Table.Head>
<Table.Head>Status</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.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each data.records as record (record.id)} {#each data.records as record (record.id)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell> <Table.Cell class="font-medium">{formatValue(record.roomName)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell> <Table.Cell>{formatValue(record.serviceName)}</Table.Cell>
<Table.Cell class="">{formatDate(record.startDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.startDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
<Table.Cell class="" <Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge ><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell ></Table.Cell
> >
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
<Table.Cell> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,20 +1,18 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import * 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import ContractFormFields from './contract-form-fields.svelte';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let { let {
open = $bindable(false), open = $bindable(false),
options,
createForm, createForm,
createData, createData,
enhanceCreate enhanceCreate
}: { }: {
open: boolean; open: boolean;
options: any;
createForm: any; createForm: any;
createData: any; createData: any;
enhanceCreate: any; enhanceCreate: any;
@@ -33,64 +31,7 @@
<Dialog.Description>Add a new contract record.</Dialog.Description> <Dialog.Description>Add a new contract record.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate> <form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="title"> <ContractFormFields form={createForm} data={createData} {options} prefix="create" />
<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>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,23 +1,24 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js'; import { Badge } from '$lib/components/ui/badge/index.js';
import MoneyField from '$lib/components/money-field.svelte';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import ContractFormFields from './contract-form-fields.svelte';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let { let {
editingRecord, editingRecord,
options,
editForm, editForm,
editData, editData,
enhanceEdit, enhanceEdit,
enhanceTransition,
onClose onClose
}: { }: {
editingRecord: any; editingRecord: any;
options: any;
editForm: any; editForm: any;
editData: any; editData: any;
enhanceEdit: any; enhanceEdit: any;
enhanceTransition: any;
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
</script> </script>
@@ -25,78 +26,49 @@
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}> <Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl"> <Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header> <Dialog.Header>
<div class="flex items-center gap-2">
<Dialog.Title>Edit contract</Dialog.Title> <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.Description>Update this contract record.</Dialog.Description>
</Dialog.Header> </Dialog.Header>
{#if editingRecord} {#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} /> <input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="title"> <ContractFormFields form={editForm} data={editData} {options} prefix="edit" />
<Form.Control id="edit-title"> </form>
{#snippet children({ props })}
<Form.Label>Title</Form.Label> <Dialog.Footer class="flex-row items-center justify-between sm:justify-between">
<Input {...props} type="text" bind:value={$editData.title} /> {#if editingRecord.status === 'draft' || editingRecord.status === 'active'}
{/snippet} <form
</Form.Control> method="POST"
<Form.FieldErrors /> action="?/transition"
</Form.Field> class="flex flex-wrap gap-2"
<Form.Field form={editForm} name="startDate"> use:enhanceTransition
<Form.Control id="edit-startDate"> >
{#snippet children({ props })} <input type="hidden" name="id" value={editingRecord.id} />
<Form.Label>Start date</Form.Label> {#if editingRecord.status === 'draft'}
<Input {...props} type="date" bind:value={$editData.startDate} /> <Button type="submit" name="targetStatus" value="void" variant="destructive"
{/snippet} >Void contract</Button
</Form.Control> >
<Form.FieldErrors /> <Button type="submit" name="targetStatus" value="active">Activate contract</Button>
</Form.Field> {:else}
<Form.Field form={editForm} name="endDate"> <Button type="submit" name="targetStatus" value="expired">Mark as expired</Button>
<Form.Control id="edit-endDate"> {/if}
{#snippet children({ props })} </form>
<Form.Label>End date</Form.Label> {:else}
<Input {...props} type="date" bind:value={$editData.endDate} /> <div></div>
{/snippet} {/if}
</Form.Control> <div class="flex gap-2">
<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>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close >{/snippet}</Dialog.Close
> >
<Button type="submit">Save changes</Button> <Button type="submit" form="edit-contract-form">Save changes</Button>
</div>
</Dialog.Footer> </Dialog.Footer>
</form>
{/if} {/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -5,7 +5,7 @@ import { db } from '$lib/server/db';
import { invoices, clients } from '$lib/server/db/schema'; import { invoices, clients } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { 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'; import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) { async function loadOptions(organizationId: string) {
@@ -51,48 +51,18 @@ export const load: PageServerLoad = async ({ locals, params }) => {
} }
})), })),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(invoiceCreateSchema), { editForm: await superValidate(zod4(invoiceEditSchema), {
id: 'invoices-create' id: 'invoices-edit',
errors: false
}), }),
editForm: await superValidate(zod4(invoiceEditSchema), { id: 'invoices-edit' }), archiveForm: await superValidate(zod4(archiveSchema), {
archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' }) id: 'invoices-archive',
errors: false
})
}; };
}; };
export const actions: Actions = { 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 }) => { edit: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals); const { activeOrganizationId } = await loadOrganizationContext(locals);
const formData = await request.formData(); const formData = await request.formData();
@@ -1,7 +1,4 @@
<script lang="ts"> <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 InvoicesTable from './invoices-table.svelte';
import EditInvoiceDialog from './edit-invoice-dialog.svelte'; import EditInvoiceDialog from './edit-invoice-dialog.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte'; import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
@@ -9,35 +6,17 @@
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import { import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
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 type { PageData } from './$types'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number]; type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); 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 editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null); let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId)); const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId)); 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 // svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, { const editForm = superForm(data.editForm, {
validators: zod4Client(invoiceEditSchema), validators: zod4Client(invoiceEditSchema),
@@ -53,12 +32,51 @@
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' }) onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
}); });
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm; const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm; const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') { function handleFormToast(
return getRelationLabel(data.options, value, optionsKey); 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) { function openEdit(record: RecordRow) {
@@ -72,7 +90,15 @@
} }
function recordName(record: Partial<RecordRow> | undefined) { 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> </script>
@@ -81,30 +107,20 @@
</svelte:head> </svelte:head>
<div class="flex flex-col gap-6"> <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> <div>
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1> <h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
<p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p> <p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p>
</div> </div>
<CreateInvoiceDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div> </div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search invoices..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<InvoicesTable <InvoicesTable
data={listData} {data}
{openEdit} {openEdit}
{openArchive} {openArchive}
{formatValue} {formatValue}
{formatDate} {formatDate}
{formatMoney} {formatMoney}
{relationLabel}
{recordName} {recordName}
/> />
</div> </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"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 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 { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/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 { Textarea } from '$lib/components/ui/textarea/index.js';
let { let {
editingRecord, editingRecord,
@@ -31,49 +32,62 @@
{#if editingRecord} {#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit> <form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} /> <input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="invoiceNumber"> <FormField form={editForm} name="invoiceNumber">
<Form.Control id="edit-invoiceNumber"> <Field.Field>
<Control id="edit-invoiceNumber">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Invoice number</Form.Label> <Field.Label>Invoice number</Field.Label>
<Input {...props} type="text" bind:value={$editData.invoiceNumber} /> <Input {...props} type="text" bind:value={$editData.invoiceNumber} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="issueDate"> </FormField>
<Form.Control id="edit-issueDate"> <FormField form={editForm} name="issueDate">
<Field.Field>
<Control id="edit-issueDate">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Issue date</Form.Label> <Field.Label>Issue date</Field.Label>
<Input {...props} type="date" bind:value={$editData.issueDate} /> <Input {...props} type="date" bind:value={$editData.issueDate} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="dueDate"> </FormField>
<Form.Control id="edit-dueDate"> <FormField form={editForm} name="dueDate">
<Field.Field>
<Control id="edit-dueDate">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Due date</Form.Label> <Field.Label>Due date</Field.Label>
<Input {...props} type="date" bind:value={$editData.dueDate} /> <Input {...props} type="date" bind:value={$editData.dueDate} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="status"> </FormField>
<Form.Control id="edit-status"> <FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Status</Form.Label> <Field.Label>Status</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="status"
<NativeSelect.Option value="draft">Draft</NativeSelect.Option> bind:value={$editData.status}
<NativeSelect.Option value="sent">Sent</NativeSelect.Option> options={[
<NativeSelect.Option value="paid">Paid</NativeSelect.Option> { value: '', label: 'Not set' },
<NativeSelect.Option value="overdue">Overdue</NativeSelect.Option> { value: 'draft', label: 'Draft' },
<NativeSelect.Option value="void">Void</NativeSelect.Option> { value: 'sent', label: 'Sent' },
</NativeSelect.Root> { value: 'paid', label: 'Paid' },
{ value: 'overdue', label: 'Overdue' },
{ value: 'void', label: 'Void' }
]}
triggerProps={props}
/>
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
<MoneyField <MoneyField
form={editForm} form={editForm}
data={editData} data={editData}
@@ -89,15 +103,17 @@
label="Total" label="Total"
id="edit-totalGbp" id="edit-totalGbp"
/> />
<Form.Field form={editForm} name="notes"> <FormField form={editForm} name="notes">
<Form.Control id="edit-notes"> <Field.Field>
<Control id="edit-notes">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Notes</Form.Label> <Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} /> <Textarea {...props} bind:value={$editData.notes} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -12,7 +12,6 @@
formatValue, formatValue,
formatDate, formatDate,
formatMoney, formatMoney,
relationLabel,
recordName recordName
}: { }: {
data: any; data: any;
@@ -21,7 +20,6 @@
formatValue: any; formatValue: any;
formatDate: any; formatDate: any;
formatMoney: any; formatMoney: any;
relationLabel: any;
recordName: any; recordName: any;
} = $props(); } = $props();
</script> </script>
@@ -32,7 +30,6 @@
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Invoice #</Table.Head> <Table.Head>Invoice #</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Issued</Table.Head> <Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head> <Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head> <Table.Head>Status</Table.Head>
@@ -44,7 +41,6 @@
{#each data.records as record (record.id)} {#each data.records as record (record.id)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell> <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.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class="" <Table.Cell class=""
@@ -4,7 +4,6 @@
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'; import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
@@ -29,8 +28,9 @@
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Name</Table.Head> <Table.Head>Name</Table.Head>
<Table.Head>Type</Table.Head> <Table.Head>Primary contact</Table.Head>
<Table.Head>Status</Table.Head> <Table.Head>Email</Table.Head>
<Table.Head>Primary address</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head> <Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -44,14 +44,9 @@
>{formatValue(record.name)}<ExternalLinkIcon class="size-3.5" /></a >{formatValue(record.name)}<ExternalLinkIcon class="size-3.5" /></a
></Table.Cell ></Table.Cell
> >
<Table.Cell class="" <Table.Cell>{formatValue(record.primaryContactName)}</Table.Cell>
><Badge variant="secondary" class="capitalize">{formatValue(record.type)}</Badge <Table.Cell>{formatValue(record.primaryContactEmail)}</Table.Cell>
></Table.Cell <Table.Cell>{formatValue(record.primaryAddressLine1)}</Table.Cell>
>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import * 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 { industryOptions } from '$lib/constants/industries';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import * as Stepper from '$lib/components/ui/stepper/index.js'; import * as Stepper from '$lib/components/ui/stepper/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js'; import { Textarea } from '$lib/components/ui/textarea/index.js';
let { let {
@@ -22,9 +25,9 @@
enhanceCreate: any; enhanceCreate: any;
} = $props(); } = $props();
const createSteps = [ const createSteps = [
{ title: 'Client', description: 'Business details' }, { title: 'Client', description: 'Client details' },
{ title: 'Contact', description: 'Primary contact' }, { title: 'Address', description: 'Primary address' },
{ title: 'Address', description: 'Primary address' } { title: 'Contact', description: 'Primary contact' }
] as const; ] as const;
</script> </script>
@@ -58,47 +61,38 @@
</Stepper.Root> </Stepper.Root>
<div class={['grid gap-2', createStep !== 0 && 'hidden']}> <div class={['grid gap-2', createStep !== 0 && 'hidden']}>
<Form.Field form={createForm} name="name"> <FormField form={createForm} name="name">
<Form.Control id="create-name"> <Field.Field>
<Control id="create-name">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Name</Form.Label> <Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.name} /> <Input {...props} type="text" bind:value={$createData.name} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="type"> </FormField>
<Form.Control id="create-type"> <FormField form={createForm} name="industry">
<Field.Field>
<Control id="create-industry">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Type</Form.Label> <Field.Label>Industry</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.type}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="industry"
<NativeSelect.Option value="business">Business</NativeSelect.Option> bind:value={$createData.industry}
<NativeSelect.Option value="sport">Sport</NativeSelect.Option> options={industryOptions}
<NativeSelect.Option value="individual">Individual</NativeSelect.Option> triggerProps={props}
</NativeSelect.Root> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="status"> </FormField>
<Form.Control id="create-status"> <FormField form={createForm} name="website">
<Field.Field>
<Control id="create-website">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Status</Form.Label> <Field.Label>Website</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.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={createForm} name="website">
<Form.Control id="create-website">
{#snippet children({ props })}
<Form.Label>Website</Form.Label>
<Input <Input
{...props} {...props}
type="url" type="url"
@@ -106,133 +100,185 @@
bind:value={$createData.website} bind:value={$createData.website}
/> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="notes"> </FormField>
<Form.Control id="create-notes"> <FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Notes</Form.Label> <Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} /> <Textarea {...props} bind:value={$createData.notes} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</div> </FormField>
<div class={['grid gap-2', createStep !== 1 && 'hidden']}>
<Form.Field form={createForm} name="primaryContactName">
<Form.Control id="create-primary-contact-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactName} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactRole">
<Form.Control id="create-primary-contact-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactRole} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactEmail">
<Form.Control id="create-primary-contact-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input {...props} type="email" bind:value={$createData.primaryContactEmail} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactPhone">
<Form.Control id="create-primary-contact-phone">
{#snippet children({ props })}
<Form.Label>Phone</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactPhone} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryContactNotes">
<Form.Control id="create-primary-contact-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.primaryContactNotes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div> </div>
<div class={['grid gap-2', createStep !== 2 && 'hidden']}> <div class={['grid gap-2', createStep !== 2 && 'hidden']}>
<Form.Field form={createForm} name="primaryAddressLabel"> <FormField form={createForm} name="primaryContactName">
<Form.Control id="create-primary-address-label"> <Field.Field>
<Control id="create-primary-contact-name">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Label</Form.Label> <Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLabel} /> <Input {...props} type="text" bind:value={$createData.primaryContactName} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="primaryAddressLine1"> </FormField>
<Form.Control id="create-primary-address-line-1"> <FormField form={createForm} name="primaryContactRole">
<Field.Field>
<Control id="create-primary-contact-role">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Address line 1</Form.Label> <Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactRole} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryContactEmail">
<Field.Field>
<Control id="create-primary-contact-email">
{#snippet children({ props })}
<Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$createData.primaryContactEmail} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryContactPhone">
<Field.Field>
<Control id="create-primary-contact-phone">
{#snippet children({ props })}
<Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactPhone} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-3 py-1">
<FormCheckbox
id="create-primary-contact-receives-invoices"
name="primaryContactReceivesInvoices"
label="Receives invoices"
description="Send invoices raised for this client to this contact."
bind:value={$createData.primaryContactReceivesInvoices}
/>
<FormCheckbox
id="create-primary-contact-receives-contracts"
name="primaryContactReceivesContracts"
label="Receives contracts"
description="Send contract documents for this client to this contact."
bind:value={$createData.primaryContactReceivesContracts}
/>
</div>
</div>
<div class={['grid gap-2', createStep !== 1 && 'hidden']}>
<FormField form={createForm} name="primaryAddressType">
<Field.Field>
<Control id="create-primary-address-type">
{#snippet children({ props })}
<Field.Label>Type</Field.Label>
<FormSelect
name="primaryAddressType"
bind:value={$createData.primaryAddressType}
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="primaryAddressLine1">
<Field.Field>
<Control id="create-primary-address-line-1">
{#snippet children({ props })}
<Field.Label>Address line 1</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine1} /> <Input {...props} type="text" bind:value={$createData.primaryAddressLine1} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="primaryAddressLine2"> </FormField>
<Form.Control id="create-primary-address-line-2"> <FormField form={createForm} name="primaryAddressLine2">
<Field.Field>
<Control id="create-primary-address-line-2">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Address line 2</Form.Label> <Field.Label>Address line 2</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine2} /> <Input {...props} type="text" bind:value={$createData.primaryAddressLine2} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<div class="grid gap-2 sm:grid-cols-2"> </FormField>
<Form.Field form={createForm} name="primaryAddressCity"> <FormField form={createForm} name="primaryAddressLine3">
<Form.Control id="create-primary-address-city"> <Field.Field>
<Control id="create-primary-address-line-3">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>City</Form.Label> <Field.Label>Address line 3</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLine3} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-2 sm:grid-cols-2">
<FormField form={createForm} name="primaryAddressCity">
<Field.Field>
<Control id="create-primary-address-city">
{#snippet children({ props })}
<Field.Label>City</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressCity} /> <Input {...props} type="text" bind:value={$createData.primaryAddressCity} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="primaryAddressRegion"> </FormField>
<Form.Control id="create-primary-address-region"> <FormField form={createForm} name="primaryAddressRegion">
<Field.Field>
<Control id="create-primary-address-region">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Region</Form.Label> <Field.Label>Region</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressRegion} /> <Input {...props} type="text" bind:value={$createData.primaryAddressRegion} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
</div> </div>
<div class="grid gap-2 sm:grid-cols-2"> <div class="grid gap-2 sm:grid-cols-2">
<Form.Field form={createForm} name="primaryAddressPostcode"> <FormField form={createForm} name="primaryAddressPostcode">
<Form.Control id="create-primary-address-postcode"> <Field.Field>
<Control id="create-primary-address-postcode">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Postcode</Form.Label> <Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressPostcode} /> <Input {...props} type="text" bind:value={$createData.primaryAddressPostcode} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={createForm} name="primaryAddressCountry"> </FormField>
<Form.Control id="create-primary-address-country"> <FormField form={createForm} name="primaryAddressCountry">
<Field.Field>
<Control id="create-primary-address-country">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Country</Form.Label> <Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressCountry} /> <Input {...props} type="text" bind:value={$createData.primaryAddressCountry} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
</div> </div>
</div> </div>
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* 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 { industryOptions } from '$lib/constants/industries';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/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 { Textarea } from '$lib/components/ui/textarea/index.js';
let { let {
editingRecord, editingRecord,
@@ -30,47 +32,38 @@
{#if editingRecord} {#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit> <form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} /> <input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name"> <FormField form={editForm} name="name">
<Form.Control id="edit-name"> <Field.Field>
<Control id="edit-name">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Name</Form.Label> <Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} /> <Input {...props} type="text" bind:value={$editData.name} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="type"> </FormField>
<Form.Control id="edit-type"> <FormField form={editForm} name="industry">
<Field.Field>
<Control id="edit-industry">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Type</Form.Label> <Field.Label>Industry</Field.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.type}> <FormSelect
<NativeSelect.Option value="">Not set</NativeSelect.Option> name="industry"
<NativeSelect.Option value="business">Business</NativeSelect.Option> bind:value={$editData.industry}
<NativeSelect.Option value="sport">Sport</NativeSelect.Option> options={industryOptions}
<NativeSelect.Option value="individual">Individual</NativeSelect.Option> triggerProps={props}
</NativeSelect.Root> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="status"> </FormField>
<Form.Control id="edit-status"> <FormField form={editForm} name="website">
<Field.Field>
<Control id="edit-website">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Status</Form.Label> <Field.Label>Website</Field.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-website">
{#snippet children({ props })}
<Form.Label>Website</Form.Label>
<Input <Input
{...props} {...props}
type="url" type="url"
@@ -78,18 +71,21 @@
bind:value={$editData.website} bind:value={$editData.website}
/> />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
<Form.Field form={editForm} name="notes"> </FormField>
<Form.Control id="edit-notes"> <FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })} {#snippet children({ props })}
<Form.Label>Notes</Form.Label> <Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} /> <Textarea {...props} bind:value={$editData.notes} />
{/snippet} {/snippet}
</Form.Control> </Control>
<Form.FieldErrors /> <Field.Error><FieldErrors /></Field.Error>
</Form.Field> </Field.Field>
</FormField>
<Dialog.Footer> <Dialog.Footer>
<Dialog.Close <Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button >{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -30,7 +30,10 @@ export const load: PageServerLoad = async ({ locals }) => {
return { return {
records, records,
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' }) archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contacts-archive',
errors: false
})
}; };
}; };
@@ -25,6 +25,8 @@
<Table.Head>Email</Table.Head> <Table.Head>Email</Table.Head>
<Table.Head>Phone</Table.Head> <Table.Head>Phone</Table.Head>
<Table.Head>Primary</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.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -37,6 +39,8 @@
<Table.Cell class="">{formatValue(record.email)}</Table.Cell> <Table.Cell class="">{formatValue(record.email)}</Table.Cell>
<Table.Cell class="">{formatValue(record.phone)}</Table.Cell> <Table.Cell class="">{formatValue(record.phone)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</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> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
+18 -5
View File
@@ -2,7 +2,7 @@ import { and, asc, eq, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server'; import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { contracts, clients } from '$lib/server/db/schema'; import { contracts, clients, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -22,15 +22,28 @@ async function loadOptions(organizationId: string) {
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals); const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db const records = await db
.select() .select({
contract: contracts,
roomName: rooms.name,
serviceName: services.name
})
.from(contracts) .from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt))) .where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt)))
.orderBy(asc(contracts.title)); .orderBy(asc(contracts.startDate));
return { return {
records, records: records.map((record) => ({
...record.contract,
roomName: record.roomName,
serviceName: record.serviceName
})),
options: await loadOptions(activeOrganizationId), options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' }) archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contracts-archive',
errors: false
})
}; };
}; };
+43 -31
View File
@@ -1,27 +1,14 @@
<script lang="ts"> <script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import ContractsTable from './contracts-table.svelte'; import ContractsTable from './contracts-table.svelte';
import ArchiveContractDialog from './archive-contract-dialog.svelte'; import ArchiveContractDialog from './archive-contract-dialog.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema'; 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 type { PageData } from './$types'; import type { PageData } from './$types';
type RecordRow = PageData['records'][number]; type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let archivingId = $state<string | null>(null); let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId)); const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
@@ -36,8 +23,48 @@
const { form: archiveData, enhance: enhanceArchive } = archiveForm; const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') { function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey); 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 openArchive(record: RecordRow) { function openArchive(record: RecordRow) {
@@ -46,7 +73,7 @@
} }
function recordName(record: Partial<RecordRow> | undefined) { function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contract'); return String(record?.roomName ?? record?.serviceName ?? 'this contract');
} }
</script> </script>
@@ -62,22 +89,7 @@
</div> </div>
</div> </div>
<ListSearch <ContractsTable {data} {openArchive} {formatValue} {formatDate} {relationLabel} {recordName} />
bind:value={listSearch.params.q}
placeholder="Search contracts..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<ContractsTable
data={listData}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div> </div>
<ArchiveContractDialog <ArchiveContractDialog
@@ -10,7 +10,6 @@
openArchive, openArchive,
formatValue, formatValue,
formatDate, formatDate,
formatMoney,
relationLabel, relationLabel,
recordName recordName
}: { }: {
@@ -18,7 +17,6 @@
openArchive: any; openArchive: any;
formatValue: any; formatValue: any;
formatDate: any; formatDate: any;
formatMoney: any;
relationLabel: any; relationLabel: any;
recordName: any; recordName: any;
} = $props(); } = $props();
@@ -29,19 +27,20 @@
<Table.Root> <Table.Root>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.Head>Title</Table.Head> <Table.Head>Room</Table.Head>
<Table.Head>Service</Table.Head>
<Table.Head>Client</Table.Head> <Table.Head>Client</Table.Head>
<Table.Head>Starts</Table.Head> <Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head> <Table.Head>Ends</Table.Head>
<Table.Head>Status</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.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{#each data.records as record (record.id)} {#each data.records as record (record.id)}
<Table.Row> <Table.Row>
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell> <Table.Cell class="font-medium">{formatValue(record.roomName)}</Table.Cell>
<Table.Cell>{formatValue(record.serviceName)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell> <Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.startDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.startDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell> <Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
@@ -49,7 +48,6 @@
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge ><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell ></Table.Cell
> >
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
<Table.Cell> <Table.Cell>
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
@@ -1,51 +0,0 @@
import { and, asc, eq, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db';
import { invoices, clients } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import 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));
return {
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
};
}
export const load: PageServerLoad = async ({ locals }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.from(invoices)
.where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt)))
.orderBy(asc(invoices.invoiceNumber));
return {
records,
options: await loadOptions(activeOrganizationId),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' })
};
};
export const actions: Actions = {
archive: async (event) => {
const { activeOrganizationId } = await loadOrganizationContext(event.locals);
const form = await superValidate(event, zod4(archiveSchema), { id: 'invoices-archive' });
if (!form.valid) return message(form, 'Invoice id is required.', { status: 400 });
await db
.update(invoices)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(and(eq(invoices.id, form.data.id), eq(invoices.organizationId, activeOrganizationId)));
return message(form, 'Invoice archived.');
}
};
@@ -1,89 +0,0 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import InvoicesTable from './invoices-table.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
formatMoney,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
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 archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this invoice');
}
</script>
<svelte:head>
<title>Invoices | Clearity</title>
</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>
<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>
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search invoices..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<InvoicesTable
data={listData}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = ({ url }) => {
redirect(308, `/dashboard/billing${url.search}`);
};
@@ -0,0 +1,71 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import OrganizationEditDialog from './organization-edit-dialog.svelte';
import type { LayoutData } from './$types';
let {
data,
children
}: {
data: LayoutData;
children: import('svelte').Snippet;
} = $props();
let editOpen = $state(false);
const tabs = [
{ value: 'details', label: 'Details' },
{ value: 'admins', label: 'Admins' }
] as const;
type TabValue = (typeof tabs)[number]['value'];
const activeTab = $derived.by<TabValue>(() => {
const segment = page.url.pathname.split('/').filter(Boolean).at(-1);
return tabs.find((tab) => tab.value === segment)?.value ?? 'details';
});
function navigateToTab(tab: TabValue) {
goto(resolve(`/dashboard/organization/${tab}`));
}
</script>
<svelte:head>
<title>{data.activeOrganization.name} | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">{data.activeOrganization.name}</h1>
<p class="text-sm text-muted-foreground">
Manage organization details, invoice payment information, and administrators.
</p>
</div>
<OrganizationEditDialog organization={data.activeOrganization} bind:open={editOpen} />
</div>
</div>
<Tabs.Root
value={activeTab}
onValueChange={(value) => {
if (value !== activeTab) navigateToTab(value as TabValue);
}}
class="w-full"
>
<Tabs.List class="flex h-auto flex-wrap justify-start">
{#each tabs as tab (tab.value)}
<Tabs.Trigger value={tab.value} aria-label={`View organization ${tab.label.toLowerCase()}`}>
{tab.label}
</Tabs.Trigger>
{/each}
</Tabs.List>
<div class="mt-4">
{@render children()}
</div>
</Tabs.Root>
</div>
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = () => {
redirect(307, '/dashboard/organization/details');
};
@@ -25,9 +25,15 @@ export const load: PageServerLoad = async ({ locals }) => {
return { return {
currentUserId: locals.user?.id, currentUserId: locals.user?.id,
users, users,
createForm: await superValidate(zod4(createAdminSchema), { id: 'create-admin' }), createForm: await superValidate(zod4(createAdminSchema), {
editForm: await superValidate(zod4(editAdminSchema), { id: 'edit-admin' }), id: 'create-admin',
deleteForm: await superValidate(zod4(deleteAdminSchema), { id: 'delete-admin' }) errors: false
}),
editForm: await superValidate(zod4(editAdminSchema), { id: 'edit-admin', errors: false }),
deleteForm: await superValidate(zod4(deleteAdminSchema), {
id: 'delete-admin',
errors: false
})
}; };
}; };
@@ -1,21 +1,15 @@
<script lang="ts"> <script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateAdminDialog from './create-admin-dialog.svelte';
import DeleteAdminDialog from './delete-admin-dialog.svelte';
import AdminsTable from './admins-table.svelte';
import EditAdminDialog from './edit-admin-dialog.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms'; import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters'; import { zod4Client } from 'sveltekit-superforms/adapters';
import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin'; import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin';
import { formatDate } from '$lib/record-utils'; import AdminsTable from './admins-table.svelte';
import { handleFormToast } from '$lib/form-feedback'; import CreateAdminDialog from './create-admin-dialog.svelte';
import DeleteAdminDialog from './delete-admin-dialog.svelte';
import EditAdminDialog from './edit-admin-dialog.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.users); let { data }: { data: PageData } = $props();
const listData = $derived({ ...data, users: listSearch.filtered });
let createOpen = $state(false); let createOpen = $state(false);
let editingUserId = $state<string | null>(null); let editingUserId = $state<string | null>(null);
@@ -23,12 +17,14 @@
const editingUser = $derived(data.users.find((user) => user.id === editingUserId)); const editingUser = $derived(data.users.find((user) => user.id === editingUserId));
const deletingUser = $derived(data.users.find((user) => user.id === deletingUserId)); const deletingUser = $derived(data.users.find((user) => user.id === deletingUserId));
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, { const createForm = superForm(data.createForm, {
validators: zod4Client(createAdminSchema), validators: zod4Client(createAdminSchema),
onUpdated: ({ form }) => handleFormToast(form, 'create-admin', () => (createOpen = false)), onUpdated: ({ form }) => handleFormToast(form, 'create-admin', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'create-admin' }) onError: ({ result }) => toast.error(result.error.message, { id: 'create-admin' })
}); });
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, { const editForm = superForm(data.editForm, {
validators: zod4Client(editAdminSchema), validators: zod4Client(editAdminSchema),
@@ -36,6 +32,7 @@
onUpdated: ({ form }) => handleFormToast(form, 'edit-admin', () => (editingUserId = null)), onUpdated: ({ form }) => handleFormToast(form, 'edit-admin', () => (editingUserId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'edit-admin' }) onError: ({ result }) => toast.error(result.error.message, { id: 'edit-admin' })
}); });
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const deleteForm = superForm(data.deleteForm, { const deleteForm = superForm(data.deleteForm, {
validators: zod4Client(deleteAdminSchema), validators: zod4Client(deleteAdminSchema),
@@ -48,6 +45,38 @@
const { form: editData, enhance: enhanceEdit } = editForm; const { form: editData, enhance: enhanceEdit } = editForm;
const { form: deleteData, enhance: enhanceDelete } = deleteForm; const { form: deleteData, enhance: enhanceDelete } = deleteForm;
function formatDate(value: Date) {
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric'
}).format(value);
}
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 openEdit(user: PageData['users'][number]) { function openEdit(user: PageData['users'][number]) {
editForm.reset({ editForm.reset({
data: { data: {
@@ -71,28 +100,19 @@
} }
</script> </script>
<svelte:head>
<title>Admins | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <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">Admins</h1> <h2 class="text-xl font-semibold tracking-tight">Administrators</h2>
<p class="text-sm text-muted-foreground">Manage Better Auth users and admin roles.</p> <p class="text-sm text-muted-foreground">
Manage users who can access and administer Clearity.
</p>
</div> </div>
<CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} /> <CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div> </div>
<ListSearch <AdminsTable {data} {openEdit} {openDelete} {formatDate} />
bind:value={listSearch.params.q}
placeholder="Search admins..."
totalCount={data.users.length}
resultCount={listSearch.filtered.length}
/>
<AdminsTable data={listData} {openEdit} {openDelete} {formatDate} />
</div> </div>
<EditAdminDialog <EditAdminDialog
@@ -0,0 +1,105 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import * 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';
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 user
</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Create user</Dialog.Title>
<Dialog.Description>Add a manually managed Better Auth user.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<FormField form={createForm} name="name">
<Field.Field>
<Control id="create-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} autocomplete="name" bind:value={$createData.name} />
{/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" autocomplete="email" bind:value={$createData.email} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="password">
<Field.Field>
<Control id="create-password">
{#snippet children({ props })}
<Field.Label>Password</Field.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
bind:value={$createData.password}
/>
{/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>
<FormSelect
name="role"
bind:value={$createData.role}
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' }
]}
triggerProps={props}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<Button type="submit">Create user</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,102 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import * 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';
let {
editingUser,
editForm,
editData,
enhanceEdit,
onClose
}: {
editingUser: any;
editForm: any;
editData: any;
enhanceEdit: any;
onClose: () => void;
} = $props();
</script>
<Dialog.Root open={!!editingUser} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Edit user</Dialog.Title>
<Dialog.Description>Update account details or set a new password.</Dialog.Description>
</Dialog.Header>
{#if editingUser}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-name">
{#snippet children({ props })}
<Field.Label>Name</Field.Label>
<Input {...props} autocomplete="name" bind:value={$editData.name} />
{/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" autocomplete="email" bind:value={$editData.email} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="password">
<Field.Field>
<Control id="edit-password">
{#snippet children({ props })}
<Field.Label>New password</Field.Label>
<Input
{...props}
type="password"
autocomplete="new-password"
placeholder="Leave blank to keep current password"
bind:value={$editData.password}
/>
{/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>
<FormSelect
name="role"
bind:value={$editData.role}
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' }
]}
triggerProps={props}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,57 @@
<script lang="ts">
import LandmarkIcon from '@lucide/svelte/icons/landmark';
import MapPinIcon from '@lucide/svelte/icons/map-pin';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const organization = $derived(data.activeOrganization);
const detailGroups = $derived([
{
title: 'Organization details',
icon: MapPinIcon,
items: [
{ label: 'Name', value: organization.name },
{ label: 'Workspace', value: organization.workspace },
{ label: 'Address', value: organization.addressLine1 },
{ label: 'Address line 2', value: organization.addressLine2 },
{ label: 'City', value: organization.city },
{ label: 'Region', value: organization.region },
{ label: 'Postcode', value: organization.postcode },
{ label: 'Country', value: organization.country }
]
},
{
title: 'Bank details',
icon: LandmarkIcon,
items: [
{ label: 'Bank name', value: organization.bankName },
{ label: 'Account name', value: organization.bankAccountName },
{ label: 'Account number', value: organization.bankAccountNumber },
{ label: 'Sort code', value: organization.bankSortCode },
{ label: 'IBAN', value: organization.bankIban },
{ label: 'SWIFT/BIC', value: organization.bankSwift }
]
}
]);
</script>
<div class="grid gap-4 lg:grid-cols-2">
{#each detailGroups as group (group.title)}
<section class="rounded-lg border">
<div class="flex items-center gap-2 border-b px-4 py-3">
<group.icon class="size-4 text-muted-foreground" />
<h2 class="font-medium">{group.title}</h2>
</div>
<dl class="grid gap-x-6 gap-y-4 p-4 sm:grid-cols-2">
{#each group.items as item (item.label)}
<div>
<dt class="text-xs font-medium text-muted-foreground uppercase">{item.label}</dt>
<dd class="mt-1 text-sm">{item.value || 'Not set'}</dd>
</div>
{/each}
</dl>
</section>
{/each}
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import OrganizationFormFields from '$lib/components/organization-form-fields.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
type Organization = {
id: string;
name: string;
workspace: string;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
region?: string | null;
postcode?: string | null;
country?: string | null;
bankName?: string | null;
bankAccountName?: string | null;
bankAccountNumber?: string | null;
bankSortCode?: string | null;
bankIban?: string | null;
bankSwift?: string | null;
};
let {
organization,
open = $bindable(false)
}: {
organization: Organization;
open: boolean;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}>Edit organization</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit organization</Dialog.Title>
<Dialog.Description>
Update organization, address, and invoice payment details.
</Dialog.Description>
</Dialog.Header>
<form method="POST" action="/dashboard/organizations/current" class="grid gap-5">
<input type="hidden" name="id" value={organization.id} />
<input type="hidden" name="redirectTo" value="/dashboard/organization/details" />
<OrganizationFormFields prefix="organization-edit" {organization} />
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<Button type="submit">Save changes</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
+3 -3
View File
@@ -33,9 +33,9 @@ export const load: PageServerLoad = async ({ locals }) => {
pricePerMonthGbp: record.pricePerMonthGbp ?? 0 pricePerMonthGbp: record.pricePerMonthGbp ?? 0
} }
})), })),
createForm: await superValidate(zod4(roomCreateSchema), { id: 'rooms-create' }), createForm: await superValidate(zod4(roomCreateSchema), { id: 'rooms-create', errors: false }),
editForm: await superValidate(zod4(roomEditSchema), { id: 'rooms-edit' }), editForm: await superValidate(zod4(roomEditSchema), { id: 'rooms-edit', errors: false }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'rooms-archive' }) archiveForm: await superValidate(zod4(archiveSchema), { id: 'rooms-archive', errors: false })
}; };
}; };
@@ -28,9 +28,15 @@ export const load: PageServerLoad = async ({ locals }) => {
description: record.description ?? '' description: record.description ?? ''
} }
})), })),
createForm: await superValidate(zod4(serviceCreateSchema), { id: 'services-create' }), createForm: await superValidate(zod4(serviceCreateSchema), {
editForm: await superValidate(zod4(serviceEditSchema), { id: 'services-edit' }), id: 'services-create',
archiveForm: await superValidate(zod4(archiveSchema), { id: 'services-archive' }) errors: false
}),
editForm: await superValidate(zod4(serviceEditSchema), { id: 'services-edit', errors: false }),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'services-archive',
errors: false
})
}; };
}; };
+2 -2
View File
@@ -35,7 +35,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
password: '' password: ''
}, },
zod4(loginSchema), zod4(loginSchema),
{ id: 'login' } { id: 'login', errors: false }
), ),
firstAdminForm: await superValidate( firstAdminForm: await superValidate(
{ {
@@ -44,7 +44,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
password: '' password: ''
}, },
zod4(firstAdminSchema), zod4(firstAdminSchema),
{ id: 'first-admin' } { id: 'first-admin', errors: false }
) )
}; };
}; };