Compare commits
16 Commits
8450712afa
...
e1e2e94bf2
| Author | SHA1 | Date | |
|---|---|---|---|
| e1e2e94bf2 | |||
| 3373762d97 | |||
| b71bb98389 | |||
| 4ee0a8f4ec | |||
| de08587b77 | |||
| 2b0bdda261 | |||
| 44165e39b1 | |||
| 3642ecfba6 | |||
| fe083ffaea | |||
| 29ff3265e8 | |||
| b1d0267bf1 | |||
| e42479a3c0 | |||
| 77cbb1d8d7 | |||
| 9f8675fb30 | |||
| 17f36e6378 | |||
| 44b34c850c |
@@ -1,6 +1,3 @@
|
|||||||
# Drizzle
|
|
||||||
DATABASE_URL=file:local.db
|
|
||||||
|
|
||||||
ORIGIN=""
|
ORIGIN=""
|
||||||
|
|
||||||
# Better Auth
|
# Better Auth
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `invoices` ADD `status` text DEFAULT 'draft' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `invoices` ADD `sent_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `invoices` ADD `paid_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `invoices` ADD `voided_at` integer;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
|||||||
"when": 1782338642144,
|
"when": 1782338642144,
|
||||||
"tag": "0015_fuzzy_vampiro",
|
"tag": "0015_fuzzy_vampiro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1782383895120,
|
||||||
|
"tag": "0016_outstanding_talkback",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+5
-3
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+25
-8
@@ -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:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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
|
||||||
|
.object({
|
||||||
clientId: requiredText('Client'),
|
clientId: requiredText('Client'),
|
||||||
roomId: requiredText('Room'),
|
roomId: requiredText('Room'),
|
||||||
serviceId: optionalText(80),
|
serviceId: optionalText(80),
|
||||||
@@ -9,6 +10,15 @@ export const bookingCreateSchema = z.object({
|
|||||||
endsAt: requiredText('End time', 40),
|
endsAt: requiredText('End time', 40),
|
||||||
status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'),
|
status: z.enum(['booked', 'confirmed', 'completed', 'cancelled']).default('booked'),
|
||||||
notes: optionalText(1000)
|
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);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { moneyGbp, optionalText, requiredText } from './shared.schema';
|
import { moneyGbp, optionalText, requiredText } from './shared.schema';
|
||||||
|
|
||||||
|
export const invoiceStatusValues = ['draft', 'sent', 'paid', 'void'] as const;
|
||||||
|
export const invoiceStatusSchema = z.enum(invoiceStatusValues);
|
||||||
|
|
||||||
export const invoiceCreateSchema = z.object({
|
export const invoiceCreateSchema = z.object({
|
||||||
clientId: requiredText('Client'),
|
clientId: requiredText('Client'),
|
||||||
invoiceNumber: requiredText('Invoice number', 80),
|
invoiceNumber: requiredText('Invoice number', 80),
|
||||||
|
status: invoiceStatusSchema.default('draft'),
|
||||||
issueDate: requiredText('Issue date', 40),
|
issueDate: requiredText('Issue date', 40),
|
||||||
dueDate: requiredText('Due date', 40),
|
dueDate: requiredText('Due date', 40),
|
||||||
subtotalGbp: moneyGbp,
|
subtotalGbp: moneyGbp,
|
||||||
|
|||||||
+14
-3
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { index, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
import { index, integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
import { clients } from './clients.schema';
|
import { clients } from './clients.schema';
|
||||||
import { organizations } from './organizations.schema';
|
import { organizations } from './organizations.schema';
|
||||||
import { timestamps } from './shared.schema';
|
import { timestamps } from './shared.schema';
|
||||||
|
|
||||||
|
export const invoiceStatusValues = ['draft', 'sent', 'paid', 'void'] as const;
|
||||||
|
|
||||||
export const invoices = sqliteTable(
|
export const invoices = sqliteTable(
|
||||||
'invoices',
|
'invoices',
|
||||||
{
|
{
|
||||||
@@ -16,6 +18,10 @@ export const invoices = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||||
invoiceNumber: text('invoice_number').notNull().unique(),
|
invoiceNumber: text('invoice_number').notNull().unique(),
|
||||||
|
status: text('status', { enum: invoiceStatusValues }).notNull().default('draft'),
|
||||||
|
sentAt: integer('sent_at', { mode: 'timestamp_ms' }),
|
||||||
|
paidAt: integer('paid_at', { mode: 'timestamp_ms' }),
|
||||||
|
voidedAt: integer('voided_at', { mode: 'timestamp_ms' }),
|
||||||
issueDate: text('issue_date').notNull(),
|
issueDate: text('issue_date').notNull(),
|
||||||
dueDate: text('due_date').notNull(),
|
dueDate: text('due_date').notNull(),
|
||||||
subtotalGbp: real('subtotal_gbp').notNull().default(0),
|
subtotalGbp: real('subtotal_gbp').notNull().default(0),
|
||||||
|
|||||||
@@ -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}.`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
formatValue,
|
formatValue,
|
||||||
@@ -8,12 +12,19 @@
|
|||||||
formatMoney,
|
formatMoney,
|
||||||
relationLabel
|
relationLabel
|
||||||
}: {
|
}: {
|
||||||
data: any;
|
data: Pick<PageData, 'records'>;
|
||||||
formatValue: any;
|
formatValue: (value: unknown) => string;
|
||||||
formatDate: any;
|
formatDate: (value: unknown) => string;
|
||||||
formatMoney: any;
|
formatMoney: (value: unknown) => string;
|
||||||
relationLabel: any;
|
relationLabel: (value: unknown, optionsKey: 'clients') => string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
function statusVariant(status: string): BadgeVariant {
|
||||||
|
if (status === 'paid') return 'default';
|
||||||
|
if (status === 'void') return 'destructive';
|
||||||
|
if (status === 'draft') return 'outline';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.records.length > 0}
|
{#if data.records.length > 0}
|
||||||
@@ -23,6 +34,7 @@
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Invoice #</Table.Head>
|
<Table.Head>Invoice #</Table.Head>
|
||||||
<Table.Head>Client</Table.Head>
|
<Table.Head>Client</Table.Head>
|
||||||
|
<Table.Head>Status</Table.Head>
|
||||||
<Table.Head>Issued</Table.Head>
|
<Table.Head>Issued</Table.Head>
|
||||||
<Table.Head>Due</Table.Head>
|
<Table.Head>Due</Table.Head>
|
||||||
<Table.Head>Total</Table.Head>
|
<Table.Head>Total</Table.Head>
|
||||||
@@ -31,8 +43,19 @@
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each data.records as record (record.id)}
|
{#each data.records as record (record.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
<Table.Cell class="font-medium"
|
||||||
|
><a
|
||||||
|
class="inline-flex items-center gap-2 underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href={resolve('/dashboard/invoices/[id]', { id: record.id })}
|
||||||
|
>{formatValue(record.invoiceNumber)}<ExternalLinkIcon class="size-3.5" /></a
|
||||||
|
></Table.Cell
|
||||||
|
>
|
||||||
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
<Table.Cell class="">{relationLabel(record.clientId, 'clients')}</Table.Cell>
|
||||||
|
<Table.Cell
|
||||||
|
><Badge variant={statusVariant(record.status)} class="capitalize"
|
||||||
|
>{formatValue(record.status)}</Badge
|
||||||
|
></Table.Cell
|
||||||
|
>
|
||||||
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
||||||
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
||||||
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
data,
|
data,
|
||||||
formatValue,
|
formatValue,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney
|
formatMoney
|
||||||
}: {
|
}: {
|
||||||
data: any;
|
data: Pick<PageData, 'records'>;
|
||||||
formatValue: any;
|
formatValue: (value: unknown) => string;
|
||||||
formatDate: any;
|
formatDate: (value: unknown) => string;
|
||||||
formatMoney: any;
|
formatMoney: (value: unknown) => string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
function statusVariant(status: string): BadgeVariant {
|
||||||
|
if (status === 'paid') return 'default';
|
||||||
|
if (status === 'void') return 'destructive';
|
||||||
|
if (status === 'draft') return 'outline';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.records.length > 0}
|
{#if data.records.length > 0}
|
||||||
@@ -20,6 +31,7 @@
|
|||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Invoice #</Table.Head>
|
<Table.Head>Invoice #</Table.Head>
|
||||||
|
<Table.Head>Status</Table.Head>
|
||||||
<Table.Head>Issued</Table.Head>
|
<Table.Head>Issued</Table.Head>
|
||||||
<Table.Head>Due</Table.Head>
|
<Table.Head>Due</Table.Head>
|
||||||
<Table.Head>Total</Table.Head>
|
<Table.Head>Total</Table.Head>
|
||||||
@@ -28,7 +40,18 @@
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each data.records as record (record.id)}
|
{#each data.records as record (record.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-medium">{formatValue(record.invoiceNumber)}</Table.Cell>
|
<Table.Cell class="font-medium"
|
||||||
|
><a
|
||||||
|
class="inline-flex items-center gap-2 underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href={resolve('/dashboard/invoices/[id]', { id: record.id })}
|
||||||
|
>{formatValue(record.invoiceNumber)}<ExternalLinkIcon class="size-3.5" /></a
|
||||||
|
></Table.Cell
|
||||||
|
>
|
||||||
|
<Table.Cell
|
||||||
|
><Badge variant={statusVariant(record.status)} class="capitalize"
|
||||||
|
>{formatValue(record.status)}</Badge
|
||||||
|
></Table.Cell
|
||||||
|
>
|
||||||
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.issueDate)}</Table.Cell>
|
||||||
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
<Table.Cell class="">{formatDate(record.dueDate)}</Table.Cell>
|
||||||
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
<Table.Cell class="">{formatMoney(record.totalGbp)}</Table.Cell>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { error, fail } from '@sveltejs/kit';
|
||||||
|
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { clients, invoiceLines, invoices } from '$lib/server/db/schema';
|
||||||
|
import { loadOrganizationContext } from '$lib/server/organizations';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'void';
|
||||||
|
type Transition = 'send' | 'markPaid' | 'void';
|
||||||
|
|
||||||
|
const allowedTransitions: Record<Transition, InvoiceStatus[]> = {
|
||||||
|
send: ['draft'],
|
||||||
|
markPaid: ['sent'],
|
||||||
|
void: ['draft', 'sent']
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionStatus: Record<Transition, InvoiceStatus> = {
|
||||||
|
send: 'sent',
|
||||||
|
markPaid: 'paid',
|
||||||
|
void: 'void'
|
||||||
|
};
|
||||||
|
|
||||||
|
const transitionMessages: Record<Transition, string> = {
|
||||||
|
send: 'Only draft invoices can be marked as sent.',
|
||||||
|
markPaid: 'Only sent invoices can be marked as paid.',
|
||||||
|
void: 'Only draft or sent invoices can be voided.'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadInvoice(organizationId: string, invoiceId: string) {
|
||||||
|
const [invoice] = await db
|
||||||
|
.select({
|
||||||
|
id: invoices.id,
|
||||||
|
organizationId: invoices.organizationId,
|
||||||
|
clientId: invoices.clientId,
|
||||||
|
clientName: clients.name,
|
||||||
|
invoiceNumber: invoices.invoiceNumber,
|
||||||
|
status: invoices.status,
|
||||||
|
sentAt: invoices.sentAt,
|
||||||
|
paidAt: invoices.paidAt,
|
||||||
|
voidedAt: invoices.voidedAt,
|
||||||
|
issueDate: invoices.issueDate,
|
||||||
|
dueDate: invoices.dueDate,
|
||||||
|
subtotalGbp: invoices.subtotalGbp,
|
||||||
|
taxGbp: invoices.taxGbp,
|
||||||
|
totalGbp: invoices.totalGbp,
|
||||||
|
notes: invoices.notes,
|
||||||
|
createdAt: invoices.createdAt,
|
||||||
|
updatedAt: invoices.updatedAt
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.innerJoin(clients, eq(invoices.clientId, clients.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invoices.id, invoiceId),
|
||||||
|
eq(invoices.organizationId, organizationId),
|
||||||
|
isNull(invoices.archivedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!invoice) error(404, 'Invoice not found.');
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transitionInvoice(
|
||||||
|
organizationId: string,
|
||||||
|
invoiceId: string,
|
||||||
|
transition: Transition
|
||||||
|
) {
|
||||||
|
const invoice = await loadInvoice(organizationId, invoiceId);
|
||||||
|
|
||||||
|
if (!allowedTransitions[transition].includes(invoice.status)) {
|
||||||
|
return fail(400, { message: transitionMessages[transition] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nextStatus = transitionStatus[transition];
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
status: nextStatus,
|
||||||
|
sentAt: transition === 'send' ? now : invoice.sentAt,
|
||||||
|
paidAt: transition === 'markPaid' ? now : invoice.paidAt,
|
||||||
|
voidedAt: transition === 'void' ? now : invoice.voidedAt,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.where(and(eq(invoices.id, invoiceId), eq(invoices.organizationId, organizationId)));
|
||||||
|
|
||||||
|
return { message: `Invoice ${invoice.invoiceNumber} marked as ${nextStatus}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||||
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||||
|
const layoutData = await parent();
|
||||||
|
const invoice = await loadInvoice(activeOrganizationId, params.id);
|
||||||
|
const lines = await db
|
||||||
|
.select()
|
||||||
|
.from(invoiceLines)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invoiceLines.invoiceId, invoice.id),
|
||||||
|
eq(invoiceLines.organizationId, activeOrganizationId),
|
||||||
|
isNull(invoiceLines.archivedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(invoiceLines.periodStart), asc(invoiceLines.description));
|
||||||
|
|
||||||
|
return {
|
||||||
|
invoice,
|
||||||
|
lines,
|
||||||
|
organization: layoutData.activeOrganization
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
send: async ({ locals, params }) => {
|
||||||
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||||
|
return transitionInvoice(activeOrganizationId, params.id, 'send');
|
||||||
|
},
|
||||||
|
markPaid: async ({ locals, params }) => {
|
||||||
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||||
|
return transitionInvoice(activeOrganizationId, params.id, 'markPaid');
|
||||||
|
},
|
||||||
|
void: async ({ locals, params }) => {
|
||||||
|
const { activeOrganizationId } = await loadOrganizationContext(locals);
|
||||||
|
return transitionInvoice(activeOrganizationId, params.id, 'void');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import BanIcon from '@lucide/svelte/icons/ban';
|
||||||
|
import CheckCircle2Icon from '@lucide/svelte/icons/check-circle-2';
|
||||||
|
import PrinterIcon from '@lucide/svelte/icons/printer';
|
||||||
|
import SendIcon from '@lucide/svelte/icons/send';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { Badge, type BadgeVariant } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const canSend = $derived(data.invoice.status === 'draft');
|
||||||
|
const canMarkPaid = $derived(data.invoice.status === 'sent');
|
||||||
|
const canVoid = $derived(data.invoice.status === 'draft' || data.invoice.status === 'sent');
|
||||||
|
|
||||||
|
const lifecycleItems = $derived(
|
||||||
|
[
|
||||||
|
{ label: 'Sent', value: data.invoice.sentAt },
|
||||||
|
{ label: 'Paid', value: data.invoice.paidAt },
|
||||||
|
{ label: 'Voided', value: data.invoice.voidedAt }
|
||||||
|
].filter((item) => item.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatValue(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') return 'Not set';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(value: string) {
|
||||||
|
return value.replace('-', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status: string): BadgeVariant {
|
||||||
|
if (status === 'paid') return 'default';
|
||||||
|
if (status === 'void') return 'destructive';
|
||||||
|
if (status === 'draft') return 'outline';
|
||||||
|
return 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value: unknown) {
|
||||||
|
const amount = typeof value === 'number' ? value : Number(value ?? 0);
|
||||||
|
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: unknown, withTime = false) {
|
||||||
|
if (!value) return 'Not set';
|
||||||
|
const date = value instanceof Date ? value : new Date(String(value));
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value);
|
||||||
|
return new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printInvoice() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.invoice.invoiceNumber} | Clearity</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="no-print flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<Button variant="ghost" href="/dashboard/billing" class="w-fit">
|
||||||
|
<ArrowLeftIcon data-icon="inline-start" />Billing
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={printInvoice}>
|
||||||
|
<PrinterIcon data-icon="inline-start" />Print
|
||||||
|
</Button>
|
||||||
|
<form method="POST" action="?/send">
|
||||||
|
<Button type="submit" disabled={!canSend}>
|
||||||
|
<SendIcon data-icon="inline-start" />Mark sent
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/markPaid">
|
||||||
|
<Button type="submit" variant="secondary" disabled={!canMarkPaid}>
|
||||||
|
<CheckCircle2Icon data-icon="inline-start" />Mark paid
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="?/void">
|
||||||
|
<Button type="submit" variant="destructive" disabled={!canVoid}>
|
||||||
|
<BanIcon data-icon="inline-start" />Void
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.message}
|
||||||
|
<div class="no-print rounded-lg border border-border bg-muted/40 px-4 py-3 text-sm">
|
||||||
|
{form.message}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="grid gap-6 lg:grid-cols-[1fr_18rem]">
|
||||||
|
<div class="space-y-5">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-muted-foreground">Invoice</p>
|
||||||
|
<h1 class="text-2xl font-semibold tracking-tight">{data.invoice.invoiceNumber}</h1>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusVariant(data.invoice.status)} class="capitalize">
|
||||||
|
{formatStatus(data.invoice.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Issued</p>
|
||||||
|
<p class="text-sm">{formatDate(data.invoice.issueDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Due</p>
|
||||||
|
<p class="text-sm">{formatDate(data.invoice.dueDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Updated</p>
|
||||||
|
<p class="text-sm">{formatDate(data.invoice.updatedAt, true)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if lifecycleItems.length > 0}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
{#each lifecycleItems as item (item.label)}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">{item.label}</p>
|
||||||
|
<p class="text-sm">{formatDate(item.value, true)}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 rounded-lg border border-border p-4">
|
||||||
|
<div class="flex justify-between gap-4 text-sm">
|
||||||
|
<span class="text-muted-foreground">Subtotal</span>
|
||||||
|
<span>{formatMoney(data.invoice.subtotalGbp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4 text-sm">
|
||||||
|
<span class="text-muted-foreground">Tax</span>
|
||||||
|
<span>{formatMoney(data.invoice.taxGbp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4 border-t border-border pt-3 text-base font-semibold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{formatMoney(data.invoice.totalGbp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-border p-4">
|
||||||
|
<h2 class="text-sm font-semibold">Client</h2>
|
||||||
|
<a
|
||||||
|
class="mt-2 inline-flex text-sm underline-offset-4 hover:text-primary hover:underline"
|
||||||
|
href={resolve('/dashboard/clients/[id]/invoices', { id: data.invoice.clientId })}
|
||||||
|
>
|
||||||
|
{data.invoice.clientName}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border p-4">
|
||||||
|
<h2 class="text-sm font-semibold">Payment</h2>
|
||||||
|
<div class="mt-2 grid gap-1 text-sm">
|
||||||
|
<p>{data.organization.name}</p>
|
||||||
|
<p>{formatValue(data.organization.bankName)}</p>
|
||||||
|
<p>{formatValue(data.organization.bankAccountName)}</p>
|
||||||
|
<p>
|
||||||
|
{formatValue(data.organization.bankSortCode)} / {formatValue(
|
||||||
|
data.organization.bankAccountNumber
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{#if data.organization.bankIban}
|
||||||
|
<p>IBAN {data.organization.bankIban}</p>
|
||||||
|
{/if}
|
||||||
|
{#if data.organization.bankSwift}
|
||||||
|
<p>SWIFT {data.organization.bankSwift}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overflow-hidden rounded-lg border border-border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Description</Table.Head>
|
||||||
|
<Table.Head>Period</Table.Head>
|
||||||
|
<Table.Head class="text-right">Qty</Table.Head>
|
||||||
|
<Table.Head class="text-right">Unit</Table.Head>
|
||||||
|
<Table.Head class="text-right">Total</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each data.lines as line (line.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-medium">{line.description}</Table.Cell>
|
||||||
|
<Table.Cell>{formatDate(line.periodStart)} - {formatDate(line.periodEnd)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{line.quantity}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(line.unitPriceGbp)}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">{formatMoney(line.totalGbp)}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if data.invoice.notes}
|
||||||
|
<section class="rounded-lg border border-border p-4">
|
||||||
|
<h2 class="text-sm font-semibold">Notes</h2>
|
||||||
|
<p class="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{data.invoice.notes}</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-slot='sidebar']),
|
||||||
|
:global([data-slot='breadcrumb']) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(body) {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
Vendored
+2
-2
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user