Compare commits
7 Commits
cac9d0e04d
...
2b0bdda261
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b0bdda261 | |||
| 44165e39b1 | |||
| 3642ecfba6 | |||
| fe083ffaea | |||
| e42479a3c0 | |||
| 77cbb1d8d7 | |||
| 9f8675fb30 |
@@ -65,5 +65,8 @@
|
|||||||
"vite": "^8.0.7",
|
"vite": "^8.0.7",
|
||||||
"wrangler": "^4.81.0",
|
"wrangler": "^4.81.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"date-fns": "^4.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9
@@ -7,6 +7,10 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.4.0
|
||||||
|
version: 4.4.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@better-auth/cli':
|
'@better-auth/cli':
|
||||||
specifier: ~1.4.21
|
specifier: ~1.4.21
|
||||||
@@ -2213,6 +2217,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==}
|
resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
date-fns@4.4.0:
|
||||||
|
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||||
|
|
||||||
dayjs@1.11.20:
|
dayjs@1.11.20:
|
||||||
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||||
|
|
||||||
@@ -5521,6 +5528,8 @@ snapshots:
|
|||||||
d3-delaunay: 6.0.4
|
d3-delaunay: 6.0.4
|
||||||
d3-scale: 4.0.2
|
d3-scale: 4.0.2
|
||||||
|
|
||||||
|
date-fns@4.4.0: {}
|
||||||
|
|
||||||
dayjs@1.11.20:
|
dayjs@1.11.20:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -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<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 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<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), 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 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<string, { month: string; label: string; totalGbp: number }>();
|
||||||
|
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.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ActivityLists from './activity-lists.svelte';
|
||||||
|
import BillingTrend from './billing-trend.svelte';
|
||||||
|
import MetricCards from './metric-cards.svelte';
|
||||||
|
import RoomSummary from './room-summary.svelte';
|
||||||
|
import { formatDate, formatMoney, formatValue } from '$lib/record-utils';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Dashboard | Clearity</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Operational overview for clients, contracts, bookings, rooms, and billing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MetricCards metrics={data.metrics} monthLabel={data.meta.monthLabel} {formatMoney} />
|
||||||
|
|
||||||
|
<div class="grid gap-4 xl:grid-cols-2">
|
||||||
|
<BillingTrend
|
||||||
|
records={data.billingTrend}
|
||||||
|
description={data.meta.billingTrendDescription}
|
||||||
|
{formatMoney}
|
||||||
|
/>
|
||||||
|
<RoomSummary metrics={data.metrics} summary={data.roomSummary} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActivityLists
|
||||||
|
upcomingBookings={data.upcomingBookings}
|
||||||
|
contractsEndingSoon={data.contractsEndingSoon}
|
||||||
|
recentInvoices={data.recentInvoices}
|
||||||
|
{formatDate}
|
||||||
|
{formatMoney}
|
||||||
|
{formatValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">{data.meta.overdueInvoiceBasis}</p>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
|
||||||
|
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||||
|
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
upcomingBookings,
|
||||||
|
contractsEndingSoon,
|
||||||
|
recentInvoices,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
formatValue
|
||||||
|
}: {
|
||||||
|
upcomingBookings: PageData['upcomingBookings'];
|
||||||
|
contractsEndingSoon: PageData['contractsEndingSoon'];
|
||||||
|
recentInvoices: PageData['recentInvoices'];
|
||||||
|
formatDate: (value: unknown, withTime?: boolean) => string;
|
||||||
|
formatMoney: (value: unknown) => string;
|
||||||
|
formatValue: (value: unknown) => string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="grid gap-4 xl:grid-cols-3">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Upcoming bookings</Card.Title>
|
||||||
|
<Card.Description>Next room and service reservations.</Card.Description>
|
||||||
|
<Card.Action
|
||||||
|
><Button href="/dashboard/bookings" variant="outline" size="sm">View all</Button
|
||||||
|
></Card.Action
|
||||||
|
>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if upcomingBookings.length > 0}
|
||||||
|
<div class="overflow-hidden rounded-lg border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Body>
|
||||||
|
{#each upcomingBookings as booking (booking.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="font-medium">{booking.roomName}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{booking.clientName}</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div>{formatDate(booking.startsAt, true)}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{formatValue(booking.serviceName)}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
<Badge variant="secondary" class="capitalize">{booking.status}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Empty.Root class="min-h-48 border">
|
||||||
|
<Empty.Media variant="icon"><CalendarClockIcon /></Empty.Media>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Title>No upcoming bookings</Empty.Title>
|
||||||
|
<Empty.Description>Future bookings will appear here.</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
</Empty.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Contracts ending soon</Card.Title>
|
||||||
|
<Card.Description>Active contracts ending in the next 60 days.</Card.Description>
|
||||||
|
<Card.Action
|
||||||
|
><Button href="/dashboard/contracts" variant="outline" size="sm">View all</Button
|
||||||
|
></Card.Action
|
||||||
|
>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if contractsEndingSoon.length > 0}
|
||||||
|
<div class="overflow-hidden rounded-lg border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Body>
|
||||||
|
{#each contractsEndingSoon as contract (contract.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="font-medium">{contract.clientName}</div>
|
||||||
|
<div class="max-w-48 truncate text-xs text-muted-foreground">
|
||||||
|
{contract.roomNames.join(', ') || formatValue(contract.serviceName)}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div>{formatDate(contract.endDate)}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
{formatMoney(contract.licenseFeeGbp)} / month
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Empty.Root class="min-h-48 border">
|
||||||
|
<Empty.Media variant="icon"><ScrollTextIcon /></Empty.Media>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Title>No near-term endings</Empty.Title>
|
||||||
|
<Empty.Description>Contracts ending soon will be listed here.</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
</Empty.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Recent invoices</Card.Title>
|
||||||
|
<Card.Description>Latest generated billing output.</Card.Description>
|
||||||
|
<Card.Action
|
||||||
|
><Button href="/dashboard/billing" variant="outline" size="sm">View all</Button
|
||||||
|
></Card.Action
|
||||||
|
>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if recentInvoices.length > 0}
|
||||||
|
<div class="overflow-hidden rounded-lg border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Body>
|
||||||
|
{#each recentInvoices as invoice (invoice.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="font-medium">{invoice.invoiceNumber}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">{invoice.clientName}</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div>{formatMoney(invoice.totalGbp)}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Issued {formatDate(invoice.issueDate)}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Empty.Root class="min-h-48 border">
|
||||||
|
<Empty.Media variant="icon"><FileTextIcon /></Empty.Media>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Title>No invoices yet</Empty.Title>
|
||||||
|
<Empty.Description>Generated invoices will appear here.</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
</Empty.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChartNoAxesColumnIncreasingIcon from '@lucide/svelte/icons/chart-no-axes-column-increasing';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
records,
|
||||||
|
description,
|
||||||
|
formatMoney
|
||||||
|
}: {
|
||||||
|
records: PageData['billingTrend'];
|
||||||
|
description: string;
|
||||||
|
formatMoney: (value: unknown) => string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const maxValue = $derived(Math.max(...records.map((record) => record.totalGbp), 0));
|
||||||
|
const hasValues = $derived(records.some((record) => record.totalGbp > 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Billing trend</Card.Title>
|
||||||
|
<Card.Description>{description}</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if hasValues}
|
||||||
|
<div class="grid h-52 grid-cols-6 items-end gap-3" aria-label="Billing trend chart">
|
||||||
|
{#each records as record (record.month)}
|
||||||
|
<div class="flex h-full min-w-0 flex-col justify-end gap-2">
|
||||||
|
<div class="flex min-h-0 flex-1 items-end">
|
||||||
|
<div
|
||||||
|
class="w-full rounded-t-md bg-primary/75"
|
||||||
|
style:height={`${Math.max((record.totalGbp / maxValue) * 100, record.totalGbp > 0 ? 4 : 0)}%`}
|
||||||
|
title={`${record.label}: ${formatMoney(record.totalGbp)}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 text-center">
|
||||||
|
<div class="truncate text-xs font-medium">{formatMoney(record.totalGbp)}</div>
|
||||||
|
<div class="truncate text-xs text-muted-foreground">{record.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Empty.Root class="min-h-52 border">
|
||||||
|
<Empty.Media variant="icon"><ChartNoAxesColumnIncreasingIcon /></Empty.Media>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Title>No issued invoices yet</Empty.Title>
|
||||||
|
<Empty.Description
|
||||||
|
>Billing totals will appear once invoices are generated.</Empty.Description
|
||||||
|
>
|
||||||
|
</Empty.Header>
|
||||||
|
</Empty.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import BanknoteIcon from '@lucide/svelte/icons/banknote';
|
||||||
|
import BriefcaseBusinessIcon from '@lucide/svelte/icons/briefcase-business';
|
||||||
|
import Building2Icon from '@lucide/svelte/icons/building-2';
|
||||||
|
import CalendarClockIcon from '@lucide/svelte/icons/calendar-clock';
|
||||||
|
import FileWarningIcon from '@lucide/svelte/icons/file-warning';
|
||||||
|
import UsersIcon from '@lucide/svelte/icons/users';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
metrics,
|
||||||
|
monthLabel,
|
||||||
|
formatMoney
|
||||||
|
}: {
|
||||||
|
metrics: PageData['metrics'];
|
||||||
|
monthLabel: string;
|
||||||
|
formatMoney: (value: unknown) => string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const cards = $derived([
|
||||||
|
{
|
||||||
|
label: 'Active clients',
|
||||||
|
value: metrics.activeClients,
|
||||||
|
detail: 'Unarchived client records',
|
||||||
|
icon: UsersIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Active contracts',
|
||||||
|
value: metrics.activeContracts,
|
||||||
|
detail: `${formatMoney(metrics.monthlyLicenseFeesGbp)} monthly license fees`,
|
||||||
|
icon: BriefcaseBusinessIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Upcoming bookings',
|
||||||
|
value: metrics.upcomingBookings,
|
||||||
|
detail: 'Future bookings excluding cancelled',
|
||||||
|
icon: CalendarClockIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Past-due invoices',
|
||||||
|
value: metrics.overdueInvoices,
|
||||||
|
detail: formatMoney(metrics.overdueInvoicesGbp),
|
||||||
|
icon: FileWarningIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${monthLabel} billing`,
|
||||||
|
value: formatMoney(metrics.monthlyBillingGbp),
|
||||||
|
detail: 'Issued invoice total',
|
||||||
|
icon: BanknoteIcon
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Room occupancy',
|
||||||
|
value: `${metrics.occupancyPercent}%`,
|
||||||
|
detail: `${metrics.allocatedRooms} of ${metrics.totalRooms} rooms allocated`,
|
||||||
|
icon: Building2Icon
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{#each cards as card (card.label)}
|
||||||
|
{@const Icon = card.icon}
|
||||||
|
<Card.Root size="sm">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Icon class="size-4 text-foreground" />
|
||||||
|
{card.label}
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<div class="text-2xl font-semibold tracking-tight">{card.value}</div>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">{card.detail}</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Building2Icon from '@lucide/svelte/icons/building-2';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||||
|
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
metrics,
|
||||||
|
summary
|
||||||
|
}: {
|
||||||
|
metrics: PageData['metrics'];
|
||||||
|
summary: PageData['roomSummary'];
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Room inventory</Card.Title>
|
||||||
|
<Card.Description>Allocation is based on rooms linked to active contracts.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="space-y-4">
|
||||||
|
{#if metrics.totalRooms > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-muted-foreground">Allocated rooms</span>
|
||||||
|
<span class="font-medium">{metrics.allocatedRooms} / {metrics.totalRooms}</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={metrics.occupancyPercent} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-lg border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Type</Table.Head>
|
||||||
|
<Table.Head class="text-right">Rooms</Table.Head>
|
||||||
|
<Table.Head class="text-right">Allocated</Table.Head>
|
||||||
|
<Table.Head class="text-right">Workstations</Table.Head>
|
||||||
|
<Table.Head class="text-right">Sq ft.</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each summary.types as type (type.type)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-medium">{type.label}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{type.total}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{type.allocated}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{type.workstations || 'Not set'}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{type.sqFt || 'Not set'}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Empty.Root class="min-h-52 border">
|
||||||
|
<Empty.Media variant="icon"><Building2Icon /></Empty.Media>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Title>No rooms configured</Empty.Title>
|
||||||
|
<Empty.Description>Add rooms before tracking inventory and allocation.</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
</Empty.Root>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
Reference in New Issue
Block a user