feat: add billing and organization workflows

This commit is contained in:
2026-06-24 13:53:52 +01:00
parent 4fd81c923b
commit 44bfb083f9
97 changed files with 10966 additions and 2044 deletions
@@ -0,0 +1,526 @@
import { fail } from '@sveltejs/kit';
import { and, asc, desc, eq, gte, isNull, like, lte, ne } from 'drizzle-orm';
import { createSearchParamsSchema, validateSearchParams } from 'runed/kit';
import { message, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db';
import {
bookings,
clients,
contracts,
invoiceLines,
invoices,
rooms,
services
} from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { Actions, PageServerLoad } from './$types';
type BillingLine = {
id: string;
clientId: string;
clientName: string;
sourceType: 'booking-room' | 'booking-service' | 'contract';
sourceId: string;
periodStart: string;
periodEnd: string;
description: string;
quantity: number;
unitPriceGbp: number;
totalGbp: number;
};
type BillingClient = {
clientId: string;
clientName: string;
totalGbp: number;
lineCount: number;
lines: BillingLine[];
};
const dayMs = 24 * 60 * 60 * 1000;
const billingTermDays = 12;
async function loadOptions(organizationId: string) {
const clientRows = await db
.select({ id: clients.id, name: clients.name })
.from(clients)
.where(and(eq(clients.organizationId, organizationId), isNull(clients.archivedAt)))
.orderBy(asc(clients.name));
return {
clients: clientRows.map((client) => ({ label: client.name, value: client.id }))
};
}
function toDateOnly(date: Date) {
return date.toISOString().slice(0, 10);
}
function parseDateOnly(value: string) {
const [year, month, day] = value.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
function addMonths(date: Date, months: number) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1));
}
function endOfMonth(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0));
}
function startOfMonth(date: Date) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
}
function minDate(a: Date, b: Date) {
return a.getTime() <= b.getTime() ? a : b;
}
function maxDate(a: Date, b: Date) {
return a.getTime() >= b.getTime() ? a : b;
}
function daysInclusive(start: Date, end: Date) {
return Math.floor((end.getTime() - start.getTime()) / dayMs) + 1;
}
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function roundQuantity(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function monthLabel(date: Date) {
return new Intl.DateTimeFormat('en-GB', {
month: 'short',
year: 'numeric',
timeZone: 'UTC'
}).format(date);
}
function defaultInvoiceDate(today = new Date()) {
return toDateOnly(new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 2)));
}
function billingSearchParamsSchema() {
return createSearchParamsSchema({
billingDate: {
type: 'date',
default: parseDateOnly(defaultInvoiceDate()),
dateFormat: 'date'
}
});
}
function isDateOnly(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return toDateOnly(parseDateOnly(value)) === value;
}
function normalizeInvoiceDate(value: FormDataEntryValue | string | null) {
if (typeof value !== 'string' || !isDateOnly(value)) return defaultInvoiceDate();
return value;
}
function invoiceDateFromUrl(url: URL) {
const { data } = validateSearchParams(url, billingSearchParamsSchema());
return toDateOnly(data.billingDate);
}
function billingCutoffs(invoiceDate: string) {
const selectedDate = parseDateOnly(invoiceDate);
const thisMonth = new Date(
Date.UTC(selectedDate.getUTCFullYear(), selectedDate.getUTCMonth(), 1)
);
const contractsCutoff = endOfMonth(addMonths(thisMonth, 1));
const servicesCutoff = endOfMonth(addMonths(thisMonth, -1));
return {
contractsCutoff: toDateOnly(contractsCutoff),
servicesCutoff: toDateOnly(servicesCutoff)
};
}
function billingDueDate(invoiceDate: string) {
const selectedDate = parseDateOnly(invoiceDate);
return toDateOnly(new Date(selectedDate.getTime() + billingTermDays * dayMs));
}
function sourceKey(line: {
sourceType: string;
sourceId: string;
periodStart: string;
periodEnd: string;
}) {
return `${line.sourceType}:${line.sourceId}:${line.periodStart}:${line.periodEnd}`;
}
function lineId(line: Pick<BillingLine, 'sourceType' | 'sourceId' | 'periodStart' | 'periodEnd'>) {
return sourceKey(line);
}
function groupBillingLines(lines: BillingLine[]) {
const clientsById = new Map<string, BillingClient>();
for (const line of lines) {
const client = clientsById.get(line.clientId) ?? {
clientId: line.clientId,
clientName: line.clientName,
totalGbp: 0,
lineCount: 0,
lines: []
};
client.lines.push(line);
client.lineCount += 1;
client.totalGbp = roundMoney(client.totalGbp + line.totalGbp);
clientsById.set(line.clientId, client);
}
return [...clientsById.values()].sort((a, b) => a.clientName.localeCompare(b.clientName));
}
function bookingPrice(row: {
startsAt: string;
endsAt: string;
internalPricePerHourGbp: number | null;
externalPricePerHourGbp: number | null;
internalPricePerDayGbp: number | null;
externalPricePerDayGbp: number | null;
}) {
const startsAt = new Date(row.startsAt);
const endsAt = new Date(row.endsAt);
const durationHours = Math.max((endsAt.getTime() - startsAt.getTime()) / (60 * 60 * 1000), 0);
const dayRate = row.externalPricePerDayGbp ?? row.internalPricePerDayGbp ?? 0;
const hourRate = row.externalPricePerHourGbp ?? row.internalPricePerHourGbp ?? 0;
if (dayRate > 0 && durationHours >= 7) {
const quantity = Math.max(1, Math.ceil(durationHours / 24));
return { quantity, unitPriceGbp: dayRate, totalGbp: roundMoney(quantity * dayRate) };
}
const quantity = roundQuantity(durationHours);
return { quantity, unitPriceGbp: hourRate, totalGbp: roundMoney(quantity * hourRate) };
}
async function buildBillingPreview(organizationId: string, invoiceDate: string) {
const cutoffs = billingCutoffs(invoiceDate);
const existingLines = await db
.select({
sourceType: invoiceLines.sourceType,
sourceId: invoiceLines.sourceId,
periodStart: invoiceLines.periodStart,
periodEnd: invoiceLines.periodEnd
})
.from(invoiceLines)
.where(and(eq(invoiceLines.organizationId, organizationId), isNull(invoiceLines.archivedAt)));
const billedSources = new Set(existingLines.map(sourceKey));
const lines: BillingLine[] = [];
const bookingRows = await db
.select({
id: bookings.id,
clientId: bookings.clientId,
clientName: clients.name,
roomName: rooms.name,
roomType: rooms.type,
startsAt: bookings.startsAt,
endsAt: bookings.endsAt,
serviceId: bookings.serviceId,
serviceName: services.name,
servicePriceGbp: services.priceGbp,
internalPricePerHourGbp: rooms.internalPricePerHourGbp,
externalPricePerHourGbp: rooms.externalPricePerHourGbp,
internalPricePerDayGbp: rooms.internalPricePerDayGbp,
externalPricePerDayGbp: rooms.externalPricePerDayGbp
})
.from(bookings)
.innerJoin(clients, eq(bookings.clientId, clients.id))
.innerJoin(rooms, eq(bookings.roomId, rooms.id))
.leftJoin(services, eq(bookings.serviceId, services.id))
.where(
and(
eq(bookings.organizationId, organizationId),
lte(bookings.startsAt, `${cutoffs.servicesCutoff}T23:59`),
ne(bookings.status, 'cancelled'),
isNull(bookings.archivedAt)
)
)
.orderBy(asc(bookings.startsAt));
for (const booking of bookingRows) {
const periodStart = booking.startsAt.slice(0, 10);
const periodEnd = booking.endsAt.slice(0, 10);
if (booking.roomType === 'meeting_room') {
const price = bookingPrice(booking);
const line = {
id: '',
clientId: booking.clientId,
clientName: booking.clientName,
sourceType: 'booking-room' as const,
sourceId: booking.id,
periodStart,
periodEnd,
description: `Meeting room: ${booking.roomName} (${periodStart})`,
...price
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
if (booking.serviceId && booking.serviceName) {
const line = {
id: '',
clientId: booking.clientId,
clientName: booking.clientName,
sourceType: 'booking-service' as const,
sourceId: booking.id,
periodStart,
periodEnd,
description: `Service: ${booking.serviceName} (${periodStart})`,
quantity: 1,
unitPriceGbp: booking.servicePriceGbp ?? 0,
totalGbp: roundMoney(booking.servicePriceGbp ?? 0)
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
}
const contractRows = await db
.select({
id: contracts.id,
clientId: contracts.clientId,
clientName: clients.name,
roomName: rooms.name,
serviceName: services.name,
licenseFeeGbp: contracts.licenseFeeGbp,
startDate: contracts.startDate,
endDate: contracts.endDate
})
.from(contracts)
.innerJoin(clients, eq(contracts.clientId, clients.id))
.leftJoin(rooms, eq(contracts.roomId, rooms.id))
.leftJoin(services, eq(contracts.serviceId, services.id))
.where(
and(
eq(contracts.organizationId, organizationId),
eq(contracts.status, 'active'),
lte(contracts.startDate, cutoffs.contractsCutoff),
gte(contracts.endDate, '1900-01-01'),
isNull(contracts.archivedAt)
)
)
.orderBy(asc(contracts.startDate));
const contractCutoff = parseDateOnly(cutoffs.contractsCutoff);
for (const contract of contractRows) {
const contractStart = parseDateOnly(contract.startDate);
const contractEnd = minDate(parseDateOnly(contract.endDate), contractCutoff);
if (contractEnd.getTime() < contractStart.getTime() || contract.licenseFeeGbp <= 0) continue;
for (
let month = startOfMonth(contractStart);
month.getTime() <= contractEnd.getTime();
month = addMonths(month, 1)
) {
const periodStartDate = maxDate(month, contractStart);
const periodEndDate = minDate(endOfMonth(month), contractEnd);
if (periodEndDate.getTime() < periodStartDate.getTime()) continue;
const periodStart = toDateOnly(periodStartDate);
const periodEnd = toDateOnly(periodEndDate);
const monthDays = daysInclusive(startOfMonth(month), endOfMonth(month));
const billedDays = daysInclusive(periodStartDate, periodEndDate);
const quantity = roundQuantity(billedDays / monthDays);
const labelParts = [contract.roomName, contract.serviceName].filter(Boolean);
const line = {
id: '',
clientId: contract.clientId,
clientName: contract.clientName,
sourceType: 'contract' as const,
sourceId: contract.id,
periodStart,
periodEnd,
description: `Contract: ${labelParts.join(' + ') || 'monthly fee'} (${monthLabel(month)})`,
quantity,
unitPriceGbp: contract.licenseFeeGbp,
totalGbp: roundMoney(quantity * contract.licenseFeeGbp)
};
line.id = lineId(line);
if (!billedSources.has(sourceKey(line)) && line.totalGbp > 0) lines.push(line);
}
}
lines.sort(
(a, b) => a.clientName.localeCompare(b.clientName) || a.periodStart.localeCompare(b.periodStart)
);
return {
...cutoffs,
invoiceDate,
dueDate: billingDueDate(invoiceDate),
lines,
clients: groupBillingLines(lines),
totalGbp: roundMoney(lines.reduce((total, line) => total + line.totalGbp, 0))
};
}
function invoiceNumberPrefix(invoiceDate: string) {
const date = parseDateOnly(invoiceDate);
return `INV-${date.getUTCFullYear()}${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
}
async function latestInvoiceSequence(organizationId: string, prefix: string) {
const [latest] = await db
.select({ invoiceNumber: invoices.invoiceNumber })
.from(invoices)
.where(
and(eq(invoices.organizationId, organizationId), like(invoices.invoiceNumber, `${prefix}-%`))
)
.orderBy(desc(invoices.invoiceNumber))
.limit(1);
return latest?.invoiceNumber ? Number(latest.invoiceNumber.split('-').at(-1) ?? 0) : 0;
}
export const load: PageServerLoad = async ({ locals, url }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const invoiceDate = invoiceDateFromUrl(url);
const records = await db
.select()
.from(invoices)
.where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt)))
.orderBy(asc(invoices.invoiceNumber));
return {
records,
options: await loadOptions(activeOrganizationId),
billingPreview: await buildBillingPreview(activeOrganizationId, invoiceDate),
archiveForm: await superValidate(zod4(archiveSchema), {
id: 'invoices-archive',
errors: false
})
};
};
export const actions: Actions = {
runBilling: async ({ locals, request }) => {
const { activeOrganizationId } = await loadOrganizationContext(locals);
const formData = await request.formData();
const invoiceDate = normalizeInvoiceDate(formData.get('billingDate'));
let includedLineIds: unknown;
try {
includedLineIds = JSON.parse(String(formData.get('includedLineIds') ?? '[]'));
} catch {
return fail(400, { message: 'Choose valid billing lines.' });
}
if (!Array.isArray(includedLineIds) || includedLineIds.some((id) => typeof id !== 'string')) {
return fail(400, { message: 'Choose valid billing lines.' });
}
const includedIds = new Set(includedLineIds);
const preview = await buildBillingPreview(activeOrganizationId, invoiceDate);
const includedLines = preview.lines.filter((line) => includedIds.has(line.id));
if (includedLines.length === 0) return fail(400, { message: 'There is nothing to bill.' });
const groupedClients = groupBillingLines(includedLines);
const issueDate = invoiceDate;
const dueDate = billingDueDate(invoiceDate);
const invoicePrefix = invoiceNumberPrefix(invoiceDate);
const latestSequence = await latestInvoiceSequence(activeOrganizationId, invoicePrefix);
await db.transaction(async (tx) => {
let invoiceOffset = 0;
for (const client of groupedClients) {
const invoiceId = crypto.randomUUID();
const invoiceNumber = `${invoicePrefix}-${String(latestSequence + invoiceOffset + 1).padStart(3, '0')}`;
invoiceOffset += 1;
await tx.insert(invoices).values({
id: invoiceId,
organizationId: activeOrganizationId,
clientId: client.clientId,
invoiceNumber,
issueDate,
dueDate,
status: 'draft',
subtotalGbp: client.totalGbp,
taxGbp: 0,
totalGbp: client.totalGbp,
notes: `Generated from billing run. Contract billing through ${preview.contractsCutoff}; services through ${preview.servicesCutoff}.`,
createdAt: new Date(),
updatedAt: new Date()
});
await tx.insert(invoiceLines).values(
client.lines.map((line) => ({
id: crypto.randomUUID(),
organizationId: activeOrganizationId,
invoiceId,
clientId: client.clientId,
sourceType: line.sourceType,
sourceId: line.sourceId,
periodStart: line.periodStart,
periodEnd: line.periodEnd,
description: line.description,
quantity: line.quantity,
unitPriceGbp: line.unitPriceGbp,
totalGbp: line.totalGbp,
createdAt: new Date(),
updatedAt: new Date()
}))
);
}
const contractBilledTo = new Map<string, string>();
for (const line of includedLines) {
if (line.sourceType !== 'contract') continue;
const current = contractBilledTo.get(line.sourceId);
if (!current || line.periodEnd > current)
contractBilledTo.set(line.sourceId, line.periodEnd);
}
for (const [contractId, billedTo] of contractBilledTo) {
await tx
.update(contracts)
.set({ billedTo, updatedAt: new Date() })
.where(
and(eq(contracts.id, contractId), eq(contracts.organizationId, activeOrganizationId))
);
}
});
return {
message: `Billing run complete. Created ${groupedClients.length} invoice${groupedClients.length === 1 ? '' : 's'}.`
};
},
archive: async (event) => {
const { activeOrganizationId } = await loadOrganizationContext(event.locals);
const form = await superValidate(event, zod4(archiveSchema), { id: 'invoices-archive' });
if (!form.valid) return message(form, 'Invoice id is required.', { status: 400 });
await db
.update(invoices)
.set({ archivedAt: new Date(), updatedAt: new Date() })
.where(and(eq(invoices.id, form.data.id), eq(invoices.organizationId, activeOrganizationId)));
return message(form, 'Invoice archived.');
}
};
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import InvoicesTable from './invoices-table.svelte';
import ArchiveInvoiceDialog from './archive-invoice-dialog.svelte';
import RunBillingDialog from './run-billing-dialog.svelte';
import { toast } from 'svelte-sonner';
import { superForm } from 'sveltekit-superforms';
import { zod4Client } from 'sveltekit-superforms/adapters';
import { archiveSchema } from '$lib/schemas/shared.schema';
import type { PageData } from './$types';
type RecordRow = PageData['records'][number];
let { data }: { data: PageData } = $props();
let archivingId = $state<string | null>(null);
const archivingRecord = $derived(data.records.find((record) => record.id === archivingId));
// svelte-ignore state_referenced_locally
const archiveForm = superForm(data.archiveForm, {
validators: zod4Client(archiveSchema),
resetForm: false,
onUpdated: ({ form }) => handleFormToast(form, 'invoices-archive', () => (archivingId = null)),
onError: ({ result }) => toast.error(result.error.message, { id: 'invoices-archive' })
});
const { form: archiveData, enhance: enhanceArchive } = archiveForm;
function handleFormToast(
form: { valid: boolean; message?: string; errors: Record<string, unknown> },
id: string,
onSuccess: () => void
) {
if (form.valid) {
if (form.message) toast.success(form.message, { id });
onSuccess();
return;
}
toast.error(form.message ?? firstError(form.errors), { id });
}
function firstError(errors: Record<string, unknown>) {
for (const value of Object.values(errors)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return 'Check the highlighted fields.';
}
function formatValue(value: unknown) {
if (value === null || value === undefined || value === '') return 'Not set';
return String(value);
}
function relationLabel(value: unknown, optionsKey: 'clients') {
if (typeof value !== 'string') return 'Not set';
return data.options[optionsKey].find((option) => option.value === value)?.label ?? value;
}
function formatMoney(value: unknown) {
const amount = typeof value === 'number' ? value : Number(value ?? 0);
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
}
function formatDate(value: unknown, withTime = false) {
if (!value || typeof value !== 'string') return 'Not set';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
}).format(date);
}
function openArchive(record: RecordRow) {
archiveForm.reset({ data: { id: record.id } });
archivingId = record.id;
}
function recordName(record: Partial<RecordRow> | undefined) {
const item = record as Partial<RecordRow> & {
name?: unknown;
title?: unknown;
invoiceNumber?: unknown;
label?: unknown;
};
return String(
item?.name ?? item?.title ?? item?.invoiceNumber ?? item?.label ?? 'this invoice'
);
}
</script>
<svelte:head>
<title>Billing | Clearity</title>
</svelte:head>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Billing</h1>
<p class="text-sm text-muted-foreground">Run billing and manage generated invoices.</p>
</div>
<RunBillingDialog
preview={data.billingPreview}
{formatMoney}
formatDate={(value) => formatDate(value)}
/>
</div>
<InvoicesTable
{data}
{openArchive}
{formatValue}
{formatDate}
{formatMoney}
{relationLabel}
{recordName}
/>
</div>
<ArchiveInvoiceDialog
{archivingRecord}
{archiveData}
{enhanceArchive}
{recordName}
onClose={() => (archivingId = null)}
/>
@@ -0,0 +1,40 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
let {
archivingRecord,
archiveData,
enhanceArchive,
onClose,
recordName
}: {
archivingRecord: any;
archiveData: any;
enhanceArchive: any;
onClose: () => void;
recordName: any;
} = $props();
</script>
<AlertDialog.Root open={!!archivingRecord} onOpenChange={(open) => !open && onClose()}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Archive 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>
@@ -0,0 +1,79 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Table from '$lib/components/ui/table/index.js';
let {
data,
openArchive,
formatValue,
formatDate,
formatMoney,
relationLabel,
recordName
}: {
data: any;
openArchive: any;
formatValue: any;
formatDate: any;
formatMoney: any;
relationLabel: any;
recordName: any;
} = $props();
</script>
{#if data.records.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Invoice #</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Issued</Table.Head>
<Table.Head>Due</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Actions</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.records as record (record.id)}
<Table.Row>
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
<Table.Cell class=""
><Badge variant="secondary" class="capitalize">{formatValue(record.status)}</Badge
></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>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No invoices have been created yet.
</div>
{/if}
@@ -0,0 +1,359 @@
<script lang="ts">
import CalendarIcon from '@lucide/svelte/icons/calendar';
import ReceiptTextIcon from '@lucide/svelte/icons/receipt-text';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { enhance } from '$app/forms';
import { parseDate, type DateValue } from '@internationalized/date';
import type { SubmitFunction } from '@sveltejs/kit';
import { createSearchParamsSchema, useSearchParams } from 'runed/kit';
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button/index.js';
import * as Calendar from '$lib/components/ui/calendar/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Popover from '$lib/components/ui/popover/index.js';
import * as Table from '$lib/components/ui/table/index.js';
type BillingLine = {
id: string;
clientId: string;
clientName: string;
sourceType: string;
periodStart: string;
periodEnd: string;
description: string;
quantity: number;
unitPriceGbp: number;
totalGbp: number;
};
type BillingClient = {
clientId: string;
clientName: string;
totalGbp: number;
lineCount: number;
lines: BillingLine[];
};
let {
preview,
formatMoney,
formatDate
}: {
preview: {
contractsCutoff: string;
servicesCutoff: string;
invoiceDate: string;
dueDate: string;
lines: BillingLine[];
clients: BillingClient[];
totalGbp: number;
};
formatMoney: (value: unknown) => string;
formatDate: (value: unknown) => string;
} = $props();
const searchParamsSchema = createSearchParamsSchema({
billingDate: {
type: 'date',
default: defaultInvoiceDate(),
dateFormat: 'date'
}
});
const searchParams = useSearchParams(searchParamsSchema, { pushHistory: false, noScroll: true });
let open = $state(false);
let datePickerOpen = $state(false);
let detailOpen = $state(false);
let selectedClientId = $state<string | null>(null);
let excludedClientIds = $state<string[]>([]);
let excludedLineIds = $state<string[]>([]);
const invoiceDateString = $derived(dateToString(searchParams.billingDate));
const invoiceDateValue = $derived(dateValueFromString(invoiceDateString));
const includedLineIds = $derived.by(() =>
preview.lines
.filter(
(line) => !excludedClientIds.includes(line.clientId) && !excludedLineIds.includes(line.id)
)
.map((line) => line.id)
);
const includedClients = $derived.by(() => {
return preview.clients
.map((client) => {
if (excludedClientIds.includes(client.clientId)) return null;
const lines = client.lines.filter((line) => !excludedLineIds.includes(line.id));
const totalGbp = roundMoney(lines.reduce((total, line) => total + line.totalGbp, 0));
return {
...client,
lines,
lineCount: lines.length,
totalGbp
};
})
.filter((client): client is BillingClient => !!client && client.lineCount > 0);
});
const includedTotal = $derived(
roundMoney(includedClients.reduce((total, client) => total + client.totalGbp, 0))
);
const selectedClient = $derived(
includedClients.find((client) => client.clientId === selectedClientId)
);
const enhanceBilling: SubmitFunction<{ message?: string }, { message?: string }> = () => {
return async ({ result, update }) => {
if (result.type === 'success') {
toast.success(String(result.data?.message ?? 'Billing run complete.'), {
id: 'run-billing'
});
open = false;
detailOpen = false;
selectedClientId = null;
excludedClientIds = [];
excludedLineIds = [];
await update();
return;
}
if (result.type === 'failure') {
toast.error(String(result.data?.message ?? 'Unable to complete billing run.'), {
id: 'run-billing'
});
return;
}
if (result.type === 'error') {
toast.error(result.error.message, { id: 'run-billing' });
}
};
};
function roundMoney(value: number) {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function dateFromString(value: string) {
return new Date(`${value}T00:00:00.000Z`);
}
function defaultInvoiceDate() {
const today = new Date();
return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 2));
}
function dateToString(value: Date) {
return value.toISOString().slice(0, 10);
}
function dateValueFromString(value: string) {
try {
return parseDate(value);
} catch {
return parseDate(preview.invoiceDate);
}
}
function changeInvoiceDate(value: DateValue | undefined) {
if (!value) return;
selectedClientId = null;
detailOpen = false;
excludedClientIds = [];
excludedLineIds = [];
searchParams.billingDate = dateFromString(value.toString());
datePickerOpen = false;
}
function openClient(clientId: string) {
selectedClientId = clientId;
detailOpen = true;
}
function removeClient(clientId: string) {
if (!excludedClientIds.includes(clientId)) excludedClientIds = [...excludedClientIds, clientId];
if (selectedClientId === clientId) {
selectedClientId = null;
detailOpen = false;
}
}
function removeLine(lineId: string) {
if (!excludedLineIds.includes(lineId)) excludedLineIds = [...excludedLineIds, lineId];
}
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
{#snippet child({ props })}
<Button {...props}><ReceiptTextIcon data-icon="inline-start" />Run billing</Button>
{/snippet}
</Dialog.Trigger>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>Run billing</Dialog.Title>
<Dialog.Description>
Contracts through {formatDate(preview.contractsCutoff)}. Bookings and services through {formatDate(
preview.servicesCutoff
)}.
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<div class="grid gap-1">
<div class="text-sm font-medium">Invoicing date</div>
<Popover.Root bind:open={datePickerOpen}>
<Popover.Trigger>
{#snippet child({ props })}
<Button variant="outline" {...props}
><CalendarIcon data-icon="inline-start" />{formatDate(invoiceDateString)}</Button
>
{/snippet}
</Popover.Trigger>
<Popover.Content align="start" class="w-auto p-0">
<Calendar.Calendar
type="single"
value={invoiceDateValue}
captionLayout="dropdown"
onValueChange={changeInvoiceDate}
/>
</Popover.Content>
</Popover.Root>
<div class="text-xs text-muted-foreground">Due {formatDate(preview.dueDate)}</div>
</div>
<div class="text-right text-sm">
<div class="text-muted-foreground">
{includedClients.length} client{includedClients.length === 1 ? '' : 's'} · {includedLineIds.length}
line{includedLineIds.length === 1 ? '' : 's'}
</div>
<div class="font-medium">{formatMoney(includedTotal)}</div>
</div>
</div>
<div class="sr-only" aria-live="polite">
<div class="text-muted-foreground">
{includedClients.length} client{includedClients.length === 1 ? '' : 's'} · {includedLineIds.length}
line{includedLineIds.length === 1 ? '' : 's'}
</div>
<div class="font-medium">{formatMoney(includedTotal)}</div>
</div>
{#if includedClients.length > 0}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Client</Table.Head>
<Table.Head>Lines</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Remove</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each includedClients as client (client.clientId)}
<Table.Row class="cursor-pointer" onclick={() => openClient(client.clientId)}>
<Table.Cell class="font-medium">{client.clientName}</Table.Cell>
<Table.Cell>{client.lineCount}</Table.Cell>
<Table.Cell>{formatMoney(client.totalGbp)}</Table.Cell>
<Table.Cell>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${client.clientName} from billing run`}
onclick={(event) => {
event.stopPropagation();
removeClient(client.clientId);
}}><Trash2Icon /></Button
>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<div class="rounded-lg border p-8 text-center text-sm text-muted-foreground">
No billable clients are currently selected.
</div>
{/if}
</div>
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Cancel</Button>
{/snippet}
</Dialog.Close>
<form method="POST" action="?/runBilling" use:enhance={enhanceBilling}>
<input type="hidden" name="billingDate" value={invoiceDateString} />
<input type="hidden" name="includedLineIds" value={JSON.stringify(includedLineIds)} />
<Button type="submit" disabled={includedLineIds.length === 0}>Complete billing run</Button>
</form>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={detailOpen}>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<Dialog.Header>
<Dialog.Title>{selectedClient?.clientName ?? 'Billing details'}</Dialog.Title>
<Dialog.Description>
{selectedClient?.lineCount ?? 0} line{selectedClient?.lineCount === 1 ? '' : 's'} · {formatMoney(
selectedClient?.totalGbp ?? 0
)}
</Dialog.Description>
</Dialog.Header>
{#if selectedClient}
<div class="overflow-hidden rounded-lg border border-border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Description</Table.Head>
<Table.Head>Period</Table.Head>
<Table.Head>Qty</Table.Head>
<Table.Head>Unit</Table.Head>
<Table.Head>Total</Table.Head>
<Table.Head class="w-12"><span class="sr-only">Remove</span></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each selectedClient.lines as line (line.id)}
<Table.Row>
<Table.Cell class="font-medium">{line.description}</Table.Cell>
<Table.Cell
>{formatDate(line.periodStart)} - {formatDate(line.periodEnd)}</Table.Cell
>
<Table.Cell>{line.quantity}</Table.Cell>
<Table.Cell>{formatMoney(line.unitPriceGbp)}</Table.Cell>
<Table.Cell>{formatMoney(line.totalGbp)}</Table.Cell>
<Table.Cell>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${line.description}`}
onclick={() => removeLine(line.id)}><Trash2Icon /></Button
>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<Dialog.Footer>
<Dialog.Close>
{#snippet child({ props })}
<Button variant="outline" {...props}>Done</Button>
{/snippet}
</Dialog.Close>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>