update: split workspace pages into reusable components
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
CREATE TABLE `contract_rooms` (
|
||||||
|
`contract_id` text NOT NULL,
|
||||||
|
`room_id` text NOT NULL,
|
||||||
|
PRIMARY KEY(`contract_id`, `room_id`),
|
||||||
|
FOREIGN KEY (`contract_id`) REFERENCES `contracts`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON UPDATE no action ON DELETE restrict
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `contract_rooms_room_id_idx` ON `contract_rooms` (`room_id`);--> statement-breakpoint
|
||||||
|
INSERT INTO `contract_rooms`("contract_id", "room_id") SELECT "id", "room_id" FROM `contracts` WHERE "room_id" IS NOT NULL;--> statement-breakpoint
|
||||||
|
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,
|
||||||
|
`service_id` text,
|
||||||
|
`license_fee_gbp` real DEFAULT 0 NOT NULL,
|
||||||
|
`deposit_gbp` real DEFAULT 0 NOT NULL,
|
||||||
|
`start_date` text NOT NULL,
|
||||||
|
`end_date` text NOT NULL,
|
||||||
|
`billed_to` text,
|
||||||
|
`status` text DEFAULT 'draft' 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 (`client_id`) REFERENCES `clients`(`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", "service_id", "license_fee_gbp", "deposit_gbp", "start_date", "end_date", "billed_to", "status", "created_at", "updated_at", "archived_at") SELECT "id", "organization_id", "client_id", "service_id", "license_fee_gbp", "deposit_gbp", "start_date", "end_date", "billed_to", "status", "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_service_id_idx` ON `contracts` (`service_id`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `invoices` DROP COLUMN `status`;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,20 @@
|
|||||||
"when": 1782313131621,
|
"when": 1782313131621,
|
||||||
"tag": "0013_flippant_jamie_braddock",
|
"tag": "0013_flippant_jamie_braddock",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1782337886282,
|
||||||
|
"tag": "0014_fixed_sunfire",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1782338642144,
|
||||||
|
"tag": "0015_fuzzy_vampiro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,6 @@ export const contactCreateSchema = z.object({
|
|||||||
role: optionalText(120),
|
role: optionalText(120),
|
||||||
email: optionalEmail,
|
email: optionalEmail,
|
||||||
phone: optionalText(80),
|
phone: optionalText(80),
|
||||||
isPrimary: booleanString,
|
|
||||||
receivesInvoices: booleanString,
|
receivesInvoices: booleanString,
|
||||||
receivesContracts: booleanString
|
receivesContracts: booleanString
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,19 +6,18 @@ export const contractStatuses = ['draft', 'active', 'expired', 'void'] as const;
|
|||||||
export const contractCreateSchema = z
|
export const contractCreateSchema = z
|
||||||
.object({
|
.object({
|
||||||
clientId: requiredText('Client'),
|
clientId: requiredText('Client'),
|
||||||
roomId: optionalText(80),
|
roomIds: z.array(z.string().uuid()).default([]),
|
||||||
serviceId: optionalText(80),
|
serviceId: optionalText(80),
|
||||||
licenseFeeGbp: moneyGbp,
|
licenseFeeGbp: moneyGbp,
|
||||||
depositGbp: moneyGbp,
|
depositGbp: moneyGbp,
|
||||||
startDate: requiredText('Start date', 40),
|
startDate: requiredText('Start date', 40),
|
||||||
endDate: requiredText('End date', 40),
|
endDate: requiredText('End date', 40)
|
||||||
notes: optionalText(1000)
|
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (!data.roomId && !data.serviceId) {
|
if (data.roomIds.length === 0 && !data.serviceId) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
path: ['roomId'],
|
path: ['roomIds'],
|
||||||
message: 'Select a room or service.'
|
message: 'Select a room or service.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { idSchema, moneyGbp, optionalText, requiredText } from './shared.schema';
|
import { moneyGbp, optionalText, requiredText } from './shared.schema';
|
||||||
|
|
||||||
export const invoiceCreateSchema = z.object({
|
export const invoiceCreateSchema = z.object({
|
||||||
clientId: requiredText('Client'),
|
clientId: requiredText('Client'),
|
||||||
invoiceNumber: requiredText('Invoice number', 80),
|
invoiceNumber: requiredText('Invoice number', 80),
|
||||||
issueDate: requiredText('Issue date', 40),
|
issueDate: requiredText('Issue date', 40),
|
||||||
dueDate: requiredText('Due date', 40),
|
dueDate: requiredText('Due date', 40),
|
||||||
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'void']).default('draft'),
|
|
||||||
subtotalGbp: moneyGbp,
|
subtotalGbp: moneyGbp,
|
||||||
taxGbp: moneyGbp,
|
taxGbp: moneyGbp,
|
||||||
totalGbp: moneyGbp,
|
totalGbp: moneyGbp,
|
||||||
notes: optionalText(1000)
|
notes: optionalText(1000)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const invoiceEditSchema = invoiceCreateSchema.extend(idSchema.shape);
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
import { index, primaryKey, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
import { clients } from './clients.schema';
|
import { clients } from './clients.schema';
|
||||||
import { organizations } from './organizations.schema';
|
import { organizations } from './organizations.schema';
|
||||||
import { rooms } from './rooms.schema';
|
import { rooms } from './rooms.schema';
|
||||||
@@ -17,7 +17,6 @@ export const contracts = sqliteTable(
|
|||||||
clientId: text('client_id')
|
clientId: text('client_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||||
roomId: text('room_id').references(() => rooms.id, { onDelete: 'restrict' }),
|
|
||||||
serviceId: text('service_id').references(() => services.id, { onDelete: 'restrict' }),
|
serviceId: text('service_id').references(() => services.id, { onDelete: 'restrict' }),
|
||||||
licenseFeeGbp: real('license_fee_gbp').notNull().default(0),
|
licenseFeeGbp: real('license_fee_gbp').notNull().default(0),
|
||||||
depositGbp: real('deposit_gbp').notNull().default(0),
|
depositGbp: real('deposit_gbp').notNull().default(0),
|
||||||
@@ -25,13 +24,27 @@ export const contracts = sqliteTable(
|
|||||||
endDate: text('end_date').notNull(),
|
endDate: text('end_date').notNull(),
|
||||||
billedTo: text('billed_to'),
|
billedTo: text('billed_to'),
|
||||||
status: text('status').notNull().default('draft'),
|
status: text('status').notNull().default('draft'),
|
||||||
notes: text('notes'),
|
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('contracts_organization_id_idx').on(table.organizationId),
|
index('contracts_organization_id_idx').on(table.organizationId),
|
||||||
index('contracts_client_id_idx').on(table.clientId),
|
index('contracts_client_id_idx').on(table.clientId),
|
||||||
index('contracts_room_id_idx').on(table.roomId),
|
|
||||||
index('contracts_service_id_idx').on(table.serviceId)
|
index('contracts_service_id_idx').on(table.serviceId)
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const contractRooms = sqliteTable(
|
||||||
|
'contract_rooms',
|
||||||
|
{
|
||||||
|
contractId: text('contract_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => contracts.id, { onDelete: 'cascade' }),
|
||||||
|
roomId: text('room_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => rooms.id, { onDelete: 'restrict' })
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.contractId, table.roomId] }),
|
||||||
|
index('contract_rooms_room_id_idx').on(table.roomId)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export const invoices = sqliteTable(
|
|||||||
invoiceNumber: text('invoice_number').notNull().unique(),
|
invoiceNumber: text('invoice_number').notNull().unique(),
|
||||||
issueDate: text('issue_date').notNull(),
|
issueDate: text('issue_date').notNull(),
|
||||||
dueDate: text('due_date').notNull(),
|
dueDate: text('due_date').notNull(),
|
||||||
status: text('status').notNull().default('draft'),
|
|
||||||
subtotalGbp: real('subtotal_gbp').notNull().default(0),
|
subtotalGbp: real('subtotal_gbp').notNull().default(0),
|
||||||
taxGbp: real('tax_gbp').notNull().default(0),
|
taxGbp: real('tax_gbp').notNull().default(0),
|
||||||
totalGbp: real('total_gbp').notNull().default(0),
|
totalGbp: real('total_gbp').notNull().default(0),
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { and, asc, desc, eq, gte, isNull, like, lte, ne } from 'drizzle-orm';
|
import { and, asc, desc, eq, gte, inArray, isNull, like, lte, ne } from 'drizzle-orm';
|
||||||
import { createSearchParamsSchema, validateSearchParams } from 'runed/kit';
|
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 { db } from '$lib/server/db';
|
||||||
import {
|
import {
|
||||||
bookings,
|
bookings,
|
||||||
clients,
|
clients,
|
||||||
contracts,
|
contracts,
|
||||||
|
contractRooms,
|
||||||
invoiceLines,
|
invoiceLines,
|
||||||
invoices,
|
invoices,
|
||||||
rooms,
|
rooms,
|
||||||
services
|
services
|
||||||
} from '$lib/server/db/schema';
|
} from '$lib/server/db/schema';
|
||||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
type BillingLine = {
|
type BillingLine = {
|
||||||
@@ -301,7 +299,6 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
|
|||||||
id: contracts.id,
|
id: contracts.id,
|
||||||
clientId: contracts.clientId,
|
clientId: contracts.clientId,
|
||||||
clientName: clients.name,
|
clientName: clients.name,
|
||||||
roomName: rooms.name,
|
|
||||||
serviceName: services.name,
|
serviceName: services.name,
|
||||||
licenseFeeGbp: contracts.licenseFeeGbp,
|
licenseFeeGbp: contracts.licenseFeeGbp,
|
||||||
startDate: contracts.startDate,
|
startDate: contracts.startDate,
|
||||||
@@ -309,7 +306,6 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
|
|||||||
})
|
})
|
||||||
.from(contracts)
|
.from(contracts)
|
||||||
.innerJoin(clients, eq(contracts.clientId, clients.id))
|
.innerJoin(clients, eq(contracts.clientId, clients.id))
|
||||||
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
|
|
||||||
.leftJoin(services, eq(contracts.serviceId, services.id))
|
.leftJoin(services, eq(contracts.serviceId, services.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -321,6 +317,25 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(asc(contracts.startDate));
|
.orderBy(asc(contracts.startDate));
|
||||||
|
const contractIds = contractRows.map((contract) => contract.id);
|
||||||
|
const contractRoomLinks =
|
||||||
|
contractIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
contractId: contractRooms.contractId,
|
||||||
|
roomName: rooms.name
|
||||||
|
})
|
||||||
|
.from(contractRooms)
|
||||||
|
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
|
||||||
|
.where(inArray(contractRooms.contractId, contractIds))
|
||||||
|
.orderBy(asc(rooms.name))
|
||||||
|
: [];
|
||||||
|
const roomNamesByContract = new Map<string, string[]>();
|
||||||
|
for (const link of contractRoomLinks) {
|
||||||
|
const existing = roomNamesByContract.get(link.contractId) ?? [];
|
||||||
|
existing.push(link.roomName);
|
||||||
|
roomNamesByContract.set(link.contractId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
const contractCutoff = parseDateOnly(cutoffs.contractsCutoff);
|
const contractCutoff = parseDateOnly(cutoffs.contractsCutoff);
|
||||||
|
|
||||||
@@ -343,7 +358,10 @@ async function buildBillingPreview(organizationId: string, invoiceDate: string)
|
|||||||
const monthDays = daysInclusive(startOfMonth(month), endOfMonth(month));
|
const monthDays = daysInclusive(startOfMonth(month), endOfMonth(month));
|
||||||
const billedDays = daysInclusive(periodStartDate, periodEndDate);
|
const billedDays = daysInclusive(periodStartDate, periodEndDate);
|
||||||
const quantity = roundQuantity(billedDays / monthDays);
|
const quantity = roundQuantity(billedDays / monthDays);
|
||||||
const labelParts = [contract.roomName, contract.serviceName].filter(Boolean);
|
const labelParts = [
|
||||||
|
...(roomNamesByContract.get(contract.id) ?? []),
|
||||||
|
contract.serviceName
|
||||||
|
].filter(Boolean);
|
||||||
const line = {
|
const line = {
|
||||||
id: '',
|
id: '',
|
||||||
clientId: contract.clientId,
|
clientId: contract.clientId,
|
||||||
@@ -407,11 +425,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
|
|||||||
return {
|
return {
|
||||||
records,
|
records,
|
||||||
options: await loadOptions(activeOrganizationId),
|
options: await loadOptions(activeOrganizationId),
|
||||||
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate),
|
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate)
|
||||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
|
||||||
id: 'invoices-archive',
|
|
||||||
errors: false
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -458,7 +472,6 @@ export const actions: Actions = {
|
|||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
issueDate,
|
issueDate,
|
||||||
dueDate,
|
dueDate,
|
||||||
status: 'draft',
|
|
||||||
subtotalGbp: client.totalGbp,
|
subtotalGbp: client.totalGbp,
|
||||||
taxGbp: 0,
|
taxGbp: 0,
|
||||||
totalGbp: client.totalGbp,
|
totalGbp: client.totalGbp,
|
||||||
@@ -508,19 +521,5 @@ export const actions: Actions = {
|
|||||||
return {
|
return {
|
||||||
message: `Billing run complete. Created ${groupedClients.length} invoice${groupedClients.length === 1 ? '' : 's'}.`
|
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.');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,51 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import InvoicesTable from './invoices-table.svelte';
|
import InvoicesTable from './invoices-table.svelte';
|
||||||
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
|
|
||||||
import RunBillingDialog from './run-billing-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';
|
import type { PageData } from './$types';
|
||||||
type RecordRow = PageData['records'][number];
|
|
||||||
let { data }: { data: PageData } = $props();
|
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) {
|
function formatValue(value: unknown) {
|
||||||
if (value === null || value === undefined || value === '') return 'Not set';
|
if (value === null || value === undefined || value === '') return 'Not set';
|
||||||
return String(value);
|
return String(value);
|
||||||
@@ -72,23 +30,6 @@
|
|||||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||||
}).format(date);
|
}).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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -108,21 +49,5 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InvoicesTable
|
<InvoicesTable {data} {formatValue} {formatDate} {formatMoney} {relationLabel} />
|
||||||
{data}
|
|
||||||
{openArchive}
|
|
||||||
{formatValue}
|
|
||||||
{formatDate}
|
|
||||||
{formatMoney}
|
|
||||||
{relationLabel}
|
|
||||||
{recordName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ArchiveInvoiceDialog
|
|
||||||
{archivingRecord}
|
|
||||||
{archiveData}
|
|
||||||
{enhanceArchive}
|
|
||||||
{recordName}
|
|
||||||
onClose={() => (archivingId = null)}
|
|
||||||
/>
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<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 invoice?</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description
|
|
||||||
>This will hide {recordName(archivingRecord)} from active invoices. 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 invoice</AlertDialog.Action
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</AlertDialog.Footer>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
openArchive,
|
|
||||||
formatValue,
|
formatValue,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
relationLabel,
|
relationLabel
|
||||||
recordName
|
|
||||||
}: {
|
}: {
|
||||||
data: any;
|
data: any;
|
||||||
openArchive: any;
|
|
||||||
formatValue: any;
|
formatValue: any;
|
||||||
formatDate: any;
|
formatDate: any;
|
||||||
formatMoney: any;
|
formatMoney: any;
|
||||||
relationLabel: any;
|
relationLabel: any;
|
||||||
recordName: any;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,9 +25,7 @@
|
|||||||
<Table.Head>Client</Table.Head>
|
<Table.Head>Client</Table.Head>
|
||||||
<Table.Head>Issued</Table.Head>
|
<Table.Head>Issued</Table.Head>
|
||||||
<Table.Head>Due</Table.Head>
|
<Table.Head>Due</Table.Head>
|
||||||
<Table.Head>Status</Table.Head>
|
|
||||||
<Table.Head>Total</Table.Head>
|
<Table.Head>Total</Table.Head>
|
||||||
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -45,28 +35,7 @@
|
|||||||
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||||
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
||||||
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
||||||
<Table.Cell class=""
|
|
||||||
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
|
|
||||||
></Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
<Table.Cell class="">{formatMoney(record.totalGbp)}</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 variant="destructive" onclick={() => openArchive(record)}
|
|
||||||
>Archive</DropdownMenu.Item
|
|
||||||
>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Root>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
role: record.role ?? '',
|
role: record.role ?? '',
|
||||||
email: record.email ?? '',
|
email: record.email ?? '',
|
||||||
phone: record.phone ?? '',
|
phone: record.phone ?? '',
|
||||||
isPrimary: record.isPrimary ? 'true' : 'false',
|
|
||||||
receivesInvoices: record.receivesInvoices ? 'true' : 'false',
|
receivesInvoices: record.receivesInvoices ? 'true' : 'false',
|
||||||
receivesContracts: record.receivesContracts ? 'true' : 'false'
|
receivesContracts: record.receivesContracts ? 'true' : 'false'
|
||||||
}
|
}
|
||||||
@@ -85,7 +84,7 @@ export const actions: Actions = {
|
|||||||
role: form.data.role || null,
|
role: form.data.role || null,
|
||||||
email: form.data.email || null,
|
email: form.data.email || null,
|
||||||
phone: form.data.phone || null,
|
phone: form.data.phone || null,
|
||||||
isPrimary: form.data.isPrimary === 'true',
|
isPrimary: false,
|
||||||
receivesInvoices: form.data.receivesInvoices === 'true',
|
receivesInvoices: form.data.receivesInvoices === 'true',
|
||||||
receivesContracts: form.data.receivesContracts === 'true',
|
receivesContracts: form.data.receivesContracts === 'true',
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -115,7 +114,6 @@ export const actions: Actions = {
|
|||||||
role: form.data.role || null,
|
role: form.data.role || null,
|
||||||
email: form.data.email || null,
|
email: form.data.email || null,
|
||||||
phone: form.data.phone || null,
|
phone: form.data.phone || null,
|
||||||
isPrimary: form.data.isPrimary === 'true',
|
|
||||||
receivesInvoices: form.data.receivesInvoices === 'true',
|
receivesInvoices: form.data.receivesInvoices === 'true',
|
||||||
receivesContracts: form.data.receivesContracts === 'true',
|
receivesContracts: form.data.receivesContracts === 'true',
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
@@ -134,6 +132,60 @@ export const actions: Actions = {
|
|||||||
return message(form, 'Contact updated.');
|
return message(form, 'Contact updated.');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
makePrimary: async ({ locals, params, request }) => {
|
||||||
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||||
|
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
|
||||||
|
id: 'contacts-primary'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!form.valid) return message(form, 'Contact id is required.', { status: 400 });
|
||||||
|
|
||||||
|
const [contact] = await db
|
||||||
|
.select({ id: contacts.id, isPrimary: contacts.isPrimary })
|
||||||
|
.from(contacts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(contacts.id, form.data.id),
|
||||||
|
eq(contacts.clientId, params.id),
|
||||||
|
eq(contacts.organizationId, activeOrganizationId),
|
||||||
|
isNull(contacts.archivedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!contact) return message(form, 'Choose a valid contact.', { status: 400 });
|
||||||
|
if (contact.isPrimary) return message(form, 'Contact is already primary.');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(contacts)
|
||||||
|
.set({ isPrimary: false, updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(contacts.clientId, params.id),
|
||||||
|
eq(contacts.organizationId, activeOrganizationId),
|
||||||
|
eq(contacts.isPrimary, true),
|
||||||
|
isNull(contacts.archivedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(contacts)
|
||||||
|
.set({ isPrimary: true, updatedAt: now })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(contacts.id, form.data.id),
|
||||||
|
eq(contacts.clientId, params.id),
|
||||||
|
eq(contacts.organizationId, activeOrganizationId),
|
||||||
|
isNull(contacts.archivedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return message(form, 'Primary contact updated.');
|
||||||
|
},
|
||||||
|
|
||||||
archive: async ({ locals, params, request }) => {
|
archive: async ({ locals, params, request }) => {
|
||||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||||
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
|
const form = await superValidate(await request.formData(), zod4(archiveSchema), {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
openEdit,
|
openEdit,
|
||||||
@@ -54,8 +55,21 @@
|
|||||||
{...props}><EllipsisVerticalIcon /></Button
|
{...props}><EllipsisVerticalIcon /></Button
|
||||||
>{/snippet}</DropdownMenu.Trigger
|
>{/snippet}</DropdownMenu.Trigger
|
||||||
>
|
>
|
||||||
<DropdownMenu.Content align="end" class="w-36">
|
<DropdownMenu.Content align="end" class="w-48">
|
||||||
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
|
<DropdownMenu.Item onclick={() => openEdit(record)}>Edit</DropdownMenu.Item>
|
||||||
|
{#if !record.isPrimary}
|
||||||
|
<form method="POST" action="?/makePrimary">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="id"
|
||||||
|
value={record.id}
|
||||||
|
class={cn(
|
||||||
|
'flex w-full cursor-default items-center rounded-md px-1.5 py-1 text-left text-sm outline-hidden select-none',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground'
|
||||||
|
)}>Make Primary Contact</button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
|
<DropdownMenu.Item variant="destructive" onclick={() => openArchive(record)}
|
||||||
>Archive</DropdownMenu.Item
|
>Archive</DropdownMenu.Item
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
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 FormCheckbox from '$lib/components/form-checkbox.svelte';
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
@@ -77,26 +76,6 @@
|
|||||||
<Field.Error><FieldErrors /></Field.Error>
|
<Field.Error><FieldErrors /></Field.Error>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField form={createForm} name="isPrimary">
|
|
||||||
<Field.Field>
|
|
||||||
<Control id="create-isPrimary">
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<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}
|
|
||||||
</Control>
|
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
|
||||||
</Field.Field>
|
|
||||||
</FormField>
|
|
||||||
<div class="grid gap-3 py-1">
|
<div class="grid gap-3 py-1">
|
||||||
<FormCheckbox
|
<FormCheckbox
|
||||||
id="create-receives-invoices"
|
id="create-receives-invoices"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
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 FormCheckbox from '$lib/components/form-checkbox.svelte';
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
@@ -75,26 +74,6 @@
|
|||||||
<Field.Error><FieldErrors /></Field.Error>
|
<Field.Error><FieldErrors /></Field.Error>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField form={editForm} name="isPrimary">
|
|
||||||
<Field.Field>
|
|
||||||
<Control id="edit-isPrimary">
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<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}
|
|
||||||
</Control>
|
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
|
||||||
</Field.Field>
|
|
||||||
</FormField>
|
|
||||||
<div class="grid gap-3 py-1">
|
<div class="grid gap-3 py-1">
|
||||||
<FormCheckbox
|
<FormCheckbox
|
||||||
id="edit-receives-invoices"
|
id="edit-receives-invoices"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
|||||||
import { message, setError, superValidate } from 'sveltekit-superforms/server';
|
import { message, setError, superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { contracts, rooms, services } from '$lib/server/db/schema';
|
import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema';
|
||||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +39,6 @@ async function loadOptions(organizationId: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
rooms: [
|
rooms: [
|
||||||
{ label: 'No room', value: '' },
|
|
||||||
...roomRows.map((room) => ({
|
...roomRows.map((room) => ({
|
||||||
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
|
label: `${room.name} · ${room.type === 'private_office' ? 'Private office' : 'Coworking desk'}`,
|
||||||
value: room.id,
|
value: room.id,
|
||||||
@@ -63,11 +62,9 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
const records = await db
|
const records = await db
|
||||||
.select({
|
.select({
|
||||||
contract: contracts,
|
contract: contracts,
|
||||||
roomName: rooms.name,
|
|
||||||
serviceName: services.name
|
serviceName: services.name
|
||||||
})
|
})
|
||||||
.from(contracts)
|
.from(contracts)
|
||||||
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
|
|
||||||
.leftJoin(services, eq(contracts.serviceId, services.id))
|
.leftJoin(services, eq(contracts.serviceId, services.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -77,29 +74,57 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(asc(contracts.startDate));
|
.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 {
|
return {
|
||||||
records: records.map((record) => ({
|
records: records.map((record) => {
|
||||||
|
const linkedRooms = roomsByContract.get(record.contract.id) ?? [];
|
||||||
|
return {
|
||||||
...record.contract,
|
...record.contract,
|
||||||
roomName: record.roomName,
|
roomName: linkedRooms.map((room) => room.name).join(', '),
|
||||||
|
roomNames: linkedRooms.map((room) => room.name),
|
||||||
|
roomIds: linkedRooms.map((room) => room.id),
|
||||||
serviceName: record.serviceName,
|
serviceName: record.serviceName,
|
||||||
formValues: {
|
formValues: {
|
||||||
id: record.contract.id,
|
id: record.contract.id,
|
||||||
clientId: record.contract.clientId ?? '',
|
clientId: record.contract.clientId ?? '',
|
||||||
roomId: record.contract.roomId ?? '',
|
roomIds: linkedRooms.map((room) => room.id),
|
||||||
serviceId: record.contract.serviceId ?? '',
|
serviceId: record.contract.serviceId ?? '',
|
||||||
licenseFeeGbp: record.contract.licenseFeeGbp,
|
licenseFeeGbp: record.contract.licenseFeeGbp,
|
||||||
depositGbp: record.contract.depositGbp,
|
depositGbp: record.contract.depositGbp,
|
||||||
startDate: record.contract.startDate ?? '',
|
startDate: record.contract.startDate ?? '',
|
||||||
endDate: record.contract.endDate ?? '',
|
endDate: record.contract.endDate ?? ''
|
||||||
notes: record.contract.notes ?? ''
|
|
||||||
}
|
}
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
options: await loadOptions(activeOrganizationId),
|
options: await loadOptions(activeOrganizationId),
|
||||||
createForm: await superValidate({ clientId: params.id }, zod4(contractCreateSchema), {
|
createForm: await superValidate(
|
||||||
|
{ clientId: params.id, roomIds: [] },
|
||||||
|
zod4(contractCreateSchema),
|
||||||
|
{
|
||||||
id: 'contracts-create',
|
id: 'contracts-create',
|
||||||
errors: false
|
errors: false
|
||||||
}),
|
}
|
||||||
|
),
|
||||||
editForm: await superValidate(zod4(contractEditSchema), {
|
editForm: await superValidate(zod4(contractEditSchema), {
|
||||||
id: 'contracts-edit',
|
id: 'contracts-edit',
|
||||||
errors: false
|
errors: false
|
||||||
@@ -117,26 +142,26 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
|
|
||||||
async function selectionError(
|
async function selectionError(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
roomId: string,
|
roomIds: string[],
|
||||||
serviceId: string
|
serviceId: string
|
||||||
): Promise<{ field: 'roomId' | 'serviceId'; message: string } | null> {
|
): Promise<{ field: 'roomIds' | 'serviceId'; message: string } | null> {
|
||||||
if (roomId) {
|
if (roomIds.length > 0) {
|
||||||
const [room] = await db
|
const uniqueRoomIds = [...new Set(roomIds)];
|
||||||
|
const validRooms = await db
|
||||||
.select({ id: rooms.id })
|
.select({ id: rooms.id })
|
||||||
.from(rooms)
|
.from(rooms)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(rooms.id, roomId),
|
inArray(rooms.id, uniqueRoomIds),
|
||||||
eq(rooms.organizationId, organizationId),
|
eq(rooms.organizationId, organizationId),
|
||||||
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
inArray(rooms.type, ['private_office', 'coworking_desk']),
|
||||||
isNull(rooms.archivedAt)
|
isNull(rooms.archivedAt)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!room) {
|
if (validRooms.length !== uniqueRoomIds.length) {
|
||||||
return {
|
return {
|
||||||
field: 'roomId',
|
field: 'roomIds',
|
||||||
message: 'Select a valid private office or coworking desk.'
|
message: 'Select a valid private office or coworking desk.'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -173,27 +198,43 @@ export const actions: Actions = {
|
|||||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||||
const invalidSelection = await selectionError(
|
const invalidSelection = await selectionError(
|
||||||
activeOrganizationId,
|
activeOrganizationId,
|
||||||
form.data.roomId,
|
form.data.roomIds,
|
||||||
form.data.serviceId
|
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 {
|
try {
|
||||||
await db.insert(contracts).values({
|
const contractId = crypto.randomUUID();
|
||||||
id: crypto.randomUUID(),
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(contracts).values({
|
||||||
|
id: contractId,
|
||||||
organizationId: activeOrganizationId,
|
organizationId: activeOrganizationId,
|
||||||
clientId: form.data.clientId,
|
clientId: form.data.clientId,
|
||||||
roomId: form.data.roomId || null,
|
|
||||||
serviceId: form.data.serviceId || null,
|
serviceId: form.data.serviceId || null,
|
||||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||||
depositGbp: form.data.depositGbp,
|
depositGbp: form.data.depositGbp,
|
||||||
startDate: form.data.startDate,
|
startDate: form.data.startDate,
|
||||||
endDate: form.data.endDate,
|
endDate: form.data.endDate,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
notes: form.data.notes || null,
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
createdAt: 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 {
|
} catch {
|
||||||
return message(form, 'Unable to create contract.', { status: 400 });
|
return message(form, 'Unable to create contract.', { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -212,23 +253,27 @@ export const actions: Actions = {
|
|||||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
||||||
const invalidSelection = await selectionError(
|
const invalidSelection = await selectionError(
|
||||||
activeOrganizationId,
|
activeOrganizationId,
|
||||||
form.data.roomId,
|
form.data.roomIds,
|
||||||
form.data.serviceId
|
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 {
|
try {
|
||||||
await db
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
.update(contracts)
|
.update(contracts)
|
||||||
.set({
|
.set({
|
||||||
clientId: form.data.clientId,
|
clientId: form.data.clientId,
|
||||||
roomId: form.data.roomId || null,
|
|
||||||
serviceId: form.data.serviceId || null,
|
serviceId: form.data.serviceId || null,
|
||||||
licenseFeeGbp: form.data.licenseFeeGbp,
|
licenseFeeGbp: form.data.licenseFeeGbp,
|
||||||
depositGbp: form.data.depositGbp,
|
depositGbp: form.data.depositGbp,
|
||||||
startDate: form.data.startDate,
|
startDate: form.data.startDate,
|
||||||
endDate: form.data.endDate,
|
endDate: form.data.endDate,
|
||||||
notes: form.data.notes || null,
|
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
@@ -238,6 +283,18 @@ export const actions: Actions = {
|
|||||||
eq(contracts.organizationId, activeOrganizationId)
|
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 {
|
} catch {
|
||||||
return message(form, 'Unable to update contract.', { status: 400 });
|
return message(form, 'Unable to update contract.', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import CalendarIcon from '@lucide/svelte/icons/calendar';
|
||||||
|
import { parseDate, type DateValue } from '@internationalized/date';
|
||||||
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
import { Field as FormField, Control, FieldErrors } from 'formsnap';
|
||||||
import FormCombobox from '$lib/components/form-combobox.svelte';
|
import FormCombobox from '$lib/components/form-combobox.svelte';
|
||||||
import MoneyField from '$lib/components/money-field.svelte';
|
import MoneyField from '$lib/components/money-field.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Calendar from '$lib/components/ui/calendar/index.js';
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
import { cn } from '$lib/utils.js';
|
||||||
|
import RoomMultiCombobox from './room-multi-combobox.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
form,
|
form,
|
||||||
@@ -27,33 +32,74 @@
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let startDateOpen = $state(false);
|
||||||
|
let endDateOpen = $state(false);
|
||||||
|
|
||||||
function roundMoney(value: number) {
|
function roundMoney(value: number) {
|
||||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePricing(roomId: string, serviceId: string) {
|
function updatePricing(roomIds: string[], serviceId: string) {
|
||||||
const room = options.rooms.find((option) => option.value === roomId);
|
const selectedRooms = options.rooms.filter((option) => roomIds.includes(option.value));
|
||||||
const service = options.services.find((option) => option.value === serviceId);
|
const service = options.services.find((option) => option.value === serviceId);
|
||||||
|
const roomLicenseFee = selectedRooms.reduce(
|
||||||
|
(total, room) => total + (room.licenseFeeGbp ?? 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const roomDeposit = selectedRooms.reduce((total, room) => total + (room.depositGbp ?? 0), 0);
|
||||||
|
|
||||||
$data.licenseFeeGbp = roundMoney((room?.licenseFeeGbp ?? 0) + (service?.licenseFeeGbp ?? 0));
|
$data.licenseFeeGbp = roundMoney(roomLicenseFee + (service?.licenseFeeGbp ?? 0));
|
||||||
$data.depositGbp = roundMoney(room?.depositGbp ?? 0);
|
$data.depositGbp = roundMoney(roomDeposit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateValue(value: string) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
try {
|
||||||
|
return parseDate(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
const parsed = dateValue(value);
|
||||||
|
if (!parsed) return 'Select date';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
}).format(new Date(`${parsed.toString()}T00:00:00.000Z`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeStartDate(value: DateValue | undefined) {
|
||||||
|
if (!value) return;
|
||||||
|
$data.startDate = value.toString();
|
||||||
|
startDateOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeEndDate(value: DateValue | undefined) {
|
||||||
|
if (!value) return;
|
||||||
|
$data.endDate = value.toString();
|
||||||
|
endDateOpen = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FormField {form} name="roomId">
|
<FormField {form} name="roomIds">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Control id={`${prefix}-roomId`}>
|
<Control id={`${prefix}-roomIds`}>
|
||||||
{#snippet children({ props })}
|
{#snippet children({ props })}
|
||||||
<Field.Label>Room</Field.Label>
|
<Field.Label>Rooms</Field.Label>
|
||||||
<FormCombobox
|
<RoomMultiCombobox
|
||||||
name="roomId"
|
name="roomIds"
|
||||||
bind:value={$data.roomId}
|
bind:value={$data.roomIds}
|
||||||
options={options.rooms}
|
options={options.rooms}
|
||||||
placeholder="Select a private office or coworking desk"
|
placeholder="Select private offices or coworking desks"
|
||||||
searchPlaceholder="Search rooms..."
|
searchPlaceholder="Search rooms..."
|
||||||
emptyMessage="No matching rooms."
|
emptyMessage="No matching rooms."
|
||||||
triggerProps={props}
|
triggerProps={props}
|
||||||
onValueChange={(roomId) => updatePricing(roomId, $data.serviceId)}
|
onValueChange={(roomIds) => updatePricing(roomIds, $data.serviceId)}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Control>
|
</Control>
|
||||||
@@ -74,7 +120,7 @@
|
|||||||
searchPlaceholder="Search services..."
|
searchPlaceholder="Search services..."
|
||||||
emptyMessage="No matching services."
|
emptyMessage="No matching services."
|
||||||
triggerProps={props}
|
triggerProps={props}
|
||||||
onValueChange={(serviceId) => updatePricing($data.roomId, serviceId)}
|
onValueChange={(serviceId) => updatePricing($data.roomIds, serviceId)}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Control>
|
</Control>
|
||||||
@@ -89,7 +135,7 @@
|
|||||||
name="licenseFeeGbp"
|
name="licenseFeeGbp"
|
||||||
label="License fee"
|
label="License fee"
|
||||||
id={`${prefix}-licenseFeeGbp`}
|
id={`${prefix}-licenseFeeGbp`}
|
||||||
description="Private office monthly price plus the selected service. You can overwrite this amount."
|
description="Selected room monthly prices plus the selected service. You can overwrite this amount."
|
||||||
/>
|
/>
|
||||||
<MoneyField
|
<MoneyField
|
||||||
{form}
|
{form}
|
||||||
@@ -107,7 +153,33 @@
|
|||||||
<Control id={`${prefix}-startDate`}>
|
<Control id={`${prefix}-startDate`}>
|
||||||
{#snippet children({ props })}
|
{#snippet children({ props })}
|
||||||
<Field.Label>Start date</Field.Label>
|
<Field.Label>Start date</Field.Label>
|
||||||
<Input {...props} type="date" bind:value={$data.startDate} />
|
<Popover.Root bind:open={startDateOpen}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props: triggerProps })}
|
||||||
|
<Button
|
||||||
|
{...triggerProps}
|
||||||
|
{...props}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class={cn(
|
||||||
|
'w-full justify-start font-normal',
|
||||||
|
!$data.startDate && 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon data-icon="inline-start" />{formatDate($data.startDate)}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content align="start" class="w-auto p-0">
|
||||||
|
<Calendar.Calendar
|
||||||
|
type="single"
|
||||||
|
value={dateValue($data.startDate)}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
onValueChange={changeStartDate}
|
||||||
|
/>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
<input type="hidden" name="startDate" value={$data.startDate} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Control>
|
</Control>
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
<Field.Error><FieldErrors /></Field.Error>
|
||||||
@@ -118,22 +190,36 @@
|
|||||||
<Control id={`${prefix}-endDate`}>
|
<Control id={`${prefix}-endDate`}>
|
||||||
{#snippet children({ props })}
|
{#snippet children({ props })}
|
||||||
<Field.Label>End date</Field.Label>
|
<Field.Label>End date</Field.Label>
|
||||||
<Input {...props} type="date" bind:value={$data.endDate} />
|
<Popover.Root bind:open={endDateOpen}>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props: triggerProps })}
|
||||||
|
<Button
|
||||||
|
{...triggerProps}
|
||||||
|
{...props}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class={cn(
|
||||||
|
'w-full justify-start font-normal',
|
||||||
|
!$data.endDate && 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon data-icon="inline-start" />{formatDate($data.endDate)}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content align="start" class="w-auto p-0">
|
||||||
|
<Calendar.Calendar
|
||||||
|
type="single"
|
||||||
|
value={dateValue($data.endDate)}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
onValueChange={changeEndDate}
|
||||||
|
/>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
<input type="hidden" name="endDate" value={$data.endDate} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Control>
|
</Control>
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
<Field.Error><FieldErrors /></Field.Error>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</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>
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||||
|
import type { ComponentProps } 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
value = $bindable([]),
|
||||||
|
options,
|
||||||
|
placeholder = 'Select rooms',
|
||||||
|
searchPlaceholder = 'Search rooms...',
|
||||||
|
emptyMessage = 'No matching rooms.',
|
||||||
|
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);
|
||||||
|
const selectedLabels = $derived(
|
||||||
|
options.filter((option) => value.includes(option.value)).map((option) => option.label)
|
||||||
|
);
|
||||||
|
const selectedLabel = $derived.by(() => {
|
||||||
|
if (selectedLabels.length === 0) return placeholder;
|
||||||
|
if (selectedLabels.length <= 2) return selectedLabels.join(', ');
|
||||||
|
return `${selectedLabels.length} rooms selected`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleRoom(roomId: string) {
|
||||||
|
value = value.includes(roomId) ? value.filter((id) => id !== roomId) : [...value, roomId];
|
||||||
|
onValueChange?.(value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
{...triggerProps}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
class={cn(
|
||||||
|
'w-full justify-between overflow-hidden font-normal',
|
||||||
|
value.length === 0 && 'text-muted-foreground',
|
||||||
|
triggerProps?.class
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span class="truncate">{selectedLabel}</span>
|
||||||
|
<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}
|
||||||
|
keywords={[option.label]}
|
||||||
|
data-checked={value.includes(option.value)}
|
||||||
|
onSelect={() => toggleRoom(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.Group>
|
||||||
|
</Command.List>
|
||||||
|
</Command.Root>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
|
{#each value as roomId (roomId)}
|
||||||
|
<input type="hidden" {name} value={roomId} />
|
||||||
|
{/each}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
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 { db } from '$lib/server/db';
|
||||||
import { invoices, clients } from '$lib/server/db/schema';
|
import { invoices, clients } from '$lib/server/db/schema';
|
||||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
import type { PageServerLoad } from './$types';
|
||||||
import { invoiceEditSchema } from '$lib/schemas/invoices.schema';
|
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
|
||||||
|
|
||||||
async function loadOptions(organizationId: string) {
|
async function loadOptions(organizationId: string) {
|
||||||
const clientRows = await db
|
const clientRows = await db
|
||||||
@@ -36,89 +32,8 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
records: records.map((record) => ({
|
records: records.map((record) => ({
|
||||||
...record,
|
...record
|
||||||
formValues: {
|
|
||||||
id: record.id,
|
|
||||||
clientId: record.clientId ?? '',
|
|
||||||
invoiceNumber: record.invoiceNumber ?? '',
|
|
||||||
issueDate: record.issueDate ?? '',
|
|
||||||
dueDate: record.dueDate ?? '',
|
|
||||||
status: record.status ?? '',
|
|
||||||
subtotalGbp: record.subtotalGbp ?? '',
|
|
||||||
taxGbp: record.taxGbp ?? '',
|
|
||||||
totalGbp: record.totalGbp ?? '',
|
|
||||||
notes: record.notes ?? ''
|
|
||||||
}
|
|
||||||
})),
|
})),
|
||||||
options: await loadOptions(activeOrganizationId),
|
options: await loadOptions(activeOrganizationId)
|
||||||
editForm: await superValidate(zod4(invoiceEditSchema), {
|
|
||||||
id: 'invoices-edit',
|
|
||||||
errors: false
|
|
||||||
}),
|
|
||||||
archiveForm: await superValidate(zod4(archiveSchema), {
|
|
||||||
id: 'invoices-archive',
|
|
||||||
errors: false
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
edit: 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(invoiceEditSchema), { id: 'invoices-edit' });
|
|
||||||
|
|
||||||
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.update(invoices)
|
|
||||||
.set({
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(invoices.id, form.data.id),
|
|
||||||
eq(invoices.clientId, params.id),
|
|
||||||
eq(invoices.organizationId, activeOrganizationId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return message(form, 'Unable to update invoice.', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return message(form, 'Invoice updated.');
|
|
||||||
},
|
|
||||||
|
|
||||||
archive: async ({ locals, params, request }) => {
|
|
||||||
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
|
||||||
const form = await superValidate(await request.formData(), 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.clientId, params.id),
|
|
||||||
eq(invoices.organizationId, activeOrganizationId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return message(form, 'Invoice archived.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,62 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import InvoicesTable from './invoices-table.svelte';
|
import InvoicesTable from './invoices-table.svelte';
|
||||||
import EditInvoiceDialog from './edit-invoice-dialog.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 { invoiceEditSchema } from '$lib/schemas/invoices.schema';
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
type RecordRow = PageData['records'][number];
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
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 editForm = superForm(data.editForm, {
|
|
||||||
validators: zod4Client(invoiceEditSchema),
|
|
||||||
resetForm: false,
|
|
||||||
onUpdated: ({ form }) => handleFormToast(form, 'invoices-edit', () => (editingId = null)),
|
|
||||||
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-edit' })
|
|
||||||
});
|
|
||||||
// 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: 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) {
|
function formatValue(value: unknown) {
|
||||||
if (value === null || value === undefined || value === '') return 'Not set';
|
if (value === null || value === undefined || value === '') return 'Not set';
|
||||||
return String(value);
|
return String(value);
|
||||||
@@ -78,28 +24,6 @@
|
|||||||
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 invoice'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -110,33 +34,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
|
<h1 class="text-2xl font-semibold tracking-tight">Invoices</h1>
|
||||||
<p class="text-sm text-muted-foreground">Manage invoice records and payment status.</p>
|
<p class="text-sm text-muted-foreground">Review issued invoice records.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InvoicesTable
|
<InvoicesTable {data} {formatValue} {formatDate} {formatMoney} />
|
||||||
{data}
|
|
||||||
{openEdit}
|
|
||||||
{openArchive}
|
|
||||||
{formatValue}
|
|
||||||
{formatDate}
|
|
||||||
{formatMoney}
|
|
||||||
{recordName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditInvoiceDialog
|
|
||||||
{editingRecord}
|
|
||||||
{editForm}
|
|
||||||
{editData}
|
|
||||||
{enhanceEdit}
|
|
||||||
onClose={() => (editingId = null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArchiveInvoiceDialog
|
|
||||||
{archivingRecord}
|
|
||||||
{archiveData}
|
|
||||||
{enhanceArchive}
|
|
||||||
{recordName}
|
|
||||||
onClose={() => (archivingId = null)}
|
|
||||||
/>
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
<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 invoice?</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description
|
|
||||||
>This will hide {recordName(archivingRecord)} from active invoices. 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 invoice</AlertDialog.Action
|
|
||||||
>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</AlertDialog.Footer>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
<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 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 invoice</Dialog.Title>
|
|
||||||
<Dialog.Description>Update this invoice 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="invoiceNumber">
|
|
||||||
<Field.Field>
|
|
||||||
<Control id="edit-invoiceNumber">
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Field.Label>Invoice number</Field.Label>
|
|
||||||
<Input {...props} type="text" bind:value={$editData.invoiceNumber} />
|
|
||||||
{/snippet}
|
|
||||||
</Control>
|
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
|
||||||
</Field.Field>
|
|
||||||
</FormField>
|
|
||||||
<FormField form={editForm} name="issueDate">
|
|
||||||
<Field.Field>
|
|
||||||
<Control id="edit-issueDate">
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Field.Label>Issue date</Field.Label>
|
|
||||||
<Input {...props} type="date" bind:value={$editData.issueDate} />
|
|
||||||
{/snippet}
|
|
||||||
</Control>
|
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
|
||||||
</Field.Field>
|
|
||||||
</FormField>
|
|
||||||
<FormField form={editForm} name="dueDate">
|
|
||||||
<Field.Field>
|
|
||||||
<Control id="edit-dueDate">
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Field.Label>Due date</Field.Label>
|
|
||||||
<Input {...props} type="date" bind:value={$editData.dueDate} />
|
|
||||||
{/snippet}
|
|
||||||
</Control>
|
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
|
||||||
</Field.Field>
|
|
||||||
</FormField>
|
|
||||||
<FormField form={editForm} name="status">
|
|
||||||
<Field.Field>
|
|
||||||
<Control id="edit-status">
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<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}
|
|
||||||
</Control>
|
|
||||||
<Field.Error><FieldErrors /></Field.Error>
|
|
||||||
</Field.Field>
|
|
||||||
</FormField>
|
|
||||||
<MoneyField
|
|
||||||
form={editForm}
|
|
||||||
data={editData}
|
|
||||||
name="subtotalGbp"
|
|
||||||
label="Subtotal"
|
|
||||||
id="edit-subtotalGbp"
|
|
||||||
/>
|
|
||||||
<MoneyField form={editForm} data={editData} name="taxGbp" label="Tax" id="edit-taxGbp" />
|
|
||||||
<MoneyField
|
|
||||||
form={editForm}
|
|
||||||
data={editData}
|
|
||||||
name="totalGbp"
|
|
||||||
label="Total"
|
|
||||||
id="edit-totalGbp"
|
|
||||||
/>
|
|
||||||
<FormField form={editForm} name="notes">
|
|
||||||
<Field.Field>
|
|
||||||
<Control id="edit-notes">
|
|
||||||
{#snippet children({ props })}
|
|
||||||
<Field.Label>Notes</Field.Label>
|
|
||||||
<Textarea {...props} bind:value={$editData.notes} />
|
|
||||||
{/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>
|
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* 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';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
openEdit,
|
|
||||||
openArchive,
|
|
||||||
formatValue,
|
formatValue,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney,
|
formatMoney
|
||||||
recordName
|
|
||||||
}: {
|
}: {
|
||||||
data: any;
|
data: any;
|
||||||
openEdit: any;
|
|
||||||
openArchive: any;
|
|
||||||
formatValue: any;
|
formatValue: any;
|
||||||
formatDate: any;
|
formatDate: any;
|
||||||
formatMoney: any;
|
formatMoney: any;
|
||||||
recordName: any;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -32,9 +22,7 @@
|
|||||||
<Table.Head>Invoice #</Table.Head>
|
<Table.Head>Invoice #</Table.Head>
|
||||||
<Table.Head>Issued</Table.Head>
|
<Table.Head>Issued</Table.Head>
|
||||||
<Table.Head>Due</Table.Head>
|
<Table.Head>Due</Table.Head>
|
||||||
<Table.Head>Status</Table.Head>
|
|
||||||
<Table.Head>Total</Table.Head>
|
<Table.Head>Total</Table.Head>
|
||||||
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
@@ -43,29 +31,7 @@
|
|||||||
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
||||||
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
||||||
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
||||||
<Table.Cell class=""
|
|
||||||
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
|
|
||||||
></Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
<Table.Cell class="">{formatMoney(record.totalGbp)}</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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||||
import { message, superValidate } from 'sveltekit-superforms/server';
|
import { message, superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { contracts, clients, rooms, services } from '$lib/server/db/schema';
|
import { contracts, contractRooms, clients, rooms, services } from '$lib/server/db/schema';
|
||||||
import { loadOrganizationContext } from '$lib/server/organizations';
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
import { archiveSchema } from '$lib/schemas/shared.schema';
|
import { archiveSchema } from '$lib/schemas/shared.schema';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -24,19 +24,36 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
const records = await db
|
const records = await db
|
||||||
.select({
|
.select({
|
||||||
contract: contracts,
|
contract: contracts,
|
||||||
roomName: rooms.name,
|
|
||||||
serviceName: services.name
|
serviceName: services.name
|
||||||
})
|
})
|
||||||
.from(contracts)
|
.from(contracts)
|
||||||
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
|
|
||||||
.leftJoin(services, eq(contracts.serviceId, services.id))
|
.leftJoin(services, eq(contracts.serviceId, services.id))
|
||||||
.where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt)))
|
.where(and(eq(contracts.organizationId, activeOrganizationId), isNull(contracts.archivedAt)))
|
||||||
.orderBy(asc(contracts.startDate));
|
.orderBy(asc(contracts.startDate));
|
||||||
|
const contractIds = records.map((record) => record.contract.id);
|
||||||
|
const roomLinks =
|
||||||
|
contractIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
contractId: contractRooms.contractId,
|
||||||
|
roomName: rooms.name
|
||||||
|
})
|
||||||
|
.from(contractRooms)
|
||||||
|
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
|
||||||
|
.where(inArray(contractRooms.contractId, contractIds))
|
||||||
|
.orderBy(asc(rooms.name))
|
||||||
|
: [];
|
||||||
|
const roomNamesByContract = new Map<string, string[]>();
|
||||||
|
for (const link of roomLinks) {
|
||||||
|
const existing = roomNamesByContract.get(link.contractId) ?? [];
|
||||||
|
existing.push(link.roomName);
|
||||||
|
roomNamesByContract.set(link.contractId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
records: records.map((record) => ({
|
records: records.map((record) => ({
|
||||||
...record.contract,
|
...record.contract,
|
||||||
roomName: record.roomName,
|
roomName: (roomNamesByContract.get(record.contract.id) ?? []).join(', '),
|
||||||
serviceName: record.serviceName
|
serviceName: record.serviceName
|
||||||
})),
|
})),
|
||||||
options: await loadOptions(activeOrganizationId),
|
options: await loadOptions(activeOrganizationId),
|
||||||
|
|||||||
Reference in New Issue
Block a user