feat: add room and service management
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
import { idSchema, moneyGbp, positiveInt, requiredText } from './shared.schema';
|
||||
|
||||
export const roomTypes = ['meeting_room', 'private_office', 'coworking_desk'] as const;
|
||||
|
||||
const optionalPositiveInt = (label: string) =>
|
||||
z.preprocess((value) => (value === '' ? undefined : value), positiveInt(label).optional());
|
||||
|
||||
const typeFields = {
|
||||
meeting_room: [
|
||||
'sqFt',
|
||||
'internalPricePerHourGbp',
|
||||
'externalPricePerHourGbp',
|
||||
'internalPricePerDayGbp',
|
||||
'externalPricePerDayGbp',
|
||||
'maxAttendees'
|
||||
],
|
||||
private_office: ['sqFt', 'workstations', 'pricePerMonthGbp'],
|
||||
coworking_desk: [
|
||||
'internalPricePerHourGbp',
|
||||
'externalPricePerHourGbp',
|
||||
'internalPricePerDayGbp',
|
||||
'externalPricePerDayGbp'
|
||||
]
|
||||
} as const satisfies Record<(typeof roomTypes)[number], readonly string[]>;
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
sqFt: 'Sq ft.',
|
||||
internalPricePerHourGbp: 'Internal price per hour',
|
||||
externalPricePerHourGbp: 'External price per hour',
|
||||
internalPricePerDayGbp: 'Internal price per day',
|
||||
externalPricePerDayGbp: 'External price per day',
|
||||
maxAttendees: 'Max attendees',
|
||||
workstations: 'Number of workstations',
|
||||
pricePerMonthGbp: 'Price per month'
|
||||
};
|
||||
|
||||
export const roomCreateSchema = z
|
||||
.object({
|
||||
name: requiredText('Room name'),
|
||||
type: z.enum(roomTypes).default('meeting_room'),
|
||||
sqFt: optionalPositiveInt('Sq ft.'),
|
||||
internalPricePerHourGbp: moneyGbp,
|
||||
externalPricePerHourGbp: moneyGbp,
|
||||
internalPricePerDayGbp: moneyGbp,
|
||||
externalPricePerDayGbp: moneyGbp,
|
||||
maxAttendees: optionalPositiveInt('Max attendees'),
|
||||
workstations: optionalPositiveInt('Number of workstations'),
|
||||
pricePerMonthGbp: moneyGbp
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
for (const field of typeFields[data.type]) {
|
||||
const value = data[field as keyof typeof data];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: [field],
|
||||
message: `${fieldLabels[field]} is required.`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const roomEditSchema = roomCreateSchema.extend(idSchema.shape);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema';
|
||||
|
||||
export const serviceCreateSchema = z.object({
|
||||
name: requiredText('Service name'),
|
||||
category: optionalText(120),
|
||||
unit: requiredText('Unit', 80).default('each'),
|
||||
priceGbp: moneyGbp,
|
||||
description: optionalText(1000)
|
||||
});
|
||||
|
||||
export const serviceEditSchema = serviceCreateSchema.extend(idSchema.shape);
|
||||
@@ -0,0 +1,163 @@
|
||||
import { 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 { rooms } from '$lib/server/db/schema';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import { roomCreateSchema, roomEditSchema } from '$lib/schemas/rooms.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const records = await db
|
||||
.select()
|
||||
.from(rooms)
|
||||
.where(isNull(rooms.archivedAt))
|
||||
.orderBy(asc(rooms.name));
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
...record,
|
||||
formValues: {
|
||||
id: record.id,
|
||||
name: record.name ?? '',
|
||||
type: record.type ?? 'meeting_room',
|
||||
sqFt: record.sqFt ?? '',
|
||||
internalPricePerHourGbp: record.internalPricePerHourGbp ?? 0,
|
||||
externalPricePerHourGbp: record.externalPricePerHourGbp ?? 0,
|
||||
internalPricePerDayGbp: record.internalPricePerDayGbp ?? 0,
|
||||
externalPricePerDayGbp: record.externalPricePerDayGbp ?? 0,
|
||||
maxAttendees: record.maxAttendees ?? '',
|
||||
workstations: record.workstations ?? '',
|
||||
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' })
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
const form = await superValidate(event, zod4(roomCreateSchema), { id: 'rooms-create' });
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
|
||||
const sharedValues = {
|
||||
name: form.data.name,
|
||||
type: form.data.type,
|
||||
sqFt: null,
|
||||
internalPricePerHourGbp: null,
|
||||
externalPricePerHourGbp: null,
|
||||
internalPricePerDayGbp: null,
|
||||
externalPricePerDayGbp: null,
|
||||
maxAttendees: null,
|
||||
workstations: null,
|
||||
pricePerMonthGbp: null,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
const roomValues =
|
||||
form.data.type === 'meeting_room'
|
||||
? {
|
||||
...sharedValues,
|
||||
sqFt: form.data.sqFt ?? null,
|
||||
internalPricePerHourGbp: form.data.internalPricePerHourGbp,
|
||||
externalPricePerHourGbp: form.data.externalPricePerHourGbp,
|
||||
internalPricePerDayGbp: form.data.internalPricePerDayGbp,
|
||||
externalPricePerDayGbp: form.data.externalPricePerDayGbp,
|
||||
maxAttendees: form.data.maxAttendees ?? null
|
||||
}
|
||||
: form.data.type === 'private_office'
|
||||
? {
|
||||
...sharedValues,
|
||||
sqFt: form.data.sqFt ?? null,
|
||||
workstations: form.data.workstations ?? null,
|
||||
pricePerMonthGbp: form.data.pricePerMonthGbp
|
||||
}
|
||||
: {
|
||||
...sharedValues,
|
||||
internalPricePerHourGbp: form.data.internalPricePerHourGbp,
|
||||
externalPricePerHourGbp: form.data.externalPricePerHourGbp,
|
||||
internalPricePerDayGbp: form.data.internalPricePerDayGbp,
|
||||
externalPricePerDayGbp: form.data.externalPricePerDayGbp
|
||||
};
|
||||
|
||||
try {
|
||||
await db.insert(rooms).values({
|
||||
id: crypto.randomUUID(),
|
||||
...roomValues,
|
||||
createdAt: new Date()
|
||||
});
|
||||
} catch {
|
||||
return message(form, 'Unable to create room.', { status: 400 });
|
||||
}
|
||||
|
||||
return message(form, 'Room created.');
|
||||
},
|
||||
|
||||
edit: async (event) => {
|
||||
const form = await superValidate(event, zod4(roomEditSchema), { id: 'rooms-edit' });
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
|
||||
const sharedValues = {
|
||||
name: form.data.name,
|
||||
type: form.data.type,
|
||||
sqFt: null,
|
||||
internalPricePerHourGbp: null,
|
||||
externalPricePerHourGbp: null,
|
||||
internalPricePerDayGbp: null,
|
||||
externalPricePerDayGbp: null,
|
||||
maxAttendees: null,
|
||||
workstations: null,
|
||||
pricePerMonthGbp: null,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
const roomValues =
|
||||
form.data.type === 'meeting_room'
|
||||
? {
|
||||
...sharedValues,
|
||||
sqFt: form.data.sqFt ?? null,
|
||||
internalPricePerHourGbp: form.data.internalPricePerHourGbp,
|
||||
externalPricePerHourGbp: form.data.externalPricePerHourGbp,
|
||||
internalPricePerDayGbp: form.data.internalPricePerDayGbp,
|
||||
externalPricePerDayGbp: form.data.externalPricePerDayGbp,
|
||||
maxAttendees: form.data.maxAttendees ?? null
|
||||
}
|
||||
: form.data.type === 'private_office'
|
||||
? {
|
||||
...sharedValues,
|
||||
sqFt: form.data.sqFt ?? null,
|
||||
workstations: form.data.workstations ?? null,
|
||||
pricePerMonthGbp: form.data.pricePerMonthGbp
|
||||
}
|
||||
: {
|
||||
...sharedValues,
|
||||
internalPricePerHourGbp: form.data.internalPricePerHourGbp,
|
||||
externalPricePerHourGbp: form.data.externalPricePerHourGbp,
|
||||
internalPricePerDayGbp: form.data.internalPricePerDayGbp,
|
||||
externalPricePerDayGbp: form.data.externalPricePerDayGbp
|
||||
};
|
||||
|
||||
try {
|
||||
await db.update(rooms).set(roomValues).where(eq(rooms.id, form.data.id));
|
||||
} catch {
|
||||
return message(form, 'Unable to update room.', { status: 400 });
|
||||
}
|
||||
|
||||
return message(form, 'Room updated.');
|
||||
},
|
||||
|
||||
archive: async (event) => {
|
||||
const form = await superValidate(event, zod4(archiveSchema), { id: 'rooms-archive' });
|
||||
|
||||
if (!form.valid) return message(form, 'Room id is required.', { status: 400 });
|
||||
|
||||
await db
|
||||
.update(rooms)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(rooms.id, form.data.id));
|
||||
|
||||
return message(form, 'Room archived.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import CreateRoomDialog from './create-room-dialog.svelte';
|
||||
import RoomsTable from './rooms-table.svelte';
|
||||
import EditRoomDialog from './edit-room-dialog.svelte';
|
||||
import ArchiveRoomDialog from './archive-room-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 { roomCreateSchema, roomEditSchema } from '$lib/schemas/rooms.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
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(roomCreateSchema),
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'rooms-create', () => (createOpen = false)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'rooms-create' })
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
const editForm = superForm(data.editForm, {
|
||||
validators: zod4Client(roomEditSchema),
|
||||
resetForm: false,
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'rooms-edit', () => (editingId = null)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'rooms-edit' })
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
const archiveForm = superForm(data.archiveForm, {
|
||||
validators: zod4Client(archiveSchema),
|
||||
resetForm: false,
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'rooms-archive', () => (archivingId = null)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'rooms-archive' })
|
||||
});
|
||||
|
||||
const { form: createData, enhance: enhanceCreate } = createForm;
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
const amount = typeof value === 'number' ? value : Number(value ?? 0);
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
|
||||
}
|
||||
|
||||
function formatRoomType(value: unknown) {
|
||||
const labels: Record<string, string> = {
|
||||
meeting_room: 'Meeting room',
|
||||
private_office: 'Private office',
|
||||
coworking_desk: 'Coworking desk'
|
||||
};
|
||||
|
||||
return labels[String(value)] ?? formatValue(value);
|
||||
}
|
||||
|
||||
function openEdit(record: RecordRow) {
|
||||
editForm.reset({ data: record.formValues as never });
|
||||
editingId = record.id;
|
||||
}
|
||||
|
||||
function openArchive(record: RecordRow) {
|
||||
archiveForm.reset({ data: { id: record.id } });
|
||||
archivingId = record.id;
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this room');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rooms | 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">Rooms</h1>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Manage meeting rooms, private offices, and coworking desks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CreateRoomDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
|
||||
</div>
|
||||
|
||||
<RoomsTable
|
||||
{data}
|
||||
{openEdit}
|
||||
{openArchive}
|
||||
{formatValue}
|
||||
{formatMoney}
|
||||
{formatRoomType}
|
||||
{recordName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditRoomDialog
|
||||
{editingRecord}
|
||||
{editForm}
|
||||
{editData}
|
||||
{enhanceEdit}
|
||||
onClose={() => (editingId = null)}
|
||||
/>
|
||||
|
||||
<ArchiveRoomDialog
|
||||
{archivingRecord}
|
||||
{archiveData}
|
||||
{enhanceArchive}
|
||||
{recordName}
|
||||
onClose={() => (archivingId = null)}
|
||||
/>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
let {
|
||||
archivingRecord,
|
||||
archiveData,
|
||||
enhanceArchive,
|
||||
onClose,
|
||||
recordName
|
||||
}: {
|
||||
archivingRecord: any;
|
||||
archiveData: any;
|
||||
enhanceArchive: any;
|
||||
onClose: () => void;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Archive room?</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
>This will hide {recordName(archivingRecord)} from active rooms. The record will remain in the
|
||||
database.</AlertDialog.Description
|
||||
>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
{#if archivingRecord}
|
||||
<form method="POST" action="?/archive" use:enhanceArchive>
|
||||
<input type="hidden" name="id" value={$archiveData.id} />
|
||||
<AlertDialog.Action type="submit" variant="destructive">Archive room</AlertDialog.Action>
|
||||
</form>
|
||||
{/if}
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,56 @@
|
||||
<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 * 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 RoomTypeFields from './room-type-fields.svelte';
|
||||
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 room</Button>
|
||||
{/snippet}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create room</Dialog.Title>
|
||||
<Dialog.Description>Add a new room record.</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} type="text" bind:value={$createData.name} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<RoomTypeFields form={createForm} data={createData} idPrefix="create" />
|
||||
<Dialog.Footer>
|
||||
<Dialog.Close
|
||||
>{#snippet child({ props })}<Button variant="outline" {...props}>Cancel</Button
|
||||
>{/snippet}</Dialog.Close
|
||||
>
|
||||
<Button type="submit">Create room</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
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 RoomTypeFields from './room-type-fields.svelte';
|
||||
let {
|
||||
editingRecord,
|
||||
editForm,
|
||||
editData,
|
||||
enhanceEdit,
|
||||
onClose
|
||||
}: {
|
||||
editingRecord: any;
|
||||
editForm: any;
|
||||
editData: any;
|
||||
enhanceEdit: any;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit room</Dialog.Title>
|
||||
<Dialog.Description>Update this room record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if editingRecord}
|
||||
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<FormField form={editForm} name="name">
|
||||
<Field.Field>
|
||||
<Control id="edit-name">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Name</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.name} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<RoomTypeFields form={editForm} data={editData} idPrefix="edit" />
|
||||
<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,108 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
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 * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
|
||||
let {
|
||||
form,
|
||||
data,
|
||||
idPrefix
|
||||
}: {
|
||||
form: any;
|
||||
data: any;
|
||||
idPrefix: string;
|
||||
} = $props();
|
||||
|
||||
const roomTypes = [
|
||||
{ value: 'meeting_room', label: 'Meeting room' },
|
||||
{ value: 'private_office', label: 'Private office' },
|
||||
{ value: 'coworking_desk', label: 'Coworking desk' }
|
||||
] as const;
|
||||
|
||||
const bookablePriceFields = [
|
||||
{ name: 'internalPricePerHourGbp', label: 'Internal price per hour' },
|
||||
{ name: 'externalPricePerHourGbp', label: 'External price per hour' },
|
||||
{ name: 'internalPricePerDayGbp', label: 'Internal price per day' },
|
||||
{ name: 'externalPricePerDayGbp', label: 'External price per day' }
|
||||
];
|
||||
|
||||
if (!$data.type) $data.type = 'meeting_room';
|
||||
</script>
|
||||
|
||||
{#snippet numberField(name: string, label: string, min = 0)}
|
||||
<FormField {form} {name}>
|
||||
<Field.Field>
|
||||
<Control id={`${idPrefix}-${name}`}>
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>{label}</Field.Label>
|
||||
<Input {...props} type="number" {min} bind:value={$data[name]} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
{/snippet}
|
||||
|
||||
<FormField {form} name="type">
|
||||
<Field.Field>
|
||||
<Control id={`${idPrefix}-type`}>
|
||||
{#snippet children({ props })}
|
||||
<input {...props} type="hidden" bind:value={$data.type} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Label>Type</Field.Label>
|
||||
<Tabs.Root bind:value={$data.type} class="gap-3">
|
||||
<Tabs.List class="grid h-9 w-full grid-cols-3">
|
||||
{#each roomTypes as roomType (roomType.value)}
|
||||
<Tabs.Trigger value={roomType.value} class="h-full px-2 text-center">
|
||||
{roomType.label}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="meeting_room" class="grid gap-2">
|
||||
{@render numberField('sqFt', 'Sq ft.', 1)}
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
{#each bookablePriceFields as field (field.name)}
|
||||
<MoneyField
|
||||
{form}
|
||||
{data}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
id={`${idPrefix}-${field.name}`}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{@render numberField('maxAttendees', 'Max attendees', 1)}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="private_office" class="grid gap-2">
|
||||
{@render numberField('sqFt', 'Sq ft.', 1)}
|
||||
{@render numberField('workstations', 'Number of workstations', 1)}
|
||||
<MoneyField
|
||||
{form}
|
||||
{data}
|
||||
name="pricePerMonthGbp"
|
||||
label="Price per month"
|
||||
id={`${idPrefix}-pricePerMonthGbp`}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="coworking_desk" class="grid gap-2 sm:grid-cols-2">
|
||||
{#each bookablePriceFields as field (field.name)}
|
||||
<MoneyField
|
||||
{form}
|
||||
{data}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
id={`${idPrefix}-${field.name}`}
|
||||
/>
|
||||
{/each}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
let {
|
||||
data,
|
||||
openEdit,
|
||||
openArchive,
|
||||
formatValue,
|
||||
formatMoney,
|
||||
formatRoomType,
|
||||
recordName
|
||||
}: {
|
||||
data: any;
|
||||
openEdit: any;
|
||||
openArchive: any;
|
||||
formatValue: any;
|
||||
formatMoney: any;
|
||||
formatRoomType: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
|
||||
function details(record: any) {
|
||||
if (record.type === 'meeting_room') {
|
||||
return [
|
||||
`${formatValue(record.sqFt)} sq ft.`,
|
||||
`${formatValue(record.maxAttendees)} max attendees`
|
||||
].join(' | ');
|
||||
}
|
||||
|
||||
if (record.type === 'private_office') {
|
||||
return [
|
||||
`${formatValue(record.sqFt)} sq ft.`,
|
||||
`${formatValue(record.workstations)} workstations`
|
||||
].join(' | ');
|
||||
}
|
||||
|
||||
return 'Bookable desk';
|
||||
}
|
||||
|
||||
function pricing(record: any) {
|
||||
if (record.type === 'private_office')
|
||||
return `${formatMoney(record.pricePerMonthGbp)} / month`;
|
||||
|
||||
return [
|
||||
`${formatMoney(record.internalPricePerHourGbp)} internal / hour`,
|
||||
`${formatMoney(record.externalPricePerHourGbp)} external / hour`
|
||||
].join(' | ');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if data.records.length > 0}
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>Details</Table.Head>
|
||||
<Table.Head>Pricing</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.name)}</Table.Cell>
|
||||
<Table.Cell class=""
|
||||
><Badge variant="secondary">{formatRoomType(record.type)}</Badge></Table.Cell
|
||||
>
|
||||
<Table.Cell class="">{details(record)}</Table.Cell>
|
||||
<Table.Cell class="">{pricing(record)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
>{#snippet child({ props })}<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${recordName(record)}`}
|
||||
{...props}><EllipsisVerticalIcon /></Button
|
||||
>{/snippet}</DropdownMenu.Trigger
|
||||
>
|
||||
<DropdownMenu.Content align="end" class="w-36">
|
||||
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
|
||||
>Archive</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
|
||||
No rooms have been created yet.
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { 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 { services } from '$lib/server/db/schema';
|
||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||
import { serviceCreateSchema, serviceEditSchema } from '$lib/schemas/services.schema';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const records = await db
|
||||
.select()
|
||||
.from(services)
|
||||
.where(isNull(services.archivedAt))
|
||||
.orderBy(asc(services.name));
|
||||
|
||||
return {
|
||||
records: records.map((record) => ({
|
||||
...record,
|
||||
formValues: {
|
||||
id: record.id,
|
||||
name: record.name ?? '',
|
||||
category: record.category ?? '',
|
||||
unit: record.unit ?? '',
|
||||
priceGbp: record.priceGbp ?? '',
|
||||
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' })
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async (event) => {
|
||||
const form = await superValidate(event, zod4(serviceCreateSchema), { id: 'services-create' });
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
|
||||
try {
|
||||
await db.insert(services).values({
|
||||
id: crypto.randomUUID(),
|
||||
name: form.data.name,
|
||||
category: form.data.category || null,
|
||||
unit: form.data.unit,
|
||||
priceGbp: form.data.priceGbp,
|
||||
description: form.data.description || null,
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
});
|
||||
} catch {
|
||||
return message(form, 'Unable to create service.', { status: 400 });
|
||||
}
|
||||
|
||||
return message(form, 'Service created.');
|
||||
},
|
||||
|
||||
edit: async (event) => {
|
||||
const form = await superValidate(event, zod4(serviceEditSchema), { id: 'services-edit' });
|
||||
|
||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(services)
|
||||
.set({
|
||||
name: form.data.name,
|
||||
category: form.data.category || null,
|
||||
unit: form.data.unit,
|
||||
priceGbp: form.data.priceGbp,
|
||||
description: form.data.description || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(services.id, form.data.id));
|
||||
} catch {
|
||||
return message(form, 'Unable to update service.', { status: 400 });
|
||||
}
|
||||
|
||||
return message(form, 'Service updated.');
|
||||
},
|
||||
|
||||
archive: async (event) => {
|
||||
const form = await superValidate(event, zod4(archiveSchema), { id: 'services-archive' });
|
||||
|
||||
if (!form.valid) return message(form, 'Service id is required.', { status: 400 });
|
||||
|
||||
await db
|
||||
.update(services)
|
||||
.set({ archivedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(services.id, form.data.id));
|
||||
|
||||
return message(form, 'Service archived.');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import CreateServiceDialog from './create-service-dialog.svelte';
|
||||
import ServicesTable from './services-table.svelte';
|
||||
import EditServiceDialog from './edit-service-dialog.svelte';
|
||||
import ArchiveServiceDialog from './archive-service-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 { serviceCreateSchema, serviceEditSchema } from '$lib/schemas/services.schema';
|
||||
import type { PageData } from './$types';
|
||||
type RecordRow = PageData['records'][number];
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
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(serviceCreateSchema),
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'services-create', () => (createOpen = false)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'services-create' })
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
const editForm = superForm(data.editForm, {
|
||||
validators: zod4Client(serviceEditSchema),
|
||||
resetForm: false,
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'services-edit', () => (editingId = null)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'services-edit' })
|
||||
});
|
||||
// svelte-ignore state_referenced_locally
|
||||
const archiveForm = superForm(data.archiveForm, {
|
||||
validators: zod4Client(archiveSchema),
|
||||
resetForm: false,
|
||||
onUpdated: ({ form }) => handleFormToast(form, 'services-archive', () => (archivingId = null)),
|
||||
onError: ({ result }) => toast.error(result.error.message, { id: 'services-archive' })
|
||||
});
|
||||
|
||||
const { form: createData, enhance: enhanceCreate } = createForm;
|
||||
const { form: editData, enhance: enhanceEdit } = editForm;
|
||||
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
|
||||
|
||||
function handleFormToast(
|
||||
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
|
||||
id: string,
|
||||
onSuccess: () => void
|
||||
) {
|
||||
if (form.valid) {
|
||||
if (form.message) toast.success(form.message, { id });
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(form.message ?? firstError(form.errors), { id });
|
||||
}
|
||||
|
||||
function firstError(errors: Record<string, unknown>) {
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
|
||||
}
|
||||
|
||||
return 'Check the highlighted fields.';
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
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 openEdit(record: RecordRow) {
|
||||
editForm.reset({ data: record.formValues as never });
|
||||
editingId = record.id;
|
||||
}
|
||||
|
||||
function openArchive(record: RecordRow) {
|
||||
archiveForm.reset({ data: { id: record.id } });
|
||||
archivingId = record.id;
|
||||
}
|
||||
|
||||
function recordName(record: Partial<RecordRow> | undefined) {
|
||||
const item = record as Partial<RecordRow> & {
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
invoiceNumber?: unknown;
|
||||
label?: unknown;
|
||||
};
|
||||
return String(
|
||||
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this service'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Services | 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">Services</h1>
|
||||
<p class="text-sm text-muted-foreground">Manage billable services and operational add-ons.</p>
|
||||
</div>
|
||||
|
||||
<CreateServiceDialog bind:open={createOpen} {createForm} {createData} {enhanceCreate} />
|
||||
</div>
|
||||
|
||||
<ServicesTable {data} {openEdit} {openArchive} {formatValue} {formatMoney} {recordName} />
|
||||
</div>
|
||||
|
||||
<EditServiceDialog
|
||||
{editingRecord}
|
||||
{editForm}
|
||||
{editData}
|
||||
{enhanceEdit}
|
||||
onClose={() => (editingId = null)}
|
||||
/>
|
||||
|
||||
<ArchiveServiceDialog
|
||||
{archivingRecord}
|
||||
{archiveData}
|
||||
{enhanceArchive}
|
||||
{recordName}
|
||||
onClose={() => (archivingId = null)}
|
||||
/>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
let {
|
||||
archivingRecord,
|
||||
archiveData,
|
||||
enhanceArchive,
|
||||
onClose,
|
||||
recordName
|
||||
}: {
|
||||
archivingRecord: any;
|
||||
archiveData: any;
|
||||
enhanceArchive: any;
|
||||
onClose: () => void;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Archive service?</AlertDialog.Title>
|
||||
<AlertDialog.Description
|
||||
>This will hide {recordName(archivingRecord)} from active services. The record will remain in
|
||||
the database.</AlertDialog.Description
|
||||
>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
{#if archivingRecord}
|
||||
<form method="POST" action="?/archive" use:enhanceArchive>
|
||||
<input type="hidden" name="id" value={$archiveData.id} />
|
||||
<AlertDialog.Action type="submit" variant="destructive"
|
||||
>Archive service</AlertDialog.Action
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -0,0 +1,96 @@
|
||||
<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 MoneyField from '$lib/components/money-field.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
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 service</Button>
|
||||
{/snippet}
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create service</Dialog.Title>
|
||||
<Dialog.Description>Add a new service record.</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} type="text" bind:value={$createData.name} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="category">
|
||||
<Field.Field>
|
||||
<Control id="create-category">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Category</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.category} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={createForm} name="unit">
|
||||
<Field.Field>
|
||||
<Control id="create-unit">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Unit</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$createData.unit} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<MoneyField
|
||||
form={createForm}
|
||||
data={createData}
|
||||
name="priceGbp"
|
||||
label="Price"
|
||||
id="create-priceGbp"
|
||||
/>
|
||||
<FormField form={createForm} name="description">
|
||||
<Field.Field>
|
||||
<Control id="create-description">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Description</Field.Label>
|
||||
<Textarea {...props} bind:value={$createData.description} />
|
||||
{/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 service</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||
import MoneyField from '$lib/components/money-field.svelte';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
let {
|
||||
editingRecord,
|
||||
editForm,
|
||||
editData,
|
||||
enhanceEdit,
|
||||
onClose
|
||||
}: {
|
||||
editingRecord: any;
|
||||
editForm: any;
|
||||
editData: any;
|
||||
enhanceEdit: any;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Root open={!!editingRecord} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit service</Dialog.Title>
|
||||
<Dialog.Description>Update this service record.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if editingRecord}
|
||||
<form method="POST" action="?/edit" class="grid gap-2" use:enhanceEdit>
|
||||
<input type="hidden" name="id" value={$editData.id} />
|
||||
<FormField form={editForm} name="name">
|
||||
<Field.Field>
|
||||
<Control id="edit-name">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Name</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.name} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="category">
|
||||
<Field.Field>
|
||||
<Control id="edit-category">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Category</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.category} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<FormField form={editForm} name="unit">
|
||||
<Field.Field>
|
||||
<Control id="edit-unit">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Unit</Field.Label>
|
||||
<Input {...props} type="text" bind:value={$editData.unit} />
|
||||
{/snippet}
|
||||
</Control>
|
||||
<Field.Error><FieldErrors /></Field.Error>
|
||||
</Field.Field>
|
||||
</FormField>
|
||||
<MoneyField
|
||||
form={editForm}
|
||||
data={editData}
|
||||
name="priceGbp"
|
||||
label="Price"
|
||||
id="edit-priceGbp"
|
||||
/>
|
||||
<FormField form={editForm} name="description">
|
||||
<Field.Field>
|
||||
<Control id="edit-description">
|
||||
{#snippet children({ props })}
|
||||
<Field.Label>Description</Field.Label>
|
||||
<Textarea {...props} bind:value={$editData.description} />
|
||||
{/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,70 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
let {
|
||||
data,
|
||||
openEdit,
|
||||
openArchive,
|
||||
formatValue,
|
||||
formatMoney,
|
||||
recordName
|
||||
}: {
|
||||
data: any;
|
||||
openEdit: any;
|
||||
openArchive: any;
|
||||
formatValue: any;
|
||||
formatMoney: any;
|
||||
recordName: any;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if data.records.length > 0}
|
||||
<div class="overflow-hidden rounded-lg border border-border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Name</Table.Head>
|
||||
<Table.Head>Category</Table.Head>
|
||||
<Table.Head>Unit</Table.Head>
|
||||
<Table.Head>Price</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.name)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.category)}</Table.Cell>
|
||||
<Table.Cell class="">{formatValue(record.unit)}</Table.Cell>
|
||||
<Table.Cell class="">{formatMoney(record.priceGbp)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
>{#snippet child({ props })}<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Open actions for ${recordName(record)}`}
|
||||
{...props}><EllipsisVerticalIcon /></Button
|
||||
>{/snippet}</DropdownMenu.Trigger
|
||||
>
|
||||
<DropdownMenu.Content align="end" class="w-36">
|
||||
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
|
||||
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
|
||||
>Archive</DropdownMenu.Item
|
||||
>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
|
||||
No services have been created yet.
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user