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,
"tag": "0006_cloudy_catseye",
"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",
"private": true,
"version": "0.0.1",
"packageManager": "pnpm@11.9.0",
"type": "module",
"scripts": {
"dev": "vite dev",
+6
View File
@@ -1,3 +1,9 @@
allowBuilds:
'@prisma/client': true
better-sqlite3: true
esbuild: true
sharp: true
workerd: true
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- workerd
+2 -6
View File
@@ -9,10 +9,6 @@
title: 'Dashboard',
url: '/dashboard'
},
{
title: 'Admins',
url: '/dashboard/admins'
},
{
title: 'Clients',
url: '/dashboard/clients'
@@ -38,8 +34,8 @@
url: '/dashboard/services'
},
{
title: 'Invoices',
url: '/dashboard/invoices'
title: 'Billing',
url: '/dashboard/billing'
},
{
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">
import { toast } from 'svelte-sonner';
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 { superForm, type SuperValidated } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
@@ -9,7 +9,6 @@
import { Input } from '$lib/components/ui/input/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '$lib/utils.js';
import { firstFormError } from '$lib/form-feedback';
import { loginSchema, type LoginSchema } from '$lib/schemas/auth';
let {
@@ -28,7 +27,7 @@
resetForm: false,
onUpdated: ({ form }) => {
if (!form.valid) {
toast.error(form.message ?? firstFormError(form.errors), {
toast.error(form.message ?? firstError(form.errors), {
id: 'login-error',
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>
<div class={cn('flex flex-col gap-6', className)} bind:this={ref} {...restProps}>
@@ -58,46 +67,49 @@
<span class="sr-only">Clearity</span>
</div>
<h1 class="text-xl font-bold">Sign in to Clearity</h1>
<Field.Description>
Use the administrator account created for this workspace.
</Field.Description>
</div>
<Form.Field form={loginForm} name="email">
<Form.Control id="email-{id}">
<FormField form={loginForm} name="email">
<Field.Field>
<Control id="email-{id}">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Field.Label>Email</Field.Label>
<Input
{...props}
aria-invalid={$tainted?.email ? props['aria-invalid'] : undefined}
type="email"
autocomplete="email"
placeholder="admin@example.com"
bind:value={$formData.email}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={loginForm} name="password">
<Form.Control id="password-{id}">
</Control>
{#if $tainted?.email}
<Field.Error><FieldErrors /></Field.Error>
{/if}
</Field.Field>
</FormField>
<FormField form={loginForm} name="password">
<Field.Field>
<Control id="password-{id}">
{#snippet children({ props })}
<Form.Label>Password</Form.Label>
<Field.Label>Password</Field.Label>
<Input
{...props}
aria-invalid={$tainted?.password ? props['aria-invalid'] : undefined}
type="password"
autocomplete="current-password"
bind:value={$formData.password}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
<Field.Description>
New users are created manually by an administrator; public registration is disabled.
</Field.Description>
</Form.Field>
</Control>
{#if $tainted?.password}
<Field.Error><FieldErrors /></Field.Error>
{/if}
</Field.Field>
</FormField>
<Field.Field>
<Button type="submit" class="w-full">Sign in</Button>
</Field.Field>
</Field.Group>
</form>
<Field.Description class="px-6 text-center">
Business and sport centre management for clients, bookings, rooms, services, and invoices.
</Field.Description>
</div>
+16 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import * as Field from '$lib/components/ui/field/index.js';
import * as InputGroup from '$lib/components/ui/input-group/index.js';
let {
@@ -8,20 +9,23 @@
data,
name,
label,
id
id,
description
}: {
form: any;
data: any;
name: string;
label: string;
id: string;
description?: string;
} = $props();
</script>
<Form.Field {form} {name}>
<Form.Control {id}>
<FormField {form} {name}>
<Field.Field>
<Control {id}>
{#snippet children({ props })}
<Form.Label>{label}</Form.Label>
<Field.Label>{label}</Field.Label>
<InputGroup.Root>
<InputGroup.Addon>
<InputGroup.Text>£</InputGroup.Text>
@@ -37,6 +41,10 @@
/>
</InputGroup.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
{#if description}
<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 CheckIcon from '@lucide/svelte/icons/check';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import LandmarkIcon from '@lucide/svelte/icons/landmark';
import PlusIcon from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import OrganizationFormFields from './organization-form-fields.svelte';
import { Button } from '$lib/components/ui/button/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 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';
type Organization = {
@@ -39,162 +38,11 @@
activeOrganization: Organization;
} = $props();
let settingsOpen = $state(false);
let createOpen = $state(false);
const redirectTo = $derived(page.url.pathname + page.url.search);
const organizationSettingsUrl = resolve('/dashboard/organization/details');
</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.MenuItem>
<DropdownMenu.Root>
@@ -225,11 +73,12 @@
<input type="hidden" name="redirectTo" value={redirectTo} />
<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"
data-organization-item
data-selected={organization.id === activeOrganization.id}
>
<button type="submit" class="contents">
<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" />
</div>
@@ -250,13 +99,7 @@
<DropdownMenu.Item
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground"
>
<button
type="button"
class="contents"
onclick={() => {
settingsOpen = true;
}}
>
<a href={organizationSettingsUrl} class="contents">
<div
class="flex aspect-square size-8 items-center justify-center rounded-lg border bg-background text-foreground"
>
@@ -264,9 +107,9 @@
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<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>
</button>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item
class="gap-2 focus:bg-sidebar-accent focus:text-sidebar-accent-foreground"
@@ -294,34 +137,6 @@
</Sidebar.MenuItem>
</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.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
@@ -334,8 +149,7 @@
<form method="POST" action="/dashboard/organizations" class="grid gap-5">
<input type="hidden" name="redirectTo" value={redirectTo} />
{@render organizationFields('organization-create')}
{@render bankFields('organization-create')}
<OrganizationFormFields prefix="organization-create" />
<Dialog.Footer>
<Dialog.Close>
@@ -348,3 +162,13 @@
</form>
</Dialog.Content>
</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 { booleanString, idSchema, optionalText, requiredText } from './shared.schema';
import { idSchema, optionalText, requiredText } from './shared.schema';
export const addressCreateSchema = z.object({
clientId: requiredText('Client'),
label: requiredText('Label', 80),
type: z.enum(['primary', 'invoicing', 'contract']).default('primary'),
line1: requiredText('Address line 1'),
line2: optionalText(160),
line3: optionalText(160),
city: requiredText('City', 100),
region: optionalText(100),
postcode: requiredText('Postcode', 24),
country: requiredText('Country', 80).default('United Kingdom'),
isPrimary: booleanString
country: requiredText('Country', 80).default('United Kingdom')
});
export const addressEditSchema = addressCreateSchema.extend(idSchema.shape);
+6 -4
View File
@@ -1,10 +1,10 @@
import { z } from 'zod';
import { industries } from '$lib/constants/industries';
import { idSchema, optionalEmail, optionalText, optionalUrl, requiredText } from './shared.schema';
export const clientCreateSchema = z.object({
name: requiredText('Client name'),
type: z.enum(['business', 'sport', 'individual']).default('business'),
status: z.enum(['active', 'prospect', 'paused']).default('active'),
industry: z.enum([...industries, '']).default(''),
website: optionalUrl,
notes: optionalText(1000)
});
@@ -16,10 +16,12 @@ export const clientOnboardingCreateSchema = clientCreateSchema.extend({
primaryContactRole: optionalText(120),
primaryContactEmail: optionalEmail,
primaryContactPhone: optionalText(80),
primaryContactNotes: optionalText(1000),
primaryAddressLabel: requiredText('Address label', 80).default('Primary'),
primaryContactReceivesInvoices: z.enum(['true', 'false']).default('false'),
primaryContactReceivesContracts: z.enum(['true', 'false']).default('false'),
primaryAddressType: z.enum(['primary', 'invoicing', 'contract']).default('primary'),
primaryAddressLine1: requiredText('Address line 1'),
primaryAddressLine2: optionalText(160),
primaryAddressLine3: optionalText(160),
primaryAddressCity: requiredText('City', 100),
primaryAddressRegion: optionalText(100),
primaryAddressPostcode: requiredText('Postcode', 24),
+2 -1
View File
@@ -14,7 +14,8 @@ export const contactCreateSchema = z.object({
email: optionalEmail,
phone: optionalText(80),
isPrimary: booleanString,
notes: optionalText(1000)
receivesInvoices: booleanString,
receivesContracts: booleanString
});
export const contactEditSchema = contactCreateSchema.extend(idSchema.shape);
+31 -6
View File
@@ -1,14 +1,39 @@
import { z } from 'zod';
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'),
title: requiredText('Contract title'),
roomId: optionalText(80),
serviceId: optionalText(80),
licenseFeeGbp: moneyGbp,
depositGbp: moneyGbp,
startDate: requiredText('Start date', 40),
endDate: optionalText(40),
status: z.enum(['draft', 'active', 'expired', 'cancelled']).default('draft'),
valueGbp: moneyGbp,
endDate: requiredText('End date', 40),
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 { organizations } from './organizations.schema';
import { timestamps } from './shared.schema';
@@ -15,14 +15,14 @@ export const addresses = sqliteTable(
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'restrict' }),
label: text('label').notNull(),
type: text('type').notNull().default('primary'),
line1: text('line_1').notNull(),
line2: text('line_2'),
line3: text('line_3'),
city: text('city').notNull(),
region: text('region'),
postcode: text('postcode').notNull(),
country: text('country').notNull().default('United Kingdom'),
isPrimary: integer('is_primary', { mode: 'boolean' }).notNull().default(false),
...timestamps
},
(table) => [
+1 -2
View File
@@ -10,8 +10,7 @@ export const clients = sqliteTable('clients', {
.notNull()
.references(() => organizations.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
type: text('type').notNull().default('business'),
status: text('status').notNull().default('active'),
industry: text('industry'),
website: text('website'),
notes: text('notes'),
...timestamps
+2 -1
View File
@@ -20,7 +20,8 @@ export const contacts = sqliteTable(
email: text('email'),
phone: text('phone'),
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
},
(table) => [
+11 -4
View File
@@ -1,6 +1,8 @@
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { clients } from './clients.schema';
import { organizations } from './organizations.schema';
import { rooms } from './rooms.schema';
import { services } from './services.schema';
import { timestamps } from './shared.schema';
export const contracts = sqliteTable(
@@ -15,16 +17,21 @@ export const contracts = sqliteTable(
clientId: text('client_id')
.notNull()
.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(),
endDate: text('end_date'),
endDate: text('end_date').notNull(),
billedTo: text('billed_to'),
status: text('status').notNull().default('draft'),
valueGbp: real('value_gbp').notNull().default(0),
notes: text('notes'),
...timestamps
},
(table) => [
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)
]
);
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> = {
dashboard: 'Dashboard',
organization: 'Organization',
details: 'Details',
admins: 'Admins',
clients: 'Clients',
addresses: 'Addresses',
@@ -21,6 +23,7 @@
bookings: 'Bookings',
rooms: 'Rooms',
services: 'Services',
billing: 'Billing',
invoices: 'Invoices',
contracts: 'Contracts'
};
@@ -25,12 +25,15 @@ export const load: PageServerLoad = async ({ locals }) => {
.select()
.from(addresses)
.where(and(eq(addresses.organizationId, activeOrganizationId), isNull(addresses.archivedAt)))
.orderBy(asc(addresses.label));
.orderBy(asc(addresses.type));
return {
records,
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.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Label</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Address</Table.Head>
<Table.Head>City</Table.Head>
<Table.Head>Postcode</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
@@ -32,11 +31,10 @@
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatValue(record.label)}</Table.Cell>
<Table.Cell class="capitalize">{formatValue(record.type)}</Table.Cell>
<Table.Cell class="">{formatValue(record.line1)}</Table.Cell>
<Table.Cell class="">{formatValue(record.city)}</Table.Cell>
<Table.Cell class="">{formatValue(record.postcode)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,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 {
records,
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 }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.select({
client: clients,
primaryContactName: contacts.name,
primaryContactEmail: contacts.email,
primaryAddressLine1: addresses.line1
})
.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)))
.orderBy(asc(clients.name));
return {
records: records.map((record) => ({
...record,
...record.client,
primaryContactName: record.primaryContactName,
primaryContactEmail: record.primaryContactEmail,
primaryAddressLine1: record.primaryAddressLine1,
formValues: {
id: record.id,
name: record.name ?? '',
type: record.type ?? '',
status: record.status ?? '',
website: record.website ?? '',
notes: record.notes ?? ''
id: record.client.id,
name: record.client.name ?? '',
industry: record.client.industry ?? '',
website: record.client.website ?? '',
notes: record.client.notes ?? ''
}
})),
createForm: await superValidate(zod4(clientOnboardingCreateSchema), { id: 'clients-create' }),
editForm: await superValidate(zod4(clientEditSchema), { id: 'clients-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'clients-archive' })
createForm: await superValidate(zod4(clientOnboardingCreateSchema), {
id: 'clients-create',
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,
organizationId: activeOrganizationId,
name: form.data.name,
type: form.data.type,
status: form.data.status,
industry: form.data.industry || null,
website: form.data.website || null,
notes: form.data.notes || null,
updatedAt: now,
@@ -69,7 +99,8 @@ export const actions: Actions = {
email: form.data.primaryContactEmail || null,
phone: form.data.primaryContactPhone || null,
isPrimary: true,
notes: form.data.primaryContactNotes || null,
receivesInvoices: form.data.primaryContactReceivesInvoices === 'true',
receivesContracts: form.data.primaryContactReceivesContracts === 'true',
createdAt: now,
updatedAt: now
});
@@ -78,14 +109,14 @@ export const actions: Actions = {
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId,
label: form.data.primaryAddressLabel,
type: form.data.primaryAddressType,
line1: form.data.primaryAddressLine1,
line2: form.data.primaryAddressLine2 || null,
line3: form.data.primaryAddressLine3 || null,
city: form.data.primaryAddressCity,
region: form.data.primaryAddressRegion || null,
postcode: form.data.primaryAddressPostcode,
country: form.data.primaryAddressCountry,
isPrimary: true,
createdAt: now,
updatedAt: now
});
@@ -108,8 +139,7 @@ export const actions: Actions = {
.update(clients)
.set({
name: form.data.name,
type: form.data.type,
status: form.data.status,
industry: form.data.industry || null,
website: form.data.website || null,
notes: form.data.notes || null,
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>
<h1 class="text-2xl font-semibold tracking-tight">Clients</h1>
<p class="text-sm text-muted-foreground">
Manage businesses, sports organisations, and individual clients.
</p>
<p class="text-sm text-muted-foreground">Manage client details, contacts, and addresses.</p>
</div>
<CreateClientDialog
@@ -13,19 +13,18 @@ import {
} from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { clientEditSchema } from '$lib/schemas/clients.schema';
import { industries } from '$lib/constants/industries';
import type { LayoutServerLoad } from './$types';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
const clientTypes = ['business', 'sport', 'individual'] as const;
const clientStatuses = ['active', 'prospect', 'paused'] as const;
function formValues(row: typeof clients.$inferSelect) {
const industry = industries.find((value) => value === row.industry);
return {
id: row.id,
name: row.name ?? '',
type: clientTypes.find((type) => type === row.type),
status: clientStatuses.find((status) => status === row.status),
industry: industry ?? ('' as const),
website: row.website ?? '',
notes: row.notes ?? ''
};
@@ -88,7 +87,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
isNull(addresses.archivedAt)
)
)
.orderBy(asc(addresses.label)),
.orderBy(asc(addresses.type)),
db
.select()
.from(contacts)
@@ -121,7 +120,7 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
isNull(contracts.archivedAt)
)
)
.orderBy(asc(contracts.title)),
.orderBy(asc(contracts.startDate)),
db
.select()
.from(bookings)
@@ -139,7 +138,8 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
return {
client,
editForm: await superValidate(formValues(client), zod4(clientEditSchema), {
id: 'clients-edit'
id: 'clients-edit',
errors: false
}),
options,
addresses: addressRows,
@@ -2,16 +2,17 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import { industryLabel, industryOptions } from '$lib/constants/industries';
import * as Field from '$lib/components/ui/field/index.js';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { clientEditSchema } from '$lib/schemas/clients.schema';
import { handleFormToast } from '$lib/form-feedback';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import type { LayoutData } from './$types';
@@ -50,6 +51,28 @@
const { form: editData, enhance: enhanceEdit } = editForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function navigateToTab(tab: TabValue) {
goto(resolve(`/dashboard/clients/[id]/${tab}`, { id: data.client.id }));
}
@@ -81,47 +104,38 @@
</Dialog.Header>
<form method="POST" action="/dashboard/clients?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name">
<Form.Control id="edit-client-name">
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-client-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="type">
<Form.Control id="edit-client-type">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="industry">
<Field.Field>
<Control id="edit-client-industry">
{#snippet children({ props })}
<Form.Label>Type</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.type}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="business">Business</NativeSelect.Option>
<NativeSelect.Option value="sport">Sport</NativeSelect.Option>
<NativeSelect.Option value="individual">Individual</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Industry</Field.Label>
<FormSelect
name="industry"
bind:value={$editData.industry}
options={industryOptions}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-client-status">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="website">
<Field.Field>
<Control id="edit-client-website">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="prospect">Prospect</NativeSelect.Option>
<NativeSelect.Option value="paused">Paused</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="website">
<Form.Control id="edit-client-website">
{#snippet children({ props })}
<Form.Label>Website</Form.Label>
<Field.Label>Website</Field.Label>
<Input
{...props}
type="url"
@@ -129,18 +143,21 @@
bind:value={$editData.website}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-client-notes">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-client-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -154,18 +171,18 @@
</div>
</div>
<div class="grid gap-3 rounded-lg border p-4 sm:grid-cols-3">
<div class="grid gap-3 rounded-lg border p-4 sm:grid-cols-2">
<div>
<p class="text-xs font-medium text-muted-foreground uppercase">Type</p>
<p class="text-sm capitalize">{data.client.type}</p>
<p class="text-xs font-medium text-muted-foreground uppercase">Industry</p>
<p class="text-sm">{industryLabel(data.client.industry)}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground uppercase">Website</p>
<p class="text-sm">{data.client.website ?? 'Not set'}</p>
</div>
<div>
<p class="text-xs font-medium text-muted-foreground uppercase">Status</p>
<p class="text-sm capitalize">{data.client.status}</p>
<div class="sm:col-span-2">
<p class="text-xs font-medium text-muted-foreground uppercase">Notes</p>
<p class="text-sm whitespace-pre-wrap">{data.client.notes || 'Not set'}</p>
</div>
</div>
@@ -32,7 +32,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
isNull(addresses.archivedAt)
)
)
.orderBy(asc(addresses.label));
.orderBy(asc(addresses.type));
return {
records: records.map((record) => ({
@@ -40,22 +40,29 @@ export const load: PageServerLoad = async ({ locals, params }) => {
formValues: {
id: record.id,
clientId: record.clientId ?? '',
label: record.label ?? '',
type: record.type ?? 'primary',
line1: record.line1 ?? '',
line2: record.line2 ?? '',
line3: record.line3 ?? '',
city: record.city ?? '',
region: record.region ?? '',
postcode: record.postcode ?? '',
country: record.country ?? '',
isPrimary: record.isPrimary ? 'true' : 'false'
country: record.country ?? ''
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(addressCreateSchema), {
id: 'addresses-create'
id: 'addresses-create',
errors: false
}),
editForm: await superValidate(zod4(addressEditSchema), { id: 'addresses-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'addresses-archive' })
editForm: await superValidate(zod4(addressEditSchema), {
id: 'addresses-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'addresses-archive',
errors: false
})
};
};
@@ -75,14 +82,14 @@ export const actions: Actions = {
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId: form.data.clientId,
label: form.data.label,
type: form.data.type,
line1: form.data.line1,
line2: form.data.line2 || null,
line3: form.data.line3 || null,
city: form.data.city,
region: form.data.region || null,
postcode: form.data.postcode,
country: form.data.country,
isPrimary: form.data.isPrimary === 'true',
updatedAt: new Date(),
createdAt: new Date()
});
@@ -106,14 +113,14 @@ export const actions: Actions = {
.update(addresses)
.set({
clientId: form.data.clientId,
label: form.data.label,
type: form.data.type,
line1: form.data.line1,
line2: form.data.line2 || null,
line3: form.data.line3 || null,
city: form.data.city,
region: form.data.region || null,
postcode: form.data.postcode,
country: form.data.country,
isPrimary: form.data.isPrimary === 'true',
updatedAt: new Date()
})
.where(
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateAddressDialog from './create-address-dialog.svelte';
import AddressesTable from './addresses-table.svelte';
import EditAddressDialog from './edit-address-dialog.svelte';
@@ -9,20 +7,11 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { addressCreateSchema, addressEditSchema } from '$lib/schemas/addresses.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -55,8 +44,31 @@
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function openEdit(record: RecordRow) {
@@ -70,7 +82,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this address');
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this address'
);
}
</script>
@@ -88,21 +108,7 @@
<CreateAddressDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search addresses..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<AddressesTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{relationLabel}
{recordName}
/>
<AddressesTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
</div>
<EditAddressDialog
@@ -9,14 +9,12 @@
openEdit,
openArchive,
formatValue,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -26,24 +24,20 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Label</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Address</Table.Head>
<Table.Head>City</Table.Head>
<Table.Head>Postcode</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatValue(record.label)}</Table.Cell>
<Table.Cell class="capitalize">{formatValue(record.type)}</Table.Cell>
<Table.Cell class="">{formatValue(record.line1)}</Table.Cell>
<Table.Cell class="">{formatValue(record.city)}</Table.Cell>
<Table.Cell class="">{formatValue(record.postcode)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,11 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let {
open = $bindable(false),
createForm,
@@ -31,82 +32,103 @@
<Dialog.Description>Add a new address record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="label">
<Form.Control id="create-label">
<FormField form={createForm} name="type">
<Field.Field>
<Control id="create-type">
{#snippet children({ props })}
<Form.Label>Label</Form.Label>
<Input {...props} type="text" bind:value={$createData.label} />
<Field.Label>Type</Field.Label>
<FormSelect
name="type"
bind:value={$createData.type}
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'invoicing', label: 'Invoicing' },
{ value: 'contract', label: 'Contract' }
]}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="line1">
<Form.Control id="create-line1">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="line1">
<Field.Field>
<Control id="create-line1">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="line2">
<Form.Control id="create-line2">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="line2">
<Field.Field>
<Control id="create-line2">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="city">
<Form.Control id="create-city">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="line3">
<Field.Field>
<Control id="create-line3">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="region">
<Form.Control id="create-region">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="region">
<Field.Field>
<Control id="create-region">
{#snippet children({ props })}
<Form.Label>Region / county</Form.Label>
<Field.Label>Region / county</Field.Label>
<Input {...props} type="text" bind:value={$createData.region} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="postcode">
<Form.Control id="create-postcode">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="postcode">
<Field.Field>
<Control id="create-postcode">
{#snippet children({ props })}
<Form.Label>Postcode</Form.Label>
<Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$createData.postcode} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="country">
<Form.Control id="create-country">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="country">
<Field.Field>
<Control id="create-country">
{#snippet children({ props })}
<Form.Label>Country</Form.Label>
<Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$createData.country} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="isPrimary">
<Form.Control id="create-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary address</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,10 +1,11 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
let {
editingRecord,
editForm,
@@ -29,82 +30,103 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="label">
<Form.Control id="edit-label">
<FormField form={editForm} name="type">
<Field.Field>
<Control id="edit-type">
{#snippet children({ props })}
<Form.Label>Label</Form.Label>
<Input {...props} type="text" bind:value={$editData.label} />
<Field.Label>Type</Field.Label>
<FormSelect
name="type"
bind:value={$editData.type}
options={[
{ value: 'primary', label: 'Primary' },
{ value: 'invoicing', label: 'Invoicing' },
{ value: 'contract', label: 'Contract' }
]}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="line1">
<Form.Control id="edit-line1">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="line1">
<Field.Field>
<Control id="edit-line1">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="line2">
<Form.Control id="edit-line2">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="line2">
<Field.Field>
<Control id="edit-line2">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="city">
<Form.Control id="edit-city">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="line3">
<Field.Field>
<Control id="edit-line3">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="region">
<Form.Control id="edit-region">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="region">
<Field.Field>
<Control id="edit-region">
{#snippet children({ props })}
<Form.Label>Region / county</Form.Label>
<Field.Label>Region / county</Field.Label>
<Input {...props} type="text" bind:value={$editData.region} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="postcode">
<Form.Control id="edit-postcode">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="postcode">
<Field.Field>
<Control id="edit-postcode">
{#snippet children({ props })}
<Form.Label>Postcode</Form.Label>
<Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$editData.postcode} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="country">
<Form.Control id="edit-country">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="country">
<Field.Field>
<Control id="edit-country">
{#snippet children({ props })}
<Form.Label>Country</Form.Label>
<Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$editData.country} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="isPrimary">
<Form.Control id="edit-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary address</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -67,10 +67,17 @@ export const load: PageServerLoad = async ({ locals, params }) => {
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(bookingCreateSchema), {
id: 'bookings-create'
id: 'bookings-create',
errors: false
}),
editForm: await superValidate(zod4(bookingEditSchema), { id: 'bookings-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'bookings-archive' })
editForm: await superValidate(zod4(bookingEditSchema), {
id: 'bookings-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'bookings-archive',
errors: false
})
};
};
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateBookingDialog from './create-booking-dialog.svelte';
import BookingsTable from './bookings-table.svelte';
import EditBookingDialog from './edit-booking-dialog.svelte';
@@ -9,21 +7,11 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -56,8 +44,48 @@
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients' | 'rooms' | 'services') {
return getRelationLabel(data.options, value, optionsKey);
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'rooms' | 'services') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openEdit(record: RecordRow) {
@@ -71,7 +99,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this booking');
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this booking'
);
}
</script>
@@ -91,15 +127,8 @@
<CreateBookingDialog bind:open={createOpen} {data} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search bookings..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<BookingsTable
data={listData}
{data}
{openEdit}
{openArchive}
{formatValue}
@@ -29,7 +29,6 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Room</Table.Head>
<Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head>
@@ -40,7 +39,6 @@
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{relationLabel(record.roomId, 'rooms')}</Table.Cell>
<Table.Cell class="">{formatDate(record.startsAt, true)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endsAt, true)}</Table.Cell>
@@ -1,11 +1,13 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormCombobox from '$lib/components/form-combobox.svelte';
import FormSelect from '$lib/components/form-select.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
data,
@@ -34,75 +36,99 @@
<Dialog.Description>Add a new booking record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="roomId">
<Form.Control id="create-roomId">
<FormField form={createForm} name="roomId">
<Field.Field>
<Control id="create-roomId">
{#snippet children({ props })}
<Form.Label>Room</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.roomId}>
{#each data.options.rooms as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
<Field.Label>Room</Field.Label>
<FormCombobox
name="roomId"
bind:value={$createData.roomId}
options={data.options.rooms}
placeholder="Select a room"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="serviceId">
<Form.Control id="create-serviceId">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="serviceId">
<Field.Field>
<Control id="create-serviceId">
{#snippet children({ props })}
<Form.Label>Service</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.serviceId}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
{#each data.options.services as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
<Field.Label>Service</Field.Label>
<FormCombobox
name="serviceId"
bind:value={$createData.serviceId}
options={data.options.services}
placeholder="Select a service"
searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="startsAt">
<Form.Control id="create-startsAt">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="startsAt">
<Field.Field>
<Control id="create-startsAt">
{#snippet children({ props })}
<Form.Label>Starts at</Form.Label>
<Field.Label>Starts at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$createData.startsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="endsAt">
<Form.Control id="create-endsAt">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="endsAt">
<Field.Field>
<Control id="create-endsAt">
{#snippet children({ props })}
<Form.Label>Ends at</Form.Label>
<Field.Label>Ends at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$createData.endsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="status">
<Field.Field>
<Control id="create-status">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="booked">Booked</NativeSelect.Option>
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option>
<NativeSelect.Option value="completed">Completed</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Status</Field.Label>
<FormSelect
name="status"
bind:value={$createData.status}
options={[
{ value: '', label: 'Not set' },
{ value: 'booked', label: 'Booked' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' }
]}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,10 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormCombobox from '$lib/components/form-combobox.svelte';
import FormSelect from '$lib/components/form-select.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
data,
@@ -32,75 +34,99 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="roomId">
<Form.Control id="edit-roomId">
<FormField form={editForm} name="roomId">
<Field.Field>
<Control id="edit-roomId">
{#snippet children({ props })}
<Form.Label>Room</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.roomId}>
{#each data.options.rooms as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
<Field.Label>Room</Field.Label>
<FormCombobox
name="roomId"
bind:value={$editData.roomId}
options={data.options.rooms}
placeholder="Select a room"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="serviceId">
<Form.Control id="edit-serviceId">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="serviceId">
<Field.Field>
<Control id="edit-serviceId">
{#snippet children({ props })}
<Form.Label>Service</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.serviceId}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
{#each data.options.services as option (option.value)}
<NativeSelect.Option value={option.value}>{option.label}</NativeSelect.Option>
{/each}
</NativeSelect.Root>
<Field.Label>Service</Field.Label>
<FormCombobox
name="serviceId"
bind:value={$editData.serviceId}
options={data.options.services}
placeholder="Select a service"
searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="startsAt">
<Form.Control id="edit-startsAt">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="startsAt">
<Field.Field>
<Control id="edit-startsAt">
{#snippet children({ props })}
<Form.Label>Starts at</Form.Label>
<Field.Label>Starts at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$editData.startsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="endsAt">
<Form.Control id="edit-endsAt">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="endsAt">
<Field.Field>
<Control id="edit-endsAt">
{#snippet children({ props })}
<Form.Label>Ends at</Form.Label>
<Field.Label>Ends at</Field.Label>
<Input {...props} type="datetime-local" bind:value={$editData.endsAt} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="booked">Booked</NativeSelect.Option>
<NativeSelect.Option value="confirmed">Confirmed</NativeSelect.Option>
<NativeSelect.Option value="completed">Completed</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Status</Field.Label>
<FormSelect
name="status"
bind:value={$editData.status}
options={[
{ value: '', label: 'Not set' },
{ value: 'booked', label: 'Booked' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'completed', label: 'Completed' },
{ value: 'cancelled', label: 'Cancelled' }
]}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -45,15 +45,23 @@ export const load: PageServerLoad = async ({ locals, params }) => {
email: record.email ?? '',
phone: record.phone ?? '',
isPrimary: record.isPrimary ? 'true' : 'false',
notes: record.notes ?? ''
receivesInvoices: record.receivesInvoices ? 'true' : 'false',
receivesContracts: record.receivesContracts ? 'true' : 'false'
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(contactCreateSchema), {
id: 'contacts-create'
id: 'contacts-create',
errors: false
}),
editForm: await superValidate(zod4(contactEditSchema), { id: 'contacts-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contacts-archive' })
editForm: await superValidate(zod4(contactEditSchema), {
id: 'contacts-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contacts-archive',
errors: false
})
};
};
@@ -78,7 +86,8 @@ export const actions: Actions = {
email: form.data.email || null,
phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true',
notes: form.data.notes || null,
receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date(),
createdAt: new Date()
});
@@ -107,7 +116,8 @@ export const actions: Actions = {
email: form.data.email || null,
phone: form.data.phone || null,
isPrimary: form.data.isPrimary === 'true',
notes: form.data.notes || null,
receivesInvoices: form.data.receivesInvoices === 'true',
receivesContracts: form.data.receivesContracts === 'true',
updatedAt: new Date()
})
.where(
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateContactDialog from './create-contact-dialog.svelte';
import ContactsTable from './contacts-table.svelte';
import EditContactDialog from './edit-contact-dialog.svelte';
@@ -9,20 +7,11 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { contactCreateSchema, contactEditSchema } from '$lib/schemas/contacts.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -55,8 +44,31 @@
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function openEdit(record: RecordRow) {
@@ -70,7 +82,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contact');
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this contact'
);
}
</script>
@@ -88,21 +108,7 @@
<CreateContactDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search contacts..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<ContactsTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{relationLabel}
{recordName}
/>
<ContactsTable {data} {openEdit} {openArchive} {formatValue} {recordName} />
</div>
<EditContactDialog
@@ -9,14 +9,12 @@
openEdit,
openArchive,
formatValue,
relationLabel,
recordName
}: {
data: any;
openEdit: any;
openArchive: any;
formatValue: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -26,24 +24,26 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Name</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head>Email</Table.Head>
<Table.Head>Phone</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head>Invoices</Table.Head>
<Table.Head>Contracts</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatValue(record.name)}</Table.Cell>
<Table.Cell class="">{formatValue(record.role)}</Table.Cell>
<Table.Cell class="">{formatValue(record.email)}</Table.Cell>
<Table.Cell class="">{formatValue(record.phone)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>{record.receivesInvoices ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>{record.receivesContracts ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,12 +1,13 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import FormCheckbox from '$lib/components/form-checkbox.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
open = $bindable(false),
createForm,
@@ -32,64 +33,86 @@
<Dialog.Description>Add a new contact record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="name">
<Form.Control id="create-name">
<FormField form={createForm} name="name">
<Field.Field>
<Control id="create-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="role">
<Form.Control id="create-role">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="role">
<Field.Field>
<Control id="create-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$createData.role} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="email">
<Form.Control id="create-email">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="email">
<Field.Field>
<Control id="create-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$createData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="phone">
<Form.Control id="create-phone">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="phone">
<Field.Field>
<Control id="create-phone">
{#snippet children({ props })}
<Form.Label>Phone</Form.Label>
<Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$createData.phone} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="isPrimary">
<Form.Control id="create-isPrimary">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="isPrimary">
<Field.Field>
<Control id="create-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary contact</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Primary contact</Field.Label>
<FormSelect
name="isPrimary"
bind:value={$createData.isPrimary}
options={[
{ value: '', label: 'Not set' },
{ value: 'false', label: 'No' },
{ value: 'true', label: 'Yes' }
]}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-3 py-1">
<FormCheckbox
id="create-receives-invoices"
name="receivesInvoices"
label="Receives invoices"
description="Send invoices raised for this client to this contact."
bind:value={$createData.receivesInvoices}
/>
<FormCheckbox
id="create-receives-contracts"
name="receivesContracts"
label="Receives contracts"
description="Send contract documents for this client to this contact."
bind:value={$createData.receivesContracts}
/>
</div>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,11 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import FormCheckbox from '$lib/components/form-checkbox.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
editForm,
@@ -30,64 +31,86 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name">
<Form.Control id="edit-name">
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="role">
<Form.Control id="edit-role">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="role">
<Field.Field>
<Control id="edit-role">
{#snippet children({ props })}
<Form.Label>Role</Form.Label>
<Field.Label>Role</Field.Label>
<Input {...props} type="text" bind:value={$editData.role} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="email">
<Form.Control id="edit-email">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="email">
<Field.Field>
<Control id="edit-email">
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Field.Label>Email</Field.Label>
<Input {...props} type="email" bind:value={$editData.email} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="phone">
<Form.Control id="edit-phone">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="phone">
<Field.Field>
<Control id="edit-phone">
{#snippet children({ props })}
<Form.Label>Phone</Form.Label>
<Field.Label>Phone</Field.Label>
<Input {...props} type="text" bind:value={$editData.phone} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="isPrimary">
<Form.Control id="edit-isPrimary">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="isPrimary">
<Field.Field>
<Control id="edit-isPrimary">
{#snippet children({ props })}
<Form.Label>Primary contact</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.isPrimary}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="false">No</NativeSelect.Option>
<NativeSelect.Option value="true">Yes</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Primary contact</Field.Label>
<FormSelect
name="isPrimary"
bind:value={$editData.isPrimary}
options={[
{ value: '', label: 'Not set' },
{ value: 'false', label: 'No' },
{ value: 'true', label: 'Yes' }
]}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-3 py-1">
<FormCheckbox
id="edit-receives-invoices"
name="receivesInvoices"
label="Receives invoices"
description="Send invoices raised for this client to this contact."
bind:value={$editData.receivesInvoices}
/>
<FormCheckbox
id="edit-receives-contracts"
name="receivesContracts"
label="Receives contracts"
description="Send contract documents for this client to this contact."
bind:value={$editData.receivesContracts}
/>
</div>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,30 +1,74 @@
import { and, asc, eq, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server';
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db';
import { contracts, clients } from '$lib/server/db/schema';
import { contracts, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
import {
contractCreateSchema,
contractEditSchema,
contractTransitionSchema
} from '$lib/schemas/contracts.schema';
import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) {
const clientRows = await db
.select({ id: clients.id, name: clients.name })
.from(clients)
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt)))
.orderBy(asc(clients.name));
const [roomRows, serviceRows] = await Promise.all([
db
.select({
id: rooms.id,
name: rooms.name,
type: rooms.type,
pricePerMonthGbp: rooms.pricePerMonthGbp
})
.from(rooms)
.where(
and(
eq(rooms.organizationId, organizationId),
inArray(rooms.type, ['private_office', 'coworking_desk']),
isNull(rooms.archivedAt)
)
)
.orderBy(asc(rooms.name)),
db
.select({ id: services.id, name: services.name, priceGbp: services.priceGbp })
.from(services)
.where(and(eq(services.organizationId, organizationId), isNull(services.archivedAt)))
.orderBy(asc(services.name))
]);
return {
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
rooms: [
{ label: 'No room', value: '' },
...roomRows.map((room) => ({
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
value: room.id,
licenseFeeGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) : 0,
depositGbp: room.type === 'private_office' ? (room.pricePerMonthGbp ?? 0) * 2 : 0
}))
],
services: [
{ label: 'No service', value: '' },
...serviceRows.map((service) => ({
label: service.name,
value: service.id,
licenseFeeGbp: service.priceGbp
}))
]
};
}
export const load: PageServerLoad = async ({ locals, params }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.select({
contract: contracts,
roomName: rooms.name,
serviceName: services.name
})
.from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(
and(
eq(contracts.clientId, params.id),
@@ -32,31 +76,91 @@ export const load: PageServerLoad = async ({ locals, params }) => {
isNull(contracts.archivedAt)
)
)
.orderBy(asc(contracts.title));
.orderBy(asc(contracts.startDate));
return {
records: records.map((record) => ({
...record,
...record.contract,
roomName: record.roomName,
serviceName: record.serviceName,
formValues: {
id: record.id,
clientId: record.clientId ?? '',
title: record.title ?? '',
startDate: record.startDate ?? '',
endDate: record.endDate ?? '',
status: record.status ?? '',
valueGbp: record.valueGbp ?? '',
notes: record.notes ?? ''
id: record.contract.id,
clientId: record.contract.clientId ?? '',
roomId: record.contract.roomId ?? '',
serviceId: record.contract.serviceId ?? '',
licenseFeeGbp: record.contract.licenseFeeGbp,
depositGbp: record.contract.depositGbp,
startDate: record.contract.startDate ?? '',
endDate: record.contract.endDate ?? '',
notes: record.contract.notes ?? ''
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), {
id: 'contracts-create'
id: 'contracts-create',
errors: false
}),
editForm: await superValidate(zod4(contractEditSchema), { id: 'contracts-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'contracts-archive' })
editForm: await superValidate(zod4(contractEditSchema), {
id: 'contracts-edit',
errors: false
}),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'contracts-archive',
errors: false
}),
transitionForm: await superValidate(zod4(contractTransitionSchema), {
id: 'contracts-transition',
errors: false
})
};
};
async function selectionError(
organizationId: string,
roomId: string,
serviceId: string
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> {
if (roomId) {
const [room] = await db
.select({ id: rooms.id })
.from(rooms)
.where(
and(
eq(rooms.id, roomId),
eq(rooms.organizationId, organizationId),
inArray(rooms.type, ['private_office', 'coworking_desk']),
isNull(rooms.archivedAt)
)
)
.limit(1);
if (!room) {
return {
field: 'roomId',
message: 'Select a valid private office or coworking desk.'
};
}
}
if (serviceId) {
const [service] = await db
.select({ id: services.id })
.from(services)
.where(
and(
eq(services.id, serviceId),
eq(services.organizationId, organizationId),
isNull(services.archivedAt)
)
)
.limit(1);
if (!service) return { field: 'serviceId', message: 'Select a valid service.' };
}
return null;
}
export const actions: Actions = {
create: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
@@ -67,17 +171,25 @@ export const actions: Actions = {
});
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const invalidSelection = await selectionError(
activeOrganizationId,
form.data.roomId,
form.data.serviceId
);
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
try {
await db.insert(contracts).values({
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId: form.data.clientId,
title: form.data.title,
roomId: form.data.roomId || null,
serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp,
startDate: form.data.startDate,
endDate: form.data.endDate || null,
status: form.data.status,
valueGbp: form.data.valueGbp,
endDate: form.data.endDate,
status: 'draft',
notes: form.data.notes || null,
updatedAt: new Date(),
createdAt: new Date()
@@ -98,17 +210,24 @@ export const actions: Actions = {
});
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const invalidSelection = await selectionError(
activeOrganizationId,
form.data.roomId,
form.data.serviceId
);
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
try {
await db
.update(contracts)
.set({
clientId: form.data.clientId,
title: form.data.title,
roomId: form.data.roomId || null,
serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp,
startDate: form.data.startDate,
endDate: form.data.endDate || null,
status: form.data.status,
valueGbp: form.data.valueGbp,
endDate: form.data.endDate,
notes: form.data.notes || null,
updatedAt: new Date()
})
@@ -126,6 +245,50 @@ export const actions: Actions = {
return message(form, 'Contract updated.');
},
transition: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(contractTransitionSchema), {
id: 'contracts-transition'
});
if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 });
const [contract] = await db
.select({ status: contracts.status })
.from(contracts)
.where(
and(
eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId),
isNull(contracts.archivedAt)
)
)
.limit(1);
const allowedTransitions: Record<string, readonly string[]> = {
draft: ['active', 'void'],
active: ['expired']
};
if (!contract || !allowedTransitions[contract.status]?.includes(form.data.targetStatus)) {
return message(form, 'That contract status change is not allowed.', { status: 400 });
}
await db
.update(contracts)
.set({ status: form.data.targetStatus, updatedAt: new Date() })
.where(
and(
eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId)
)
);
return message(form, `Contract marked ${form.data.targetStatus}.`);
},
archive: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
@@ -1,6 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateContractDialog from './create-contract-dialog.svelte';
import ContractsTable from './contracts-table.svelte';
import EditContractDialog from './edit-contract-dialog.svelte';
@@ -10,21 +8,14 @@
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
formatMoney,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { contractCreateSchema, contractEditSchema } from '$lib/schemas/contracts.schema';
contractCreateSchema,
contractEditSchema,
contractTransitionSchema
} from '$lib/schemas/contracts.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
@@ -52,17 +43,67 @@
onUpdated: ({ form }) => handleFormToast(form, 'contracts-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-archive' })
});
// svelte-ignore state_referenced_locally
const transitionForm = superForm(data.transitionForm, {
validators: zod4Client(contractTransitionSchema),
resetForm: false,
onUpdated: ({ form }) =>
handleFormToast(form, 'contracts-transition', () => (editingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'contracts-transition' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
const { enhance: enhanceTransition } = transitionForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openEdit(record: RecordRow) {
editForm.reset({ data: record.formValues as never });
transitionForm.reset({
data: {
id: record.id,
targetStatus: record.status === 'active' ? 'expired' : 'active'
}
});
editingId = record.id;
}
@@ -72,7 +113,7 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contract');
return String(record?.roomName ?? record?.serviceName ?? 'this contract');
}
</script>
@@ -87,33 +128,25 @@
<p class="text-sm text-muted-foreground">Manage client agreements and commercial terms.</p>
</div>
<CreateContractDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
<CreateContractDialog
bind:open={createOpen}
options={data.options}
{createForm}
{createData}
{enhanceCreate}
/>
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search contracts..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<ContractsTable
data={listData}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
<ContractsTable {data} {openEdit} {openArchive} {formatValue} {formatDate} {recordName} />
</div>
<EditContractDialog
{editingRecord}
options={data.options}
{editForm}
{editData}
{enhanceEdit}
{enhanceTransition}
onClose={() => (editingId = null)}
/>
@@ -0,0 +1,139 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormCombobox from '$lib/components/form-combobox.svelte';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
form,
data,
options,
prefix
}: {
form: any;
data: any;
options: {
rooms: readonly {
value: string;
label: string;
licenseFeeGbp?: number;
depositGbp?: number;
}[];
services: readonly { value: string; label: string; licenseFeeGbp?: number }[];
};
prefix: string;
} = $props();
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function updatePricing(roomId: string, serviceId: string) {
const room = options.rooms.find((option) => option.value === roomId);
const service = options.services.find((option) => option.value === serviceId);
$data.licenseFeeGbp = roundMoney((room?.licenseFeeGbp ?? 0) + (service?.licenseFeeGbp ?? 0));
$data.depositGbp = roundMoney(room?.depositGbp ?? 0);
}
</script>
<FormField {form} name="roomId">
<Field.Field>
<Control id={`${prefix}-roomId`}>
{#snippet children({ props })}
<Field.Label>Room</Field.Label>
<FormCombobox
name="roomId"
bind:value={$data.roomId}
options={options.rooms}
placeholder="Select a private office or coworking desk"
searchPlaceholder="Search rooms..."
emptyMessage="No matching rooms."
triggerProps={props}
onValueChange={(roomId) => updatePricing(roomId, $data.serviceId)}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField {form} name="serviceId">
<Field.Field>
<Control id={`${prefix}-serviceId`}>
{#snippet children({ props })}
<Field.Label>Service</Field.Label>
<FormCombobox
name="serviceId"
bind:value={$data.serviceId}
options={options.services}
placeholder="Select a service"
searchPlaceholder="Search services..."
emptyMessage="No matching services."
triggerProps={props}
onValueChange={(serviceId) => updatePricing($data.roomId, serviceId)}
/>
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<div class="grid gap-2 sm:grid-cols-2">
<MoneyField
{form}
{data}
name="licenseFeeGbp"
label="License fee"
id={`${prefix}-licenseFeeGbp`}
description="Private office monthly price plus the selected service. You can overwrite this amount."
/>
<MoneyField
{form}
{data}
name="depositGbp"
label="Deposit"
id={`${prefix}-depositGbp`}
description="Defaults to two months of private office fees. Coworking desks and services require no deposit."
/>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<FormField {form} name="startDate">
<Field.Field>
<Control id={`${prefix}-startDate`}>
{#snippet children({ props })}
<Field.Label>Start date</Field.Label>
<Input {...props} type="date" bind:value={$data.startDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField {form} name="endDate">
<Field.Field>
<Control id={`${prefix}-endDate`}>
{#snippet children({ props })}
<Field.Label>End date</Field.Label>
<Input {...props} type="date" bind:value={$data.endDate} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
<FormField {form} name="notes">
<Field.Field>
<Control id={`${prefix}-notes`}>
{#snippet children({ props })}
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$data.notes} />
{/snippet}
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
@@ -11,8 +11,6 @@
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
@@ -20,8 +18,6 @@
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -31,27 +27,25 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Room</Table.Head>
<Table.Head>Service</Table.Head>
<Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Value</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="font-medium">{formatValue(record.roomName)}</Table.Cell>
<Table.Cell>{formatValue(record.serviceName)}</Table.Cell>
<Table.Cell class="">{formatDate(record.startDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,20 +1,18 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import MoneyField from '$lib/components/money-field.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import ContractFormFields from './contract-form-fields.svelte';
let {
open = $bindable(false),
options,
createForm,
createData,
enhanceCreate
}: {
open: boolean;
options: any;
createForm: any;
createData: any;
enhanceCreate: any;
@@ -33,64 +31,7 @@
<Dialog.Description>Add a new contract record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="title">
<Form.Control id="create-title">
{#snippet children({ props })}
<Form.Label>Title</Form.Label>
<Input {...props} type="text" bind:value={$createData.title} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="startDate">
<Form.Control id="create-startDate">
{#snippet children({ props })}
<Form.Label>Start date</Form.Label>
<Input {...props} type="date" bind:value={$createData.startDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="endDate">
<Form.Control id="create-endDate">
{#snippet children({ props })}
<Form.Label>End date</Form.Label>
<Input {...props} type="date" bind:value={$createData.endDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="expired">Expired</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<MoneyField
form={createForm}
data={createData}
name="valueGbp"
label="Value"
id="create-valueGbp"
/>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<ContractFormFields form={createForm} data={createData} {options} prefix="create" />
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -1,23 +1,24 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import MoneyField from '$lib/components/money-field.svelte';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import ContractFormFields from './contract-form-fields.svelte';
let {
editingRecord,
options,
editForm,
editData,
enhanceEdit,
enhanceTransition,
onClose
}: {
editingRecord: any;
options: any;
editForm: any;
editData: any;
enhanceEdit: any;
enhanceTransition: any;
onClose: () => void;
} = $props();
</script>
@@ -25,78 +26,49 @@
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<div class="flex items-center gap-2">
<Dialog.Title>Edit contract</Dialog.Title>
{#if editingRecord}
<Badge variant="secondary" class="capitalize">{editingRecord.status}</Badge>
{/if}
</div>
<Dialog.Description>Update this contract record.</Dialog.Description>
</Dialog.Header>
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<form id="edit-contract-form" method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="title">
<Form.Control id="edit-title">
{#snippet children({ props })}
<Form.Label>Title</Form.Label>
<Input {...props} type="text" bind:value={$editData.title} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="startDate">
<Form.Control id="edit-startDate">
{#snippet children({ props })}
<Form.Label>Start date</Form.Label>
<Input {...props} type="date" bind:value={$editData.startDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="endDate">
<Form.Control id="edit-endDate">
{#snippet children({ props })}
<Form.Label>End date</Form.Label>
<Input {...props} type="date" bind:value={$editData.endDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="expired">Expired</NativeSelect.Option>
<NativeSelect.Option value="cancelled">Cancelled</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<MoneyField
form={editForm}
data={editData}
name="valueGbp"
label="Value"
id="edit-valueGbp"
/>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Dialog.Footer>
<ContractFormFields form={editForm} data={editData} {options} prefix="edit" />
</form>
<Dialog.Footer class="flex-row items-center justify-between sm:justify-between">
{#if editingRecord.status === 'draft' || editingRecord.status === 'active'}
<form
method="POST"
action="?/transition"
class="flex flex-wrap gap-2"
use:enhanceTransition
>
<input type="hidden" name="id" value={editingRecord.id} />
{#if editingRecord.status === 'draft'}
<Button type="submit" name="targetStatus" value="void" variant="destructive"
>Void contract</Button
>
<Button type="submit" name="targetStatus" value="active">Activate contract</Button>
{:else}
<Button type="submit" name="targetStatus" value="expired">Mark as expired</Button>
{/if}
</form>
{:else}
<div></div>
{/if}
<div class="flex gap-2">
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Save changes</Button>
<Button type="submit" form="edit-contract-form">Save changes</Button>
</div>
</Dialog.Footer>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -5,7 +5,7 @@ import { db } from '$lib/server/db';
import { invoices, clients } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.schema';
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
import type { Actions, PageServerLoad } from './$types';
async function loadOptions(organizationId: string) {
@@ -51,48 +51,18 @@ export const load: PageServerLoad = async ({ locals, params }) => {
}
})),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate({ clientId: params.id }, zod4(invoiceCreateSchema), {
id: 'invoices-create'
editForm: await superValidate(zod4(invoiceEditSchema), {
id: 'invoices-edit',
errors: false
}),
editForm: await superValidate(zod4(invoiceEditSchema), { id: 'invoices-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'invoices-archive' })
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
};
};
export const actions: Actions = {
create: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const formData = await request.formData();
formData.set('clientId', params.id);
const form = await superValidate(formData, zod4(invoiceCreateSchema), {
id: 'invoices-create'
});
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
try {
await db.insert(invoices).values({
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId: form.data.clientId,
invoiceNumber: form.data.invoiceNumber,
issueDate: form.data.issueDate,
dueDate: form.data.dueDate,
status: form.data.status,
subtotalGbp: form.data.subtotalGbp,
taxGbp: form.data.taxGbp,
totalGbp: form.data.totalGbp,
notes: form.data.notes || null,
updatedAt: new Date(),
createdAt: new Date()
});
} catch {
return message(form, 'Unable to create invoice.', { status: 400 });
}
return message(form, 'Invoice created.');
},
edit: async ({ locals, params, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const formData = await request.formData();
@@ -1,7 +1,4 @@
<script lang="ts">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import CreateInvoiceDialog from './create-invoice-dialog.svelte';
import InvoicesTable from './invoices-table.svelte';
import EditInvoiceDialog from './edit-invoice-dialog.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
@@ -9,35 +6,17 @@
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
formatValue,
formatMoney,
formatDate,
recordName as getRecordName,
relationLabel as getRelationLabel
} from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import { invoiceCreateSchema, invoiceEditSchema } from '$lib/schemas/invoices.schema';
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.records);
const listData = $derived({ ...data, records: listSearch.filtered });
let createOpen = $state(false);
let editingId = $state<string | null>(null);
let archivingId = $state<string | null>(null);
const editingRecord = $derived(data.records.find((record) => record.id === editingId));
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(invoiceCreateSchema),
onUpdated: ({ form }) => handleFormToast(form, 'invoices-create', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-create' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(invoiceEditSchema),
@@ -53,12 +32,51 @@
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
});
const { form: createData, enhance: enhanceCreate } = createForm;
const { form: editData, enhance: enhanceEdit } = editForm;
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function relationLabel(value: unknown, optionsKey: 'clients') {
return getRelationLabel(data.options, value, optionsKey);
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openEdit(record: RecordRow) {
@@ -72,7 +90,15 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this invoice');
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
);
}
</script>
@@ -81,30 +107,20 @@
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div>
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
<p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p>
</div>
<CreateInvoiceDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search invoices..."
totalCount={data.records.length}
resultCount={listSearch.filtered.length}
/>
<InvoicesTable
data={listData}
{data}
{openEdit}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
@@ -1,118 +0,0 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import MoneyField from '$lib/components/money-field.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
open = $bindable(false),
createForm,
createData,
enhanceCreate
}: {
open: boolean;
createForm: any;
createData: any;
enhanceCreate: any;
} = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><PlusIcon data-icon="inline-start" />Create invoice</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create invoice</Dialog.Title>
<Dialog.Description>Add a new invoice record.</Dialog.Description>
</Dialog.Header>
<form method="POST" action="?/create" class="grid gap-2" use:enhanceCreate>
<Form.Field form={createForm} name="invoiceNumber">
<Form.Control id="create-invoiceNumber">
{#snippet children({ props })}
<Form.Label>Invoice number</Form.Label>
<Input {...props} type="text" bind:value={$createData.invoiceNumber} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="issueDate">
<Form.Control id="create-issueDate">
{#snippet children({ props })}
<Form.Label>Issue date</Form.Label>
<Input {...props} type="date" bind:value={$createData.issueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="dueDate">
<Form.Control id="create-dueDate">
{#snippet children({ props })}
<Form.Label>Due date</Form.Label>
<Input {...props} type="date" bind:value={$createData.dueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="sent">Sent</NativeSelect.Option>
<NativeSelect.Option value="paid">Paid</NativeSelect.Option>
<NativeSelect.Option value="overdue">Overdue</NativeSelect.Option>
<NativeSelect.Option value="void">Void</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<MoneyField
form={createForm}
data={createData}
name="subtotalGbp"
label="Subtotal"
id="create-subtotalGbp"
/>
<MoneyField
form={createForm}
data={createData}
name="taxGbp"
label="Tax"
id="create-taxGbp"
/>
<MoneyField
form={createForm}
data={createData}
name="totalGbp"
label="Total"
id="create-totalGbp"
/>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
>{/snippet}</Dialog.Close
>
<Button type="submit">Create invoice</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
@@ -1,11 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import MoneyField from '$lib/components/money-field.svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
@@ -31,49 +32,62 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="invoiceNumber">
<Form.Control id="edit-invoiceNumber">
<FormField form={editForm} name="invoiceNumber">
<Field.Field>
<Control id="edit-invoiceNumber">
{#snippet children({ props })}
<Form.Label>Invoice number</Form.Label>
<Field.Label>Invoice number</Field.Label>
<Input {...props} type="text" bind:value={$editData.invoiceNumber} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="issueDate">
<Form.Control id="edit-issueDate">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="issueDate">
<Field.Field>
<Control id="edit-issueDate">
{#snippet children({ props })}
<Form.Label>Issue date</Form.Label>
<Field.Label>Issue date</Field.Label>
<Input {...props} type="date" bind:value={$editData.issueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="dueDate">
<Form.Control id="edit-dueDate">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="dueDate">
<Field.Field>
<Control id="edit-dueDate">
{#snippet children({ props })}
<Form.Label>Due date</Form.Label>
<Field.Label>Due date</Field.Label>
<Input {...props} type="date" bind:value={$editData.dueDate} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="status">
<Field.Field>
<Control id="edit-status">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="draft">Draft</NativeSelect.Option>
<NativeSelect.Option value="sent">Sent</NativeSelect.Option>
<NativeSelect.Option value="paid">Paid</NativeSelect.Option>
<NativeSelect.Option value="overdue">Overdue</NativeSelect.Option>
<NativeSelect.Option value="void">Void</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Status</Field.Label>
<FormSelect
name="status"
bind:value={$editData.status}
options={[
{ value: '', label: 'Not set' },
{ value: 'draft', label: 'Draft' },
{ value: 'sent', label: 'Sent' },
{ value: 'paid', label: 'Paid' },
{ value: 'overdue', label: 'Overdue' },
{ value: 'void', label: 'Void' }
]}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<MoneyField
form={editForm}
data={editData}
@@ -89,15 +103,17 @@
label="Total"
id="edit-totalGbp"
/>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -12,7 +12,6 @@
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
@@ -21,7 +20,6 @@
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
@@ -32,7 +30,6 @@
<Table.Header>
<Table.Row>
<Table.Head>Invoice #</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head>
@@ -44,7 +41,6 @@
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class=""
@@ -4,7 +4,6 @@
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
@@ -29,8 +28,9 @@
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Primary contact</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.Row>
</Table.Header>
@@ -44,14 +44,9 @@
>{formatValue(record.name)}<ExternalLinkIcon class="size-3.5" /></a
></Table.Cell
>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.type)}</Badge
></Table.Cell
>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell>{formatValue(record.primaryContactName)}</Table.Cell>
<Table.Cell>{formatValue(record.primaryContactEmail)}</Table.Cell>
<Table.Cell>{formatValue(record.primaryAddressLine1)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,11 +1,14 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import PlusIcon from '@lucide/svelte/icons/plus';
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import FormSelect from '$lib/components/form-select.svelte';
import FormCheckbox from '$lib/components/form-checkbox.svelte';
import { 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 * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import * as Stepper from '$lib/components/ui/stepper/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
@@ -22,9 +25,9 @@
enhanceCreate: any;
} = $props();
const createSteps = [
{ title: 'Client', description: 'Business details' },
{ title: 'Contact', description: 'Primary contact' },
{ title: 'Address', description: 'Primary address' }
{ title: 'Client', description: 'Client details' },
{ title: 'Address', description: 'Primary address' },
{ title: 'Contact', description: 'Primary contact' }
] as const;
</script>
@@ -58,47 +61,38 @@
</Stepper.Root>
<div class={['grid gap-2', createStep !== 0 && 'hidden']}>
<Form.Field form={createForm} name="name">
<Form.Control id="create-name">
<FormField form={createForm} name="name">
<Field.Field>
<Control id="create-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="type">
<Form.Control id="create-type">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="industry">
<Field.Field>
<Control id="create-industry">
{#snippet children({ props })}
<Form.Label>Type</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$createData.type}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="business">Business</NativeSelect.Option>
<NativeSelect.Option value="sport">Sport</NativeSelect.Option>
<NativeSelect.Option value="individual">Individual</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Industry</Field.Label>
<FormSelect
name="industry"
bind:value={$createData.industry}
options={industryOptions}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="status">
<Form.Control id="create-status">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="website">
<Field.Field>
<Control id="create-website">
{#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="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>
<Field.Label>Website</Field.Label>
<Input
{...props}
type="url"
@@ -106,133 +100,185 @@
bind:value={$createData.website}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="notes">
<Form.Control id="create-notes">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="notes">
<Field.Field>
<Control id="create-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$createData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<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>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
<div class={['grid gap-2', createStep !== 2 && 'hidden']}>
<Form.Field form={createForm} name="primaryAddressLabel">
<Form.Control id="create-primary-address-label">
<FormField form={createForm} name="primaryContactName">
<Field.Field>
<Control id="create-primary-contact-name">
{#snippet children({ props })}
<Form.Label>Label</Form.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressLabel} />
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryContactName} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressLine1">
<Form.Control id="create-primary-address-line-1">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryContactRole">
<Field.Field>
<Control id="create-primary-contact-role">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressLine2">
<Form.Control id="create-primary-address-line-2">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressLine2">
<Field.Field>
<Control id="create-primary-address-line-2">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<div class="grid gap-2 sm:grid-cols-2">
<Form.Field form={createForm} name="primaryAddressCity">
<Form.Control id="create-primary-address-city">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressLine3">
<Field.Field>
<Control id="create-primary-address-line-3">
{#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} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressRegion">
<Form.Control id="create-primary-address-region">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressRegion">
<Field.Field>
<Control id="create-primary-address-region">
{#snippet children({ props })}
<Form.Label>Region</Form.Label>
<Field.Label>Region</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressRegion} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<Form.Field form={createForm} name="primaryAddressPostcode">
<Form.Control id="create-primary-address-postcode">
<FormField form={createForm} name="primaryAddressPostcode">
<Field.Field>
<Control id="create-primary-address-postcode">
{#snippet children({ props })}
<Form.Label>Postcode</Form.Label>
<Field.Label>Postcode</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressPostcode} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={createForm} name="primaryAddressCountry">
<Form.Control id="create-primary-address-country">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={createForm} name="primaryAddressCountry">
<Field.Field>
<Control id="create-primary-address-country">
{#snippet children({ props })}
<Form.Label>Country</Form.Label>
<Field.Label>Country</Field.Label>
<Input {...props} type="text" bind:value={$createData.primaryAddressCountry} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
</div>
</div>
@@ -1,10 +1,12 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as Form from '$lib/components/ui/form/index.js';
import { Field as FormField, Control, FieldErrors } from 'formsnap';
import 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 * as Dialog from '$lib/components/ui/dialog/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
editingRecord,
@@ -30,47 +32,38 @@
{#if editingRecord}
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
<input type="hidden" name="id" value={$editData.id} />
<Form.Field form={editForm} name="name">
<Form.Control id="edit-name">
<FormField form={editForm} name="name">
<Field.Field>
<Control id="edit-name">
{#snippet children({ props })}
<Form.Label>Name</Form.Label>
<Field.Label>Name</Field.Label>
<Input {...props} type="text" bind:value={$editData.name} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="type">
<Form.Control id="edit-type">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="industry">
<Field.Field>
<Control id="edit-industry">
{#snippet children({ props })}
<Form.Label>Type</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.type}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="business">Business</NativeSelect.Option>
<NativeSelect.Option value="sport">Sport</NativeSelect.Option>
<NativeSelect.Option value="individual">Individual</NativeSelect.Option>
</NativeSelect.Root>
<Field.Label>Industry</Field.Label>
<FormSelect
name="industry"
bind:value={$editData.industry}
options={industryOptions}
triggerProps={props}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="status">
<Form.Control id="edit-status">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="website">
<Field.Field>
<Control id="edit-website">
{#snippet children({ props })}
<Form.Label>Status</Form.Label>
<NativeSelect.Root {...props} class="w-full" bind:value={$editData.status}>
<NativeSelect.Option value="">Not set</NativeSelect.Option>
<NativeSelect.Option value="active">Active</NativeSelect.Option>
<NativeSelect.Option value="prospect">Prospect</NativeSelect.Option>
<NativeSelect.Option value="paused">Paused</NativeSelect.Option>
</NativeSelect.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="website">
<Form.Control id="edit-website">
{#snippet children({ props })}
<Form.Label>Website</Form.Label>
<Field.Label>Website</Field.Label>
<Input
{...props}
type="url"
@@ -78,18 +71,21 @@
bind:value={$editData.website}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field form={editForm} name="notes">
<Form.Control id="edit-notes">
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<FormField form={editForm} name="notes">
<Field.Field>
<Control id="edit-notes">
{#snippet children({ props })}
<Form.Label>Notes</Form.Label>
<Field.Label>Notes</Field.Label>
<Textarea {...props} bind:value={$editData.notes} />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</Control>
<Field.Error><FieldErrors /></Field.Error>
</Field.Field>
</FormField>
<Dialog.Footer>
<Dialog.Close
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
@@ -30,7 +30,10 @@ export const load: PageServerLoad = async ({ locals }) => {
return {
records,
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>Phone</Table.Head>
<Table.Head>Primary</Table.Head>
<Table.Head>Invoices</Table.Head>
<Table.Head>Contracts</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
@@ -37,6 +39,8 @@
<Table.Cell class="">{formatValue(record.email)}</Table.Cell>
<Table.Cell class="">{formatValue(record.phone)}</Table.Cell>
<Table.Cell class="">{record.isPrimary ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>{record.receivesInvoices ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>{record.receivesContracts ? 'Yes' : 'No'}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
+18 -5
View File
@@ -2,7 +2,7 @@ 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 { contracts, clients } from '$lib/server/db/schema';
import { contracts, clients, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types';
@@ -22,15 +22,28 @@ async function loadOptions(organizationId: string) {
export const load: PageServerLoad = async ({ locals }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const records = await db
.select()
.select({
contract: contracts,
roomName: rooms.name,
serviceName: services.name
})
.from(contracts)
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt)))
.orderBy(asc(contracts.title));
.orderBy(asc(contracts.startDate));
return {
records,
records: records.map((record) => ({
...record.contract,
roomName: record.roomName,
serviceName: record.serviceName
})),
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">
import { createListSearch } from '$lib/list-search.svelte';
import ListSearch from '$lib/components/list-search.svelte';
import ContractsTable from './contracts-table.svelte';
import ArchiveContractDialog from './archive-contract-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));
@@ -36,8 +23,48 @@
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') {
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) {
@@ -46,7 +73,7 @@
}
function recordName(record: Partial<RecordRow> | undefined) {
return getRecordName(record, 'this contract');
return String(record?.roomName ?? record?.serviceName ?? 'this contract');
}
</script>
@@ -62,22 +89,7 @@
</div>
</div>
<ListSearch
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}
/>
<ContractsTable {data} {openArchive} {formatValue} {formatDate} {relationLabel} {recordName} />
</div>
<ArchiveContractDialog
@@ -10,7 +10,6 @@
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
@@ -18,7 +17,6 @@
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
@@ -29,19 +27,20 @@
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Room</Table.Head>
<Table.Head>Service</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Starts</Table.Head>
<Table.Head>Ends</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Value</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.title)}</Table.Cell>
<Table.Cell class="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="">{formatDate(record.startDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.endDate)}</Table.Cell>
@@ -49,7 +48,6 @@
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></Table.Cell
>
<Table.Cell class="">{formatMoney(record.valueGbp)}</Table.Cell>
<Table.Cell>
<DropdownMenu.Root>
<DropdownMenu.Trigger
@@ -1,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 {
currentUserId: locals.user?.id,
users,
createForm: await superValidate(zod4(createAdminSchema), { id: 'create-admin' }),
editForm: await superValidate(zod4(editAdminSchema), { id: 'edit-admin' }),
deleteForm: await superValidate(zod4(deleteAdminSchema), { id: 'delete-admin' })
createForm: await superValidate(zod4(createAdminSchema), {
id: 'create-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">
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 { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { createAdminSchema, deleteAdminSchema, editAdminSchema } from '$lib/schemas/admin';
import { formatDate } from '$lib/record-utils';
import { handleFormToast } from '$lib/form-feedback';
import AdminsTable from './admins-table.svelte';
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';
let { data }: { data: PageData } = $props();
const listSearch = createListSearch(() => data.users);
const listData = $derived({ ...data, users: listSearch.filtered });
let { data }: { data: PageData } = $props();
let createOpen = $state(false);
let editingUserId = $state<string | null>(null);
@@ -23,12 +17,14 @@
const editingUser = $derived(data.users.find((user) => user.id === editingUserId));
const deletingUser = $derived(data.users.find((user) => user.id === deletingUserId));
// svelte-ignore state_referenced_locally
const createForm = superForm(data.createForm, {
validators: zod4Client(createAdminSchema),
onUpdated: ({ form }) => handleFormToast(form, 'create-admin', () => (createOpen = false)),
onError: ({ result }) => toast.error(result.error.message, { id: 'create-admin' })
});
// svelte-ignore state_referenced_locally
const editForm = superForm(data.editForm, {
validators: zod4Client(editAdminSchema),
@@ -36,6 +32,7 @@
onUpdated: ({ form }) => handleFormToast(form, 'edit-admin', () => (editingUserId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'edit-admin' })
});
// svelte-ignore state_referenced_locally
const deleteForm = superForm(data.deleteForm, {
validators: zod4Client(deleteAdminSchema),
@@ -48,6 +45,38 @@
const { form: editData, enhance: enhanceEdit } = editForm;
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]) {
editForm.reset({
data: {
@@ -71,28 +100,19 @@
}
</script>
<svelte:head>
<title>Admins | 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">Admins</h1>
<p class="text-sm text-muted-foreground">Manage Better Auth users and admin roles.</p>
<h2 class="text-xl font-semibold tracking-tight">Administrators</h2>
<p class="text-sm text-muted-foreground">
Manage users who can access and administer Clearity.
</p>
</div>
<CreateAdminDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
</div>
<ListSearch
bind:value={listSearch.params.q}
placeholder="Search admins..."
totalCount={data.users.length}
resultCount={listSearch.filtered.length}
/>
<AdminsTable data={listData} {openEdit} {openDelete} {formatDate} />
<AdminsTable {data} {openEdit} {openDelete} {formatDate} />
</div>
<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
}
})),
createForm: await superValidate(zod4(roomCreateSchema), { id: 'rooms-create' }),
editForm: await superValidate(zod4(roomEditSchema), { id: 'rooms-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'rooms-archive' })
createForm: await superValidate(zod4(roomCreateSchema), { id: 'rooms-create', errors: false }),
editForm: await superValidate(zod4(roomEditSchema), { id: 'rooms-edit', errors: false }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'rooms-archive', errors: false })
};
};
@@ -28,9 +28,15 @@ export const load: PageServerLoad = async ({ locals }) => {
description: record.description ?? ''
}
})),
createForm: await superValidate(zod4(serviceCreateSchema), { id: 'services-create' }),
editForm: await superValidate(zod4(serviceEditSchema), { id: 'services-edit' }),
archiveForm: await superValidate(zod4(archiveSchema), { id: 'services-archive' })
createForm: await superValidate(zod4(serviceCreateSchema), {
id: 'services-create',
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: ''
},
zod4(loginSchema),
{ id: 'login' }
{ id: 'login', errors: false }
),
firstAdminForm: await superValidate(
{
@@ -44,7 +44,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
password: ''
},
zod4(firstAdminSchema),
{ id: 'first-admin' }
{ id: 'first-admin', errors: false }
)
};
};