Compare commits

..

16 Commits

Author SHA1 Message Date
kdaniel2410 e1e2e94bf2 feat: manage invoice lifecycle 2026-06-25 12:16:06 +01:00
kdaniel2410 3373762d97 feat: add invoice lifecycle schema 2026-06-25 12:16:06 +01:00
kdaniel2410 b71bb98389 Merge pull request 'feat: prevent room scheduling conflicts' (#3) from codex/booking-availability-guards into main
Reviewed-on: #3
2026-06-25 11:14:03 +00:00
kdaniel2410 4ee0a8f4ec Merge pull request 'feat: add Cloudflare D1 runtime support' (#4) from codex/d1-production-runtime into main
Reviewed-on: #4
2026-06-25 11:10:44 +00:00
kdaniel2410 de08587b77 feat: use D1 binding for local database 2026-06-25 12:09:44 +01:00
kdaniel2410 2b0bdda261 Merge pull request 'feat: build dashboard reporting' (#1) from codex/dashboard-reporting into main
Reviewed-on: #1
2026-06-25 10:59:28 +00:00
kdaniel2410 44165e39b1 fix: show latest invoice in billing trend 2026-06-25 11:50:24 +01:00
kdaniel2410 3642ecfba6 refactor: use date-fns for dashboard dates 2026-06-25 11:48:18 +01:00
kdaniel2410 fe083ffaea fix: balance dashboard reporting cards 2026-06-25 11:46:34 +01:00
kdaniel2410 29ff3265e8 docs: document D1 deployment flow 2026-06-25 11:41:00 +01:00
kdaniel2410 b1d0267bf1 feat: add D1 runtime database binding 2026-06-25 11:40:56 +01:00
kdaniel2410 e42479a3c0 chore: refresh worker configuration types 2026-06-25 11:40:12 +01:00
kdaniel2410 77cbb1d8d7 feat: build dashboard overview UI 2026-06-25 11:40:09 +01:00
kdaniel2410 9f8675fb30 feat: add dashboard reporting load 2026-06-25 11:40:04 +01:00
kdaniel2410 17f36e6378 feat: guard room availability writes 2026-06-25 11:39:44 +01:00
kdaniel2410 44b34c850c feat: add scheduling conflict helpers 2026-06-25 11:39:34 +01:00
19 changed files with 1125 additions and 67 deletions
-3
View File
@@ -1,6 +1,3 @@
# Drizzle
DATABASE_URL=file:local.db
ORIGIN="" ORIGIN=""
# Better Auth # Better Auth
+16 -22
View File
@@ -2,7 +2,7 @@
Clearity is a workspace, client, and contract management platform for managed office operations. It tracks clients, contacts, addresses, rooms, services, bookings, contracts, invoices, and workspace admins from a SvelteKit dashboard. Clearity is a workspace, client, and contract management platform for managed office operations. It tracks clients, contacts, addresses, rooms, services, bookings, contracts, invoices, and workspace admins from a SvelteKit dashboard.
The app is built with SvelteKit, Svelte 5, shadcn-svelte, Superforms, Better Auth, Drizzle ORM, SQLite/libSQL, and Cloudflare Workers. The app is built with SvelteKit, Svelte 5, shadcn-svelte, Superforms, Better Auth, Drizzle ORM, Cloudflare D1, and Cloudflare Workers.
## Development ## Development
@@ -15,7 +15,6 @@ pnpm install
Create a local `.env` file: Create a local `.env` file:
```sh ```sh
DATABASE_URL=file:local.db
ORIGIN=http://localhost:5173 ORIGIN=http://localhost:5173
BETTER_AUTH_SECRET=replace-with-a-long-random-secret BETTER_AUTH_SECRET=replace-with-a-long-random-secret
``` ```
@@ -29,7 +28,7 @@ openssl rand -base64 32
Apply database migrations: Apply database migrations:
```sh ```sh
DATABASE_URL=file:local.db pnpm db:migrate pnpm db:migrate:local
``` ```
Start the development server: Start the development server:
@@ -47,34 +46,36 @@ pnpm check # Wrangler types, SvelteKit sync, and svelte-check
pnpm lint # Prettier check and ESLint pnpm lint # Prettier check and ESLint
pnpm format # Format the codebase pnpm format # Format the codebase
pnpm db:generate # Generate Drizzle migrations after schema changes pnpm db:generate # Generate Drizzle migrations after schema changes
pnpm db:migrate # Apply Drizzle migrations to DATABASE_URL pnpm db:migrate:local # Apply migrations to the local D1 database
pnpm db:studio # Open Drizzle Studio pnpm db:migrate:remote # Apply migrations to the remote D1 database
pnpm gen # Regenerate Cloudflare Worker types pnpm gen # Regenerate Cloudflare Worker types
``` ```
## Environment Variables ## Environment Variables And Bindings
Required: Required:
- `DATABASE_URL`: SQLite/libSQL connection URL. Use `file:local.db` for local development.
- `ORIGIN`: Public app origin, for example `http://localhost:5173` locally or `https://clearity.example.com` in production. - `ORIGIN`: Public app origin, for example `http://localhost:5173` locally or `https://clearity.example.com` in production.
- `BETTER_AUTH_SECRET`: Secret used by Better Auth. - `BETTER_AUTH_SECRET`: Secret used by Better Auth.
- Cloudflare D1 binding `DB`, configured in `wrangler.jsonc`.
Local development uses the same `DB` binding shape as production. SvelteKit's Cloudflare adapter populates `platform.env.DB` from `wrangler.jsonc` during dev and preview, and Wrangler stores local D1 data in its local state directory.
## Database ## Database
The application schema lives in `src/lib/server/db`. Drizzle migration files live in `drizzle`. The application schema lives in `src/lib/server/db`. Drizzle migration files live in `drizzle`.
Local development currently uses the libSQL client through `DATABASE_URL`, so the fastest local database is a SQLite file: Clearity always uses Drizzle's Cloudflare D1 driver through the `DB` binding. Apply migrations to the local D1 database before starting the app:
```sh ```sh
DATABASE_URL=file:local.db pnpm db:migrate pnpm db:migrate:local
``` ```
For schema changes: For schema changes:
```sh ```sh
pnpm db:generate pnpm db:generate
DATABASE_URL=file:local.db pnpm db:migrate pnpm db:migrate:local
``` ```
## Production Deployment ## Production Deployment
@@ -91,10 +92,10 @@ pnpm wrangler whoami
Create a production D1 database: Create a production D1 database:
```sh ```sh
pnpm wrangler d1 create clearity-production pnpm wrangler d1 create clearity-production --binding DB
``` ```
Wrangler prints a `database_id`. Add it to `wrangler.jsonc`: Wrangler prints a `database_id`. Replace the placeholder in `wrangler.jsonc`:
```jsonc ```jsonc
{ {
@@ -102,7 +103,8 @@ Wrangler prints a `database_id`. Add it to `wrangler.jsonc`:
{ {
"binding": "DB", "binding": "DB",
"database_name": "clearity-production", "database_name": "clearity-production",
"database_id": "<database_id>" "database_id": "<database_id>",
"migrations_dir": "drizzle"
} }
] ]
} }
@@ -117,9 +119,7 @@ pnpm gen
Apply SQL migrations to the remote D1 database: Apply SQL migrations to the remote D1 database:
```sh ```sh
for file in drizzle/*.sql; do pnpm wrangler d1 migrations apply clearity-production --remote
pnpm wrangler d1 execute clearity-production --remote --file "$file"
done
``` ```
Set production secrets: Set production secrets:
@@ -129,12 +129,6 @@ pnpm wrangler secret put ORIGIN
pnpm wrangler secret put BETTER_AUTH_SECRET pnpm wrangler secret put BETTER_AUTH_SECRET
``` ```
This codebase currently reads the database through `DATABASE_URL`. If production is deployed against Cloudflare D1, wire the runtime database client to the `DB` D1 binding before deploying. If production is deployed against a hosted libSQL database instead, set `DATABASE_URL` as a Worker secret:
```sh
pnpm wrangler secret put DATABASE_URL
```
Build and deploy: Build and deploy:
```sh ```sh
-3
View File
@@ -1,11 +1,8 @@
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({ export default defineConfig({
schema: './src/lib/server/db/schema.ts', schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite', dialect: 'sqlite',
dbCredentials: { url: process.env.DATABASE_URL },
verbose: true, verbose: true,
strict: true strict: true
}); });
+5 -3
View File
@@ -13,9 +13,9 @@
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"gen": "wrangler types", "gen": "wrangler types",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate:local": "wrangler d1 migrations apply clearity-production --local",
"db:migrate:remote": "wrangler d1 migrations apply clearity-production --remote",
"auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes" "auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
}, },
"devDependencies": { "devDependencies": {
@@ -24,7 +24,6 @@
"@eslint/js": "latest", "@eslint/js": "latest",
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
"@internationalized/date": "^3.12.1", "@internationalized/date": "^3.12.1",
"@libsql/client": "^0.17.2",
"@lucide/svelte": "^1.16.0", "@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-cloudflare": "^7.2.8", "@sveltejs/adapter-cloudflare": "^7.2.8",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
@@ -65,5 +64,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"
} }
} }
+25 -8
View File
@@ -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
@@ -23,9 +27,6 @@ importers:
'@internationalized/date': '@internationalized/date':
specifier: ^3.12.1 specifier: ^3.12.1
version: 3.12.1 version: 3.12.1
'@libsql/client':
specifier: ^0.17.2
version: 0.17.3
'@lucide/svelte': '@lucide/svelte':
specifier: ^1.16.0 specifier: ^1.16.0
version: 1.16.0(svelte@5.55.9(@typescript-eslint/types@8.59.4)) version: 1.16.0(svelte@5.55.9(@typescript-eslint/types@8.59.4))
@@ -2213,6 +2214,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==}
@@ -4759,10 +4763,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
optional: true
'@libsql/core@0.17.3': '@libsql/core@0.17.3':
dependencies: dependencies:
js-base64: 3.7.8 js-base64: 3.7.8
optional: true
'@libsql/darwin-arm64@0.5.29': '@libsql/darwin-arm64@0.5.29':
optional: true optional: true
@@ -4777,6 +4783,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
optional: true
'@libsql/isomorphic-ws@0.1.5': '@libsql/isomorphic-ws@0.1.5':
dependencies: dependencies:
@@ -4785,6 +4792,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
optional: true
'@libsql/linux-arm-gnueabihf@0.5.29': '@libsql/linux-arm-gnueabihf@0.5.29':
optional: true optional: true
@@ -4823,7 +4831,8 @@ snapshots:
'@tybys/wasm-util': 0.10.2 '@tybys/wasm-util': 0.10.2
optional: true optional: true
'@neon-rs/load@0.0.4': {} '@neon-rs/load@0.0.4':
optional: true
'@noble/ciphers@2.2.0': {} '@noble/ciphers@2.2.0': {}
@@ -5072,6 +5081,7 @@ snapshots:
'@types/ws@8.18.1': '@types/ws@8.18.1':
dependencies: dependencies:
'@types/node': 24.12.4 '@types/node': 24.12.4
optional: true
'@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.4)': '@typeschema/class-validator@0.3.0(@types/json-schema@7.0.15)(class-validator@0.14.4)':
dependencies: dependencies:
@@ -5521,6 +5531,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
@@ -5557,7 +5569,8 @@ snapshots:
destr@2.0.5: {} destr@2.0.5: {}
detect-libc@2.0.2: {} detect-libc@2.0.2:
optional: true
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@@ -5976,7 +5989,8 @@ snapshots:
jose@6.2.3: {} jose@6.2.3: {}
js-base64@3.7.8: {} js-base64@3.7.8:
optional: true
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -6066,6 +6080,7 @@ snapshots:
'@libsql/linux-x64-gnu': 0.5.29 '@libsql/linux-x64-gnu': 0.5.29
'@libsql/linux-x64-musl': 0.5.29 '@libsql/linux-x64-musl': 0.5.29
'@libsql/win32-x64-msvc': 0.5.29 '@libsql/win32-x64-msvc': 0.5.29
optional: true
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@@ -6356,7 +6371,8 @@ snapshots:
prettier@3.8.3: {} prettier@3.8.3: {}
promise-limit@2.7.0: {} promise-limit@2.7.0:
optional: true
prompts@2.4.2: prompts@2.4.2:
dependencies: dependencies:
@@ -6878,7 +6894,8 @@ snapshots:
ws@8.20.1: {} ws@8.20.1: {}
ws@8.21.0: {} ws@8.21.0:
optional: true
wsl-utils@0.1.0: wsl-utils@0.1.0:
dependencies: dependencies:
+20 -10
View File
@@ -1,14 +1,24 @@
import { z } from 'zod'; import { z } from 'zod';
import { idSchema, optionalText, requiredText } from './shared.schema'; import { idSchema, optionalText, requiredText } from './shared.schema';
export const bookingCreateSchema = z.object({ export const bookingCreateSchema = z
clientId: requiredText('Client'), .object({
roomId: requiredText('Room'), clientId: requiredText('Client'),
serviceId: optionalText(80), roomId: requiredText('Room'),
startsAt: requiredText('Start time', 40), serviceId: optionalText(80),
endsAt: requiredText('End time', 40), startsAt: requiredText('Start time', 40),
status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'), endsAt: requiredText('End time', 40),
notes: optionalText(1000) status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'),
}); notes: optionalText(1000)
})
.superRefine((data, ctx) => {
if (data.startsAt && data.endsAt && data.endsAt <= data.startsAt) {
ctx.addIssue({
code: 'custom',
path: ['endsAt'],
message: 'End time must be after the start time.'
});
}
});
export const bookingEditSchema = bookingCreateSchema.extend(idSchema.shape); export const bookingEditSchema = bookingCreateSchema.safeExtend(idSchema.shape);
+14 -3
View File
@@ -3,13 +3,24 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins'; import { admin } from 'better-auth/plugins';
import { sveltekitCookies } from 'better-auth/svelte-kit'; import { sveltekitCookies } from 'better-auth/svelte-kit';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { building } from '$app/environment';
import { getRequestEvent } from '$app/server'; import { getRequestEvent } from '$app/server';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as schema from '$lib/server/db/schema';
const buildTimeOrigin = 'http://localhost:5173';
const buildTimeSecret = 'clearity-build-time-placeholder-secret';
const origin = env.ORIGIN ?? (building ? buildTimeOrigin : undefined);
const secret = env.BETTER_AUTH_SECRET ?? (building ? buildTimeSecret : undefined);
if (!origin) throw new Error('ORIGIN is not set');
if (!secret) throw new Error('BETTER_AUTH_SECRET is not set');
export const auth = betterAuth({ export const auth = betterAuth({
baseURL: env.ORIGIN, baseURL: origin,
secret: env.BETTER_AUTH_SECRET, secret,
database: drizzleAdapter(db, { provider: 'sqlite' }), database: drizzleAdapter(db, { provider: 'sqlite', schema }),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
disableSignUp: true disableSignUp: true
+50 -6
View File
@@ -1,10 +1,54 @@
import { drizzle } from 'drizzle-orm/libsql'; import { getRequestEvent } from '$app/server';
import { createClient } from '@libsql/client'; import { drizzle as drizzleD1, type DrizzleD1Database } from 'drizzle-orm/d1';
import * as schema from './schema'; import * as schema from './schema';
import { env } from '$env/dynamic/private';
if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); type Schema = typeof schema;
const client = createClient({ url: env.DATABASE_URL }); export type Database = DrizzleD1Database<Schema> & { $client: D1Database };
export const db = drizzle(client, { schema }); const d1Databases = new WeakMap<D1Database, Database>();
function getD1Binding() {
try {
return getRequestEvent().platform?.env.DB;
} catch {
return undefined;
}
}
function getD1Db(binding: D1Database) {
const cached = d1Databases.get(binding);
if (cached) return cached;
const database = drizzleD1(binding, { schema });
d1Databases.set(binding, database);
return database;
}
export function getDb(): Database {
const d1Binding = getD1Binding();
if (d1Binding) return getD1Db(d1Binding);
throw new Error('Cloudflare D1 DB binding is not available');
}
export const db = new Proxy({} as Database, {
get(_target, property) {
const database = getDb();
const value = Reflect.get(database, property);
return typeof value === 'function' ? value.bind(database) : value;
},
getOwnPropertyDescriptor(_target, property) {
return Reflect.getOwnPropertyDescriptor(getDb(), property);
},
getPrototypeOf() {
return Reflect.getPrototypeOf(getDb());
},
has(_target, property) {
return property in getDb();
},
ownKeys() {
return Reflect.ownKeys(getDb());
}
});
+140
View File
@@ -0,0 +1,140 @@
import { and, asc, eq, gt, gte, inArray, isNull, lt, lte, ne } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { bookings, contracts, contractRooms, rooms } from '$lib/server/db/schema';
const rentableContractRoomTypes = ['private_office', 'coworking_desk'] as const;
export type BookingRoomConflict = {
id: string;
roomName: string;
startsAt: string;
endsAt: string;
};
export type ContractRoomConflict = {
contractId: string;
roomId: string;
roomName: string;
startDate: string;
endDate: string;
};
export function bookingIntervalsOverlap(
startsAt: string,
endsAt: string,
existingStartsAt: string,
existingEndsAt: string
) {
return startsAt < existingEndsAt && endsAt > existingStartsAt;
}
export function contractPeriodsOverlap(
startDate: string,
endDate: string,
existingStartDate: string,
existingEndDate: string
) {
return startDate <= existingEndDate && endDate >= existingStartDate;
}
export async function findBookingRoomConflict({
organizationId,
roomId,
startsAt,
endsAt,
status,
excludingBookingId
}: {
organizationId: string;
roomId: string;
startsAt: string;
endsAt: string;
status: string;
excludingBookingId?: string;
}): Promise<BookingRoomConflict | null> {
if (status === 'cancelled') return null;
const baseConditions = [
eq(bookings.organizationId, organizationId),
eq(bookings.roomId, roomId),
isNull(bookings.archivedAt),
ne(bookings.status, 'cancelled'),
lt(bookings.startsAt, endsAt),
gt(bookings.endsAt, startsAt)
];
const where = excludingBookingId
? and(...baseConditions, ne(bookings.id, excludingBookingId))
: and(...baseConditions);
const [conflict] = await db
.select({
id: bookings.id,
roomName: rooms.name,
startsAt: bookings.startsAt,
endsAt: bookings.endsAt
})
.from(bookings)
.innerJoin(rooms, eq(bookings.roomId, rooms.id))
.where(where)
.orderBy(asc(bookings.startsAt))
.limit(1);
return conflict ?? null;
}
export async function findActiveContractRoomConflict({
organizationId,
roomIds,
startDate,
endDate,
excludingContractId
}: {
organizationId: string;
roomIds: string[];
startDate: string;
endDate: string;
excludingContractId?: string;
}): Promise<ContractRoomConflict | null> {
const uniqueRoomIds = [...new Set(roomIds)];
if (uniqueRoomIds.length === 0) return null;
const baseConditions = [
eq(contracts.organizationId, organizationId),
eq(contracts.status, 'active'),
isNull(contracts.archivedAt),
inArray(contractRooms.roomId, uniqueRoomIds),
eq(rooms.organizationId, organizationId),
inArray(rooms.type, rentableContractRoomTypes),
isNull(rooms.archivedAt),
lte(contracts.startDate, endDate),
gte(contracts.endDate, startDate)
];
const where = excludingContractId
? and(...baseConditions, ne(contracts.id, excludingContractId))
: and(...baseConditions);
const [conflict] = await db
.select({
contractId: contracts.id,
roomId: contractRooms.roomId,
roomName: rooms.name,
startDate: contracts.startDate,
endDate: contracts.endDate
})
.from(contractRooms)
.innerJoin(contracts, eq(contractRooms.contractId, contracts.id))
.innerJoin(rooms, eq(contractRooms.roomId, rooms.id))
.where(where)
.orderBy(asc(contracts.startDate), asc(rooms.name))
.limit(1);
return conflict ?? null;
}
export function bookingConflictMessage(conflict: BookingRoomConflict) {
return `${conflict.roomName} is already booked from ${conflict.startsAt} to ${conflict.endsAt}.`;
}
export function contractConflictMessage(conflict: ContractRoomConflict) {
return `${conflict.roomName} is already assigned to an active contract from ${conflict.startDate} to ${conflict.endDate}.`;
}
+351
View File
@@ -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.'
}
};
};
+45
View File
@@ -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>
+163
View File
@@ -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>
+57
View File
@@ -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>
@@ -1,9 +1,10 @@
import { and, asc, eq, isNull } from 'drizzle-orm'; import { and, asc, eq, isNull } from 'drizzle-orm';
import { message, superValidate } from 'sveltekit-superforms/server'; import { message, setError, superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters'; import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { bookings, clients, rooms, services } from '$lib/server/db/schema'; import { bookings, clients, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { bookingConflictMessage, findBookingRoomConflict } from '$lib/server/scheduling';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema'; import { bookingCreateSchema, bookingEditSchema } from '$lib/schemas/bookings.schema';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -92,6 +93,15 @@ export const actions: Actions = {
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const conflict = await findBookingRoomConflict({
organizationId: activeOrganizationId,
roomId: form.data.roomId,
startsAt: form.data.startsAt,
endsAt: form.data.endsAt,
status: form.data.status
});
if (conflict) return setError(form, 'roomId', bookingConflictMessage(conflict));
try { try {
await db.insert(bookings).values({ await db.insert(bookings).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -123,6 +133,16 @@ export const actions: Actions = {
if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 }); if (!form.valid) return message(form, 'Check the highlighted fields.', { status: 400 });
const conflict = await findBookingRoomConflict({
organizationId: activeOrganizationId,
roomId: form.data.roomId,
startsAt: form.data.startsAt,
endsAt: form.data.endsAt,
status: form.data.status,
excludingBookingId: form.data.id
});
if (conflict) return setError(form, 'roomId', bookingConflictMessage(conflict));
try { try {
await db await db
.update(bookings) .update(bookings)
@@ -140,7 +160,8 @@ export const actions: Actions = {
and( and(
eq(bookings.id, form.data.id), eq(bookings.id, form.data.id),
eq(bookings.clientId, params.id), eq(bookings.clientId, params.id),
eq(bookings.organizationId, activeOrganizationId) eq(bookings.organizationId, activeOrganizationId),
isNull(bookings.archivedAt)
) )
); );
} catch { } catch {
@@ -165,7 +186,8 @@ export const actions: Actions = {
and( and(
eq(bookings.id, form.data.id), eq(bookings.id, form.data.id),
eq(bookings.clientId, params.id), eq(bookings.clientId, params.id),
eq(bookings.organizationId, activeOrganizationId) eq(bookings.organizationId, activeOrganizationId),
isNull(bookings.archivedAt)
) )
); );
@@ -4,6 +4,7 @@ import { zod4 } from 'sveltekit-superforms/adapters';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema'; import { contracts, contractRooms, rooms, services } from '$lib/server/db/schema';
import { loadOrganizationContext } from '$lib/server/organizations'; import { loadOrganizationContext } from '$lib/server/organizations';
import { contractConflictMessage, findActiveContractRoomConflict } from '$lib/server/scheduling';
import { archiveSchema } from '$lib/schemas/shared.schema'; import { archiveSchema } from '$lib/schemas/shared.schema';
import { import {
contractCreateSchema, contractCreateSchema,
@@ -263,6 +264,34 @@ export const actions: Actions = {
return message(form, invalidSelection.message, { status: 400 }); return message(form, invalidSelection.message, { status: 400 });
} }
const [existingContract] = await db
.select({ status: contracts.status })
.from(contracts)
.where(
and(
eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId),
isNull(contracts.archivedAt)
)
)
.limit(1);
if (!existingContract) return message(form, 'Choose a valid contract.', { status: 400 });
if (existingContract.status === 'active') {
const conflict = await findActiveContractRoomConflict({
organizationId: activeOrganizationId,
roomIds: form.data.roomIds,
startDate: form.data.startDate,
endDate: form.data.endDate,
excludingContractId: form.data.id
});
if (conflict) {
return message(form, contractConflictMessage(conflict), { status: 400 });
}
}
try { try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx await tx
@@ -280,7 +309,8 @@ export const actions: Actions = {
and( and(
eq(contracts.id, form.data.id), eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id), eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId) eq(contracts.organizationId, activeOrganizationId),
isNull(contracts.archivedAt)
) )
); );
@@ -311,7 +341,11 @@ export const actions: Actions = {
if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 }); if (!form.valid) return message(form, 'Choose a valid contract action.', { status: 400 });
const [contract] = await db const [contract] = await db
.select({ status: contracts.status }) .select({
status: contracts.status,
startDate: contracts.startDate,
endDate: contracts.endDate
})
.from(contracts) .from(contracts)
.where( .where(
and( and(
@@ -332,6 +366,23 @@ export const actions: Actions = {
return message(form, 'That contract status change is not allowed.', { status: 400 }); return message(form, 'That contract status change is not allowed.', { status: 400 });
} }
if (form.data.targetStatus === 'active') {
const roomLinks = await db
.select({ roomId: contractRooms.roomId })
.from(contractRooms)
.where(eq(contractRooms.contractId, form.data.id));
const conflict = await findActiveContractRoomConflict({
organizationId: activeOrganizationId,
roomIds: roomLinks.map((link) => link.roomId),
startDate: contract.startDate,
endDate: contract.endDate,
excludingContractId: form.data.id
});
if (conflict) {
return message(form, contractConflictMessage(conflict), { status: 400 });
}
}
await db await db
.update(contracts) .update(contracts)
.set({ status: form.data.targetStatus, updatedAt: new Date() }) .set({ status: form.data.targetStatus, updatedAt: new Date() })
@@ -339,7 +390,8 @@ export const actions: Actions = {
and( and(
eq(contracts.id, form.data.id), eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id), eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId) eq(contracts.organizationId, activeOrganizationId),
isNull(contracts.archivedAt)
) )
); );
@@ -361,7 +413,8 @@ export const actions: Actions = {
and( and(
eq(contracts.id, form.data.id), eq(contracts.id, form.data.id),
eq(contracts.clientId, params.id), eq(contracts.clientId, params.id),
eq(contracts.organizationId, activeOrganizationId) eq(contracts.organizationId, activeOrganizationId),
isNull(contracts.archivedAt)
) )
); );
+77
View File
@@ -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>
+67
View File
@@ -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>
+2 -2
View File
@@ -1,9 +1,9 @@
/* eslint-disable */ /* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: 61c64c9cd1c2465ff3a92bc5ac82d57d) // Generated by Wrangler by running `wrangler types` (hash: 36a638c2dc42c34e7d5ddc208fd03581)
// Runtime types generated with workerd@1.20260521.1 2026-05-23 nodejs_als // Runtime types generated with workerd@1.20260521.1 2026-05-23 nodejs_als
interface __BaseEnv_Env { interface __BaseEnv_Env {
DB: D1Database;
ASSETS: Fetcher; ASSETS: Fetcher;
DATABASE_URL: string;
ORIGIN: string; ORIGIN: string;
BETTER_AUTH_SECRET: string; BETTER_AUTH_SECRET: string;
} }
+11
View File
@@ -8,6 +8,17 @@
"binding": "ASSETS", "binding": "ASSETS",
"directory": ".svelte-kit/cloudflare" "directory": ".svelte-kit/cloudflare"
}, },
"d1_databases": [
{
"binding": "DB",
"database_name": "clearity-production",
"database_id": "<replace-with-d1-database-id>",
"migrations_dir": "drizzle"
}
],
"secrets": {
"required": ["ORIGIN", "BETTER_AUTH_SECRET"]
},
"workers_dev": true, "workers_dev": true,
"preview_urls": true "preview_urls": true
} }