Files
clearity/src/routes/dashboard/+page.server.ts
T

370 lines
10 KiB
TypeScript

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<string, string> = {
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<number>`coalesce(sum(${invoices.totalGbp}), 0)`
})
.from(invoices)
.where(
and(
eq(invoices.organizationId, activeOrganizationId),
lt(invoices.dueDate, todayDate),
isNull(invoices.archivedAt)
)
),
db
.select({
valueGbp: sql<number>`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<string, string[]>();
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<string, { month: string; label: string; totalGbp: number }>();
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.'
}
};
};