update: split workspace pages into reusable components

This commit is contained in:
2026-06-24 23:31:04 +01:00
parent 5654d3d570
commit cac9d0e04d
27 changed files with 4701 additions and 734 deletions
@@ -2,7 +2,7 @@ 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, rooms, services } from '$lib/server/db/schema';
import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import {
@@ -39,7 +39,6 @@ async function loadOptions(organizationId: string) {
return {
rooms: [
{ label: 'No room', value: '' },
...roomRows.map((room) => ({
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
value: room.id,
@@ -63,11 +62,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
const records = await db
.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(
@@ -77,29 +74,57 @@ export const load: PageServerLoad = async ({ locals, params }) => {
)
)
.orderBy(asc(contracts.startDate));
const contractIds = records.map((record) => record.contract.id);
const roomLinks =
contractIds.length > 0
? await db
.select({
contractId: contractRooms.contractId,
roomId: rooms.id,
roomName: rooms.name
})
.from(contractRooms)
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
.where(inArray(contractRooms.contractId, contractIds))
.orderBy(asc(rooms.name))
: [];
const roomsByContract = new Map<string, { id: string; name: string }[]>();
for (const link of roomLinks) {
const existing = roomsByContract.get(link.contractId) ?? [];
existing.push({ id: link.roomId, name: link.roomName });
roomsByContract.set(link.contractId, existing);
}
return {
records: records.map((record) => ({
...record.contract,
roomName: record.roomName,
serviceName: record.serviceName,
formValues: {
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',
errors: false
records: records.map((record) => {
const linkedRooms = roomsByContract.get(record.contract.id) ?? [];
return {
...record.contract,
roomName: linkedRooms.map((room) => room.name).join(', '),
roomNames: linkedRooms.map((room) => room.name),
roomIds: linkedRooms.map((room) => room.id),
serviceName: record.serviceName,
formValues: {
id: record.contract.id,
clientId: record.contract.clientId ?? '',
roomIds: linkedRooms.map((room) => room.id),
serviceId: record.contract.serviceId ?? '',
licenseFeeGbp: record.contract.licenseFeeGbp,
depositGbp: record.contract.depositGbp,
startDate: record.contract.startDate ?? '',
endDate: record.contract.endDate ?? ''
}
};
}),
options: await loadOptions(activeOrganizationId),
createForm: await superValidate(
{ clientId: params.id, roomIds: [] },
zod4(contractCreateSchema),
{
id: 'contracts-create',
errors: false
}
),
editForm: await superValidate(zod4(contractEditSchema), {
id: 'contracts-edit',
errors: false
@@ -117,26 +142,26 @@ export const load: PageServerLoad = async ({ locals, params }) => {
async function selectionError(
organizationId: string,
roomId: string,
roomIds: string[],
serviceId: string
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> {
if (roomId) {
const [room] = await db
): Promise<{ field: 'roomIds' | 'serviceId'; message: string } | null> {
if (roomIds.length > 0) {
const uniqueRoomIds = [...new Set(roomIds)];
const validRooms = await db
.select({ id: rooms.id })
.from(rooms)
.where(
and(
eq(rooms.id, roomId),
inArray(rooms.id, uniqueRoomIds),
eq(rooms.organizationId, organizationId),
inArray(rooms.type, ['private_office', 'coworking_desk']),
isNull(rooms.archivedAt)
)
)
.limit(1);
);
if (!room) {
if (validRooms.length !== uniqueRoomIds.length) {
return {
field: 'roomId',
field: 'roomIds',
message: 'Select a valid private office or coworking desk.'
};
}
@@ -173,26 +198,42 @@ 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.roomIds,
form.data.serviceId
);
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
if (invalidSelection) {
if (invalidSelection.field === 'serviceId') {
return setError(form, invalidSelection.field, invalidSelection.message);
}
return message(form, invalidSelection.message, { status: 400 });
}
try {
await db.insert(contracts).values({
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
clientId: form.data.clientId,
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,
status: 'draft',
notes: form.data.notes || null,
updatedAt: new Date(),
createdAt: new Date()
const contractId = crypto.randomUUID();
await db.transaction(async (tx) => {
await tx.insert(contracts).values({
id: contractId,
organizationId: activeOrganizationId,
clientId: form.data.clientId,
serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp,
startDate: form.data.startDate,
endDate: form.data.endDate,
status: 'draft',
updatedAt: new Date(),
createdAt: new Date()
});
const uniqueRoomIds = [...new Set(form.data.roomIds)];
if (uniqueRoomIds.length > 0) {
await tx.insert(contractRooms).values(
uniqueRoomIds.map((roomId) => ({
contractId,
roomId
}))
);
}
});
} catch {
return message(form, 'Unable to create contract.', { status: 400 });
@@ -212,32 +253,48 @@ 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.roomIds,
form.data.serviceId
);
if (invalidSelection) return setError(form, invalidSelection.field, invalidSelection.message);
if (invalidSelection) {
if (invalidSelection.field === 'serviceId') {
return setError(form, invalidSelection.field, invalidSelection.message);
}
return message(form, invalidSelection.message, { status: 400 });
}
try {
await db
.update(contracts)
.set({
clientId: form.data.clientId,
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,
notes: form.data.notes || null,
updatedAt: new Date()
})
.where(
and(
eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId)
)
);
await db.transaction(async (tx) => {
await tx
.update(contracts)
.set({
clientId: form.data.clientId,
serviceId: form.data.serviceId || null,
licenseFeeGbp: form.data.licenseFeeGbp,
depositGbp: form.data.depositGbp,
startDate: form.data.startDate,
endDate: form.data.endDate,
updatedAt: new Date()
})
.where(
and(
eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId)
)
);
await tx.delete(contractRooms).where(eq(contractRooms.contractId, form.data.id));
const uniqueRoomIds = [...new Set(form.data.roomIds)];
if (uniqueRoomIds.length > 0) {
await tx.insert(contractRooms).values(
uniqueRoomIds.map((roomId) => ({
contractId: form.data.id,
roomId
}))
);
}
});
} catch {
return message(form, 'Unable to update contract.', { status: 400 });
}