diff --git a/package.json b/package.json index abfa2ee..e63a233 100644 --- a/package.json +++ b/package.json @@ -65,5 +65,8 @@ "vite": "^8.0.7", "wrangler": "^4.81.0", "zod": "^4.4.3" + }, + "dependencies": { + "date-fns": "^4.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9ff036..e2af95b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + date-fns: + specifier: ^4.4.0 + version: 4.4.0 devDependencies: '@better-auth/cli': specifier: ~1.4.21 @@ -2213,6 +2217,9 @@ packages: resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==} engines: {node: '>=12'} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -5521,6 +5528,8 @@ snapshots: d3-delaunay: 6.0.4 d3-scale: 4.0.2 + date-fns@4.4.0: {} + dayjs@1.11.20: optional: true diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts new file mode 100644 index 0000000..0e8042c --- /dev/null +++ b/src/routes/dashboard/+page.server.ts @@ -0,0 +1,351 @@ +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.' + } + }; +}; diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index e69de29..b4f0826 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -0,0 +1,45 @@ + + + + Dashboard | Clearity + + +
+
+

Dashboard

+

+ Operational overview for clients, contracts, bookings, rooms, and billing. +

+
+ + + +
+ + +
+ + + +

{data.meta.overdueInvoiceBasis}

+
diff --git a/src/routes/dashboard/activity-lists.svelte b/src/routes/dashboard/activity-lists.svelte new file mode 100644 index 0000000..a4966b0 --- /dev/null +++ b/src/routes/dashboard/activity-lists.svelte @@ -0,0 +1,163 @@ + + +
+ + + Upcoming bookings + Next room and service reservations. + + + + {#if upcomingBookings.length > 0} +
+ + + {#each upcomingBookings as booking (booking.id)} + + +
{booking.roomName}
+
{booking.clientName}
+
+ +
{formatDate(booking.startsAt, true)}
+
+ {formatValue(booking.serviceName)} +
+
+ + {booking.status} + +
+ {/each} +
+
+
+ {:else} + + + + No upcoming bookings + Future bookings will appear here. + + + {/if} +
+
+ + + + Contracts ending soon + Active contracts ending in the next 60 days. + + + + {#if contractsEndingSoon.length > 0} +
+ + + {#each contractsEndingSoon as contract (contract.id)} + + +
{contract.clientName}
+
+ {contract.roomNames.join(', ') || formatValue(contract.serviceName)} +
+
+ +
{formatDate(contract.endDate)}
+
+ {formatMoney(contract.licenseFeeGbp)} / month +
+
+
+ {/each} +
+
+
+ {:else} + + + + No near-term endings + Contracts ending soon will be listed here. + + + {/if} +
+
+ + + + Recent invoices + Latest generated billing output. + + + + {#if recentInvoices.length > 0} +
+ + + {#each recentInvoices as invoice (invoice.id)} + + +
{invoice.invoiceNumber}
+
{invoice.clientName}
+
+ +
{formatMoney(invoice.totalGbp)}
+
+ Issued {formatDate(invoice.issueDate)} +
+
+
+ {/each} +
+
+
+ {:else} + + + + No invoices yet + Generated invoices will appear here. + + + {/if} +
+
+
diff --git a/src/routes/dashboard/billing-trend.svelte b/src/routes/dashboard/billing-trend.svelte new file mode 100644 index 0000000..cb4499f --- /dev/null +++ b/src/routes/dashboard/billing-trend.svelte @@ -0,0 +1,57 @@ + + + + + Billing trend + {description} + + + {#if hasValues} +
+ {#each records as record (record.month)} +
+
+
0 ? 4 : 0)}%`} + title={`${record.label}: ${formatMoney(record.totalGbp)}`} + >
+
+
+
{formatMoney(record.totalGbp)}
+
{record.label}
+
+
+ {/each} +
+ {:else} + + + + No issued invoices yet + Billing totals will appear once invoices are generated. + + + {/if} +
+
diff --git a/src/routes/dashboard/metric-cards.svelte b/src/routes/dashboard/metric-cards.svelte new file mode 100644 index 0000000..95a6882 --- /dev/null +++ b/src/routes/dashboard/metric-cards.svelte @@ -0,0 +1,77 @@ + + +
+ {#each cards as card (card.label)} + {@const Icon = card.icon} + + + + + {card.label} + + + +
{card.value}
+

{card.detail}

+
+
+ {/each} +
diff --git a/src/routes/dashboard/room-summary.svelte b/src/routes/dashboard/room-summary.svelte new file mode 100644 index 0000000..ba8d615 --- /dev/null +++ b/src/routes/dashboard/room-summary.svelte @@ -0,0 +1,67 @@ + + + + + Room inventory + Allocation is based on rooms linked to active contracts. + + + {#if metrics.totalRooms > 0} +
+
+ Allocated rooms + {metrics.allocatedRooms} / {metrics.totalRooms} +
+ +
+ +
+ + + + Type + Rooms + Allocated + Workstations + Sq ft. + + + + {#each summary.types as type (type.type)} + + {type.label} + {type.total} + {type.allocated} + {type.workstations || 'Not set'} + {type.sqFt || 'Not set'} + + {/each} + + +
+ {:else} + + + + No rooms configured + Add rooms before tracking inventory and allocation. + + + {/if} +
+