diff --git a/src/routes/dashboard/billing/invoices-table.svelte b/src/routes/dashboard/billing/invoices-table.svelte index 5cc5e84..e085bcc 100644 --- a/src/routes/dashboard/billing/invoices-table.svelte +++ b/src/routes/dashboard/billing/invoices-table.svelte @@ -1,6 +1,10 @@ {#if data.records.length > 0} @@ -23,6 +34,7 @@ Invoice # Client + Status Issued Due Total @@ -31,8 +43,19 @@ {#each data.records as record (record.id)} - {formatValue(record.invoiceNumber)} + {formatValue(record.invoiceNumber)} {relationLabel(record.clientId, 'clients')} + {formatValue(record.status)} {formatDate(record.issueDate)} {formatDate(record.dueDate)} {formatMoney(record.totalGbp)} diff --git a/src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte b/src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte index e1a83c2..7bdbaf6 100644 --- a/src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte +++ b/src/routes/dashboard/clients/[id]/invoices/invoices-table.svelte @@ -1,17 +1,28 @@ {#if data.records.length > 0} @@ -20,6 +31,7 @@ Invoice # + Status Issued Due Total @@ -28,7 +40,18 @@ {#each data.records as record (record.id)} - {formatValue(record.invoiceNumber)} + {formatValue(record.invoiceNumber)} + {formatValue(record.status)} {formatDate(record.issueDate)} {formatDate(record.dueDate)} {formatMoney(record.totalGbp)} diff --git a/src/routes/dashboard/invoices/[id]/+page.server.ts b/src/routes/dashboard/invoices/[id]/+page.server.ts new file mode 100644 index 0000000..29d09c4 --- /dev/null +++ b/src/routes/dashboard/invoices/[id]/+page.server.ts @@ -0,0 +1,129 @@ +import { error, fail } from '@sveltejs/kit'; +import { and, asc, eq, isNull } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { clients, invoiceLines, invoices } from '$lib/server/db/schema'; +import { loadOrganizationContext } from '$lib/server/organizations'; +import type { Actions, PageServerLoad } from './$types'; + +type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'void'; +type Transition = 'send' | 'markPaid' | 'void'; + +const allowedTransitions: Record = { + send: ['draft'], + markPaid: ['sent'], + void: ['draft', 'sent'] +}; + +const transitionStatus: Record = { + send: 'sent', + markPaid: 'paid', + void: 'void' +}; + +const transitionMessages: Record = { + send: 'Only draft invoices can be marked as sent.', + markPaid: 'Only sent invoices can be marked as paid.', + void: 'Only draft or sent invoices can be voided.' +}; + +async function loadInvoice(organizationId: string, invoiceId: string) { + const [invoice] = await db + .select({ + id: invoices.id, + organizationId: invoices.organizationId, + clientId: invoices.clientId, + clientName: clients.name, + invoiceNumber: invoices.invoiceNumber, + status: invoices.status, + sentAt: invoices.sentAt, + paidAt: invoices.paidAt, + voidedAt: invoices.voidedAt, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + subtotalGbp: invoices.subtotalGbp, + taxGbp: invoices.taxGbp, + totalGbp: invoices.totalGbp, + notes: invoices.notes, + createdAt: invoices.createdAt, + updatedAt: invoices.updatedAt + }) + .from(invoices) + .innerJoin(clients, eq(invoices.clientId, clients.id)) + .where( + and( + eq(invoices.id, invoiceId), + eq(invoices.organizationId, organizationId), + isNull(invoices.archivedAt) + ) + ) + .limit(1); + + if (!invoice) error(404, 'Invoice not found.'); + return invoice; +} + +async function transitionInvoice( + organizationId: string, + invoiceId: string, + transition: Transition +) { + const invoice = await loadInvoice(organizationId, invoiceId); + + if (!allowedTransitions[transition].includes(invoice.status)) { + return fail(400, { message: transitionMessages[transition] }); + } + + const now = new Date(); + const nextStatus = transitionStatus[transition]; + + await db + .update(invoices) + .set({ + status: nextStatus, + sentAt: transition === 'send' ? now : invoice.sentAt, + paidAt: transition === 'markPaid' ? now : invoice.paidAt, + voidedAt: transition === 'void' ? now : invoice.voidedAt, + updatedAt: now + }) + .where(and(eq(invoices.id, invoiceId), eq(invoices.organizationId, organizationId))); + + return { message: `Invoice ${invoice.invoiceNumber} marked as ${nextStatus}.` }; +} + +export const load: PageServerLoad = async ({ locals, params, parent }) => { + const { activeOrganizationId } = await loadOrganizationContext(locals); + const layoutData = await parent(); + const invoice = await loadInvoice(activeOrganizationId, params.id); + const lines = await db + .select() + .from(invoiceLines) + .where( + and( + eq(invoiceLines.invoiceId, invoice.id), + eq(invoiceLines.organizationId, activeOrganizationId), + isNull(invoiceLines.archivedAt) + ) + ) + .orderBy(asc(invoiceLines.periodStart), asc(invoiceLines.description)); + + return { + invoice, + lines, + organization: layoutData.activeOrganization + }; +}; + +export const actions: Actions = { + send: async ({ locals, params }) => { + const { activeOrganizationId } = await loadOrganizationContext(locals); + return transitionInvoice(activeOrganizationId, params.id, 'send'); + }, + markPaid: async ({ locals, params }) => { + const { activeOrganizationId } = await loadOrganizationContext(locals); + return transitionInvoice(activeOrganizationId, params.id, 'markPaid'); + }, + void: async ({ locals, params }) => { + const { activeOrganizationId } = await loadOrganizationContext(locals); + return transitionInvoice(activeOrganizationId, params.id, 'void'); + } +}; diff --git a/src/routes/dashboard/invoices/[id]/+page.svelte b/src/routes/dashboard/invoices/[id]/+page.svelte new file mode 100644 index 0000000..b285117 --- /dev/null +++ b/src/routes/dashboard/invoices/[id]/+page.svelte @@ -0,0 +1,238 @@ + + + + {data.invoice.invoiceNumber} | Clearity + + +
+
+ + +
+ +
+ +
+
+ +
+
+ +
+
+
+ + {#if form?.message} +
+ {form.message} +
+ {/if} + +
+
+
+
+

Invoice

+

{data.invoice.invoiceNumber}

+
+ + {formatStatus(data.invoice.status)} + +
+ +
+
+

Issued

+

{formatDate(data.invoice.issueDate)}

+
+
+

Due

+

{formatDate(data.invoice.dueDate)}

+
+
+

Updated

+

{formatDate(data.invoice.updatedAt, true)}

+
+
+ + {#if lifecycleItems.length > 0} +
+ {#each lifecycleItems as item (item.label)} +
+

{item.label}

+

{formatDate(item.value, true)}

+
+ {/each} +
+ {/if} +
+ +
+
+ Subtotal + {formatMoney(data.invoice.subtotalGbp)} +
+
+ Tax + {formatMoney(data.invoice.taxGbp)} +
+
+ Total + {formatMoney(data.invoice.totalGbp)} +
+
+
+ +
+ + +
+

Payment

+
+

{data.organization.name}

+

{formatValue(data.organization.bankName)}

+

{formatValue(data.organization.bankAccountName)}

+

+ {formatValue(data.organization.bankSortCode)} / {formatValue( + data.organization.bankAccountNumber + )} +

+ {#if data.organization.bankIban} +

IBAN {data.organization.bankIban}

+ {/if} + {#if data.organization.bankSwift} +

SWIFT {data.organization.bankSwift}

+ {/if} +
+
+
+ +
+ + + + Description + Period + Qty + Unit + Total + + + + {#each data.lines as line (line.id)} + + {line.description} + {formatDate(line.periodStart)} - {formatDate(line.periodEnd)} + {line.quantity} + {formatMoney(line.unitPriceGbp)} + {formatMoney(line.totalGbp)} + + {/each} + + +
+ + {#if data.invoice.notes} +
+

Notes

+

{data.invoice.notes}

+
+ {/if} +
+ +