370 lines
10 KiB
TypeScript
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.'
|
|
}
|
|
};
|
|
};
|