import { addDays, addMonths, endOfMonth, format, parseISO, startOfMonth } from 'date-fns'; 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'; function dateOnly(date: Date) { return format(date, 'yyyy-MM-dd'); } function dateTimeLocal(date: Date) { return format(date, "yyyy-MM-dd'T'HH:mm"); } function monthKey(date: Date) { return format(date, 'yyyy-MM'); } function monthLabel(date: Date) { return format(date, 'MMM yyyy'); } 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 currentTrendStart = 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), 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 hasCurrentTrendInvoices = billingTrendInvoiceRows.some( (invoice) => parseISO(invoice.issueDate).getTime() >= currentTrendStart.getTime() ); const trendEnd = hasCurrentTrendInvoices || recentInvoiceRows.length === 0 ? today : parseISO(recentInvoiceRows[0].issueDate); const trendStart = startOfMonth(addMonths(trendEnd, -5)); const billingTrendMap = new Map(); for (let index = 0; index < 6; index += 1) { const month = addMonths(trendStart, index); billingTrendMap.set(monthKey(month), { month: monthKey(month), label: monthLabel(month), totalGbp: 0 }); } for (const invoice of billingTrendInvoiceRows) { const issuedAt = parseISO(invoice.issueDate); 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), billingTrendDescription: `Issued invoice totals from ${monthLabel(trendStart)} to ${monthLabel(trendEnd)}.`, overdueInvoiceBasis: 'Invoices have no payment status yet, so this shows invoices past due date.' } }; };