feat: add billing and organization workflows
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user