feat: manage invoice lifecycle
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
formatValue,
|
formatValue,
|
||||||
@@ -8,12 +12,19 @@
|
|||||||
formatMoney,
|
formatMoney,
|
||||||
relationLabel
|
relationLabel
|
||||||
}: {
|
}: {
|
||||||
data: any;
|
data: Pick<PageData, 'records'>;
|
||||||
formatValue: any;
|
formatValue: (value: unknown) => string;
|
||||||
formatDate: any;
|
formatDate: (value: unknown) => string;
|
||||||
formatMoney: any;
|
formatMoney: (value: unknown) => string;
|
||||||
relationLabel: any;
|
relationLabel: (value: unknown, optionsKey: 'clients') => string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
function statusVariant(status: string): BadgeVariant {
|
||||||
|
if (status === 'paid') return 'default';
|
||||||
|
if (status === 'void') return 'destructive';
|
||||||
|
if (status === 'draft') return 'outline';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.records.length > 0}
|
{#if data.records.length > 0}
|
||||||
@@ -23,6 +34,7 @@
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Invoice #</Table.Head>
|
<Table.Head>Invoice #</Table.Head>
|
||||||
<Table.Head>Client</Table.Head>
|
<Table.Head>Client</Table.Head>
|
||||||
|
<Table.Head>Status</Table.Head>
|
||||||
<Table.Head>Issued</Table.Head>
|
<Table.Head>Issued</Table.Head>
|
||||||
<Table.Head>Due</Table.Head>
|
<Table.Head>Due</Table.Head>
|
||||||
<Table.Head>Total</Table.Head>
|
<Table.Head>Total</Table.Head>
|
||||||
@@ -31,8 +43,19 @@
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each data.records as record (record.id)}
|
{#each data.records as record (record.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
<Table.Cell class="font-medium"
|
||||||
|
><a
|
||||||
|
class="inline-flex items-center gap-2 underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href={resolve('/dashboard/invoices/[id]', { id: record.id })}
|
||||||
|
>{formatValue(record.invoiceNumber)}<ExternalLinkIcon class="size-3.5" /></a
|
||||||
|
></Table.Cell
|
||||||
|
>
|
||||||
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||||
|
<Table.Cell
|
||||||
|
><Badge variant={statusVariant(record.status)} class="capitalize"
|
||||||
|
>{formatValue(record.status)}</Badge
|
||||||
|
></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="">{formatMoney(record.totalGbp)}</Table.Cell>
|
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
formatValue,
|
formatValue,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney
|
formatMoney
|
||||||
}: {
|
}: {
|
||||||
data: any;
|
data: Pick<PageData, 'records'>;
|
||||||
formatValue: any;
|
formatValue: (value: unknown) => string;
|
||||||
formatDate: any;
|
formatDate: (value: unknown) => string;
|
||||||
formatMoney: any;
|
formatMoney: (value: unknown) => string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
function statusVariant(status: string): BadgeVariant {
|
||||||
|
if (status === 'paid') return 'default';
|
||||||
|
if (status === 'void') return 'destructive';
|
||||||
|
if (status === 'draft') return 'outline';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.records.length > 0}
|
{#if data.records.length > 0}
|
||||||
@@ -20,6 +31,7 @@
|
|||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Invoice #</Table.Head>
|
<Table.Head>Invoice #</Table.Head>
|
||||||
|
<Table.Head>Status</Table.Head>
|
||||||
<Table.Head>Issued</Table.Head>
|
<Table.Head>Issued</Table.Head>
|
||||||
<Table.Head>Due</Table.Head>
|
<Table.Head>Due</Table.Head>
|
||||||
<Table.Head>Total</Table.Head>
|
<Table.Head>Total</Table.Head>
|
||||||
@@ -28,7 +40,18 @@
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each data.records as record (record.id)}
|
{#each data.records as record (record.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
<Table.Cell class="font-medium"
|
||||||
|
><a
|
||||||
|
class="inline-flex items-center gap-2 underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href={resolve('/dashboard/invoices/[id]', { id: record.id })}
|
||||||
|
>{formatValue(record.invoiceNumber)}<ExternalLinkIcon class="size-3.5" /></a
|
||||||
|
></Table.Cell
|
||||||
|
>
|
||||||
|
<Table.Cell
|
||||||
|
><Badge variant={statusVariant(record.status)} class="capitalize"
|
||||||
|
>{formatValue(record.status)}</Badge
|
||||||
|
></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="">{formatMoney(record.totalGbp)}</Table.Cell>
|
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
||||||
|
|||||||
@@ -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<Transition, InvoiceStatus[]> = {
|
||||||
|
send: ['draft'],
|
||||||
|
markPaid: ['sent'],
|
||||||
|
void: ['draft', 'sent']
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionStatus: Record<Transition, InvoiceStatus> = {
|
||||||
|
send: 'sent',
|
||||||
|
markPaid: 'paid',
|
||||||
|
void: 'void'
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionMessages: Record<Transition, string> = {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import BanIcon from '@lucide/svelte/icons/ban';
|
||||||
|
import CheckCircle2Icon from '@lucide/svelte/icons/check-circle-2';
|
||||||
|
import PrinterIcon from '@lucide/svelte/icons/printer';
|
||||||
|
import SendIcon from '@lucide/svelte/icons/send';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const canSend = $derived(data.invoice.status === 'draft');
|
||||||
|
const canMarkPaid = $derived(data.invoice.status === 'sent');
|
||||||
|
const canVoid = $derived(data.invoice.status === 'draft' || data.invoice.status === 'sent');
|
||||||
|
|
||||||
|
const lifecycleItems = $derived(
|
||||||
|
[
|
||||||
|
{ label: 'Sent', value: data.invoice.sentAt },
|
||||||
|
{ label: 'Paid', value: data.invoice.paidAt },
|
||||||
|
{ label: 'Voided', value: data.invoice.voidedAt }
|
||||||
|
].filter((item) => item.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') return 'Not set';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(value: string) {
|
||||||
|
return value.replace('-', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status: string): BadgeVariant {
|
||||||
|
if (status === 'paid') return 'default';
|
||||||
|
if (status === 'void') return 'destructive';
|
||||||
|
if (status === 'draft') return 'outline';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) return 'Not set';
|
||||||
|
const date = value instanceof Date ? value : new Date(String(value));
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printInvoice() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.invoice.invoiceNumber} | Clearity</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="no-print flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Button variant="ghost" href="/dashboard/billing" class="w-fit">
|
||||||
|
<ArrowLeftIcon data-icon="inline-start" />Billing
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={printInvoice}>
|
||||||
|
<PrinterIcon data-icon="inline-start" />Print
|
||||||
|
</Button>
|
||||||
|
<form method="POST" action="?/send">
|
||||||
|
<Button type="submit" disabled={!canSend}>
|
||||||
|
<SendIcon data-icon="inline-start" />Mark sent
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/markPaid">
|
||||||
|
<Button type="submit" variant="secondary" disabled={!canMarkPaid}>
|
||||||
|
<CheckCircle2Icon data-icon="inline-start" />Mark paid
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/void">
|
||||||
|
<Button type="submit" variant="destructive" disabled={!canVoid}>
|
||||||
|
<BanIcon data-icon="inline-start" />Void
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.message}
|
||||||
|
<div class="no-print rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="grid gap-6 lg:grid-cols-[1fr_18rem]">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Invoice</p>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">{data.invoice.invoiceNumber}</h1>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusVariant(data.invoice.status)} class="capitalize">
|
||||||
|
{formatStatus(data.invoice.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Issued</p>
|
||||||
|
<p class="text-sm">{formatDate(data.invoice.issueDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Due</p>
|
||||||
|
<p class="text-sm">{formatDate(data.invoice.dueDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Updated</p>
|
||||||
|
<p class="text-sm">{formatDate(data.invoice.updatedAt, true)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if lifecycleItems.length > 0}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
{#each lifecycleItems as item (item.label)}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">{item.label}</p>
|
||||||
|
<p class="text-sm">{formatDate(item.value, true)}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 rounded-lg border border-border p-4">
|
||||||
|
<div class="flex justify-between gap-4 text-sm">
|
||||||
|
<span class="text-muted-foreground">Subtotal</span>
|
||||||
|
<span>{formatMoney(data.invoice.subtotalGbp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4 text-sm">
|
||||||
|
<span class="text-muted-foreground">Tax</span>
|
||||||
|
<span>{formatMoney(data.invoice.taxGbp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4 border-t border-border pt-3 text-base font-semibold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{formatMoney(data.invoice.totalGbp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-border p-4">
|
||||||
|
<h2 class="text-sm font-semibold">Client</h2>
|
||||||
|
<a
|
||||||
|
class="mt-2 inline-flex text-sm underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href={resolve('/dashboard/clients/[id]/invoices', { id: data.invoice.clientId })}
|
||||||
|
>
|
||||||
|
{data.invoice.clientName}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border p-4">
|
||||||
|
<h2 class="text-sm font-semibold">Payment</h2>
|
||||||
|
<div class="mt-2 grid gap-1 text-sm">
|
||||||
|
<p>{data.organization.name}</p>
|
||||||
|
<p>{formatValue(data.organization.bankName)}</p>
|
||||||
|
<p>{formatValue(data.organization.bankAccountName)}</p>
|
||||||
|
<p>
|
||||||
|
{formatValue(data.organization.bankSortCode)} / {formatValue(
|
||||||
|
data.organization.bankAccountNumber
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{#if data.organization.bankIban}
|
||||||
|
<p>IBAN {data.organization.bankIban}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.organization.bankSwift}
|
||||||
|
<p>SWIFT {data.organization.bankSwift}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section 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 class="text-right">Qty</Table.Head>
|
||||||
|
<Table.Head class="text-right">Unit</Table.Head>
|
||||||
|
<Table.Head class="text-right">Total</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each data.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 class="text-right">{line.quantity}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(line.unitPriceGbp)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(line.totalGbp)}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if data.invoice.notes}
|
||||||
|
<section class="rounded-lg border border-border p-4">
|
||||||
|
<h2 class="text-sm font-semibold">Notes</h2>
|
||||||
|
<p class="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{data.invoice.notes}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-slot='sidebar']),
|
||||||
|
:global([data-slot='breadcrumb']) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user