diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts new file mode 100644 index 0000000..e74bdcd --- /dev/null +++ b/src/routes/dashboard/+page.server.ts @@ -0,0 +1,369 @@ +import { and, asc, count, desc, eq, gte, inArray, isNull, lt, lte, ne, sql } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { + bookings, + clients, + contracts, + contractRooms, + invoices, + rooms, + services +} from '$lib/server/db/schema'; +import { loadOrganizationContext } from '$lib/server/organizations'; +import type { PageServerLoad } from './$types'; + +const dayMs = 24 * 60 * 60 * 1000; + +function pad(value: number) { + return String(value).padStart(2, '0'); +} + +function dateOnly(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function dateTimeLocal(date: Date) { + return `${dateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function startOfMonth(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function endOfMonth(date: Date) { + return new Date(date.getFullYear(), date.getMonth() + 1, 0); +} + +function addDays(date: Date, days: number) { + return new Date(date.getTime() + days * dayMs); +} + +function addMonths(date: Date, months: number) { + return new Date(date.getFullYear(), date.getMonth() + months, 1); +} + +function monthKey(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`; +} + +function monthLabel(date: Date) { + return new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric' }).format(date); +} + +function roomTypeLabel(type: string) { + const labels: Record = { + meeting_room: 'Meeting rooms', + private_office: 'Private offices', + coworking_desk: 'Coworking desks' + }; + + return labels[type] ?? type; +} + +function numberValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +export const load: PageServerLoad = async ({ locals }) => { + const { activeOrganizationId } = await loadOrganizationContext(locals); + const today = new Date(); + const todayDate = dateOnly(today); + const nowDateTime = dateTimeLocal(today); + const soonDate = dateOnly(addDays(today, 60)); + const monthStart = dateOnly(startOfMonth(today)); + const monthEnd = dateOnly(endOfMonth(today)); + const sixMonthsAgo = startOfMonth(addMonths(today, -5)); + + const activeContractCondition = and( + eq(contracts.organizationId, activeOrganizationId), + eq(contracts.status, 'active'), + lte(contracts.startDate, todayDate), + gte(contracts.endDate, todayDate), + isNull(contracts.archivedAt) + ); + + const [ + [{ total: activeClients }], + activeContractRows, + [{ total: upcomingBookings }], + [overdueInvoiceSummary], + [monthlyBillingSummary], + roomRows, + upcomingBookingRows, + endingContractRows, + recentInvoiceRows, + billingTrendInvoiceRows + ] = await Promise.all([ + db + .select({ total: count() }) + .from(clients) + .where(and(eq(clients.organizationId, activeOrganizationId), isNull(clients.archivedAt))), + db + .select({ + id: contracts.id, + licenseFeeGbp: contracts.licenseFeeGbp + }) + .from(contracts) + .where(activeContractCondition), + db + .select({ total: count() }) + .from(bookings) + .where( + and( + eq(bookings.organizationId, activeOrganizationId), + gte(bookings.startsAt, nowDateTime), + ne(bookings.status, 'cancelled'), + isNull(bookings.archivedAt) + ) + ), + db + .select({ + total: count(), + valueGbp: sql`coalesce(sum(${invoices.totalGbp}), 0)` + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, activeOrganizationId), + lt(invoices.dueDate, todayDate), + isNull(invoices.archivedAt) + ) + ), + db + .select({ + valueGbp: sql`coalesce(sum(${invoices.totalGbp}), 0)` + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, activeOrganizationId), + gte(invoices.issueDate, monthStart), + lte(invoices.issueDate, monthEnd), + isNull(invoices.archivedAt) + ) + ), + db + .select({ + id: rooms.id, + name: rooms.name, + type: rooms.type, + sqFt: rooms.sqFt, + workstations: rooms.workstations, + pricePerMonthGbp: rooms.pricePerMonthGbp + }) + .from(rooms) + .where(and(eq(rooms.organizationId, activeOrganizationId), isNull(rooms.archivedAt))) + .orderBy(asc(rooms.name)), + db + .select({ + id: bookings.id, + clientName: clients.name, + roomName: rooms.name, + serviceName: services.name, + startsAt: bookings.startsAt, + endsAt: bookings.endsAt, + status: bookings.status + }) + .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, activeOrganizationId), + gte(bookings.startsAt, nowDateTime), + ne(bookings.status, 'cancelled'), + isNull(bookings.archivedAt) + ) + ) + .orderBy(asc(bookings.startsAt)) + .limit(6), + db + .select({ + id: contracts.id, + clientName: clients.name, + serviceName: services.name, + licenseFeeGbp: contracts.licenseFeeGbp, + startDate: contracts.startDate, + endDate: contracts.endDate, + status: contracts.status + }) + .from(contracts) + .innerJoin(clients, eq(contracts.clientId, clients.id)) + .leftJoin(services, eq(contracts.serviceId, services.id)) + .where( + and( + eq(contracts.organizationId, activeOrganizationId), + eq(contracts.status, 'active'), + gte(contracts.endDate, todayDate), + lte(contracts.endDate, soonDate), + isNull(contracts.archivedAt) + ) + ) + .orderBy(asc(contracts.endDate)) + .limit(6), + db + .select({ + id: invoices.id, + invoiceNumber: invoices.invoiceNumber, + clientName: clients.name, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + totalGbp: invoices.totalGbp + }) + .from(invoices) + .innerJoin(clients, eq(invoices.clientId, clients.id)) + .where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt))) + .orderBy(desc(invoices.issueDate), desc(invoices.createdAt)) + .limit(6), + db + .select({ + issueDate: invoices.issueDate, + totalGbp: invoices.totalGbp + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, activeOrganizationId), + gte(invoices.issueDate, dateOnly(sixMonthsAgo)), + isNull(invoices.archivedAt) + ) + ) + ]); + + const activeContractIds = activeContractRows.map((contract) => contract.id); + const activeContractRoomLinks = + activeContractIds.length > 0 + ? await db + .select({ + contractId: contractRooms.contractId, + roomId: contractRooms.roomId, + roomName: rooms.name, + roomType: rooms.type + }) + .from(contractRooms) + .innerJoin(rooms, eq(contractRooms.roomId, rooms.id)) + .where( + and( + inArray(contractRooms.contractId, activeContractIds), + eq(rooms.organizationId, activeOrganizationId), + isNull(rooms.archivedAt) + ) + ) + .orderBy(asc(rooms.name)) + : []; + + const endingContractIds = endingContractRows.map((contract) => contract.id); + const endingContractRoomLinks = + endingContractIds.length > 0 + ? await db + .select({ + contractId: contractRooms.contractId, + roomName: rooms.name + }) + .from(contractRooms) + .innerJoin(rooms, eq(contractRooms.roomId, rooms.id)) + .where( + and( + inArray(contractRooms.contractId, endingContractIds), + eq(rooms.organizationId, activeOrganizationId), + isNull(rooms.archivedAt) + ) + ) + .orderBy(asc(rooms.name)) + : []; + const roomNamesByEndingContract = new Map(); + for (const link of endingContractRoomLinks) { + const existing = roomNamesByEndingContract.get(link.contractId) ?? []; + existing.push(link.roomName); + roomNamesByEndingContract.set(link.contractId, existing); + } + + const allocatedRoomIds = new Set(activeContractRoomLinks.map((link) => link.roomId)); + const monthlyLicenseFeesGbp = activeContractRows.reduce( + (total, contract) => total + numberValue(contract.licenseFeeGbp), + 0 + ); + + const roomTypes = new Map< + string, + { + type: string; + label: string; + total: number; + allocated: number; + workstations: number; + sqFt: number; + } + >(); + for (const room of roomRows) { + const summary = roomTypes.get(room.type) ?? { + type: room.type, + label: roomTypeLabel(room.type), + total: 0, + allocated: 0, + workstations: 0, + sqFt: 0 + }; + summary.total += 1; + summary.allocated += allocatedRoomIds.has(room.id) ? 1 : 0; + summary.workstations += room.workstations ?? 0; + summary.sqFt += room.sqFt ?? 0; + roomTypes.set(room.type, summary); + } + + const billingTrendMap = new Map(); + for (let index = 0; index < 6; index += 1) { + const month = addMonths(sixMonthsAgo, index); + billingTrendMap.set(monthKey(month), { + month: monthKey(month), + label: monthLabel(month), + totalGbp: 0 + }); + } + for (const invoice of billingTrendInvoiceRows) { + const issuedAt = new Date(`${invoice.issueDate}T00:00:00`); + const key = monthKey(issuedAt); + const existing = billingTrendMap.get(key); + if (!existing) continue; + existing.totalGbp += numberValue(invoice.totalGbp); + } + const billingTrend = [...billingTrendMap.values()]; + + return { + metrics: { + activeClients, + activeContracts: activeContractRows.length, + upcomingBookings, + overdueInvoices: overdueInvoiceSummary.total, + overdueInvoicesGbp: numberValue(overdueInvoiceSummary.valueGbp), + monthlyBillingGbp: numberValue(monthlyBillingSummary.valueGbp), + monthlyLicenseFeesGbp, + totalRooms: roomRows.length, + allocatedRooms: allocatedRoomIds.size, + occupancyPercent: + roomRows.length > 0 ? Math.round((allocatedRoomIds.size / roomRows.length) * 100) : 0 + }, + roomSummary: { + types: [...roomTypes.values()], + allocatedRooms: activeContractRoomLinks.map((link) => ({ + roomId: link.roomId, + roomName: link.roomName, + roomType: roomTypeLabel(link.roomType) + })) + }, + billingTrend, + upcomingBookings: upcomingBookingRows, + contractsEndingSoon: endingContractRows.map((contract) => ({ + ...contract, + roomNames: roomNamesByEndingContract.get(contract.id) ?? [] + })), + recentInvoices: recentInvoiceRows, + meta: { + monthLabel: monthLabel(today), + overdueInvoiceBasis: + 'Invoices have no payment status yet, so this shows invoices past due date.' + } + }; +};