Compare commits
30
Commits
backup-prod14
...
V1.71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9087650e4c | ||
|
|
88e7325099 | ||
|
|
29b3d5d4e5 | ||
|
|
b3584e6c4d | ||
|
|
e203fb6c74 | ||
|
|
b51c1360e2 | ||
|
|
ef32d5a917 | ||
|
|
515707c9e4 | ||
|
|
a4945a87cf | ||
|
|
0b7f0a474e | ||
|
|
a70333f5e4 | ||
|
|
9cc330030b | ||
|
|
59acc32a46 | ||
|
|
183a2c99ed | ||
|
|
745af4184f | ||
|
|
279cd19308 | ||
|
|
02a12ee9e0 | ||
|
|
de791fd17d | ||
|
|
ed1d3a8c12 | ||
|
|
87bf9a6151 | ||
|
|
3f7b2d51db | ||
|
|
5970d707af | ||
|
|
e296e80e48 | ||
|
|
a0161a67d2 | ||
|
|
d06f80e572 | ||
|
|
be4dd5b47f | ||
|
|
e0f0b2f1e9 | ||
|
|
390e1dc0ea | ||
|
|
fa8686276d | ||
|
|
498d7d8a7d |
@@ -86,8 +86,23 @@ npm run dev:photos # go run ./cmd/photo-api
|
|||||||
npm run build:photos # go build -o bin/photo-api
|
npm run build:photos # go build -o bin/photo-api
|
||||||
npm run migrate:photos # apply photos_* migrations
|
npm run migrate:photos # apply photos_* migrations
|
||||||
npm run test:photos # go test ./...
|
npm run test:photos # go test ./...
|
||||||
|
|
||||||
|
# Move the photo library between storage backends (both must be configured
|
||||||
|
# in photo-api/.env; the source is left untouched, reruns skip what is there)
|
||||||
|
npm run sync:photos:to-s3 # local disk -> S3
|
||||||
|
npm run sync:photos:to-local # S3 -> local disk
|
||||||
|
npm run sync:photos -- to-s3 --dry-run # flags: --dry-run --overwrite
|
||||||
|
# --concurrency=N --gallery=<id>
|
||||||
|
|
||||||
|
# Uploads are deduplicated per gallery by content hash. Photos uploaded before
|
||||||
|
# that existed need hashing once (idempotent, deletes nothing):
|
||||||
|
npm run backfill:photos:checksums
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After a sync, set `STORAGE_BACKEND=s3` (or `local`) in `photo-api/.env` and
|
||||||
|
restart the service to serve from the new backend. See
|
||||||
|
[`photo-api/README.md`](photo-api/README.md#move-the-library-between-backends).
|
||||||
|
|
||||||
You can also run per workspace:
|
You can also run per workspace:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -117,6 +132,7 @@ Key settings (see `photo-api/.env.example`):
|
|||||||
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
|
- **DB**: `DB_TYPE` and `DATABASE_URL` — point at the **same** database as the backend
|
||||||
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
|
- **Auth**: none needed for user auth — the service validates Better Auth session cookies against the shared database. `PHOTO_VIEW_SECRET` signs gallery image view tokens (falls back to `JWT_SECRET` during migration).
|
||||||
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
|
- **Storage**: `STORAGE_PATH` (local disk) or `S3_ENDPOINT` + `S3_BUCKET` (S3/Garage/MinIO); S3 downloads use short-lived presigned URLs
|
||||||
|
- **Storage switch**: `STORAGE_BACKEND=auto|local|s3` (`auto` = S3 when it is configured). Keep both sides configured and flip this one line to move between them; `npm run sync:photos:to-s3` / `:to-local` copies the existing library first
|
||||||
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
|
- **Uploads/worker**: `MAX_UPLOAD_MB`, `WORKER_CONCURRENCY` (a worker generates thumb/preview JPEG variants with EXIF stripped)
|
||||||
|
|
||||||
### Frontend (`frontend/.env`)
|
### Frontend (`frontend/.env`)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+381
-5
@@ -1,7 +1,8 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { db, dbAll, events } from './index.js';
|
import { db, dbAll, dbGet, events, users, expenseCategories } from './index.js';
|
||||||
import { sql, eq } from 'drizzle-orm';
|
import { sql, eq, ne } from 'drizzle-orm';
|
||||||
import { uniqueSlug } from '../lib/slugify.js';
|
import { uniqueSlug } from '../lib/slugify.js';
|
||||||
|
import { generateId, getNow } from '../lib/utils.js';
|
||||||
|
|
||||||
const dbType = process.env.DB_TYPE || 'sqlite';
|
const dbType = process.env.DB_TYPE || 'sqlite';
|
||||||
console.log(`Database type: ${dbType}`);
|
console.log(`Database type: ${dbType}`);
|
||||||
@@ -94,6 +95,8 @@ async function migrate() {
|
|||||||
banner_url TEXT,
|
banner_url TEXT,
|
||||||
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
external_booking_url TEXT,
|
external_booking_url TEXT,
|
||||||
|
presale_closure_enabled INTEGER,
|
||||||
|
presale_close_minutes_before INTEGER,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
@@ -107,6 +110,19 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN external_booking_url TEXT`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN walk_in_price REAL`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Add short description columns to events
|
// Add short description columns to events
|
||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
await (db as any).run(sql`ALTER TABLE events ADD COLUMN short_description TEXT`);
|
||||||
@@ -133,17 +149,25 @@ async function migrate() {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
|
-- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone
|
||||||
|
-- shape only survives in databases created before the split into
|
||||||
|
-- first/last name, where the ALTERs below relaxed it; a fresh database
|
||||||
|
-- must not recreate constraints the app no longer satisfies (door
|
||||||
|
-- walk-ins have neither an email nor a phone).
|
||||||
CREATE TABLE IF NOT EXISTS tickets (
|
CREATE TABLE IF NOT EXISTS tickets (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
event_id TEXT NOT NULL REFERENCES events(id),
|
event_id TEXT NOT NULL REFERENCES events(id),
|
||||||
attendee_name TEXT NOT NULL,
|
attendee_first_name TEXT NOT NULL,
|
||||||
attendee_email TEXT NOT NULL,
|
attendee_last_name TEXT,
|
||||||
attendee_phone TEXT NOT NULL,
|
attendee_email TEXT,
|
||||||
|
attendee_phone TEXT,
|
||||||
|
attendee_ruc TEXT,
|
||||||
preferred_language TEXT,
|
preferred_language TEXT,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
checkin_at TEXT,
|
checkin_at TEXT,
|
||||||
qr_code TEXT,
|
qr_code TEXT,
|
||||||
|
admin_note TEXT,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
@@ -259,6 +283,40 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
// Door check-in screen: split pre-sale vs door revenue and record the tender
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Idempotency records for door check-in actions (retries / double taps)
|
||||||
|
await (db as any).run(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
undo_state TEXT,
|
||||||
|
undone_at TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: tickets.booking_source (online | walk_in | admin). On first run,
|
||||||
|
// backfill walk-ins created on the door screen: each one left an idempotency
|
||||||
|
// record whose undo_state is {kind: 'created', ticketId}. Nothing else can be
|
||||||
|
// identified reliably, so all other existing tickets stay 'online'.
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_source TEXT NOT NULL DEFAULT 'online'`);
|
||||||
|
await (db as any).run(sql`
|
||||||
|
UPDATE tickets SET booking_source = 'walk_in'
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT json_extract(undo_state, '$.ticketId') FROM idempotency_keys
|
||||||
|
WHERE scope = 'door-checkin' AND json_extract(undo_state, '$.kind') = 'created'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
@@ -355,6 +413,14 @@ async function migrate() {
|
|||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POS card terminal at the door: on globally by default, NULL override = inherit
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -483,6 +549,8 @@ async function migrate() {
|
|||||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
maintenance_message TEXT,
|
maintenance_message TEXT,
|
||||||
maintenance_message_es TEXT,
|
maintenance_message_es TEXT,
|
||||||
|
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
updated_by TEXT REFERENCES users(id)
|
updated_by TEXT REFERENCES users(id)
|
||||||
)
|
)
|
||||||
@@ -493,6 +561,14 @@ async function migrate() {
|
|||||||
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
|
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id TEXT REFERENCES events(id)`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Pre-sale closure site-wide defaults
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).run(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Legal pages table for admin-editable legal content
|
// Legal pages table for admin-editable legal content
|
||||||
await (db as any).run(sql`
|
await (db as any).run(sql`
|
||||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||||
@@ -703,6 +779,8 @@ async function migrate() {
|
|||||||
banner_url VARCHAR(500),
|
banner_url VARCHAR(500),
|
||||||
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
external_booking_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
external_booking_url VARCHAR(500),
|
external_booking_url VARCHAR(500),
|
||||||
|
presale_closure_enabled INTEGER,
|
||||||
|
presale_close_minutes_before INTEGER,
|
||||||
created_at TIMESTAMP NOT NULL,
|
created_at TIMESTAMP NOT NULL,
|
||||||
updated_at TIMESTAMP NOT NULL
|
updated_at TIMESTAMP NOT NULL
|
||||||
)
|
)
|
||||||
@@ -716,6 +794,19 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN external_booking_url VARCHAR(500)`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Pre-sale closure per-event overrides (NULL = inherit site_settings default)
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_closure_enabled INTEGER`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN presale_close_minutes_before INTEGER`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Walk-in (door) price. NULL = fall back to price; 0 = free walk-in.
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN walk_in_price DECIMAL(10, 2)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Add short description columns to events
|
// Add short description columns to events
|
||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description VARCHAR(300)`);
|
||||||
@@ -836,6 +927,39 @@ async function migrate() {
|
|||||||
try {
|
try {
|
||||||
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
// Door check-in screen: split pre-sale vs door revenue and record the tender
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Idempotency records for door check-in actions (retries / double taps)
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||||
|
key VARCHAR(128) PRIMARY KEY,
|
||||||
|
scope VARCHAR(64) NOT NULL,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
undo_state TEXT,
|
||||||
|
undone_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: tickets.booking_source (online | walk_in | admin), with the
|
||||||
|
// same one-time walk-in backfill as the sqlite branch.
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_source VARCHAR(20) NOT NULL DEFAULT 'online'`);
|
||||||
|
await (db as any).execute(sql`
|
||||||
|
UPDATE tickets SET booking_source = 'walk_in'
|
||||||
|
WHERE id::text IN (
|
||||||
|
SELECT undo_state::json->>'ticketId' FROM idempotency_keys
|
||||||
|
WHERE scope = 'door-checkin' AND undo_state IS NOT NULL
|
||||||
|
AND undo_state::json->>'kind' = 'created'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Invoices table
|
// Invoices table
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
@@ -930,6 +1054,14 @@ async function migrate() {
|
|||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POS card terminal at the door: on globally by default, NULL override = inherit
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN pos_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE event_payment_overrides ADD COLUMN pos_enabled INTEGER`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id UUID PRIMARY KEY,
|
id UUID PRIMARY KEY,
|
||||||
@@ -1058,6 +1190,8 @@ async function migrate() {
|
|||||||
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
maintenance_mode INTEGER NOT NULL DEFAULT 0,
|
||||||
maintenance_message TEXT,
|
maintenance_message TEXT,
|
||||||
maintenance_message_es TEXT,
|
maintenance_message_es TEXT,
|
||||||
|
presale_closure_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
presale_close_minutes_before INTEGER NOT NULL DEFAULT 120,
|
||||||
updated_at TIMESTAMP NOT NULL,
|
updated_at TIMESTAMP NOT NULL,
|
||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
)
|
)
|
||||||
@@ -1068,6 +1202,14 @@ async function migrate() {
|
|||||||
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
|
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN featured_event_id UUID REFERENCES events(id)`);
|
||||||
} catch (e) { /* column may already exist */ }
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
// Pre-sale closure site-wide defaults
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_closure_enabled INTEGER NOT NULL DEFAULT 1`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
try {
|
||||||
|
await (db as any).execute(sql`ALTER TABLE site_settings ADD COLUMN presale_close_minutes_before INTEGER NOT NULL DEFAULT 120`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
// Legal pages table for admin-editable legal content
|
// Legal pages table for admin-editable legal content
|
||||||
await (db as any).execute(sql`
|
await (db as any).execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS legal_pages (
|
CREATE TABLE IF NOT EXISTS legal_pages (
|
||||||
@@ -1195,6 +1337,178 @@ async function migrate() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Event finance, partners & team access ====================
|
||||||
|
// New tables only, so one engine-neutral block with a type map instead of a
|
||||||
|
// copy per branch. Money is whole PYG (INTEGER), percentages are basis points.
|
||||||
|
const T = dbType === 'sqlite'
|
||||||
|
? { id: 'TEXT', ts: 'TEXT', str: 'TEXT', text: 'TEXT' }
|
||||||
|
: { id: 'UUID', ts: 'TIMESTAMP', str: 'VARCHAR(300)', text: 'TEXT' };
|
||||||
|
const run = (stmt: string) =>
|
||||||
|
dbType === 'sqlite' ? (db as any).run(sql.raw(stmt)) : (db as any).execute(sql.raw(stmt));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run(`ALTER TABLE events ADD COLUMN series ${dbType === 'sqlite' ? 'TEXT' : 'VARCHAR(100)'}`);
|
||||||
|
} catch (e) { /* column may already exist */ }
|
||||||
|
|
||||||
|
const financeTables = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS expense_categories (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
name_en ${T.str} NOT NULL,
|
||||||
|
name_es ${T.str} NOT NULL,
|
||||||
|
color ${T.str} NOT NULL DEFAULT '#6B7280',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at ${T.ts} NOT NULL,
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS expense_templates (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
name ${T.str} NOT NULL,
|
||||||
|
category_id ${T.id} REFERENCES expense_categories(id),
|
||||||
|
description ${T.text},
|
||||||
|
calc_type ${T.str} NOT NULL,
|
||||||
|
amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
minimum_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at ${T.ts} NOT NULL,
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS expense_template_packs (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
name ${T.str} NOT NULL,
|
||||||
|
description ${T.text},
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at ${T.ts} NOT NULL,
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS expense_template_pack_items (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
pack_id ${T.id} NOT NULL REFERENCES expense_template_packs(id),
|
||||||
|
template_id ${T.id} NOT NULL REFERENCES expense_templates(id),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS event_partners (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||||
|
user_id ${T.id} REFERENCES users(id),
|
||||||
|
external_name ${T.str},
|
||||||
|
role_label ${T.str},
|
||||||
|
share_type ${T.str} NOT NULL,
|
||||||
|
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fixed_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
threshold_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loss_rule ${T.str} NOT NULL DEFAULT 'none',
|
||||||
|
loss_cap_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
payout_status ${T.str} NOT NULL DEFAULT 'pending',
|
||||||
|
payout_date ${T.ts},
|
||||||
|
payout_method ${T.str},
|
||||||
|
payout_note ${T.text},
|
||||||
|
created_at ${T.ts} NOT NULL,
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS event_expenses (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||||
|
category_id ${T.id} REFERENCES expense_categories(id),
|
||||||
|
template_id ${T.id} REFERENCES expense_templates(id),
|
||||||
|
description ${T.str} NOT NULL,
|
||||||
|
calc_type ${T.str} NOT NULL DEFAULT 'fixed',
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
unit_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
minimum_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
computed_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_locked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status ${T.str} NOT NULL DEFAULT 'planned',
|
||||||
|
paid_by_partner_id ${T.id} REFERENCES event_partners(id),
|
||||||
|
receipt_url ${T.str},
|
||||||
|
expense_date ${T.ts},
|
||||||
|
created_by ${T.id} REFERENCES users(id),
|
||||||
|
updated_by ${T.id} REFERENCES users(id),
|
||||||
|
created_at ${T.ts} NOT NULL,
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS event_other_income (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||||
|
description ${T.str} NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
created_by ${T.id} REFERENCES users(id),
|
||||||
|
created_at ${T.ts} NOT NULL,
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS payment_method_fees (
|
||||||
|
method ${T.str} PRIMARY KEY,
|
||||||
|
percent_bp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fixed_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at ${T.ts} NOT NULL,
|
||||||
|
updated_by ${T.id} REFERENCES users(id)
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS event_finance_state (
|
||||||
|
event_id ${T.id} PRIMARY KEY REFERENCES events(id),
|
||||||
|
status ${T.str} NOT NULL DEFAULT 'open',
|
||||||
|
finalized_at ${T.ts},
|
||||||
|
finalized_by ${T.id} REFERENCES users(id),
|
||||||
|
snapshot_json ${T.text},
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS event_members (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
event_id ${T.id} NOT NULL REFERENCES events(id),
|
||||||
|
user_id ${T.id} NOT NULL REFERENCES users(id),
|
||||||
|
role_preset ${T.str} NOT NULL,
|
||||||
|
permissions ${T.text} NOT NULL DEFAULT '{}',
|
||||||
|
created_by ${T.id} REFERENCES users(id),
|
||||||
|
created_at ${T.ts} NOT NULL,
|
||||||
|
updated_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS finance_audit_log (
|
||||||
|
id ${T.id} PRIMARY KEY,
|
||||||
|
event_id ${T.id},
|
||||||
|
actor_user_id ${T.id} REFERENCES users(id),
|
||||||
|
entity_type ${T.str} NOT NULL,
|
||||||
|
entity_id ${T.str},
|
||||||
|
action ${T.str} NOT NULL,
|
||||||
|
before_json ${T.text},
|
||||||
|
after_json ${T.text},
|
||||||
|
created_at ${T.ts} NOT NULL
|
||||||
|
)`,
|
||||||
|
];
|
||||||
|
for (const stmt of financeTables) {
|
||||||
|
await run(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults, only when the tables are still empty so archived/deleted rows stay gone.
|
||||||
|
const now = getNow();
|
||||||
|
const categoryCount = await dbGet<any>(
|
||||||
|
(db as any).select({ count: sql<number>`count(*)` }).from(sql`expense_categories`)
|
||||||
|
);
|
||||||
|
if (Number(categoryCount?.count || 0) === 0) {
|
||||||
|
const defaults: [string, string, string][] = [
|
||||||
|
['Venue', 'Lugar', '#2563EB'],
|
||||||
|
['Instructor / host', 'Instructor / anfitrión', '#7C3AED'],
|
||||||
|
['Food & drinks', 'Comida y bebidas', '#EA580C'],
|
||||||
|
['Staff', 'Personal', '#0D9488'],
|
||||||
|
['Marketing', 'Marketing', '#DB2777'],
|
||||||
|
['Supplies', 'Materiales', '#CA8A04'],
|
||||||
|
['Other', 'Otros', '#6B7280'],
|
||||||
|
];
|
||||||
|
for (const [i, [en, es, color]] of defaults.entries()) {
|
||||||
|
await (db as any).insert(expenseCategories).values({
|
||||||
|
id: generateId(), nameEn: en, nameEs: es, color, sortOrder: i, archived: dbType === 'sqlite' ? false : 0,
|
||||||
|
createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('Seeded default expense categories.');
|
||||||
|
}
|
||||||
|
// One fee row per payments.provider in use; 0% until an admin sets them.
|
||||||
|
for (const method of ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos']) {
|
||||||
|
await run(`INSERT INTO payment_method_fees (method, percent_bp, fixed_amount, updated_at)
|
||||||
|
VALUES ('${method}', 0, 0, ${dbType === 'sqlite' ? `'${new Date().toISOString()}'` : 'NOW()'})
|
||||||
|
ON CONFLICT (method) DO NOTHING`);
|
||||||
|
}
|
||||||
|
|
||||||
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
|
||||||
const indexStatements = [
|
const indexStatements = [
|
||||||
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
|
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
|
||||||
@@ -1203,6 +1517,8 @@ async function migrate() {
|
|||||||
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
|
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
|
||||||
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
|
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
|
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`,
|
||||||
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
|
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
|
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
|
||||||
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
|
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
|
||||||
@@ -1210,6 +1526,15 @@ async function migrate() {
|
|||||||
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS auth_accounts_provider_account_idx ON auth_accounts(provider_id, account_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
|
`CREATE INDEX IF NOT EXISTS auth_verifications_identifier_idx ON auth_verifications(identifier)`,
|
||||||
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
|
`CREATE INDEX IF NOT EXISTS auth_rate_limits_key_idx ON auth_rate_limits(key)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS event_expenses_event_id_idx ON event_expenses(event_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS event_other_income_event_id_idx ON event_other_income(event_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS event_partners_event_id_idx ON event_partners(event_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS event_partners_user_id_idx ON event_partners(user_id)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS event_members_event_user_idx ON event_members(event_id, user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS event_members_user_id_idx ON event_members(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS expense_template_pack_items_pack_id_idx ON expense_template_pack_items(pack_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS finance_audit_log_event_id_idx ON finance_audit_log(event_id, created_at)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS events_series_idx ON events(series)`,
|
||||||
];
|
];
|
||||||
for (const stmt of indexStatements) {
|
for (const stmt of indexStatements) {
|
||||||
try {
|
try {
|
||||||
@@ -1296,6 +1621,57 @@ async function migrate() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== users.email normalization ====================
|
||||||
|
// Better Auth lowercases the address on every lookup and write it performs,
|
||||||
|
// but the users.email unique index is case-sensitive on both dialects. Rows
|
||||||
|
// written outside Better Auth (guest bookings, door sales, admin-added
|
||||||
|
// tickets) used to keep the address exactly as typed, so a buyer who entered
|
||||||
|
// "John@Gmail.com" was invisible to sign-in and to Google account linking:
|
||||||
|
// signing in with Google minted a SECOND user row and left their tickets
|
||||||
|
// stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this
|
||||||
|
// fixes the rows already in the table.
|
||||||
|
//
|
||||||
|
// Idempotent, and deliberately conservative: a row is only lowercased when
|
||||||
|
// nothing already occupies the lowercase address. A genuine collision means
|
||||||
|
// two user rows for the same person, each with its own tickets, invoices and
|
||||||
|
// payments — merging those is a judgement call, not a migration, so they are
|
||||||
|
// reported for manual review instead.
|
||||||
|
const lowercaseEmailsSql = `
|
||||||
|
UPDATE users SET email = LOWER(email)
|
||||||
|
WHERE email <> LOWER(email)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
if (dbType === 'sqlite') {
|
||||||
|
await (db as any).run(sql.raw(lowercaseEmailsSql));
|
||||||
|
} else {
|
||||||
|
await (db as any).execute(sql.raw(lowercaseEmailsSql));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever still differs from its own lowercase form is exactly the set the
|
||||||
|
// UPDATE refused to touch, i.e. the collisions.
|
||||||
|
const collisions = await dbAll<{ id: string; email: string }>(
|
||||||
|
(db as any)
|
||||||
|
.select({ id: (users as any).id, email: (users as any).email })
|
||||||
|
.from(users)
|
||||||
|
.where(ne((users as any).email, sql`LOWER(${(users as any).email})`))
|
||||||
|
);
|
||||||
|
if (collisions.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` +
|
||||||
|
`lowercase address is already taken. Sign-in and Google linking only ever reach ` +
|
||||||
|
`the lowercase row, so these need a manual merge:`
|
||||||
|
);
|
||||||
|
for (const row of collisions) {
|
||||||
|
const canonical = row.email.toLowerCase();
|
||||||
|
const existing = await dbGet<{ id: string }>(
|
||||||
|
(db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical))
|
||||||
|
);
|
||||||
|
console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
// Backfill slugs for any events that don't have one yet (shared across DB types).
|
||||||
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
|
||||||
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
|
||||||
|
|||||||
+375
-1
@@ -82,12 +82,20 @@ export const sqliteEvents = sqliteTable('events', {
|
|||||||
location: text('location').notNull(),
|
location: text('location').notNull(),
|
||||||
locationUrl: text('location_url'),
|
locationUrl: text('location_url'),
|
||||||
price: real('price').notNull().default(0),
|
price: real('price').notNull().default(0),
|
||||||
|
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
|
||||||
|
// Admin/staff only: never serialized to public event responses.
|
||||||
|
walkInPrice: real('walk_in_price'),
|
||||||
currency: text('currency').notNull().default('PYG'),
|
currency: text('currency').notNull().default('PYG'),
|
||||||
capacity: integer('capacity').notNull().default(50),
|
capacity: integer('capacity').notNull().default(50),
|
||||||
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
||||||
bannerUrl: text('banner_url'),
|
bannerUrl: text('banner_url'),
|
||||||
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
|
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
externalBookingUrl: text('external_booking_url'),
|
externalBookingUrl: text('external_booking_url'),
|
||||||
|
// Pre-sale closure: null = inherit the site_settings default
|
||||||
|
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }),
|
||||||
|
presaleCloseMinutesBefore: integer('presale_close_minutes_before'),
|
||||||
|
// Groups recurring events (e.g. "Morning Club") for the cross-event finance overview
|
||||||
|
series: text('series'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -118,13 +126,16 @@ export const sqliteTickets = sqliteTable('tickets', {
|
|||||||
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false),
|
||||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||||
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
|
paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'),
|
||||||
|
// How the booking was made: public checkout, a walk-in on the door screen, or
|
||||||
|
// added by an admin. Distinct from payments.source, which is where money was taken.
|
||||||
|
bookingSource: text('booking_source', { enum: ['online', 'walk_in', 'admin'] }).notNull().default('online'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sqlitePayments = sqliteTable('payments', {
|
export const sqlitePayments = sqliteTable('payments', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
|
ticketId: text('ticket_id').notNull().references(() => sqliteTickets.id),
|
||||||
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago'] }).notNull(),
|
provider: text('provider', { enum: ['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago', 'pos'] }).notNull(),
|
||||||
amount: real('amount').notNull(),
|
amount: real('amount').notNull(),
|
||||||
currency: text('currency').notNull().default('PYG'),
|
currency: text('currency').notNull().default('PYG'),
|
||||||
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
status: text('status', { enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled', 'on_hold'] }).notNull().default('pending'),
|
||||||
@@ -138,10 +149,32 @@ export const sqlitePayments = sqliteTable('payments', {
|
|||||||
paidByAdminId: text('paid_by_admin_id'),
|
paidByAdminId: text('paid_by_admin_id'),
|
||||||
adminNote: text('admin_note'), // Internal admin notes
|
adminNote: text('admin_note'), // Internal admin notes
|
||||||
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
|
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
|
||||||
|
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
|
||||||
|
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
|
||||||
|
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
|
||||||
|
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
|
||||||
|
// 'guest' is a zero-amount comp entry and carries no revenue.
|
||||||
|
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] }),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Idempotency records for door check-in actions.
|
||||||
|
//
|
||||||
|
// The door screen fires check-ins / walk-in creations optimistically and retries
|
||||||
|
// on flaky venue wifi, so every action carries a client-generated key. The first
|
||||||
|
// request stores its response here; replays return that stored response instead
|
||||||
|
// of creating a second ticket, payment or check-in. `undoState` holds exactly
|
||||||
|
// what the action changed so the 10-second Undo can reverse it precisely.
|
||||||
|
export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
scope: text('scope').notNull(),
|
||||||
|
result: text('result').notNull(), // JSON response body of the original request
|
||||||
|
undoState: text('undo_state'), // JSON describing how to reverse the action
|
||||||
|
undoneAt: text('undone_at'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Payment Options Configuration Table (global settings)
|
// Payment Options Configuration Table (global settings)
|
||||||
export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -169,6 +202,8 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
|
|||||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
|
cashEnabled: integer('cash_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
cashInstructions: text('cash_instructions'),
|
cashInstructions: text('cash_instructions'),
|
||||||
cashInstructionsEs: text('cash_instructions_es'),
|
cashInstructionsEs: text('cash_instructions_es'),
|
||||||
|
// POS card terminal at the door (Scanner only, never offered at online checkout)
|
||||||
|
posEnabled: integer('pos_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
// Booking settings
|
// Booking settings
|
||||||
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
|
allowDuplicateBookings: integer('allow_duplicate_bookings', { mode: 'boolean' }).notNull().default(false),
|
||||||
// Metadata
|
// Metadata
|
||||||
@@ -201,6 +236,7 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
|
|||||||
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
|
cashEnabled: integer('cash_enabled', { mode: 'boolean' }),
|
||||||
cashInstructions: text('cash_instructions'),
|
cashInstructions: text('cash_instructions'),
|
||||||
cashInstructionsEs: text('cash_instructions_es'),
|
cashInstructionsEs: text('cash_instructions_es'),
|
||||||
|
posEnabled: integer('pos_enabled', { mode: 'boolean' }),
|
||||||
// Metadata
|
// Metadata
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
@@ -365,11 +401,160 @@ export const sqliteSiteSettings = sqliteTable('site_settings', {
|
|||||||
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
maintenanceMode: integer('maintenance_mode', { mode: 'boolean' }).notNull().default(false),
|
||||||
maintenanceMessage: text('maintenance_message'),
|
maintenanceMessage: text('maintenance_message'),
|
||||||
maintenanceMessageEs: text('maintenance_message_es'),
|
maintenanceMessageEs: text('maintenance_message_es'),
|
||||||
|
// Pre-sale closure defaults (events inherit these unless they override)
|
||||||
|
presaleClosureEnabled: integer('presale_closure_enabled', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
presaleCloseMinutesBefore: integer('presale_close_minutes_before').notNull().default(120),
|
||||||
// Metadata
|
// Metadata
|
||||||
updatedAt: text('updated_at').notNull(),
|
updatedAt: text('updated_at').notNull(),
|
||||||
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== Event finance (SQLite) ====================
|
||||||
|
// All money columns are whole PYG (integer). Percentages are stored in basis
|
||||||
|
// points (hundredths of a percent): 290 = 2.90%. JSON columns are text.
|
||||||
|
|
||||||
|
export const sqliteExpenseCategories = sqliteTable('expense_categories', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
nameEn: text('name_en').notNull(),
|
||||||
|
nameEs: text('name_es').notNull(),
|
||||||
|
color: text('color').notNull().default('#6B7280'),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteExpenseTemplates = sqliteTable('expense_templates', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
|
||||||
|
description: text('description'),
|
||||||
|
// fixed | per_ticket_sold | per_checked_in | percent_of_revenue | minimum_spend
|
||||||
|
calcType: text('calc_type').notNull(),
|
||||||
|
amount: integer('amount').notNull().default(0),
|
||||||
|
percentBp: integer('percent_bp').notNull().default(0),
|
||||||
|
minimumAmount: integer('minimum_amount').notNull().default(0),
|
||||||
|
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteExpenseTemplatePacks = sqliteTable('expense_template_packs', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
archived: integer('archived', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteExpenseTemplatePackItems = sqliteTable('expense_template_pack_items', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
packId: text('pack_id').notNull().references(() => sqliteExpenseTemplatePacks.id),
|
||||||
|
templateId: text('template_id').notNull().references(() => sqliteExpenseTemplates.id),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEventPartners = sqliteTable('event_partners', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||||
|
userId: text('user_id').references(() => sqliteUsers.id),
|
||||||
|
externalName: text('external_name'),
|
||||||
|
roleLabel: text('role_label'),
|
||||||
|
// percent_profit | percent_revenue | fixed | fixed_plus_percent_above_threshold
|
||||||
|
shareType: text('share_type').notNull(),
|
||||||
|
percentBp: integer('percent_bp').notNull().default(0),
|
||||||
|
fixedAmount: integer('fixed_amount').notNull().default(0),
|
||||||
|
thresholdAmount: integer('threshold_amount').notNull().default(0),
|
||||||
|
// proportional | none | capped
|
||||||
|
lossRule: text('loss_rule').notNull().default('none'),
|
||||||
|
lossCapAmount: integer('loss_cap_amount').notNull().default(0),
|
||||||
|
payoutStatus: text('payout_status').notNull().default('pending'),
|
||||||
|
payoutDate: text('payout_date'),
|
||||||
|
payoutMethod: text('payout_method'),
|
||||||
|
payoutNote: text('payout_note'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEventExpenses = sqliteTable('event_expenses', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||||
|
categoryId: text('category_id').references(() => sqliteExpenseCategories.id),
|
||||||
|
templateId: text('template_id').references(() => sqliteExpenseTemplates.id),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
calcType: text('calc_type').notNull().default('fixed'),
|
||||||
|
// fixed: units bought; auto types: the count used at the last calculation
|
||||||
|
quantity: integer('quantity').notNull().default(1),
|
||||||
|
unitAmount: integer('unit_amount').notNull().default(0),
|
||||||
|
percentBp: integer('percent_bp').notNull().default(0),
|
||||||
|
minimumAmount: integer('minimum_amount').notNull().default(0),
|
||||||
|
computedAmount: integer('computed_amount').notNull().default(0),
|
||||||
|
// Locked rows keep computed_amount instead of following ticket counts
|
||||||
|
isLocked: integer('is_locked', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
status: text('status').notNull().default('planned'), // planned | paid
|
||||||
|
// NULL = the organization paid; otherwise the partner who fronted it
|
||||||
|
paidByPartnerId: text('paid_by_partner_id').references(() => sqliteEventPartners.id),
|
||||||
|
receiptUrl: text('receipt_url'),
|
||||||
|
expenseDate: text('expense_date'),
|
||||||
|
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||||
|
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEventOtherIncome = sqliteTable('event_other_income', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||||
|
description: text('description').notNull(),
|
||||||
|
amount: integer('amount').notNull(),
|
||||||
|
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyed by payments.provider (door payments carry the legacy provider too)
|
||||||
|
export const sqlitePaymentMethodFees = sqliteTable('payment_method_fees', {
|
||||||
|
method: text('method').primaryKey(),
|
||||||
|
percentBp: integer('percent_bp').notNull().default(0),
|
||||||
|
fixedAmount: integer('fixed_amount').notNull().default(0),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
updatedBy: text('updated_by').references(() => sqliteUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEventFinanceState = sqliteTable('event_finance_state', {
|
||||||
|
eventId: text('event_id').primaryKey().references(() => sqliteEvents.id),
|
||||||
|
status: text('status').notNull().default('open'), // open | finalized | paid_out
|
||||||
|
finalizedAt: text('finalized_at'),
|
||||||
|
finalizedBy: text('finalized_by').references(() => sqliteUsers.id),
|
||||||
|
snapshotJson: text('snapshot_json'),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteEventMembers = sqliteTable('event_members', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
eventId: text('event_id').notNull().references(() => sqliteEvents.id),
|
||||||
|
userId: text('user_id').notNull().references(() => sqliteUsers.id),
|
||||||
|
rolePreset: text('role_preset').notNull(), // staff | collaborator | co_manager
|
||||||
|
// JSON {permissionKey: boolean} applied on top of the preset
|
||||||
|
permissions: text('permissions').notNull().default('{}'),
|
||||||
|
createdBy: text('created_by').references(() => sqliteUsers.id),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
updatedAt: text('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sqliteFinanceAuditLog = sqliteTable('finance_audit_log', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
eventId: text('event_id'), // NULL for global finance settings
|
||||||
|
actorUserId: text('actor_user_id').references(() => sqliteUsers.id),
|
||||||
|
entityType: text('entity_type').notNull(),
|
||||||
|
entityId: text('entity_id'),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
beforeJson: text('before_json'),
|
||||||
|
afterJson: text('after_json'),
|
||||||
|
createdAt: text('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== PostgreSQL Schema ====================
|
// ==================== PostgreSQL Schema ====================
|
||||||
export const pgUsers = pgTable('users', {
|
export const pgUsers = pgTable('users', {
|
||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
@@ -448,12 +633,19 @@ export const pgEvents = pgTable('events', {
|
|||||||
location: varchar('location', { length: 500 }).notNull(),
|
location: varchar('location', { length: 500 }).notNull(),
|
||||||
locationUrl: varchar('location_url', { length: 500 }),
|
locationUrl: varchar('location_url', { length: 500 }),
|
||||||
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'),
|
||||||
|
// Charged for walk-ins at the door (Scanner). Null = fall back to price; 0 = free.
|
||||||
|
// Admin/staff only: never serialized to public event responses.
|
||||||
|
walkInPrice: decimal('walk_in_price', { precision: 10, scale: 2 }),
|
||||||
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
currency: varchar('currency', { length: 10 }).notNull().default('PYG'),
|
||||||
capacity: pgInteger('capacity').notNull().default(50),
|
capacity: pgInteger('capacity').notNull().default(50),
|
||||||
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
status: varchar('status', { length: 20 }).notNull().default('draft'),
|
||||||
bannerUrl: varchar('banner_url', { length: 500 }),
|
bannerUrl: varchar('banner_url', { length: 500 }),
|
||||||
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
|
externalBookingEnabled: pgInteger('external_booking_enabled').notNull().default(0),
|
||||||
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
|
externalBookingUrl: varchar('external_booking_url', { length: 500 }),
|
||||||
|
// Pre-sale closure: null = inherit the site_settings default
|
||||||
|
presaleClosureEnabled: pgInteger('presale_closure_enabled'),
|
||||||
|
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before'),
|
||||||
|
series: varchar('series', { length: 100 }),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -484,6 +676,8 @@ export const pgTickets = pgTable('tickets', {
|
|||||||
isGuest: pgInteger('is_guest').notNull().default(0),
|
isGuest: pgInteger('is_guest').notNull().default(0),
|
||||||
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
// Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue
|
||||||
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
|
paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'),
|
||||||
|
// online | walk_in | admin — see sqliteTickets.bookingSource
|
||||||
|
bookingSource: varchar('booking_source', { length: 20 }).notNull().default('online'),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -504,10 +698,26 @@ export const pgPayments = pgTable('payments', {
|
|||||||
paidByAdminId: uuid('paid_by_admin_id'),
|
paidByAdminId: uuid('paid_by_admin_id'),
|
||||||
adminNote: pgText('admin_note'),
|
adminNote: pgText('admin_note'),
|
||||||
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
|
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
|
||||||
|
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
|
||||||
|
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
|
||||||
|
source: varchar('source', { length: 20 }).notNull().default('presale'),
|
||||||
|
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
|
||||||
|
// 'guest' is a zero-amount comp entry and carries no revenue.
|
||||||
|
method: varchar('method', { length: 20 }),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Idempotency records for door check-in actions (see sqliteIdempotencyKeys).
|
||||||
|
export const pgIdempotencyKeys = pgTable('idempotency_keys', {
|
||||||
|
key: varchar('key', { length: 128 }).primaryKey(),
|
||||||
|
scope: varchar('scope', { length: 64 }).notNull(),
|
||||||
|
result: pgText('result').notNull(),
|
||||||
|
undoState: pgText('undo_state'),
|
||||||
|
undoneAt: timestamp('undone_at'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Payment Options Configuration Table (global settings)
|
// Payment Options Configuration Table (global settings)
|
||||||
export const pgPaymentOptions = pgTable('payment_options', {
|
export const pgPaymentOptions = pgTable('payment_options', {
|
||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
@@ -531,6 +741,7 @@ export const pgPaymentOptions = pgTable('payment_options', {
|
|||||||
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
cashEnabled: pgInteger('cash_enabled').notNull().default(1),
|
||||||
cashInstructions: pgText('cash_instructions'),
|
cashInstructions: pgText('cash_instructions'),
|
||||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||||
|
posEnabled: pgInteger('pos_enabled').notNull().default(1),
|
||||||
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
|
allowDuplicateBookings: pgInteger('allow_duplicate_bookings').notNull().default(0),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
@@ -560,6 +771,7 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
|
|||||||
cashEnabled: pgInteger('cash_enabled'),
|
cashEnabled: pgInteger('cash_enabled'),
|
||||||
cashInstructions: pgText('cash_instructions'),
|
cashInstructions: pgText('cash_instructions'),
|
||||||
cashInstructionsEs: pgText('cash_instructions_es'),
|
cashInstructionsEs: pgText('cash_instructions_es'),
|
||||||
|
posEnabled: pgInteger('pos_enabled'),
|
||||||
createdAt: timestamp('created_at').notNull(),
|
createdAt: timestamp('created_at').notNull(),
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
});
|
});
|
||||||
@@ -723,17 +935,159 @@ export const pgSiteSettings = pgTable('site_settings', {
|
|||||||
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
maintenanceMode: pgInteger('maintenance_mode').notNull().default(0),
|
||||||
maintenanceMessage: pgText('maintenance_message'),
|
maintenanceMessage: pgText('maintenance_message'),
|
||||||
maintenanceMessageEs: pgText('maintenance_message_es'),
|
maintenanceMessageEs: pgText('maintenance_message_es'),
|
||||||
|
// Pre-sale closure defaults (events inherit these unless they override)
|
||||||
|
presaleClosureEnabled: pgInteger('presale_closure_enabled').notNull().default(1),
|
||||||
|
presaleCloseMinutesBefore: pgInteger('presale_close_minutes_before').notNull().default(120),
|
||||||
// Metadata
|
// Metadata
|
||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== Event finance (PostgreSQL) ====================
|
||||||
|
// See the SQLite block: integer PYG, basis-point percentages, JSON as text.
|
||||||
|
|
||||||
|
export const pgExpenseCategories = pgTable('expense_categories', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
nameEn: varchar('name_en', { length: 100 }).notNull(),
|
||||||
|
nameEs: varchar('name_es', { length: 100 }).notNull(),
|
||||||
|
color: varchar('color', { length: 20 }).notNull().default('#6B7280'),
|
||||||
|
sortOrder: pgInteger('sort_order').notNull().default(0),
|
||||||
|
archived: pgInteger('archived').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgExpenseTemplates = pgTable('expense_templates', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
name: varchar('name', { length: 200 }).notNull(),
|
||||||
|
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
|
||||||
|
description: pgText('description'),
|
||||||
|
calcType: varchar('calc_type', { length: 40 }).notNull(),
|
||||||
|
amount: pgInteger('amount').notNull().default(0),
|
||||||
|
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||||
|
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
|
||||||
|
archived: pgInteger('archived').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgExpenseTemplatePacks = pgTable('expense_template_packs', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
name: varchar('name', { length: 200 }).notNull(),
|
||||||
|
description: pgText('description'),
|
||||||
|
archived: pgInteger('archived').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgExpenseTemplatePackItems = pgTable('expense_template_pack_items', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
packId: uuid('pack_id').notNull().references(() => pgExpenseTemplatePacks.id),
|
||||||
|
templateId: uuid('template_id').notNull().references(() => pgExpenseTemplates.id),
|
||||||
|
sortOrder: pgInteger('sort_order').notNull().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEventPartners = pgTable('event_partners', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
|
userId: uuid('user_id').references(() => pgUsers.id),
|
||||||
|
externalName: varchar('external_name', { length: 200 }),
|
||||||
|
roleLabel: varchar('role_label', { length: 100 }),
|
||||||
|
shareType: varchar('share_type', { length: 40 }).notNull(),
|
||||||
|
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||||
|
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
|
||||||
|
thresholdAmount: pgInteger('threshold_amount').notNull().default(0),
|
||||||
|
lossRule: varchar('loss_rule', { length: 20 }).notNull().default('none'),
|
||||||
|
lossCapAmount: pgInteger('loss_cap_amount').notNull().default(0),
|
||||||
|
payoutStatus: varchar('payout_status', { length: 20 }).notNull().default('pending'),
|
||||||
|
payoutDate: timestamp('payout_date'),
|
||||||
|
payoutMethod: varchar('payout_method', { length: 50 }),
|
||||||
|
payoutNote: pgText('payout_note'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEventExpenses = pgTable('event_expenses', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
|
categoryId: uuid('category_id').references(() => pgExpenseCategories.id),
|
||||||
|
templateId: uuid('template_id').references(() => pgExpenseTemplates.id),
|
||||||
|
description: varchar('description', { length: 300 }).notNull(),
|
||||||
|
calcType: varchar('calc_type', { length: 40 }).notNull().default('fixed'),
|
||||||
|
quantity: pgInteger('quantity').notNull().default(1),
|
||||||
|
unitAmount: pgInteger('unit_amount').notNull().default(0),
|
||||||
|
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||||
|
minimumAmount: pgInteger('minimum_amount').notNull().default(0),
|
||||||
|
computedAmount: pgInteger('computed_amount').notNull().default(0),
|
||||||
|
isLocked: pgInteger('is_locked').notNull().default(0),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('planned'),
|
||||||
|
paidByPartnerId: uuid('paid_by_partner_id').references(() => pgEventPartners.id),
|
||||||
|
receiptUrl: varchar('receipt_url', { length: 500 }),
|
||||||
|
expenseDate: timestamp('expense_date'),
|
||||||
|
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||||
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEventOtherIncome = pgTable('event_other_income', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
|
description: varchar('description', { length: 300 }).notNull(),
|
||||||
|
amount: pgInteger('amount').notNull(),
|
||||||
|
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgPaymentMethodFees = pgTable('payment_method_fees', {
|
||||||
|
method: varchar('method', { length: 50 }).primaryKey(),
|
||||||
|
percentBp: pgInteger('percent_bp').notNull().default(0),
|
||||||
|
fixedAmount: pgInteger('fixed_amount').notNull().default(0),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
updatedBy: uuid('updated_by').references(() => pgUsers.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEventFinanceState = pgTable('event_finance_state', {
|
||||||
|
eventId: uuid('event_id').primaryKey().references(() => pgEvents.id),
|
||||||
|
status: varchar('status', { length: 20 }).notNull().default('open'),
|
||||||
|
finalizedAt: timestamp('finalized_at'),
|
||||||
|
finalizedBy: uuid('finalized_by').references(() => pgUsers.id),
|
||||||
|
snapshotJson: pgText('snapshot_json'),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgEventMembers = pgTable('event_members', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
|
||||||
|
userId: uuid('user_id').notNull().references(() => pgUsers.id),
|
||||||
|
rolePreset: varchar('role_preset', { length: 20 }).notNull(),
|
||||||
|
permissions: pgText('permissions').notNull().default('{}'),
|
||||||
|
createdBy: uuid('created_by').references(() => pgUsers.id),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pgFinanceAuditLog = pgTable('finance_audit_log', {
|
||||||
|
id: uuid('id').primaryKey(),
|
||||||
|
eventId: uuid('event_id'),
|
||||||
|
actorUserId: uuid('actor_user_id').references(() => pgUsers.id),
|
||||||
|
entityType: varchar('entity_type', { length: 50 }).notNull(),
|
||||||
|
// Text, not uuid: settings rows are keyed by method name
|
||||||
|
entityId: varchar('entity_id', { length: 100 }),
|
||||||
|
action: varchar('action', { length: 50 }).notNull(),
|
||||||
|
beforeJson: pgText('before_json'),
|
||||||
|
afterJson: pgText('after_json'),
|
||||||
|
createdAt: timestamp('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Export the appropriate schema based on DB_TYPE
|
// Export the appropriate schema based on DB_TYPE
|
||||||
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
export const users = dbType === 'postgres' ? pgUsers : sqliteUsers;
|
||||||
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
|
||||||
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
|
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
|
||||||
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
|
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
|
||||||
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
|
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
|
||||||
|
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
|
||||||
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
|
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
|
||||||
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
|
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
|
||||||
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
|
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
|
||||||
@@ -751,6 +1105,17 @@ export const legalSettings = dbType === 'postgres' ? pgLegalSettings : sqliteLeg
|
|||||||
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
export const siteSettings = dbType === 'postgres' ? pgSiteSettings : sqliteSiteSettings;
|
||||||
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
export const legalPages = dbType === 'postgres' ? pgLegalPages : sqliteLegalPages;
|
||||||
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
export const faqQuestions = dbType === 'postgres' ? pgFaqQuestions : sqliteFaqQuestions;
|
||||||
|
export const expenseCategories = dbType === 'postgres' ? pgExpenseCategories : sqliteExpenseCategories;
|
||||||
|
export const expenseTemplates = dbType === 'postgres' ? pgExpenseTemplates : sqliteExpenseTemplates;
|
||||||
|
export const expenseTemplatePacks = dbType === 'postgres' ? pgExpenseTemplatePacks : sqliteExpenseTemplatePacks;
|
||||||
|
export const expenseTemplatePackItems = dbType === 'postgres' ? pgExpenseTemplatePackItems : sqliteExpenseTemplatePackItems;
|
||||||
|
export const eventPartners = dbType === 'postgres' ? pgEventPartners : sqliteEventPartners;
|
||||||
|
export const eventExpenses = dbType === 'postgres' ? pgEventExpenses : sqliteEventExpenses;
|
||||||
|
export const eventOtherIncome = dbType === 'postgres' ? pgEventOtherIncome : sqliteEventOtherIncome;
|
||||||
|
export const paymentMethodFees = dbType === 'postgres' ? pgPaymentMethodFees : sqlitePaymentMethodFees;
|
||||||
|
export const eventFinanceState = dbType === 'postgres' ? pgEventFinanceState : sqliteEventFinanceState;
|
||||||
|
export const eventMembers = dbType === 'postgres' ? pgEventMembers : sqliteEventMembers;
|
||||||
|
export const financeAuditLog = dbType === 'postgres' ? pgFinanceAuditLog : sqliteFinanceAuditLog;
|
||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type User = typeof sqliteUsers.$inferSelect;
|
export type User = typeof sqliteUsers.$inferSelect;
|
||||||
@@ -785,3 +1150,12 @@ export type FaqQuestion = typeof sqliteFaqQuestions.$inferSelect;
|
|||||||
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
export type NewFaqQuestion = typeof sqliteFaqQuestions.$inferInsert;
|
||||||
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
export type LegalSettings = typeof sqliteLegalSettings.$inferSelect;
|
||||||
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
export type NewLegalSettings = typeof sqliteLegalSettings.$inferInsert;
|
||||||
|
export type ExpenseCategory = typeof sqliteExpenseCategories.$inferSelect;
|
||||||
|
export type ExpenseTemplate = typeof sqliteExpenseTemplates.$inferSelect;
|
||||||
|
export type ExpenseTemplatePack = typeof sqliteExpenseTemplatePacks.$inferSelect;
|
||||||
|
export type EventPartner = typeof sqliteEventPartners.$inferSelect;
|
||||||
|
export type EventExpense = typeof sqliteEventExpenses.$inferSelect;
|
||||||
|
export type EventOtherIncome = typeof sqliteEventOtherIncome.$inferSelect;
|
||||||
|
export type PaymentMethodFee = typeof sqlitePaymentMethodFees.$inferSelect;
|
||||||
|
export type EventFinanceState = typeof sqliteEventFinanceState.$inferSelect;
|
||||||
|
export type EventMember = typeof sqliteEventMembers.$inferSelect;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import authExtRoutes from './routes/authExt.js';
|
|||||||
import { getClientIp } from './lib/rateLimit.js';
|
import { getClientIp } from './lib/rateLimit.js';
|
||||||
import eventsRoutes from './routes/events.js';
|
import eventsRoutes from './routes/events.js';
|
||||||
import ticketsRoutes from './routes/tickets.js';
|
import ticketsRoutes from './routes/tickets.js';
|
||||||
|
import doorRoutes from './routes/door.js';
|
||||||
|
import eventFinanceRoutes from './routes/eventFinance.js';
|
||||||
|
import financeRoutes from './routes/finance.js';
|
||||||
import usersRoutes from './routes/users.js';
|
import usersRoutes from './routes/users.js';
|
||||||
import contactsRoutes from './routes/contacts.js';
|
import contactsRoutes from './routes/contacts.js';
|
||||||
import paymentsRoutes from './routes/payments.js';
|
import paymentsRoutes from './routes/payments.js';
|
||||||
@@ -767,6 +770,119 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ==================== Door Check-in Screen ====================
|
||||||
|
'/api/events/{eventId}/door-attendees': {
|
||||||
|
get: {
|
||||||
|
tags: ['Tickets'],
|
||||||
|
summary: 'Full attendee list for the door check-in screen',
|
||||||
|
description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Event, attendees and check-in stats' },
|
||||||
|
404: { description: 'Event not found' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/events/{eventId}/door-checkin': {
|
||||||
|
post: {
|
||||||
|
tags: ['Tickets'],
|
||||||
|
summary: 'Check in, settle payment, or create a walk-in (atomic)',
|
||||||
|
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. The server prices the charge from the event: walk-ins pay the walk-in price (or the ticket price when none is set) x quantity, existing tickets the ticket price x quantity. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['idempotencyKey'],
|
||||||
|
properties: {
|
||||||
|
ticketId: { type: 'string' },
|
||||||
|
attendee: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['firstName'],
|
||||||
|
properties: {
|
||||||
|
firstName: { type: 'string' },
|
||||||
|
lastName: { type: 'string' },
|
||||||
|
phone: { type: 'string' },
|
||||||
|
email: { type: 'string', format: 'email' },
|
||||||
|
ruc: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['method'],
|
||||||
|
properties: {
|
||||||
|
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] },
|
||||||
|
quantity: { type: 'integer', minimum: 1, maximum: 50, description: 'Tickets paid for in one go (defaults to 1). The server multiplies it by the resolved unit price.' },
|
||||||
|
amount: { type: 'number', description: 'Ignored unless amountOverride is true.' },
|
||||||
|
amountOverride: { type: 'boolean', description: 'Charge `amount` instead of the computed price. Admin/organizer only; written to audit_logs.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
|
||||||
|
idempotencyKey: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
|
||||||
|
200: { description: 'Replay of an already-processed idempotencyKey' },
|
||||||
|
400: { description: 'Ticket belongs to a different event, or the payment method is not enabled for this event' },
|
||||||
|
403: { description: 'amountOverride sent by a role that may not override the door amount' },
|
||||||
|
404: { description: 'Event or ticket not found' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/events/{eventId}/door-checkin/undo': {
|
||||||
|
post: {
|
||||||
|
tags: ['Tickets'],
|
||||||
|
summary: 'Reverse one door check-in action',
|
||||||
|
description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['idempotencyKey'],
|
||||||
|
properties: { idempotencyKey: { type: 'string' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Action reversed (or already undone)' },
|
||||||
|
404: { description: 'No action recorded for this key' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/events/{eventId}/door-summary': {
|
||||||
|
get: {
|
||||||
|
tags: ['Payments'],
|
||||||
|
summary: 'Door cash-up and pre-sale/door revenue split',
|
||||||
|
description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
parameters: [
|
||||||
|
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Door totals by method, door lines, and pre-sale totals' },
|
||||||
|
404: { description: 'Event not found' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'/api/tickets/{id}/checkin': {
|
'/api/tickets/{id}/checkin': {
|
||||||
post: {
|
post: {
|
||||||
tags: ['Tickets'],
|
tags: ['Tickets'],
|
||||||
@@ -1908,6 +2024,11 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
app.route('/api/auth-ext', authExtRoutes);
|
app.route('/api/auth-ext', authExtRoutes);
|
||||||
|
// Door check-in screen endpoints live under /api/events/:eventId/door-*.
|
||||||
|
// Mounted first so the generic /:id routes below can never shadow them.
|
||||||
|
app.route('/api/events', doorRoutes);
|
||||||
|
// Per-event finance, partners and team access (/api/events/:id/finance, /expenses, /members, ...)
|
||||||
|
app.route('/api/events', eventFinanceRoutes);
|
||||||
app.route('/api/events', eventsRoutes);
|
app.route('/api/events', eventsRoutes);
|
||||||
app.route('/api/tickets', ticketsRoutes);
|
app.route('/api/tickets', ticketsRoutes);
|
||||||
app.route('/api/users', usersRoutes);
|
app.route('/api/users', usersRoutes);
|
||||||
@@ -1923,6 +2044,7 @@ app.route('/api/site-settings', siteSettingsRoutes);
|
|||||||
app.route('/api/legal-pages', legalPagesRoutes);
|
app.route('/api/legal-pages', legalPagesRoutes);
|
||||||
app.route('/api/legal-settings', legalSettingsRoutes);
|
app.route('/api/legal-settings', legalSettingsRoutes);
|
||||||
app.route('/api/faq', faqRoutes);
|
app.route('/api/faq', faqRoutes);
|
||||||
|
app.route('/api/finance', financeRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.notFound((c) => {
|
app.notFound((c) => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { execFileSync } from 'child_process';
|
|||||||
import { mkdtempSync } from 'fs';
|
import { mkdtempSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
|
||||||
|
import { normalizeEmail } from './utils.js';
|
||||||
|
|
||||||
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
|
// Environment must be pinned BEFORE the db/betterAuth singletons are imported
|
||||||
// (dotenv never overrides pre-set values).
|
// (dotenv never overrides pre-set values).
|
||||||
@@ -13,7 +15,13 @@ process.env.DATABASE_URL = dbPath;
|
|||||||
process.env.FRONTEND_URL = 'http://localhost:3002';
|
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||||
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
|
process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef';
|
||||||
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
|
delete process.env.REDIS_URL; // memory lockout/rate-limit backends
|
||||||
delete process.env.GOOGLE_CLIENT_ID;
|
|
||||||
|
// Google IS configured here: account linking is the whole point of the tests at
|
||||||
|
// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely
|
||||||
|
// when this is unset. No real credentials are involved — the id tokens are
|
||||||
|
// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below.
|
||||||
|
const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com';
|
||||||
|
process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID;
|
||||||
|
|
||||||
// Capture outgoing auth emails (magic links, password resets)
|
// Capture outgoing auth emails (magic links, password resets)
|
||||||
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
|
const sentEmails: Array<{ to: string; subject: string; html: string }> = [];
|
||||||
@@ -41,6 +49,85 @@ function extractToken(html: string, param = 'token'): string {
|
|||||||
return decodeURIComponent(match![1]);
|
return decodeURIComponent(match![1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Google Identity Services stub -------------------------------------
|
||||||
|
// verifyGoogleIdToken() checks signature, issuer, audience and max age against
|
||||||
|
// Google's published JWKS; its only network call is that JWKS fetch. Signing
|
||||||
|
// with our own key and serving our own JWKS exercises the real verification
|
||||||
|
// path without touching the network or needing OAuth credentials.
|
||||||
|
const GOOGLE_KID = 'spanglish-test-key';
|
||||||
|
let googlePrivateKey: CryptoKey;
|
||||||
|
|
||||||
|
async function installGoogleStub() {
|
||||||
|
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
|
||||||
|
googlePrivateKey = privateKey as CryptoKey;
|
||||||
|
const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' };
|
||||||
|
|
||||||
|
const realFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async (input: any, init?: any) => {
|
||||||
|
const url = typeof input === 'string' ? input : (input?.url ?? String(input));
|
||||||
|
if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) {
|
||||||
|
return new Response(JSON.stringify({ keys: [jwk] }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return realFetch(input, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) {
|
||||||
|
return new SignJWT({
|
||||||
|
email: opts.email,
|
||||||
|
email_verified: opts.emailVerified ?? true,
|
||||||
|
name: opts.name ?? 'Google User',
|
||||||
|
picture: 'https://example.test/avatar.png',
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
|
||||||
|
.setIssuer('https://accounts.google.com')
|
||||||
|
.setAudience(GOOGLE_CLIENT_ID)
|
||||||
|
.setSubject(opts.sub)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('10m')
|
||||||
|
.sign(googlePrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInWithGoogle(
|
||||||
|
opts: Parameters<typeof googleIdToken>[0],
|
||||||
|
returnHeaders = false
|
||||||
|
): Promise<any> {
|
||||||
|
const token = await googleIdToken(opts);
|
||||||
|
return auth.api.signInSocial({
|
||||||
|
body: { provider: 'google', idToken: { token } },
|
||||||
|
headers: new Headers(),
|
||||||
|
...(returnHeaders ? { returnHeaders: true } : {}),
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts):
|
||||||
|
* unclaimed, unverified, and with no auth_accounts row at all. */
|
||||||
|
function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
|
||||||
|
VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(id, email, name, now, now);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userRow(email: string) {
|
||||||
|
return sqlite
|
||||||
|
.prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?')
|
||||||
|
.get(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleAccountsFor(userId: string) {
|
||||||
|
return sqlite
|
||||||
|
.prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'")
|
||||||
|
.all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
function cookieHeaders(setCookie: string | null): Headers {
|
function cookieHeaders(setCookie: string | null): Headers {
|
||||||
const sessionPart = (setCookie || '')
|
const sessionPart = (setCookie || '')
|
||||||
.split(/,(?=[^ ;]+=)/)
|
.split(/,(?=[^ ;]+=)/)
|
||||||
@@ -60,6 +147,7 @@ beforeAll(() => {
|
|||||||
({ db } = await import('../db/index.js'));
|
({ db } = await import('../db/index.js'));
|
||||||
const Database = (await import('better-sqlite3')).default;
|
const Database = (await import('better-sqlite3')).default;
|
||||||
sqlite = new Database(dbPath);
|
sqlite = new Database(dbPath);
|
||||||
|
await installGoogleStub();
|
||||||
})();
|
})();
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
@@ -291,3 +379,141 @@ describe('Better Auth integration', () => {
|
|||||||
expect(days).toBeLessThan(7.5);
|
expect(days).toBeLessThan(7.5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('Google sign-in and account linking', () => {
|
||||||
|
it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => {
|
||||||
|
const id = insertBookingUser('booking-user-1', 'buyer@test.py');
|
||||||
|
expect(userRow('buyer@test.py').email_verified).toBe(0);
|
||||||
|
|
||||||
|
const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' });
|
||||||
|
|
||||||
|
expect(res.user.id).toBe(id);
|
||||||
|
expect(googleAccountsFor(id)).toHaveLength(1);
|
||||||
|
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims the booking account so the resulting session is actually accepted', async () => {
|
||||||
|
// getAuthUser() (lib/auth.ts) rejects any session whose user is not
|
||||||
|
// 'active', so linking alone would leave the user looking logged out.
|
||||||
|
const id = insertBookingUser('booking-user-2', 'buyer2@test.py');
|
||||||
|
|
||||||
|
const { headers, response } = await signInWithGoogle(
|
||||||
|
{ email: 'buyer2@test.py', sub: 'google-sub-buyer2' },
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(response.user.id).toBe(id);
|
||||||
|
|
||||||
|
const row = userRow('buyer2@test.py');
|
||||||
|
expect(row.account_status).toBe('active');
|
||||||
|
expect(row.is_claimed).toBe(1);
|
||||||
|
expect(row.email_verified).toBe(1);
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: cookieHeaders(headers.get('set-cookie')),
|
||||||
|
});
|
||||||
|
expect(session?.user.id).toBe(id);
|
||||||
|
expect((session?.user as any).accountStatus).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never reactivates a suspended account through a Google link', async () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)`
|
||||||
|
)
|
||||||
|
.run('suspended-google', 'suspended-google@test.py', now, now);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' })
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(userRow('suspended-google@test.py').account_status).toBe('suspended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links Google onto an email/password account created after the Better Auth migration', async () => {
|
||||||
|
// Better Auth writes email_verified = 0 on sign-up (requireEmailVerification
|
||||||
|
// is off), which used to be enough to block linking on its own.
|
||||||
|
await auth.api.signUpEmail({
|
||||||
|
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' },
|
||||||
|
});
|
||||||
|
expect(userRow('pwuser@test.py').email_verified).toBe(0);
|
||||||
|
const id = userRow('pwuser@test.py').id;
|
||||||
|
|
||||||
|
const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' });
|
||||||
|
|
||||||
|
expect(res.user.id).toBe(id);
|
||||||
|
expect(googleAccountsFor(id)).toHaveLength(1);
|
||||||
|
// The credential account survives: they can still sign in with a password.
|
||||||
|
const after = await auth.api.signInEmail({
|
||||||
|
body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' },
|
||||||
|
});
|
||||||
|
expect(after.user.id).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => {
|
||||||
|
const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
|
||||||
|
const row = userRow('fresh@test.py');
|
||||||
|
expect(row.id).toBe(first.user.id);
|
||||||
|
expect(row.email_verified).toBe(1);
|
||||||
|
expect(row.account_status).toBe('active');
|
||||||
|
|
||||||
|
const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' });
|
||||||
|
expect(second.user.id).toBe(first.user.id);
|
||||||
|
expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1);
|
||||||
|
expect(googleAccountsFor(first.user.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an id token minted for a different client id', async () => {
|
||||||
|
const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' })
|
||||||
|
.setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID })
|
||||||
|
.setIssuer('https://accounts.google.com')
|
||||||
|
.setAudience('some-other-app.apps.googleusercontent.com')
|
||||||
|
.setSubject('google-sub-forged')
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('10m')
|
||||||
|
.sign(googlePrivateKey);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
auth.api.signInSocial({
|
||||||
|
body: { provider: 'google', idToken: { token } },
|
||||||
|
headers: new Headers(),
|
||||||
|
} as any)
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(userRow('forged@test.py')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('users.email normalization', () => {
|
||||||
|
it('normalizes an address to its canonical stored form', () => {
|
||||||
|
expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const insert = (id: string, email: string, status = 'unclaimed') =>
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(id, email, status, now, now);
|
||||||
|
|
||||||
|
insert('legacy-mixed', 'John@Example.com');
|
||||||
|
// A pair that genuinely collides: the migration must leave both alone.
|
||||||
|
insert('legacy-dup-lower', 'dup@example.com');
|
||||||
|
insert('legacy-dup-mixed', 'Dup@Example.com');
|
||||||
|
|
||||||
|
// The backfill lives in migrate.ts and is idempotent, so just re-run it.
|
||||||
|
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||||
|
|
||||||
|
expect(userRow('john@example.com').id).toBe('legacy-mixed');
|
||||||
|
expect(userRow('John@Example.com')).toBeUndefined();
|
||||||
|
expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed');
|
||||||
|
expect(userRow('dup@example.com').id).toBe('legacy-dup-lower');
|
||||||
|
|
||||||
|
// ...and the lowercased row is now reachable by Google sign-in.
|
||||||
|
const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' });
|
||||||
|
expect(res.user.id).toBe('legacy-mixed');
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
|
|||||||
@@ -209,6 +209,25 @@ export const auth = betterAuth({
|
|||||||
// Google verifies email ownership, so linking by email is safe — this
|
// Google verifies email ownership, so linking by email is safe — this
|
||||||
// matches the legacy /api/auth/google auto-link behavior.
|
// matches the legacy /api/auth/google auto-link behavior.
|
||||||
trustedProviders: ['google'],
|
trustedProviders: ['google'],
|
||||||
|
// `trustedProviders` alone is NOT enough: better-auth ORs a second,
|
||||||
|
// independent gate — `requireLocalEmailVerified` (default true) — which
|
||||||
|
// refuses the link whenever the LOCAL users.email_verified is false.
|
||||||
|
// That is the state of every guest-booking user (routes/tickets.ts,
|
||||||
|
// routes/door.ts insert email_verified = false) and of every
|
||||||
|
// email/password signup made after the Better Auth migration, so Google
|
||||||
|
// sign-in failed for them with "account not linked".
|
||||||
|
//
|
||||||
|
// The local flag adds nothing here: the Google ID token is signature-
|
||||||
|
// verified against Google's JWKS with issuer/audience/max-age checks and
|
||||||
|
// carries its own `email_verified`, so Google — not our column — is what
|
||||||
|
// proves ownership of the address.
|
||||||
|
//
|
||||||
|
// NOTE: upstream marks this option deprecated ("the gate will become
|
||||||
|
// unconditional"). better-auth is pinned exactly at 1.6.25 in both
|
||||||
|
// workspaces, and betterAuth.integration.test.ts covers this path, so an
|
||||||
|
// upgrade that drops the option fails CI rather than silently locking
|
||||||
|
// ticket buyers out again.
|
||||||
|
requireLocalEmailVerified: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -246,6 +265,45 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
account: {
|
||||||
|
create: {
|
||||||
|
// Fires for both branches of the OAuth path: createOAuthUser (new user)
|
||||||
|
// and linkAccount (existing user), since both go through the adapter's
|
||||||
|
// createWithHooks(..., 'account').
|
||||||
|
after: async (account) => {
|
||||||
|
if (account.providerId !== 'google') return;
|
||||||
|
// Attaching a Google account proves ownership of the address, so a
|
||||||
|
// row created during guest booking is now a real, claimed account.
|
||||||
|
// Without this, getAuthUser() (lib/auth.ts) rejects the brand-new
|
||||||
|
// session because accountStatus is still 'unclaimed' — the user gets
|
||||||
|
// a cookie and still looks logged out. Mirrors the tail of the
|
||||||
|
// magic-link claim flow in routes/authExt.ts.
|
||||||
|
//
|
||||||
|
// Scoped to 'unclaimed' in the WHERE clause so a suspended account is
|
||||||
|
// never silently reactivated by linking Google to it.
|
||||||
|
try {
|
||||||
|
await (db as any)
|
||||||
|
.update(authUsers)
|
||||||
|
// `emailVerified` is deliberately left alone: better-auth's link
|
||||||
|
// branch sets it right after this hook, but only when Google's
|
||||||
|
// id_token actually asserted email_verified.
|
||||||
|
.set({
|
||||||
|
isClaimed: true,
|
||||||
|
accountStatus: 'active',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq((authUsers as any).id, account.userId),
|
||||||
|
eq((authUsers as any).accountStatus, 'unclaimed')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[auth] Failed to claim account on Google link:', err?.message || err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
hooks: {
|
hooks: {
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) {
|
|||||||
/**
|
/**
|
||||||
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
|
* Query: per-event breakdown of paid vs claimed seats, grouped by event.
|
||||||
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
|
* paidCount = confirmed + checked_in; claimedCount = pending_approval-held.
|
||||||
* Pass `eventId` to restrict to one event (still returns a grouped row).
|
* Pass `eventId` to restrict to one event (still returns a grouped row), or an
|
||||||
|
* array of ids to restrict to those events (e.g. one page of a listing). Callers
|
||||||
|
* must skip the query for an empty array.
|
||||||
*/
|
*/
|
||||||
export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
|
export function eventSeatBreakdownQuery(executor: any, eventId?: string | string[]) {
|
||||||
const query = executor
|
const query = executor
|
||||||
.select({
|
.select({
|
||||||
eventId: (tickets as any).eventId,
|
eventId: (tickets as any).eventId,
|
||||||
@@ -65,6 +67,8 @@ export function eventSeatBreakdownQuery(executor: any, eventId?: string) {
|
|||||||
})
|
})
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
|
.leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id));
|
||||||
return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query)
|
const scoped = Array.isArray(eventId)
|
||||||
.groupBy((tickets as any).eventId);
|
? query.where(inArray((tickets as any).eventId, eventId))
|
||||||
|
: eventId ? query.where(eq((tickets as any).eventId, eventId)) : query;
|
||||||
|
return scoped.groupBy((tickets as any).eventId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Door payment tenders.
|
||||||
|
//
|
||||||
|
// The door check-in screen offers five tenders. Each maps onto an
|
||||||
|
// existing payments.provider so the rest of the app (capacity, sweeps, admin
|
||||||
|
// payment lists, receipts) keeps working unchanged, while payments.method
|
||||||
|
// records which tender was actually used for the end-of-night cash-up.
|
||||||
|
//
|
||||||
|
// Bitcoin currently maps to the 'lightning' provider but records the payment as
|
||||||
|
// already made — the same trust model as cash, no invoice generated. When a real
|
||||||
|
// Lightning flow lands it slots in here: the tender keeps its name and provider,
|
||||||
|
// only the settlement path in routes/door.ts changes.
|
||||||
|
//
|
||||||
|
// POS is the physical card terminal. Staff open the POS step, which shows the
|
||||||
|
// amount to key into the terminal, charge the card, then confirm "Mark as paid";
|
||||||
|
// only that confirmation reaches this API, recorded like any other tender. A
|
||||||
|
// future automatic amount push to the terminal belongs in the POS step itself
|
||||||
|
// (frontend PosChargePanel) plus a 'terminal' settlement here — nothing talks to
|
||||||
|
// the terminal today.
|
||||||
|
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, dbGet, paymentOptions, eventPaymentOverrides } from '../db/index.js';
|
||||||
|
|
||||||
|
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'pos', 'guest'] as const;
|
||||||
|
|
||||||
|
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
|
||||||
|
|
||||||
|
interface DoorTender {
|
||||||
|
/** Existing payments.provider this tender is stored as. */
|
||||||
|
provider: 'cash' | 'lightning' | 'bank_transfer' | 'pos';
|
||||||
|
/** Human label used in payment references and toasts. */
|
||||||
|
label: string;
|
||||||
|
/** Comp tenders carry no revenue and always record a zero amount. */
|
||||||
|
isComp: boolean;
|
||||||
|
/**
|
||||||
|
* How the money is confirmed before the door screen records it:
|
||||||
|
* 'on_tap' staff already hold the money when they tap (cash, …)
|
||||||
|
* 'staff_confirm' staff charge an external device first, then confirm
|
||||||
|
*/
|
||||||
|
confirmation: 'on_tap' | 'staff_confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
|
||||||
|
cash: { provider: 'cash', label: 'cash', isComp: false, confirmation: 'on_tap' },
|
||||||
|
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false, confirmation: 'on_tap' },
|
||||||
|
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false, confirmation: 'on_tap' },
|
||||||
|
pos: { provider: 'pos', label: 'POS', isComp: false, confirmation: 'staff_confirm' },
|
||||||
|
guest: { provider: 'cash', label: 'guest', isComp: true, confirmation: 'on_tap' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenders that can be switched off per event through payment options. Only POS
|
||||||
|
* is configurable: the other door tenders are always available to staff.
|
||||||
|
*/
|
||||||
|
export function enabledDoorMethods(opts: { posEnabled: boolean }): DoorPaymentMethod[] {
|
||||||
|
return DOOR_PAYMENT_METHODS.filter((m) => m !== 'pos' || opts.posEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Door tenders available for this event (POS can be switched off per event). */
|
||||||
|
export async function loadDoorMethods(eventId: string): Promise<DoorPaymentMethod[]> {
|
||||||
|
const [globalOptions, overrides] = await Promise.all([
|
||||||
|
dbGet<any>((db as any).select().from(paymentOptions)),
|
||||||
|
dbGet<any>(
|
||||||
|
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, eventId))
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
// Override wins when set; POS defaults to on when nothing is configured.
|
||||||
|
const posEnabled = overrides?.posEnabled ?? globalOptions?.posEnabled ?? true;
|
||||||
|
return enabledDoorMethods({ posEnabled: posEnabled === true || posEnabled === 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
|
||||||
|
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticket paymentStatus a tender settles to: comps are 'comp', everything else 'paid'. */
|
||||||
|
export function paymentStatusForMethod(method: DoorPaymentMethod): 'paid' | 'comp' {
|
||||||
|
return DOOR_TENDERS[method].isComp ? 'comp' : 'paid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount actually recorded: comps are always zero regardless of what was requested. */
|
||||||
|
export function amountForMethod(method: DoorPaymentMethod, requested: number): number {
|
||||||
|
return DOOR_TENDERS[method].isComp ? 0 : Math.max(0, requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doorReference(method: DoorPaymentMethod): string {
|
||||||
|
return DOOR_TENDERS[method].isComp
|
||||||
|
? 'Door — guest (comp)'
|
||||||
|
: `Door — paid by ${DOOR_TENDERS[method].label}`;
|
||||||
|
}
|
||||||
@@ -74,8 +74,8 @@ export async function sendPaymentReceipt(paymentId: string): Promise<{ success:
|
|||||||
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
|
||||||
|
|
||||||
const paymentMethodNames: Record<string, Record<string, string>> = {
|
const paymentMethodNames: Record<string, Record<string, string>> = {
|
||||||
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
|
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago', pos: 'POS' },
|
||||||
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
|
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago', pos: 'POS' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// Per-event access control.
|
||||||
|
//
|
||||||
|
// Global roles still work exactly as before: every route passes the roles it
|
||||||
|
// always allowed (`globalRoles`). On top of that, a user linked to one event
|
||||||
|
// through event_members gets the permissions of their role preset, adjusted by
|
||||||
|
// per-member overrides — for that event only. A collaborator with role 'user'
|
||||||
|
// therefore reaches the routes of their own events and gets 403 everywhere
|
||||||
|
// else.
|
||||||
|
//
|
||||||
|
// Finance and team management are deliberately NOT part of any global role
|
||||||
|
// except admin: organizers only see them on events where an admin granted it.
|
||||||
|
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { db, dbGet, eventMembers, tickets, payments } from '../db/index.js';
|
||||||
|
import { getAuthUser, type AuthUser } from './auth.js';
|
||||||
|
|
||||||
|
export const EVENT_PERMISSIONS = [
|
||||||
|
'view_overview',
|
||||||
|
'check_in',
|
||||||
|
'view_attendees_names',
|
||||||
|
'view_attendees_pii',
|
||||||
|
'email_attendees',
|
||||||
|
'view_payments',
|
||||||
|
'view_finance',
|
||||||
|
'edit_expenses',
|
||||||
|
'edit_own_expenses_only',
|
||||||
|
'view_full_split',
|
||||||
|
'edit_event',
|
||||||
|
'manage_team',
|
||||||
|
] as const;
|
||||||
|
export type EventPermission = (typeof EVENT_PERMISSIONS)[number];
|
||||||
|
|
||||||
|
export const ROLE_PRESETS = ['staff', 'collaborator', 'co_manager'] as const;
|
||||||
|
export type RolePreset = (typeof ROLE_PRESETS)[number];
|
||||||
|
|
||||||
|
export const PRESET_PERMISSIONS: Record<RolePreset, readonly EventPermission[]> = {
|
||||||
|
staff: ['view_overview', 'check_in', 'view_attendees_names'],
|
||||||
|
// Sees the event's P&L and only their own share, not the full split.
|
||||||
|
collaborator: ['view_overview', 'view_finance'],
|
||||||
|
co_manager: EVENT_PERMISSIONS.filter((p) => p !== 'manage_team'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a global role can already do on every event, mirroring the existing
|
||||||
|
* route allowlists. Used for the UI (which tabs to show) and for new routes.
|
||||||
|
*/
|
||||||
|
export const GLOBAL_ROLE_PERMISSIONS: Record<string, readonly EventPermission[]> = {
|
||||||
|
admin: EVENT_PERMISSIONS,
|
||||||
|
organizer: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii', 'email_attendees', 'view_payments', 'edit_event'],
|
||||||
|
staff: ['view_overview', 'check_in', 'view_attendees_names', 'view_attendees_pii'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isEventPermission(key: string): key is EventPermission {
|
||||||
|
return (EVENT_PERMISSIONS as readonly string[]).includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOverrides(raw: unknown): Partial<Record<EventPermission, boolean>> {
|
||||||
|
let obj: any = raw;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
try { obj = JSON.parse(raw); } catch { obj = {}; }
|
||||||
|
}
|
||||||
|
const out: Partial<Record<EventPermission, boolean>> = {};
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
|
if (isEventPermission(k) && typeof v === 'boolean') out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preset permissions with the member's overrides applied in either direction. */
|
||||||
|
export function resolveMemberPermissions(preset: string, overrides: unknown): Set<EventPermission> {
|
||||||
|
const base = PRESET_PERMISSIONS[preset as RolePreset] || [];
|
||||||
|
const set = new Set<EventPermission>(base);
|
||||||
|
for (const [k, v] of Object.entries(parseOverrides(overrides))) {
|
||||||
|
if (v) set.add(k as EventPermission); else set.delete(k as EventPermission);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventAccess {
|
||||||
|
eventId: string | null;
|
||||||
|
/** True when the user's global role passed the route's allowlist. */
|
||||||
|
global: boolean;
|
||||||
|
role: string;
|
||||||
|
permissions: Set<EventPermission>;
|
||||||
|
membership: { id: string; rolePreset: RolePreset } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMembership(userId: string, eventId: string) {
|
||||||
|
return dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(eventMembers)
|
||||||
|
.where(and(eq((eventMembers as any).eventId, eventId), eq((eventMembers as any).userId, userId)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Union of what the user's global role and their membership (if any) grant on this event. */
|
||||||
|
export async function getEffectivePermissions(user: Pick<AuthUser, 'id' | 'role'>, eventId: string): Promise<EventAccess> {
|
||||||
|
const permissions = new Set<EventPermission>(GLOBAL_ROLE_PERMISSIONS[user.role] || []);
|
||||||
|
const member = await getMembership(user.id, eventId);
|
||||||
|
if (member) {
|
||||||
|
for (const p of resolveMemberPermissions(member.rolePreset, member.permissions)) permissions.add(p);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
eventId,
|
||||||
|
global: user.role === 'admin',
|
||||||
|
role: user.role,
|
||||||
|
permissions,
|
||||||
|
membership: member ? { id: member.id, rolePreset: member.rolePreset } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canUnfinalize(access: EventAccess): boolean {
|
||||||
|
return access.role === 'admin' || access.membership?.rolePreset === 'co_manager';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Event id resolvers ====================
|
||||||
|
|
||||||
|
type EventIdResolver = (c: Context) => Promise<string | null> | string | null;
|
||||||
|
|
||||||
|
export const eventFromParam = (name = 'id'): EventIdResolver => (c) => c.req.param(name) || null;
|
||||||
|
|
||||||
|
export const eventFromQuery = (name = 'eventId'): EventIdResolver => (c) => c.req.query(name) || null;
|
||||||
|
|
||||||
|
/** Reads eventId from a JSON body. Hono caches the parsed body, so validators can read it again. */
|
||||||
|
export const eventFromBody = (name = 'eventId'): EventIdResolver => async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json();
|
||||||
|
return typeof body?.[name] === 'string' ? body[name] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Query string first, then the JSON body (routes that accept both). */
|
||||||
|
export const eventFromQueryOrBody = (name = 'eventId'): EventIdResolver => async (c) =>
|
||||||
|
eventFromQuery(name)(c) || (c.req.method === 'GET' ? null : await eventFromBody(name)(c));
|
||||||
|
|
||||||
|
export const eventFromTicketParam = (name = 'id'): EventIdResolver => async (c) => {
|
||||||
|
const id = c.req.param(name);
|
||||||
|
if (!id) return null;
|
||||||
|
const row = await dbGet<any>(
|
||||||
|
(db as any).select({ eventId: (tickets as any).eventId }).from(tickets).where(eq((tickets as any).id, id))
|
||||||
|
);
|
||||||
|
return row?.eventId ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventFromPaymentParam = (name = 'id'): EventIdResolver => async (c) => {
|
||||||
|
const id = c.req.param(name);
|
||||||
|
if (!id) return null;
|
||||||
|
const row = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ eventId: (tickets as any).eventId })
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||||
|
.where(eq((payments as any).id, id))
|
||||||
|
);
|
||||||
|
return row?.eventId ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Middleware ====================
|
||||||
|
|
||||||
|
export interface RequireEventPermissionOptions {
|
||||||
|
/** Global roles that pass on every event (the route's existing allowlist). Default: admin only. */
|
||||||
|
globalRoles?: readonly string[];
|
||||||
|
/** Where the event id comes from. Default: the :id path param. */
|
||||||
|
eventId?: EventIdResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow the request when the user's global role is in `globalRoles`, or when
|
||||||
|
* their membership on the resolved event grants any of `keys`. Sets
|
||||||
|
* c.get('user') like requireAuth, and c.get('eventAccess') for handlers that
|
||||||
|
* shape their response (e.g. hide attendee contact details).
|
||||||
|
*/
|
||||||
|
export function requireEventPermission(
|
||||||
|
keys: EventPermission | readonly EventPermission[],
|
||||||
|
opts: RequireEventPermissionOptions = {},
|
||||||
|
) {
|
||||||
|
const wanted = (Array.isArray(keys) ? keys : [keys]) as readonly EventPermission[];
|
||||||
|
const globalRoles = opts.globalRoles || ['admin'];
|
||||||
|
const resolve = opts.eventId || eventFromParam('id');
|
||||||
|
|
||||||
|
return async (c: Context, next: () => Promise<void>) => {
|
||||||
|
const user = await getAuthUser(c);
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
c.set('user', user);
|
||||||
|
|
||||||
|
if (globalRoles.includes(user.role)) {
|
||||||
|
const eventId = await resolve(c);
|
||||||
|
c.set('eventAccess', {
|
||||||
|
eventId,
|
||||||
|
global: true,
|
||||||
|
role: user.role,
|
||||||
|
permissions: new Set(GLOBAL_ROLE_PERMISSIONS[user.role] || EVENT_PERMISSIONS),
|
||||||
|
membership: null,
|
||||||
|
} satisfies EventAccess);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = await resolve(c);
|
||||||
|
if (!eventId) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
const access = await getEffectivePermissions(user, eventId);
|
||||||
|
if (!wanted.some((k) => access.permissions.has(k))) {
|
||||||
|
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: wanted[0] }, 403);
|
||||||
|
}
|
||||||
|
c.set('eventAccess', access);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEventAccess(c: Context): EventAccess | null {
|
||||||
|
return ((c as any).get('eventAccess') as EventAccess | undefined) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the request may see attendee contact details (email, phone, RUC). */
|
||||||
|
export function canSeeAttendeePii(c: Context): boolean {
|
||||||
|
const access = getEventAccess(c);
|
||||||
|
return !access || access.global || access.permissions.has('view_attendees_pii');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop attendee contact details for members without view_attendees_pii. */
|
||||||
|
export function redactAttendee<T extends Record<string, any>>(t: T): T {
|
||||||
|
const { attendeeEmail, attendeePhone, attendeeRuc, ...rest } = t as any;
|
||||||
|
return { ...rest, attendeeEmail: null, attendeePhone: null, attendeeRuc: null } as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// finance_audit_log rows. Returned as TxOps so every change and its audit row
|
||||||
|
// commit (or roll back) together.
|
||||||
|
|
||||||
|
import { financeAuditLog } from '../../db/index.js';
|
||||||
|
import { generateId, getNow } from '../utils.js';
|
||||||
|
import { insertOp, type TxOp } from '../txOps.js';
|
||||||
|
|
||||||
|
export type FinanceEntity =
|
||||||
|
| 'expense' | 'other_income' | 'partner' | 'finance_state' | 'member'
|
||||||
|
| 'expense_category' | 'expense_template' | 'expense_template_pack' | 'payment_fee';
|
||||||
|
|
||||||
|
export function financeAuditOp(entry: {
|
||||||
|
eventId: string | null;
|
||||||
|
actorUserId: string;
|
||||||
|
entityType: FinanceEntity;
|
||||||
|
entityId: string | null;
|
||||||
|
action: string;
|
||||||
|
before?: unknown;
|
||||||
|
after?: unknown;
|
||||||
|
}): TxOp {
|
||||||
|
return insertOp(financeAuditLog, {
|
||||||
|
id: generateId(),
|
||||||
|
eventId: entry.eventId,
|
||||||
|
actorUserId: entry.actorUserId,
|
||||||
|
entityType: entry.entityType,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
action: entry.action,
|
||||||
|
beforeJson: entry.before === undefined || entry.before === null ? null : JSON.stringify(entry.before),
|
||||||
|
afterJson: entry.after === undefined || entry.after === null ? null : JSON.stringify(entry.after),
|
||||||
|
createdAt: getNow(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
calculateEventFinance,
|
||||||
|
expenseAmount,
|
||||||
|
paymentFee,
|
||||||
|
roundPyg,
|
||||||
|
type FinanceExpense,
|
||||||
|
type FinanceInput,
|
||||||
|
type FinancePartner,
|
||||||
|
type FinancePayment,
|
||||||
|
} from './calculate.js';
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
|
||||||
|
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
|
||||||
|
});
|
||||||
|
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
|
||||||
|
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
|
||||||
|
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
|
||||||
|
});
|
||||||
|
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
|
||||||
|
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
|
||||||
|
lossRule: 'none', lossCapAmount: 0, ...over,
|
||||||
|
});
|
||||||
|
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
|
||||||
|
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
|
||||||
|
});
|
||||||
|
const sumShares = (r: ReturnType<typeof calculateEventFinance>) =>
|
||||||
|
r.split.partners.reduce((s, p) => s + p.share, 0) + r.split.organization;
|
||||||
|
|
||||||
|
describe('calculateEventFinance: profit case', () => {
|
||||||
|
const studio = partner({ name: 'Studio', percentBp: 3000 });
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketsSold: 20,
|
||||||
|
checkedIn: 18,
|
||||||
|
payments: [
|
||||||
|
...Array.from({ length: 15 }, () => pay(100000)),
|
||||||
|
...Array.from({ length: 3 }, () => pay(100000, { provider: 'lightning' })),
|
||||||
|
pay(120000, { provider: 'cash', source: 'door', paidAt: '2026-09-05T20:00:00Z' }),
|
||||||
|
pay(120000, { provider: 'pos', source: 'door', paidAt: '2026-09-05T20:10:00Z' }),
|
||||||
|
],
|
||||||
|
fees: [
|
||||||
|
{ method: 'tpago', percentBp: 350, fixedAmount: 0 }, // 3.5%
|
||||||
|
{ method: 'pos', percentBp: 290, fixedAmount: 500 },
|
||||||
|
],
|
||||||
|
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 150000 }],
|
||||||
|
expenses: [expense({ unitAmount: 800000, status: 'paid' }), expense({ calcType: 'per_checked_in', unitAmount: 15000 })],
|
||||||
|
partners: [studio],
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('splits gross into pre-sale and door and by method', () => {
|
||||||
|
expect(r.revenue.gross).toBe(2040000);
|
||||||
|
expect(r.revenue.presale).toBe(1800000);
|
||||||
|
expect(r.revenue.door).toBe(240000);
|
||||||
|
expect(r.revenue.byMethod.map((m) => [m.method, m.gross])).toEqual([
|
||||||
|
['tpago', 1500000], ['lightning', 300000], ['cash', 120000], ['pos', 120000],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges fees per payment from the method rules', () => {
|
||||||
|
// tpago: 15 x 3500; pos: 3480 + 500; lightning and cash have no rule
|
||||||
|
expect(r.revenue.fees).toBe(15 * 3500 + 3980);
|
||||||
|
expect(r.revenue.byMethod.find((m) => m.method === 'pos')!.fees).toBe(3980);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds other income into net revenue and subtracts expenses', () => {
|
||||||
|
expect(r.revenue.net).toBe(2040000 - 56480 + 150000);
|
||||||
|
expect(r.expenses.total).toBe(800000 + 18 * 15000);
|
||||||
|
expect(r.expenses.paid).toBe(800000);
|
||||||
|
expect(r.expenses.planned).toBe(270000);
|
||||||
|
expect(r.profit).toBe(2133520 - 1070000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the partner its percentage of profit and the organization the rest', () => {
|
||||||
|
expect(r.split.partners[0].share).toBe(roundPyg(1063520 * 0.3));
|
||||||
|
expect(r.split.organization).toBe(1063520 - roundPyg(1063520 * 0.3));
|
||||||
|
expect(sumShares(r)).toBe(r.profit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a cumulative sales timeline and a waterfall ending in the split', () => {
|
||||||
|
expect(r.salesTimeline).toEqual([
|
||||||
|
{ date: '2026-09-01', tickets: 18, revenue: 1800000 },
|
||||||
|
{ date: '2026-09-05', tickets: 20, revenue: 2040000 },
|
||||||
|
]);
|
||||||
|
expect(r.waterfall.map((w) => w.key)).toEqual([
|
||||||
|
'gross', 'refunds', 'fees', 'otherIncome', 'net', 'expenses', 'profit', `partner:${studio.id}`, 'organization',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateEventFinance: refunds', () => {
|
||||||
|
it('subtracts refunded payments and charges no fee on them', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
payments: [pay(100000), pay(100000), pay(100000, { status: 'refunded' })],
|
||||||
|
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
|
||||||
|
}));
|
||||||
|
expect(r.revenue.gross).toBe(300000);
|
||||||
|
expect(r.revenue.refunds).toBe(100000);
|
||||||
|
expect(r.revenue.sales).toBe(200000);
|
||||||
|
expect(r.revenue.fees).toBe(20000);
|
||||||
|
expect(r.revenue.net).toBe(180000);
|
||||||
|
expect(r.salesTimeline.at(-1)).toEqual({ date: '2026-09-01', tickets: 2, revenue: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses sales after refunds as the base for revenue percentages', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
payments: [pay(100000), pay(100000, { status: 'refunded' })],
|
||||||
|
expenses: [expense({ calcType: 'percent_of_revenue', percentBp: 1000 })],
|
||||||
|
}));
|
||||||
|
expect(r.expenses.total).toBe(10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expense calc types', () => {
|
||||||
|
const ctx = { ticketsSold: 30, checkedIn: 25, sales: 3000000 };
|
||||||
|
|
||||||
|
it('fixed multiplies units by the unit amount', () => {
|
||||||
|
expect(expenseAmount(expense({ quantity: 3, unitAmount: 50000 }), ctx)).toEqual({ quantity: 3, amount: 150000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('per_ticket_sold follows tickets sold', () => {
|
||||||
|
expect(expenseAmount(expense({ calcType: 'per_ticket_sold', unitAmount: 10000 }), ctx)).toEqual({ quantity: 30, amount: 300000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('per_checked_in follows check-ins', () => {
|
||||||
|
expect(expenseAmount(expense({ calcType: 'per_checked_in', unitAmount: 10000 }), ctx)).toEqual({ quantity: 25, amount: 250000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('percent_of_revenue takes basis points of sales, rounded to the guaraní', () => {
|
||||||
|
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 1250 }), ctx).amount).toBe(375000);
|
||||||
|
expect(expenseAmount(expense({ calcType: 'percent_of_revenue', percentBp: 333 }), { ...ctx, sales: 1001 }).amount).toBe(33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('minimum_spend charges the minimum until per-head spend passes it', () => {
|
||||||
|
const e = expense({ calcType: 'minimum_spend', unitAmount: 40000, minimumAmount: 1500000 });
|
||||||
|
expect(expenseAmount(e, { ...ctx, checkedIn: 20 }).amount).toBe(1500000); // 800k < minimum
|
||||||
|
expect(expenseAmount(e, { ...ctx, checkedIn: 50 }).amount).toBe(2000000); // 2.0M > minimum
|
||||||
|
expect(expenseAmount(e, { ...ctx, checkedIn: 0 }).amount).toBe(1500000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locked rows keep their stored amount regardless of counts', () => {
|
||||||
|
const e = expense({ calcType: 'per_checked_in', unitAmount: 10000, isLocked: true, computedAmount: 123000, quantity: 12 });
|
||||||
|
expect(expenseAmount(e, ctx)).toEqual({ quantity: 12, amount: 123000 });
|
||||||
|
const r = calculateEventFinance(input({ checkedIn: 99, expenses: [e] }));
|
||||||
|
expect(r.expenses.lines[0]).toMatchObject({ amount: 123000, auto: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks unlocked non-fixed rows as auto-calculated', () => {
|
||||||
|
const r = calculateEventFinance(input({ expenses: [expense(), expense({ calcType: 'per_ticket_sold' })] }));
|
||||||
|
expect(r.expenses.lines.map((l) => l.auto)).toEqual([false, true]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateEventFinance: loss rules', () => {
|
||||||
|
// Net revenue 500k, expenses 1.5M => profit -1,000,000
|
||||||
|
const lossInput = (p: FinancePartner) => input({
|
||||||
|
payments: [pay(500000)],
|
||||||
|
expenses: [expense({ unitAmount: 1500000 })],
|
||||||
|
partners: [p],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proportional: the partner carries its percentage of the loss', () => {
|
||||||
|
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'proportional' })));
|
||||||
|
expect(r.profit).toBe(-1000000);
|
||||||
|
expect(r.split.partners[0].share).toBe(-400000);
|
||||||
|
expect(r.split.organization).toBe(-600000);
|
||||||
|
expect(sumShares(r)).toBe(r.profit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('none: the organization carries the whole loss', () => {
|
||||||
|
const r = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'none' })));
|
||||||
|
expect(r.split.partners[0].share).toBe(0);
|
||||||
|
expect(r.split.organization).toBe(-1000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('capped: the partner carries its percentage up to the cap', () => {
|
||||||
|
const capped = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 250000 })));
|
||||||
|
expect(capped.split.partners[0].share).toBe(-250000);
|
||||||
|
expect(capped.split.organization).toBe(-750000);
|
||||||
|
// A cap larger than the proportional share does not increase it
|
||||||
|
const loose = calculateEventFinance(lossInput(partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 900000 })));
|
||||||
|
expect(loose.split.partners[0].share).toBe(-400000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loss rules do not change a profitable split', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
payments: [pay(1000000)],
|
||||||
|
partners: [partner({ percentBp: 4000, lossRule: 'capped', lossCapAmount: 1 })],
|
||||||
|
}));
|
||||||
|
expect(r.split.partners[0].share).toBe(400000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateEventFinance: reimbursements', () => {
|
||||||
|
it('adds paid costs a partner fronted to their payout without counting them twice', () => {
|
||||||
|
const host = partner({ name: 'Host', percentBp: 5000 });
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
payments: [pay(1000000)],
|
||||||
|
expenses: [
|
||||||
|
expense({ unitAmount: 200000, status: 'paid', paidByPartnerId: host.id }),
|
||||||
|
expense({ unitAmount: 100000, status: 'planned', paidByPartnerId: host.id }), // not fronted yet
|
||||||
|
expense({ unitAmount: 100000, status: 'paid' }),
|
||||||
|
],
|
||||||
|
partners: [host],
|
||||||
|
}));
|
||||||
|
expect(r.profit).toBe(600000);
|
||||||
|
const h = r.split.partners[0];
|
||||||
|
expect(h.reimbursement).toBe(200000);
|
||||||
|
expect(h.share).toBe(300000);
|
||||||
|
expect(h.payout).toBe(500000);
|
||||||
|
expect(r.split.organization).toBe(300000);
|
||||||
|
expect(sumShares(r)).toBe(r.profit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nets a loss share against a reimbursement', () => {
|
||||||
|
const host = partner({ percentBp: 5000, lossRule: 'proportional' });
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
payments: [pay(100000)],
|
||||||
|
expenses: [expense({ unitAmount: 300000, status: 'paid', paidByPartnerId: host.id })],
|
||||||
|
partners: [host],
|
||||||
|
}));
|
||||||
|
expect(r.profit).toBe(-200000);
|
||||||
|
expect(r.split.partners[0]).toMatchObject({ share: -100000, reimbursement: 300000, payout: 200000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateEventFinance: partner share types', () => {
|
||||||
|
it('percent_revenue is paid on sales after refunds, before profit shares', () => {
|
||||||
|
const venue = partner({ name: 'Venue', shareType: 'percent_revenue', percentBp: 2000 });
|
||||||
|
const cohost = partner({ name: 'Co-host', shareType: 'percent_profit', percentBp: 5000 });
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
payments: [pay(600000), pay(400000), pay(100000, { status: 'refunded' })],
|
||||||
|
expenses: [expense({ unitAmount: 300000 })],
|
||||||
|
partners: [venue, cohost],
|
||||||
|
}));
|
||||||
|
expect(r.profit).toBe(700000);
|
||||||
|
expect(r.split.partners[0].share).toBe(200000); // 20% of 1,000,000
|
||||||
|
expect(r.split.distributable).toBe(500000);
|
||||||
|
expect(r.split.partners[1].share).toBe(250000);
|
||||||
|
expect(r.split.organization).toBe(250000);
|
||||||
|
expect(sumShares(r)).toBe(r.profit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('percent_revenue is still owed when the event loses money', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
payments: [pay(500000)],
|
||||||
|
expenses: [expense({ unitAmount: 800000 })],
|
||||||
|
partners: [partner({ shareType: 'percent_revenue', percentBp: 1000 })],
|
||||||
|
}));
|
||||||
|
expect(r.split.partners[0].share).toBe(50000);
|
||||||
|
expect(r.split.organization).toBe(-350000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fixed is owed regardless of profit', () => {
|
||||||
|
const r = calculateEventFinance(input({ partners: [partner({ shareType: 'fixed', fixedAmount: 300000 })] }));
|
||||||
|
expect(r.split.partners[0].share).toBe(300000);
|
||||||
|
expect(r.split.organization).toBe(-300000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fixed_plus_percent_above_threshold pays the fixed part plus a percentage above the threshold', () => {
|
||||||
|
const p = partner({ shareType: 'fixed_plus_percent_above_threshold', fixedAmount: 100000, percentBp: 1000, thresholdAmount: 500000 });
|
||||||
|
const high = calculateEventFinance(input({ payments: [pay(1600000)], partners: [p] }));
|
||||||
|
// distributable = 1.6M - 100k fixed = 1.5M; 10% of (1.5M - 500k) = 100k
|
||||||
|
expect(high.split.partners[0].share).toBe(200000);
|
||||||
|
expect(sumShares(high)).toBe(high.profit);
|
||||||
|
const low = calculateEventFinance(input({ payments: [pay(400000)], partners: [p] }));
|
||||||
|
expect(low.split.partners[0].share).toBe(100000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateEventFinance: break-even', () => {
|
||||||
|
it('finds the smallest ticket count that covers all expenses', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 100000,
|
||||||
|
ticketsSold: 4,
|
||||||
|
expenses: [expense({ unitAmount: 1000000 }), expense({ calcType: 'per_ticket_sold', unitAmount: 20000 })],
|
||||||
|
}));
|
||||||
|
// 80k contribution per ticket => ceil(1,000,000 / 80,000) = 13
|
||||||
|
expect(r.breakEven).toEqual({ tickets: 13, ticketPrice: 100000, remaining: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for minimum spend, other income and fees', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 100000,
|
||||||
|
fees: [{ method: 'tpago', percentBp: 1000, fixedAmount: 0 }],
|
||||||
|
otherIncome: [{ id: 'i', description: 's', amount: 90000 }],
|
||||||
|
expenses: [expense({ calcType: 'minimum_spend', unitAmount: 30000, minimumAmount: 900000 })],
|
||||||
|
}));
|
||||||
|
// 90k net per ticket, 90k income: 9 tickets => 810k + 90k = 900k = minimum
|
||||||
|
expect(r.breakEven.tickets).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null when a ticket cannot cover its own variable cost, or the price is 0', () => {
|
||||||
|
expect(calculateEventFinance(input({ expenses: [expense({ calcType: 'per_ticket_sold', unitAmount: 150000 }), expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
|
||||||
|
expect(calculateEventFinance(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1 })] })).breakEven.tickets).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when there is nothing to cover', () => {
|
||||||
|
expect(calculateEventFinance(input()).breakEven.tickets).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('paymentFee', () => {
|
||||||
|
it('adds the fixed part and skips free payments', () => {
|
||||||
|
expect(paymentFee(100000, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(3400);
|
||||||
|
expect(paymentFee(0, { method: 'pos', percentBp: 290, fixedAmount: 500 })).toBe(0);
|
||||||
|
expect(paymentFee(100000, undefined)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression scenarios pinned while reworking the Finance tab UI: the UI now
|
||||||
|
// derives its waterfall, break-even and lifecycle from these results, so the
|
||||||
|
// numbers themselves must not move.
|
||||||
|
describe('calculateEventFinance: pinned scenarios', () => {
|
||||||
|
const tpagoFee = { method: 'tpago', percentBp: 290, fixedAmount: 0 };
|
||||||
|
|
||||||
|
it('no expenses: profit is revenue after fees and break-even is 0', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||||
|
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
|
||||||
|
fees: [tpagoFee],
|
||||||
|
}));
|
||||||
|
expect(r.revenue).toMatchObject({ gross: 200000, presale: 200000, door: 0, fees: 5800, sales: 200000, net: 194200 });
|
||||||
|
expect(r.expenses.total).toBe(0);
|
||||||
|
expect(r.profit).toBe(194200);
|
||||||
|
expect(r.breakEven).toEqual({ tickets: 0, ticketPrice: 50000, remaining: 0 });
|
||||||
|
expect(r.split).toEqual({ distributable: 194200, partners: [], organization: 194200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a loss: planned and paid costs above revenue, break-even beyond sales', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||||
|
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
|
||||||
|
fees: [tpagoFee],
|
||||||
|
expenses: [
|
||||||
|
expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000, status: 'paid' }),
|
||||||
|
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
expect(r.revenue).toMatchObject({ gross: 200000, presale: 150000, door: 50000, fees: 4350, net: 195650 });
|
||||||
|
expect(r.expenses).toMatchObject({ total: 340000, paid: 300000, planned: 40000 });
|
||||||
|
expect(r.profit).toBe(-144350);
|
||||||
|
// 218 bp fee mix: each extra ticket adds 50000 - 1090 - 10000 = 38910 against 300000 fixed.
|
||||||
|
expect(r.breakEven).toEqual({ tickets: 8, ticketPrice: 50000, remaining: 4 });
|
||||||
|
expect(r.split.organization).toBe(-144350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partners: fixed deal first, then percent of what is left, reimbursement on top', () => {
|
||||||
|
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
|
||||||
|
const venue = partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 });
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
|
||||||
|
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
|
||||||
|
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
|
||||||
|
expenses: [
|
||||||
|
expense({ calcType: 'fixed', quantity: 1, unitAmount: 200000, status: 'paid' }),
|
||||||
|
expense({ calcType: 'fixed', quantity: 1, unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
|
||||||
|
],
|
||||||
|
partners: [ana, venue],
|
||||||
|
}));
|
||||||
|
expect(r.revenue).toMatchObject({ gross: 1000000, fees: 0, otherIncome: 50000, net: 1050000 });
|
||||||
|
expect(r.profit).toBe(700000);
|
||||||
|
expect(r.split.distributable).toBe(600000);
|
||||||
|
const byName = Object.fromEntries(r.split.partners.map((p) => [p.name, p]));
|
||||||
|
expect(byName.Ana).toMatchObject({ share: 180000, reimbursement: 150000, payout: 330000 });
|
||||||
|
expect(byName['Venue Co']).toMatchObject({ share: 100000, reimbursement: 0, payout: 100000 });
|
||||||
|
expect(r.split.organization).toBe(420000);
|
||||||
|
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateEventFinance: break-even edges', () => {
|
||||||
|
it('lands exactly on the ticket where profit reaches 0', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 100000, ticketsSold: 1,
|
||||||
|
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
|
||||||
|
}));
|
||||||
|
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports 0 remaining once sales pass break-even', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 100000, ticketsSold: 5,
|
||||||
|
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 250000 })],
|
||||||
|
}));
|
||||||
|
expect(r.breakEven).toEqual({ tickets: 3, ticketPrice: 100000, remaining: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('other income can cover costs before any ticket', () => {
|
||||||
|
const r = calculateEventFinance(input({
|
||||||
|
ticketPrice: 100000,
|
||||||
|
otherIncome: [{ id: 'i', description: 'Sponsor', amount: 500000 }],
|
||||||
|
expenses: [expense({ calcType: 'fixed', quantity: 1, unitAmount: 300000 })],
|
||||||
|
}));
|
||||||
|
expect(r.breakEven.tickets).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
/**
|
||||||
|
* Event P&L: a pure function from an event's money facts to the numbers the
|
||||||
|
* Finance tab, the partner statements and the finalize snapshot show.
|
||||||
|
*
|
||||||
|
* Everything is whole PYG. Percentages arrive in basis points (290 = 2.90%)
|
||||||
|
* and every percentage product is rounded to the nearest guaraní once, at the
|
||||||
|
* line it applies to, so totals always equal the sum of the lines shown.
|
||||||
|
*
|
||||||
|
* The waterfall:
|
||||||
|
* gross (paid + later-refunded payments)
|
||||||
|
* - refunds
|
||||||
|
* - payment fees (per payment, from payment_method_fees by provider)
|
||||||
|
* + other income
|
||||||
|
* = net revenue
|
||||||
|
* - expenses (planned and paid; auto rows follow the current counts unless locked)
|
||||||
|
* = profit / loss
|
||||||
|
* then the split:
|
||||||
|
* 1. fixed amounts and revenue shares are owed regardless of profit, so they
|
||||||
|
* come off first and leave the distributable profit;
|
||||||
|
* 2. profit shares are taken from the distributable profit, applying each
|
||||||
|
* partner's loss rule when it is negative;
|
||||||
|
* 3. the organization keeps the remainder.
|
||||||
|
* Reimbursements for costs a partner fronted are already inside expenses (so
|
||||||
|
* they are not deducted twice); they are added to that partner's payout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CALC_TYPES = ['fixed', 'per_ticket_sold', 'per_checked_in', 'percent_of_revenue', 'minimum_spend'] as const;
|
||||||
|
export type CalcType = (typeof CALC_TYPES)[number];
|
||||||
|
|
||||||
|
export const SHARE_TYPES = ['percent_profit', 'percent_revenue', 'fixed', 'fixed_plus_percent_above_threshold'] as const;
|
||||||
|
export type ShareType = (typeof SHARE_TYPES)[number];
|
||||||
|
|
||||||
|
export const LOSS_RULES = ['proportional', 'none', 'capped'] as const;
|
||||||
|
export type LossRule = (typeof LOSS_RULES)[number];
|
||||||
|
|
||||||
|
export interface FinancePayment {
|
||||||
|
id: string;
|
||||||
|
/** PYG recorded at payment time (Lightning included). */
|
||||||
|
amount: number;
|
||||||
|
/** payments.provider: tpago | bank_transfer | lightning | cash | pos | bancard */
|
||||||
|
provider: string;
|
||||||
|
source: 'presale' | 'door';
|
||||||
|
status: 'paid' | 'refunded';
|
||||||
|
paidAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceExpense {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
categoryId: string | null;
|
||||||
|
calcType: CalcType;
|
||||||
|
quantity: number;
|
||||||
|
unitAmount: number;
|
||||||
|
percentBp: number;
|
||||||
|
minimumAmount: number;
|
||||||
|
/** Stored amount; authoritative for locked rows. */
|
||||||
|
computedAmount: number;
|
||||||
|
isLocked: boolean;
|
||||||
|
status: 'planned' | 'paid';
|
||||||
|
/** null = paid by the organization. */
|
||||||
|
paidByPartnerId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceOtherIncome {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeeRule {
|
||||||
|
method: string;
|
||||||
|
percentBp: number;
|
||||||
|
fixedAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancePartner {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shareType: ShareType;
|
||||||
|
percentBp: number;
|
||||||
|
fixedAmount: number;
|
||||||
|
thresholdAmount: number;
|
||||||
|
lossRule: LossRule;
|
||||||
|
lossCapAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceInput {
|
||||||
|
/** Current online ticket price, used for break-even. */
|
||||||
|
ticketPrice: number;
|
||||||
|
/** Paid, non-cancelled tickets (comps excluded). */
|
||||||
|
ticketsSold: number;
|
||||||
|
/** Checked-in tickets, comps included (they still eat and drink). */
|
||||||
|
checkedIn: number;
|
||||||
|
payments: FinancePayment[];
|
||||||
|
expenses: FinanceExpense[];
|
||||||
|
otherIncome: FinanceOtherIncome[];
|
||||||
|
fees: FeeRule[];
|
||||||
|
partners: FinancePartner[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseLine {
|
||||||
|
id: string;
|
||||||
|
/** Count the amount was derived from (units, tickets or check-ins). */
|
||||||
|
quantity: number;
|
||||||
|
amount: number;
|
||||||
|
/** True when the amount follows ticket counts / revenue. */
|
||||||
|
auto: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MethodTotals {
|
||||||
|
method: string;
|
||||||
|
count: number;
|
||||||
|
gross: number;
|
||||||
|
refunds: number;
|
||||||
|
fees: number;
|
||||||
|
net: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartnerResult {
|
||||||
|
partnerId: string;
|
||||||
|
name: string;
|
||||||
|
shareType: ShareType;
|
||||||
|
/** What the base amount was for the percentage part (profit or revenue). */
|
||||||
|
basis: number;
|
||||||
|
/** Positive = paid to the partner; negative = the partner carries part of a loss. */
|
||||||
|
share: number;
|
||||||
|
reimbursement: number;
|
||||||
|
/** share + reimbursement. Negative means the partner owes the organization. */
|
||||||
|
payout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceResult {
|
||||||
|
counts: { ticketsSold: number; checkedIn: number; payments: number };
|
||||||
|
revenue: {
|
||||||
|
gross: number;
|
||||||
|
presale: number;
|
||||||
|
door: number;
|
||||||
|
refunds: number;
|
||||||
|
fees: number;
|
||||||
|
otherIncome: number;
|
||||||
|
/** gross - refunds: the base for revenue percentages. */
|
||||||
|
sales: number;
|
||||||
|
net: number;
|
||||||
|
byMethod: MethodTotals[];
|
||||||
|
};
|
||||||
|
expenses: {
|
||||||
|
lines: ExpenseLine[];
|
||||||
|
total: number;
|
||||||
|
planned: number;
|
||||||
|
paid: number;
|
||||||
|
byCategory: { categoryId: string | null; planned: number; paid: number; total: number }[];
|
||||||
|
};
|
||||||
|
profit: number;
|
||||||
|
breakEven: {
|
||||||
|
/** Tickets needed at ticketPrice for profit >= 0; null when unreachable. */
|
||||||
|
tickets: number | null;
|
||||||
|
ticketPrice: number;
|
||||||
|
/** Tickets still needed beyond those already sold. */
|
||||||
|
remaining: number | null;
|
||||||
|
};
|
||||||
|
split: {
|
||||||
|
/** Profit left after fixed amounts and revenue shares. */
|
||||||
|
distributable: number;
|
||||||
|
partners: PartnerResult[];
|
||||||
|
organization: number;
|
||||||
|
};
|
||||||
|
waterfall: { key: string; label?: string; amount: number }[];
|
||||||
|
/** Cumulative paid sales per day (YYYY-MM-DD, UTC). */
|
||||||
|
salesTimeline: { date: string; tickets: number; revenue: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round half away from zero so a loss and a profit of the same size split symmetrically. */
|
||||||
|
export function roundPyg(value: number): number {
|
||||||
|
return Math.sign(value) * Math.round(Math.abs(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBp(base: number, bp: number): number {
|
||||||
|
return roundPyg((base * bp) / 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paymentFee(amount: number, rule: FeeRule | undefined): number {
|
||||||
|
if (!rule || amount <= 0) return 0;
|
||||||
|
return applyBp(amount, rule.percentBp) + rule.fixedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount for one expense row at the given counts. Locked rows keep their stored amount. */
|
||||||
|
export function expenseAmount(
|
||||||
|
e: Pick<FinanceExpense, 'calcType' | 'quantity' | 'unitAmount' | 'percentBp' | 'minimumAmount' | 'computedAmount' | 'isLocked'>,
|
||||||
|
ctx: { ticketsSold: number; checkedIn: number; sales: number },
|
||||||
|
): { quantity: number; amount: number } {
|
||||||
|
if (e.isLocked) return { quantity: e.quantity, amount: e.computedAmount };
|
||||||
|
switch (e.calcType) {
|
||||||
|
case 'per_ticket_sold':
|
||||||
|
return { quantity: ctx.ticketsSold, amount: e.unitAmount * ctx.ticketsSold };
|
||||||
|
case 'per_checked_in':
|
||||||
|
return { quantity: ctx.checkedIn, amount: e.unitAmount * ctx.checkedIn };
|
||||||
|
case 'percent_of_revenue':
|
||||||
|
return { quantity: 1, amount: applyBp(Math.max(0, ctx.sales), e.percentBp) };
|
||||||
|
case 'minimum_spend':
|
||||||
|
return { quantity: ctx.checkedIn, amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn) };
|
||||||
|
case 'fixed':
|
||||||
|
default:
|
||||||
|
return { quantity: e.quantity, amount: e.unitAmount * e.quantity };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAutoCalc(calcType: CalcType): boolean {
|
||||||
|
return calcType !== 'fixed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePartners(profit: number, sales: number, partners: FinancePartner[], reimbursements: Map<string, number>) {
|
||||||
|
// Pass 1: amounts owed regardless of profit.
|
||||||
|
const upfront = new Map<string, number>();
|
||||||
|
for (const p of partners) {
|
||||||
|
let owed = 0;
|
||||||
|
if (p.shareType === 'fixed' || p.shareType === 'fixed_plus_percent_above_threshold') owed = p.fixedAmount;
|
||||||
|
else if (p.shareType === 'percent_revenue') owed = applyBp(Math.max(0, sales), p.percentBp);
|
||||||
|
upfront.set(p.id, owed);
|
||||||
|
}
|
||||||
|
const distributable = profit - [...upfront.values()].reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
// Pass 2: profit-based parts, taken from the distributable profit.
|
||||||
|
let profitParts = 0;
|
||||||
|
const results: PartnerResult[] = partners.map((p) => {
|
||||||
|
let basis = p.shareType === 'percent_revenue' ? sales : distributable;
|
||||||
|
let profitPart = 0;
|
||||||
|
if (p.shareType === 'percent_profit') {
|
||||||
|
const raw = applyBp(distributable, p.percentBp);
|
||||||
|
if (distributable >= 0 || p.lossRule === 'proportional') profitPart = raw;
|
||||||
|
else if (p.lossRule === 'capped') profitPart = Math.max(raw, -Math.abs(p.lossCapAmount));
|
||||||
|
} else if (p.shareType === 'fixed_plus_percent_above_threshold') {
|
||||||
|
basis = Math.max(0, distributable - p.thresholdAmount);
|
||||||
|
profitPart = applyBp(basis, p.percentBp);
|
||||||
|
}
|
||||||
|
profitParts += profitPart;
|
||||||
|
const share = (upfront.get(p.id) || 0) + profitPart;
|
||||||
|
const reimbursement = reimbursements.get(p.id) || 0;
|
||||||
|
return { partnerId: p.id, name: p.name, shareType: p.shareType, basis, share, reimbursement, payout: share + reimbursement };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { distributable, partners: results, organization: distributable - profitParts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Profit at a hypothetical ticket count, for break-even. Assumes every sold ticket checks in. */
|
||||||
|
function projectedProfit(n: number, input: FinanceInput, feeRate: { bp: number; fixed: number }, otherIncome: number): number {
|
||||||
|
const sales = n * input.ticketPrice;
|
||||||
|
const fees = n > 0 && input.ticketPrice > 0 ? n * (applyBp(input.ticketPrice, feeRate.bp) + feeRate.fixed) : 0;
|
||||||
|
const ctx = { ticketsSold: n, checkedIn: n, sales };
|
||||||
|
const expenses = input.expenses.reduce((sum, e) => sum + expenseAmount(e, ctx).amount, 0);
|
||||||
|
return sales - fees + otherIncome - expenses;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BREAK_EVEN_SEARCH_LIMIT = 100000;
|
||||||
|
|
||||||
|
export function calculateEventFinance(input: FinanceInput): FinanceResult {
|
||||||
|
const feeByMethod = new Map(input.fees.map((f) => [f.method, f]));
|
||||||
|
|
||||||
|
// ---- Revenue
|
||||||
|
const methods = new Map<string, MethodTotals>();
|
||||||
|
let gross = 0, presale = 0, door = 0, refunds = 0, fees = 0;
|
||||||
|
for (const p of input.payments) {
|
||||||
|
const m = methods.get(p.provider) || { method: p.provider, count: 0, gross: 0, refunds: 0, fees: 0, net: 0 };
|
||||||
|
const fee = p.status === 'paid' ? paymentFee(p.amount, feeByMethod.get(p.provider)) : 0;
|
||||||
|
m.count += 1;
|
||||||
|
m.gross += p.amount;
|
||||||
|
gross += p.amount;
|
||||||
|
if (p.source === 'door') door += p.amount; else presale += p.amount;
|
||||||
|
if (p.status === 'refunded') {
|
||||||
|
// Refunds are whole-payment; the processor fee on a refunded payment is
|
||||||
|
// not tracked, so it is treated as returned too.
|
||||||
|
m.refunds += p.amount;
|
||||||
|
refunds += p.amount;
|
||||||
|
}
|
||||||
|
m.fees += fee;
|
||||||
|
fees += fee;
|
||||||
|
m.net = m.gross - m.refunds - m.fees;
|
||||||
|
methods.set(p.provider, m);
|
||||||
|
}
|
||||||
|
const otherIncome = input.otherIncome.reduce((sum, i) => sum + i.amount, 0);
|
||||||
|
const sales = gross - refunds;
|
||||||
|
const net = sales - fees + otherIncome;
|
||||||
|
|
||||||
|
// ---- Expenses
|
||||||
|
const ctx = { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, sales };
|
||||||
|
const lines: ExpenseLine[] = [];
|
||||||
|
const byCategory = new Map<string | null, { categoryId: string | null; planned: number; paid: number; total: number }>();
|
||||||
|
const reimbursements = new Map<string, number>();
|
||||||
|
let planned = 0, paid = 0;
|
||||||
|
for (const e of input.expenses) {
|
||||||
|
const { quantity, amount } = expenseAmount(e, ctx);
|
||||||
|
lines.push({ id: e.id, quantity, amount, auto: isAutoCalc(e.calcType) && !e.isLocked });
|
||||||
|
const cat = byCategory.get(e.categoryId) || { categoryId: e.categoryId, planned: 0, paid: 0, total: 0 };
|
||||||
|
if (e.status === 'paid') {
|
||||||
|
paid += amount;
|
||||||
|
cat.paid += amount;
|
||||||
|
if (e.paidByPartnerId) reimbursements.set(e.paidByPartnerId, (reimbursements.get(e.paidByPartnerId) || 0) + amount);
|
||||||
|
} else {
|
||||||
|
planned += amount;
|
||||||
|
cat.planned += amount;
|
||||||
|
}
|
||||||
|
cat.total += amount;
|
||||||
|
byCategory.set(e.categoryId, cat);
|
||||||
|
}
|
||||||
|
const expenseTotal = planned + paid;
|
||||||
|
const profit = net - expenseTotal;
|
||||||
|
|
||||||
|
// ---- Break-even at the current price, using the fee mix seen so far
|
||||||
|
// (or the most expensive configured method before any sales).
|
||||||
|
const paidPayments = input.payments.filter((p) => p.status === 'paid' && p.amount > 0);
|
||||||
|
const feeRate = paidPayments.length > 0 && gross > 0
|
||||||
|
? { bp: Math.round((fees / Math.max(1, sales)) * 10000), fixed: 0 }
|
||||||
|
: input.fees.reduce((worst, f) => (f.percentBp > worst.bp ? { bp: f.percentBp, fixed: f.fixedAmount } : worst), { bp: 0, fixed: 0 });
|
||||||
|
const profitAt = (n: number) => projectedProfit(n, input, feeRate, otherIncome);
|
||||||
|
let breakEvenTickets: number | null = null;
|
||||||
|
if (input.ticketPrice > 0) {
|
||||||
|
// Profit is non-decreasing in n for every calc type, so a doubling search
|
||||||
|
// followed by bisection finds the smallest n with profit >= 0.
|
||||||
|
if (profitAt(0) >= 0) {
|
||||||
|
breakEvenTickets = 0;
|
||||||
|
} else {
|
||||||
|
let lo = 0; // profitAt(lo) < 0
|
||||||
|
let hi = 1;
|
||||||
|
while (hi < BREAK_EVEN_SEARCH_LIMIT && profitAt(hi) < 0) {
|
||||||
|
lo = hi;
|
||||||
|
hi = Math.min(hi * 2, BREAK_EVEN_SEARCH_LIMIT);
|
||||||
|
}
|
||||||
|
if (profitAt(hi) >= 0) {
|
||||||
|
while (lo + 1 < hi) {
|
||||||
|
const mid = Math.floor((lo + hi) / 2);
|
||||||
|
if (profitAt(mid) >= 0) hi = mid; else lo = mid;
|
||||||
|
}
|
||||||
|
breakEvenTickets = hi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Split
|
||||||
|
const split = computePartners(profit, sales, input.partners, reimbursements);
|
||||||
|
|
||||||
|
// ---- Waterfall (signed amounts, in display order)
|
||||||
|
const waterfall: FinanceResult['waterfall'] = [
|
||||||
|
{ key: 'gross', amount: gross },
|
||||||
|
{ key: 'refunds', amount: -refunds },
|
||||||
|
{ key: 'fees', amount: -fees },
|
||||||
|
{ key: 'otherIncome', amount: otherIncome },
|
||||||
|
{ key: 'net', amount: net },
|
||||||
|
{ key: 'expenses', amount: -expenseTotal },
|
||||||
|
{ key: 'profit', amount: profit },
|
||||||
|
...split.partners.map((p) => ({ key: `partner:${p.partnerId}`, label: p.name, amount: -p.share })),
|
||||||
|
{ key: 'organization', amount: split.organization },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- Cumulative sales per day
|
||||||
|
const byDay = new Map<string, { tickets: number; revenue: number }>();
|
||||||
|
for (const p of input.payments) {
|
||||||
|
if (p.status !== 'paid' || !p.paidAt) continue;
|
||||||
|
const day = new Date(p.paidAt).toISOString().slice(0, 10);
|
||||||
|
const d = byDay.get(day) || { tickets: 0, revenue: 0 };
|
||||||
|
d.tickets += 1;
|
||||||
|
d.revenue += p.amount;
|
||||||
|
byDay.set(day, d);
|
||||||
|
}
|
||||||
|
let runTickets = 0, runRevenue = 0;
|
||||||
|
const salesTimeline = [...byDay.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([date, d]) => {
|
||||||
|
runTickets += d.tickets;
|
||||||
|
runRevenue += d.revenue;
|
||||||
|
return { date, tickets: runTickets, revenue: runRevenue };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts: { ticketsSold: input.ticketsSold, checkedIn: input.checkedIn, payments: input.payments.length },
|
||||||
|
revenue: {
|
||||||
|
gross, presale, door, refunds, fees, otherIncome, sales, net,
|
||||||
|
byMethod: [...methods.values()].sort((a, b) => b.gross - a.gross),
|
||||||
|
},
|
||||||
|
expenses: {
|
||||||
|
lines,
|
||||||
|
total: expenseTotal,
|
||||||
|
planned,
|
||||||
|
paid,
|
||||||
|
byCategory: [...byCategory.values()].sort((a, b) => b.total - a.total),
|
||||||
|
},
|
||||||
|
profit,
|
||||||
|
breakEven: {
|
||||||
|
tickets: breakEvenTickets,
|
||||||
|
ticketPrice: input.ticketPrice,
|
||||||
|
remaining: breakEvenTickets === null ? null : Math.max(0, breakEvenTickets - input.ticketsSold),
|
||||||
|
},
|
||||||
|
split,
|
||||||
|
waterfall,
|
||||||
|
salesTimeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// Reads an event's money facts from the database and runs calculateEventFinance.
|
||||||
|
// Once an event is finalized, the frozen snapshot is returned instead so the
|
||||||
|
// numbers partners were paid on never drift with later ticket changes.
|
||||||
|
|
||||||
|
import { and, eq, inArray, sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
db, dbAll, dbGet, events, tickets, payments, eventExpenses, eventOtherIncome, eventPartners,
|
||||||
|
paymentMethodFees, eventFinanceState, users,
|
||||||
|
} from '../../db/index.js';
|
||||||
|
import { calculateEventFinance, type FinanceInput, type FinanceResult, type CalcType, type ShareType, type LossRule } from './calculate.js';
|
||||||
|
|
||||||
|
export const num = (v: any): number => {
|
||||||
|
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
export const pyg = (v: any): number => Math.round(num(v));
|
||||||
|
export const iso = (v: any): string | null => {
|
||||||
|
if (!v) return null;
|
||||||
|
const d = v instanceof Date ? v : new Date(v);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||||
|
};
|
||||||
|
export const bool = (v: any): boolean => v === true || v === 1 || v === '1';
|
||||||
|
|
||||||
|
export type FinanceStatus = 'open' | 'finalized' | 'paid_out';
|
||||||
|
|
||||||
|
export interface FinanceSnapshot {
|
||||||
|
version: 1;
|
||||||
|
computedAt: string;
|
||||||
|
result: FinanceResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeExpense(e: any) {
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
eventId: e.eventId,
|
||||||
|
categoryId: e.categoryId ?? null,
|
||||||
|
templateId: e.templateId ?? null,
|
||||||
|
description: e.description,
|
||||||
|
calcType: e.calcType as CalcType,
|
||||||
|
quantity: pyg(e.quantity),
|
||||||
|
unitAmount: pyg(e.unitAmount),
|
||||||
|
percentBp: pyg(e.percentBp),
|
||||||
|
minimumAmount: pyg(e.minimumAmount),
|
||||||
|
computedAmount: pyg(e.computedAmount),
|
||||||
|
isLocked: bool(e.isLocked),
|
||||||
|
status: e.status as 'planned' | 'paid',
|
||||||
|
paidByPartnerId: e.paidByPartnerId ?? null,
|
||||||
|
receiptUrl: e.receiptUrl ?? null,
|
||||||
|
expenseDate: iso(e.expenseDate),
|
||||||
|
createdBy: e.createdBy ?? null,
|
||||||
|
updatedBy: e.updatedBy ?? null,
|
||||||
|
createdAt: iso(e.createdAt),
|
||||||
|
updatedAt: iso(e.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializePartner(p: any, userName?: string | null) {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
eventId: p.eventId,
|
||||||
|
userId: p.userId ?? null,
|
||||||
|
externalName: p.externalName ?? null,
|
||||||
|
name: p.externalName || userName || 'Partner',
|
||||||
|
roleLabel: p.roleLabel ?? null,
|
||||||
|
shareType: p.shareType as ShareType,
|
||||||
|
percentBp: pyg(p.percentBp),
|
||||||
|
fixedAmount: pyg(p.fixedAmount),
|
||||||
|
thresholdAmount: pyg(p.thresholdAmount),
|
||||||
|
lossRule: p.lossRule as LossRule,
|
||||||
|
lossCapAmount: pyg(p.lossCapAmount),
|
||||||
|
payoutStatus: p.payoutStatus as 'pending' | 'paid',
|
||||||
|
payoutDate: iso(p.payoutDate),
|
||||||
|
payoutMethod: p.payoutMethod ?? null,
|
||||||
|
payoutNote: p.payoutNote ?? null,
|
||||||
|
createdAt: iso(p.createdAt),
|
||||||
|
updatedAt: iso(p.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeIncome(i: any) {
|
||||||
|
return {
|
||||||
|
id: i.id,
|
||||||
|
eventId: i.eventId,
|
||||||
|
description: i.description,
|
||||||
|
amount: pyg(i.amount),
|
||||||
|
createdBy: i.createdBy ?? null,
|
||||||
|
createdAt: iso(i.createdAt),
|
||||||
|
updatedAt: iso(i.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SerializedExpense = ReturnType<typeof serializeExpense>;
|
||||||
|
export type SerializedPartner = ReturnType<typeof serializePartner>;
|
||||||
|
export type SerializedIncome = ReturnType<typeof serializeIncome>;
|
||||||
|
|
||||||
|
export async function getFinanceState(eventId: string) {
|
||||||
|
const row = await dbGet<any>((db as any).select().from(eventFinanceState).where(eq((eventFinanceState as any).eventId, eventId)));
|
||||||
|
let snapshot: FinanceSnapshot | null = null;
|
||||||
|
if (row?.snapshotJson) {
|
||||||
|
try { snapshot = JSON.parse(row.snapshotJson); } catch { snapshot = null; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exists: !!row,
|
||||||
|
status: (row?.status || 'open') as FinanceStatus,
|
||||||
|
finalizedAt: iso(row?.finalizedAt),
|
||||||
|
finalizedBy: row?.finalizedBy ?? null,
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPartners(eventId: string): Promise<SerializedPartner[]> {
|
||||||
|
const rows = await dbAll<any>((db as any).select().from(eventPartners).where(eq((eventPartners as any).eventId, eventId)));
|
||||||
|
const userIds = [...new Set(rows.map((r: any) => r.userId).filter(Boolean))] as string[];
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
const us = await dbAll<any>(
|
||||||
|
(db as any).select({ id: (users as any).id, name: (users as any).name }).from(users).where(inArray((users as any).id, userIds))
|
||||||
|
);
|
||||||
|
for (const u of us) names.set(u.id, u.name);
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
.map((r: any) => serializePartner(r, r.userId ? names.get(r.userId) : null))
|
||||||
|
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadExpenses(eventId: string): Promise<SerializedExpense[]> {
|
||||||
|
const rows = await dbAll<any>((db as any).select().from(eventExpenses).where(eq((eventExpenses as any).eventId, eventId)));
|
||||||
|
return rows
|
||||||
|
.map(serializeExpense)
|
||||||
|
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOtherIncome(eventId: string): Promise<SerializedIncome[]> {
|
||||||
|
const rows = await dbAll<any>((db as any).select().from(eventOtherIncome).where(eq((eventOtherIncome as any).eventId, eventId)));
|
||||||
|
return rows
|
||||||
|
.map(serializeIncome)
|
||||||
|
.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFeeRules() {
|
||||||
|
const rows = await dbAll<any>((db as any).select().from(paymentMethodFees));
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
method: r.method as string,
|
||||||
|
percentBp: pyg(r.percentBp),
|
||||||
|
fixedAmount: pyg(r.fixedAmount),
|
||||||
|
updatedAt: iso(r.updatedAt),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ticket counts the auto-calculated expenses follow. */
|
||||||
|
export async function loadTicketCounts(eventId: string) {
|
||||||
|
const row = await dbGet<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({
|
||||||
|
sold: sql<number>`sum(case when ${(tickets as any).status} in ('confirmed', 'checked_in') and ${(tickets as any).paymentStatus} = 'paid' then 1 else 0 end)`,
|
||||||
|
checkedIn: sql<number>`sum(case when ${(tickets as any).status} = 'checked_in' then 1 else 0 end)`,
|
||||||
|
})
|
||||||
|
.from(tickets)
|
||||||
|
.where(eq((tickets as any).eventId, eventId))
|
||||||
|
);
|
||||||
|
return { ticketsSold: Number(row?.sold || 0), checkedIn: Number(row?.checkedIn || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildFinanceInput(eventId: string, event: any) {
|
||||||
|
const [counts, payRows, expenses, otherIncome, partners, fees] = await Promise.all([
|
||||||
|
loadTicketCounts(eventId),
|
||||||
|
dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({
|
||||||
|
id: (payments as any).id,
|
||||||
|
amount: (payments as any).amount,
|
||||||
|
provider: (payments as any).provider,
|
||||||
|
source: (payments as any).source,
|
||||||
|
status: (payments as any).status,
|
||||||
|
paidAt: (payments as any).paidAt,
|
||||||
|
createdAt: (payments as any).createdAt,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||||
|
.where(and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
inArray((payments as any).status, ['paid', 'refunded'])
|
||||||
|
))
|
||||||
|
),
|
||||||
|
loadExpenses(eventId),
|
||||||
|
loadOtherIncome(eventId),
|
||||||
|
loadPartners(eventId),
|
||||||
|
loadFeeRules(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const input: FinanceInput = {
|
||||||
|
ticketPrice: pyg(event.price),
|
||||||
|
ticketsSold: counts.ticketsSold,
|
||||||
|
checkedIn: counts.checkedIn,
|
||||||
|
payments: payRows
|
||||||
|
.map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
amount: pyg(p.amount),
|
||||||
|
provider: p.provider,
|
||||||
|
source: p.source === 'door' ? 'door' as const : 'presale' as const,
|
||||||
|
status: p.status as 'paid' | 'refunded',
|
||||||
|
paidAt: iso(p.paidAt) || iso(p.createdAt),
|
||||||
|
}))
|
||||||
|
// Comps are recorded as 0 PYG payments; they are not sales.
|
||||||
|
.filter((p) => p.amount > 0),
|
||||||
|
expenses: expenses.map((e) => ({
|
||||||
|
id: e.id, description: e.description, categoryId: e.categoryId, calcType: e.calcType, quantity: e.quantity,
|
||||||
|
unitAmount: e.unitAmount, percentBp: e.percentBp, minimumAmount: e.minimumAmount, computedAmount: e.computedAmount,
|
||||||
|
isLocked: e.isLocked, status: e.status, paidByPartnerId: e.paidByPartnerId,
|
||||||
|
})),
|
||||||
|
otherIncome: otherIncome.map((i) => ({ id: i.id, description: i.description, amount: i.amount })),
|
||||||
|
fees,
|
||||||
|
partners: partners.map((p) => ({
|
||||||
|
id: p.id, name: p.name, shareType: p.shareType, percentBp: p.percentBp, fixedAmount: p.fixedAmount,
|
||||||
|
thresholdAmount: p.thresholdAmount, lossRule: p.lossRule, lossCapAmount: p.lossCapAmount,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return { input, expenses, otherIncome, partners, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvent(eventId: string) {
|
||||||
|
return dbGet<any>((db as any).select().from(events).where(eq((events as any).id, eventId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's finance numbers plus the rows behind them. Uses the finalize
|
||||||
|
* snapshot when there is one; otherwise calculates live.
|
||||||
|
*/
|
||||||
|
export async function getEventFinance(eventId: string, event?: any) {
|
||||||
|
const ev = event || await getEvent(eventId);
|
||||||
|
if (!ev) return null;
|
||||||
|
const [state, built] = await Promise.all([getFinanceState(eventId), buildFinanceInput(eventId, ev)]);
|
||||||
|
const frozen = state.status !== 'open' && state.snapshot;
|
||||||
|
const result = frozen ? state.snapshot!.result : calculateEventFinance(built.input);
|
||||||
|
return {
|
||||||
|
event: ev,
|
||||||
|
state,
|
||||||
|
live: !frozen,
|
||||||
|
computedAt: frozen ? state.snapshot!.computedAt : new Date().toISOString(),
|
||||||
|
result,
|
||||||
|
expenses: built.expenses,
|
||||||
|
otherIncome: built.otherIncome,
|
||||||
|
partners: built.partners,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// Partner statement PDF: the event P&L summary, the partner's deal, what they
|
||||||
|
// fronted, and the resulting payout. Uses the ticket PDF's brand helpers.
|
||||||
|
|
||||||
|
import {
|
||||||
|
COLORS, PAGE_W, PAGE_H, MARGIN, CONTENT_W, ACCENT_H, FOOTER_H, LOGO_RATIO,
|
||||||
|
getLogo, drawLabel, drawDivider, createDoc, collect, siteUrl,
|
||||||
|
} from '../pdf.js';
|
||||||
|
import type { FinanceResult, PartnerResult } from './calculate.js';
|
||||||
|
import type { SerializedExpense, SerializedPartner } from './load.js';
|
||||||
|
|
||||||
|
export function formatPygAmount(amount: number): string {
|
||||||
|
const sign = amount < 0 ? '-' : '';
|
||||||
|
return `${sign}${Math.abs(Math.round(amount)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.')} PYG`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = (bp: number) => `${(bp / 100).toLocaleString('es-PY', { maximumFractionDigits: 2 })}%`;
|
||||||
|
|
||||||
|
const STRINGS = {
|
||||||
|
en: {
|
||||||
|
title: 'Partner statement',
|
||||||
|
draft: 'DRAFT: the event is not finalized, numbers may still change.',
|
||||||
|
event: 'Event',
|
||||||
|
partner: 'Partner',
|
||||||
|
summary: 'Event summary',
|
||||||
|
gross: 'Gross ticket revenue',
|
||||||
|
refunds: 'Refunds',
|
||||||
|
fees: 'Payment fees',
|
||||||
|
otherIncome: 'Other income',
|
||||||
|
net: 'Net revenue',
|
||||||
|
expenses: 'Expenses',
|
||||||
|
profit: 'Profit / loss',
|
||||||
|
deal: 'Agreement',
|
||||||
|
share: 'Share',
|
||||||
|
reimbursements: 'Reimbursements (costs you paid)',
|
||||||
|
none: 'None',
|
||||||
|
payout: 'Total payout',
|
||||||
|
owes: 'Amount owed to the organization',
|
||||||
|
status: 'Payout status',
|
||||||
|
paid: 'Paid',
|
||||||
|
pending: 'Pending',
|
||||||
|
generated: 'Generated',
|
||||||
|
lossNote: { proportional: 'shares losses proportionally', none: 'does not share losses', capped: 'shares losses up to' },
|
||||||
|
shareTypes: {
|
||||||
|
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} of profit`,
|
||||||
|
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} of ticket revenue`,
|
||||||
|
fixed: (p: SerializedPartner) => `Fixed ${formatPygAmount(p.fixedAmount)}`,
|
||||||
|
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
|
||||||
|
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} of profit above ${formatPygAmount(p.thresholdAmount)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
title: 'Liquidación de socio',
|
||||||
|
draft: 'BORRADOR: el evento no está cerrado, los números pueden cambiar.',
|
||||||
|
event: 'Evento',
|
||||||
|
partner: 'Socio',
|
||||||
|
summary: 'Resumen del evento',
|
||||||
|
gross: 'Ingresos brutos por entradas',
|
||||||
|
refunds: 'Reembolsos',
|
||||||
|
fees: 'Comisiones de pago',
|
||||||
|
otherIncome: 'Otros ingresos',
|
||||||
|
net: 'Ingresos netos',
|
||||||
|
expenses: 'Gastos',
|
||||||
|
profit: 'Ganancia / pérdida',
|
||||||
|
deal: 'Acuerdo',
|
||||||
|
share: 'Participación',
|
||||||
|
reimbursements: 'Reembolsos (gastos que pagaste)',
|
||||||
|
none: 'Ninguno',
|
||||||
|
payout: 'Total a pagar',
|
||||||
|
owes: 'Monto adeudado a la organización',
|
||||||
|
status: 'Estado del pago',
|
||||||
|
paid: 'Pagado',
|
||||||
|
pending: 'Pendiente',
|
||||||
|
generated: 'Generado',
|
||||||
|
lossNote: { proportional: 'comparte pérdidas proporcionalmente', none: 'no comparte pérdidas', capped: 'comparte pérdidas hasta' },
|
||||||
|
shareTypes: {
|
||||||
|
percent_profit: (p: SerializedPartner) => `${pct(p.percentBp)} de la ganancia`,
|
||||||
|
percent_revenue: (p: SerializedPartner) => `${pct(p.percentBp)} de los ingresos por entradas`,
|
||||||
|
fixed: (p: SerializedPartner) => `Fijo ${formatPygAmount(p.fixedAmount)}`,
|
||||||
|
fixed_plus_percent_above_threshold: (p: SerializedPartner) =>
|
||||||
|
`${formatPygAmount(p.fixedAmount)} + ${pct(p.percentBp)} de la ganancia sobre ${formatPygAmount(p.thresholdAmount)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface StatementData {
|
||||||
|
locale: 'en' | 'es';
|
||||||
|
event: { title: string; startDatetime: string | Date; location: string };
|
||||||
|
finalized: boolean;
|
||||||
|
timezone?: string;
|
||||||
|
result: FinanceResult;
|
||||||
|
partner: SerializedPartner;
|
||||||
|
line: PartnerResult | undefined;
|
||||||
|
frontedExpenses: SerializedExpense[];
|
||||||
|
/** Amount per expense id, from the same result. */
|
||||||
|
expenseAmounts: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePartnerStatementPDF(data: StatementData): Promise<Buffer> {
|
||||||
|
const t = STRINGS[data.locale];
|
||||||
|
const doc = createDoc();
|
||||||
|
const done = collect(doc);
|
||||||
|
const tz = data.timezone || 'America/Asuncion';
|
||||||
|
const dateFmt = new Intl.DateTimeFormat(data.locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'long', timeZone: tz });
|
||||||
|
|
||||||
|
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
|
||||||
|
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
|
||||||
|
|
||||||
|
let y = MARGIN + 6;
|
||||||
|
const logo = getLogo();
|
||||||
|
if (logo) {
|
||||||
|
const w = 120;
|
||||||
|
doc.image(logo, MARGIN, y, { width: w });
|
||||||
|
y += w / LOGO_RATIO + 16;
|
||||||
|
} else {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(18).fillColor(COLORS.navy).text('spanglish social', MARGIN, y);
|
||||||
|
y += 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.font('Helvetica-Bold').fontSize(22).fillColor(COLORS.navy).text(t.title, MARGIN, y, { width: CONTENT_W });
|
||||||
|
y += 32;
|
||||||
|
|
||||||
|
if (!data.finalized) {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(9).fillColor(COLORS.orange).text(t.draft, MARGIN, y, { width: CONTENT_W });
|
||||||
|
y += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = CONTENT_W / 2;
|
||||||
|
drawLabel(doc, t.event, y, col - 12);
|
||||||
|
drawLabel(doc, t.partner, y, col, MARGIN + col);
|
||||||
|
y += 14;
|
||||||
|
doc.font('Helvetica-Bold').fontSize(12).fillColor(COLORS.navy);
|
||||||
|
doc.text(data.event.title, MARGIN, y, { width: col - 12 });
|
||||||
|
doc.text(data.partner.name, MARGIN + col, y, { width: col });
|
||||||
|
y += 16;
|
||||||
|
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||||
|
doc.text(`${dateFmt.format(new Date(data.event.startDatetime))} · ${data.event.location}`, MARGIN, y, { width: col - 12 });
|
||||||
|
if (data.partner.roleLabel) doc.text(data.partner.roleLabel, MARGIN + col, y, { width: col });
|
||||||
|
y += 34;
|
||||||
|
|
||||||
|
const row = (label: string, amount: number, opts: { bold?: boolean } = {}) => {
|
||||||
|
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.bold ? 12 : 10.5).fillColor(COLORS.navy);
|
||||||
|
doc.text(label, MARGIN, y, { width: CONTENT_W - 160 });
|
||||||
|
doc.text(formatPygAmount(amount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
|
||||||
|
y += opts.bold ? 20 : 17;
|
||||||
|
};
|
||||||
|
|
||||||
|
drawLabel(doc, t.summary, y);
|
||||||
|
y += 16;
|
||||||
|
const r = data.result;
|
||||||
|
row(t.gross, r.revenue.gross);
|
||||||
|
if (r.revenue.refunds) row(t.refunds, -r.revenue.refunds);
|
||||||
|
row(t.fees, -r.revenue.fees);
|
||||||
|
if (r.revenue.otherIncome) row(t.otherIncome, r.revenue.otherIncome);
|
||||||
|
drawDivider(doc, y); y += 8;
|
||||||
|
row(t.net, r.revenue.net, { bold: true });
|
||||||
|
row(t.expenses, -r.expenses.total);
|
||||||
|
drawDivider(doc, y); y += 8;
|
||||||
|
row(t.profit, r.profit, { bold: true });
|
||||||
|
y += 18;
|
||||||
|
|
||||||
|
drawLabel(doc, t.deal, y);
|
||||||
|
y += 16;
|
||||||
|
let deal = t.shareTypes[data.partner.shareType](data.partner);
|
||||||
|
if (data.partner.shareType === 'percent_profit') {
|
||||||
|
deal += data.partner.lossRule === 'capped'
|
||||||
|
? ` · ${t.lossNote.capped} ${formatPygAmount(data.partner.lossCapAmount)}`
|
||||||
|
: ` · ${t.lossNote[data.partner.lossRule]}`;
|
||||||
|
}
|
||||||
|
doc.font('Helvetica').fontSize(10.5).fillColor(COLORS.navy).text(deal, MARGIN, y, { width: CONTENT_W });
|
||||||
|
y += 26;
|
||||||
|
|
||||||
|
const share = data.line?.share ?? 0;
|
||||||
|
const reimbursement = data.line?.reimbursement ?? 0;
|
||||||
|
const payout = data.line?.payout ?? 0;
|
||||||
|
row(t.share, share);
|
||||||
|
|
||||||
|
drawLabel(doc, t.reimbursements, y + 4);
|
||||||
|
y += 20;
|
||||||
|
if (data.frontedExpenses.length === 0) {
|
||||||
|
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted).text(t.none, MARGIN, y);
|
||||||
|
y += 16;
|
||||||
|
} else {
|
||||||
|
for (const e of data.frontedExpenses) {
|
||||||
|
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||||
|
doc.text(e.description, MARGIN + 10, y, { width: CONTENT_W - 170 });
|
||||||
|
doc.text(formatPygAmount(data.expenseAmounts.get(e.id) ?? e.computedAmount), MARGIN + CONTENT_W - 160, y, { width: 160, align: 'right' });
|
||||||
|
y += 15;
|
||||||
|
}
|
||||||
|
row(t.reimbursements, reimbursement);
|
||||||
|
}
|
||||||
|
y += 6;
|
||||||
|
drawDivider(doc, y); y += 10;
|
||||||
|
row(payout < 0 ? t.owes : t.payout, Math.abs(payout), { bold: true });
|
||||||
|
y += 6;
|
||||||
|
|
||||||
|
doc.font('Helvetica').fontSize(10).fillColor(COLORS.muted);
|
||||||
|
const status = data.partner.payoutStatus === 'paid'
|
||||||
|
? `${t.paid}${data.partner.payoutDate ? ` · ${dateFmt.format(new Date(data.partner.payoutDate))}` : ''}${data.partner.payoutMethod ? ` · ${data.partner.payoutMethod}` : ''}`
|
||||||
|
: t.pending;
|
||||||
|
doc.text(`${t.status}: ${status}`, MARGIN, y, { width: CONTENT_W });
|
||||||
|
if (data.partner.payoutNote) {
|
||||||
|
y += 15;
|
||||||
|
doc.text(data.partner.payoutNote, MARGIN, y, { width: CONTENT_W });
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerY = PAGE_H - FOOTER_H;
|
||||||
|
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
|
||||||
|
doc.font('Helvetica').fontSize(9).fillColor(COLORS.footerMuted)
|
||||||
|
.text(`${t.generated} ${dateFmt.format(new Date())}`, MARGIN, footerY + FOOTER_H / 2 - 5, { width: CONTENT_W / 2 });
|
||||||
|
doc.font('Helvetica-Bold').fontSize(10).fillColor('#FFFFFF')
|
||||||
|
.text(siteUrl().domain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return done;
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
// settlement) and the booking is auto-approved on success. No admin involved.
|
// settlement) and the booking is auto-approved on success. No admin involved.
|
||||||
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
// Currently Lightning; future online gateways (e.g. Stripe) go here.
|
||||||
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
// - 'manual': a human must verify the money arrived (TPago, bank transfer,
|
||||||
// card handled offline, cash at the door). These are never auto-confirmed
|
// card handled offline, cash or the POS terminal at the door). These are
|
||||||
// and never auto-failed; an admin settles them by hand. Bank transfer and
|
// never auto-confirmed and never auto-failed; an admin settles them by hand. Bank transfer and
|
||||||
// TPago additionally expose an online "I've paid" step that moves the
|
// TPago additionally expose an online "I've paid" step that moves the
|
||||||
// payment to 'pending_approval'.
|
// payment to 'pending_approval'.
|
||||||
//
|
//
|
||||||
@@ -22,6 +22,8 @@ export const PAYMENT_PROVIDERS: Record<string, { kind: PaymentProviderKind }> =
|
|||||||
bank_transfer: { kind: 'manual' },
|
bank_transfer: { kind: 'manual' },
|
||||||
card: { kind: 'manual' },
|
card: { kind: 'manual' },
|
||||||
cash: { kind: 'manual' },
|
cash: { kind: 'manual' },
|
||||||
|
// Card on the physical POS terminal at the door; staff confirm it by hand
|
||||||
|
pos: { kind: 'manual' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
|
export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter(
|
||||||
|
|||||||
+354
-197
@@ -1,6 +1,8 @@
|
|||||||
// PDF Ticket Generation Service
|
// PDF Ticket Generation Service
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
interface TicketData {
|
interface TicketData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,235 +17,390 @@ interface TicketData {
|
|||||||
locationUrl?: string;
|
locationUrl?: string;
|
||||||
};
|
};
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
|
/** 'en' | 'es' - drives the labels and the date/time format on the ticket */
|
||||||
|
locale?: string;
|
||||||
|
/** Optional perk line shown under the ticket holder (falls back to the terms line) */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Brand ====================
|
||||||
|
|
||||||
|
export const COLORS = {
|
||||||
|
navy: '#002F44',
|
||||||
|
orange: '#F5821F',
|
||||||
|
cream: '#FDF8F0',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
cardBorder: '#EFE6D8',
|
||||||
|
divider: '#E7DFD1',
|
||||||
|
label: '#9AA3AC',
|
||||||
|
muted: '#6B7580',
|
||||||
|
footerMuted: '#7FA3B5',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PAGE_W = 595.28;
|
||||||
|
export const PAGE_H = 841.89;
|
||||||
|
export const MARGIN = 48;
|
||||||
|
export const CONTENT_W = PAGE_W - MARGIN * 2;
|
||||||
|
export const ACCENT_H = 10;
|
||||||
|
export const FOOTER_H = 48;
|
||||||
|
|
||||||
|
export const LOGO_RATIO = 1158 / 324;
|
||||||
|
|
||||||
|
const STRINGS = {
|
||||||
|
en: {
|
||||||
|
scan: 'SCAN AT THE ENTRANCE',
|
||||||
|
venue: 'VENUE',
|
||||||
|
holder: 'TICKET HOLDER',
|
||||||
|
terms: 'This ticket is non-transferable. One scan per entry.',
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
scan: 'ESCANEÁ AL INGRESAR',
|
||||||
|
venue: 'LUGAR',
|
||||||
|
holder: 'TITULAR',
|
||||||
|
terms: 'Esta entrada es personal e intransferible. Un escaneo por ingreso.',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function strings(locale?: string) {
|
||||||
|
return locale === 'es' ? STRINGS.es : STRINGS.en;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a QR code as a data URL
|
* Locate the logo. `../../assets` resolves to backend/assets from both
|
||||||
|
* src/lib (tsx) and dist/lib (compiled), with the frontend copy as a fallback.
|
||||||
|
*/
|
||||||
|
function loadLogo(): Buffer | null {
|
||||||
|
const candidates = [
|
||||||
|
new URL('../../assets/logo-spanglish.png', import.meta.url),
|
||||||
|
new URL('../../../frontend/public/images/logo-spanglish.png', import.meta.url),
|
||||||
|
].map((u) => fileURLToPath(u));
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) return readFileSync(path);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let logoCache: Buffer | null | undefined;
|
||||||
|
export function getLogo(): Buffer | null {
|
||||||
|
if (logoCache === undefined) logoCache = loadLogo();
|
||||||
|
return logoCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a QR code as a PNG buffer
|
||||||
*/
|
*/
|
||||||
async function generateQRCode(data: string): Promise<Buffer> {
|
async function generateQRCode(data: string): Promise<Buffer> {
|
||||||
return QRCode.toBuffer(data, {
|
return QRCode.toBuffer(data, {
|
||||||
type: 'png',
|
type: 'png',
|
||||||
width: 200,
|
width: 600,
|
||||||
margin: 2,
|
margin: 1,
|
||||||
errorCorrectionLevel: 'M',
|
errorCorrectionLevel: 'M',
|
||||||
|
color: { dark: '#000000', light: '#FFFFFF' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date for display using site timezone
|
* Short date + time as shown in the ticket header:
|
||||||
|
* en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30"
|
||||||
*/
|
*/
|
||||||
function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string {
|
function formatWhen(
|
||||||
const date = new Date(dateStr);
|
startStr: string,
|
||||||
return date.toLocaleDateString('en-US', {
|
endStr: string | undefined,
|
||||||
weekday: 'long',
|
timezone: string,
|
||||||
year: 'numeric',
|
locale: string
|
||||||
month: 'long',
|
): string {
|
||||||
day: 'numeric',
|
const isEs = locale === 'es';
|
||||||
timeZone: timezone,
|
const start = new Date(startStr);
|
||||||
});
|
const tag = isEs ? 'es-ES' : 'en-US';
|
||||||
|
|
||||||
|
const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone });
|
||||||
|
const month = start
|
||||||
|
.toLocaleDateString(tag, { month: 'short', timeZone: timezone })
|
||||||
|
.replace(/\.$/, '')
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
const time = (d: Date) =>
|
||||||
|
d
|
||||||
|
.toLocaleTimeString(tag, {
|
||||||
|
hour: isEs ? '2-digit' : 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: !isEs,
|
||||||
|
timeZone: timezone,
|
||||||
|
})
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
const date = isEs ? `${day} ${month}` : `${month} ${day}`;
|
||||||
|
const end = endStr ? new Date(endStr) : null;
|
||||||
|
const when = end ? `${time(start)} – ${time(end)}` : time(start);
|
||||||
|
|
||||||
|
return `${date} · ${when}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format time for display using site timezone
|
* Events store the venue as a single string; the part before the first comma
|
||||||
|
* reads as the venue name and the remainder as its address.
|
||||||
*/
|
*/
|
||||||
function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string {
|
function splitLocation(location: string): { name: string; address?: string } {
|
||||||
const date = new Date(dateStr);
|
const idx = location.indexOf(',');
|
||||||
return date.toLocaleTimeString('en-US', {
|
if (idx === -1) return { name: location.trim() };
|
||||||
hour: '2-digit',
|
return {
|
||||||
minute: '2-digit',
|
name: location.slice(0, idx).trim(),
|
||||||
hour12: true,
|
address: location.slice(idx + 1).trim() || undefined,
|
||||||
timeZone: timezone,
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Drawing helpers ====================
|
||||||
|
|
||||||
|
export function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) {
|
||||||
|
doc
|
||||||
|
.font('Helvetica-Bold')
|
||||||
|
.fontSize(8)
|
||||||
|
.fillColor(COLORS.label)
|
||||||
|
.text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawDivider(doc: PDFKit.PDFDocument, y: number) {
|
||||||
|
doc
|
||||||
|
.moveTo(MARGIN, y)
|
||||||
|
.lineTo(PAGE_W - MARGIN, y)
|
||||||
|
.lineWidth(1)
|
||||||
|
.strokeColor(COLORS.divider)
|
||||||
|
.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Centered text with letter spacing: pdfkit also spaces the last glyph, so nudge it back. */
|
||||||
|
function drawSpacedCentered(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
text: string,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
spacing: number
|
||||||
|
) {
|
||||||
|
doc.text(text, x - spacing / 2, y, { width, align: 'center', characterSpacing: spacing });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailBlock {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw (or, with `measureOnly`, just measure) the venue / ticket holder / note
|
||||||
|
* block. Returns its total height so the caller can anchor it above the footer.
|
||||||
|
*/
|
||||||
|
function renderDetails(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
blocks: DetailBlock[],
|
||||||
|
note: string,
|
||||||
|
yStart: number,
|
||||||
|
measureOnly: boolean
|
||||||
|
): number {
|
||||||
|
let y = yStart;
|
||||||
|
|
||||||
|
blocks.forEach((block, i) => {
|
||||||
|
if (i > 0) {
|
||||||
|
y += 14;
|
||||||
|
if (!measureOnly) drawDivider(doc, y);
|
||||||
|
y += 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!measureOnly) drawLabel(doc, block.label, y);
|
||||||
|
y += 15;
|
||||||
|
|
||||||
|
doc.font('Helvetica-Bold').fontSize(13);
|
||||||
|
if (!measureOnly) doc.fillColor(COLORS.navy).text(block.value, MARGIN, y, { width: CONTENT_W });
|
||||||
|
y += doc.heightOfString(block.value, { width: CONTENT_W }) + 3;
|
||||||
|
|
||||||
|
if (block.sub) {
|
||||||
|
doc.font('Helvetica').fontSize(10.5);
|
||||||
|
if (!measureOnly) doc.fillColor(COLORS.muted).text(block.sub, MARGIN, y, { width: CONTENT_W });
|
||||||
|
y += doc.heightOfString(block.sub, { width: CONTENT_W }) + 3;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
y += 16;
|
||||||
|
doc.font('Helvetica').fontSize(10.5);
|
||||||
|
if (!measureOnly) doc.fillColor(COLORS.muted).text(note, MARGIN, y, { width: CONTENT_W });
|
||||||
|
y += doc.heightOfString(note, { width: CONTENT_W });
|
||||||
|
|
||||||
|
return y - yStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one full-page ticket. Assumes the page is already added.
|
||||||
|
*/
|
||||||
|
function renderTicketPage(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
ticket: TicketData,
|
||||||
|
qrBuffer: Buffer,
|
||||||
|
siteDomain: string,
|
||||||
|
index = 0,
|
||||||
|
total = 1
|
||||||
|
) {
|
||||||
|
const locale = ticket.locale === 'es' ? 'es' : 'en';
|
||||||
|
const t = strings(locale);
|
||||||
|
const tz = ticket.timezone || 'America/Asuncion';
|
||||||
|
const footerY = PAGE_H - FOOTER_H;
|
||||||
|
|
||||||
|
// ==================== Background ====================
|
||||||
|
doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream);
|
||||||
|
doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange);
|
||||||
|
|
||||||
|
// ==================== Logo ====================
|
||||||
|
const logo = getLogo();
|
||||||
|
let headerY = MARGIN + 6;
|
||||||
|
|
||||||
|
if (logo) {
|
||||||
|
const logoW = 158;
|
||||||
|
doc.image(logo, MARGIN, headerY, { width: logoW });
|
||||||
|
headerY += logoW / LOGO_RATIO;
|
||||||
|
} else {
|
||||||
|
doc.font('Helvetica-Bold').fontSize(21).fillColor(COLORS.navy).text('spanglish social', MARGIN, headerY);
|
||||||
|
headerY += 26;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Title + date ====================
|
||||||
|
const titleY = headerY + 30;
|
||||||
|
const when = formatWhen(ticket.event.startDatetime, ticket.event.endDatetime, tz, locale);
|
||||||
|
|
||||||
|
doc.font('Helvetica-Bold').fontSize(11.5);
|
||||||
|
const whenW = Math.min(doc.widthOfString(when) + 2, CONTENT_W * 0.5);
|
||||||
|
const titleW = CONTENT_W - whenW - 20;
|
||||||
|
|
||||||
|
doc.font('Helvetica-Bold').fontSize(26);
|
||||||
|
if (doc.widthOfString(ticket.event.title) > titleW) doc.fontSize(20);
|
||||||
|
doc.fillColor(COLORS.navy).text(ticket.event.title, MARGIN, titleY, { width: titleW });
|
||||||
|
const titleBottom = doc.y;
|
||||||
|
|
||||||
|
doc
|
||||||
|
.font('Helvetica-Bold')
|
||||||
|
.fontSize(11.5)
|
||||||
|
.fillColor(COLORS.orange)
|
||||||
|
.text(when, PAGE_W - MARGIN - whenW, titleY + 9, { width: whenW, align: 'right' });
|
||||||
|
|
||||||
|
// ==================== Layout: card fills what the detail block leaves ====================
|
||||||
|
const venue = splitLocation(ticket.event.location);
|
||||||
|
const note = ticket.note || t.terms;
|
||||||
|
const blocks: DetailBlock[] = [
|
||||||
|
{ label: t.venue, value: venue.name, sub: venue.address },
|
||||||
|
{ label: t.holder, value: ticket.attendeeName, sub: ticket.attendeeEmail },
|
||||||
|
];
|
||||||
|
|
||||||
|
const detailsH = renderDetails(doc, blocks, note, 0, true);
|
||||||
|
const detailsY = footerY - 46 - detailsH;
|
||||||
|
|
||||||
|
const cardY = Math.max(titleBottom, titleY + 36) + 24;
|
||||||
|
const cardX = MARGIN;
|
||||||
|
const cardW = CONTENT_W;
|
||||||
|
const cardH = Math.max(300, Math.min(detailsY - 32 - cardY, 430));
|
||||||
|
|
||||||
|
doc
|
||||||
|
.roundedRect(cardX, cardY, cardW, cardH, 14)
|
||||||
|
.lineWidth(1)
|
||||||
|
.fillAndStroke(COLORS.card, COLORS.cardBorder);
|
||||||
|
|
||||||
|
// ==================== QR card contents ====================
|
||||||
|
const labelH = 12;
|
||||||
|
const codeH = 24;
|
||||||
|
const qrSize = Math.min(236, cardH - (labelH + 20 + 22 + codeH + 44));
|
||||||
|
const stackH = labelH + 20 + qrSize + 22 + codeH;
|
||||||
|
let inner = cardY + (cardH - stackH) / 2;
|
||||||
|
|
||||||
|
doc.font('Helvetica-Bold').fontSize(8.5).fillColor(COLORS.label);
|
||||||
|
drawSpacedCentered(doc, t.scan, cardX, inner, cardW, 2);
|
||||||
|
|
||||||
|
if (total > 1) {
|
||||||
|
doc
|
||||||
|
.font('Helvetica-Bold')
|
||||||
|
.fontSize(8.5)
|
||||||
|
.fillColor(COLORS.label)
|
||||||
|
.text(`${index + 1} / ${total}`, cardX, inner, { width: cardW - 22, align: 'right', characterSpacing: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
inner += labelH + 20;
|
||||||
|
doc.image(qrBuffer, (PAGE_W - qrSize) / 2, inner, { width: qrSize, height: qrSize });
|
||||||
|
inner += qrSize + 22;
|
||||||
|
|
||||||
|
const code = ticket.qrCode || ticket.id.slice(0, 8).toUpperCase();
|
||||||
|
doc.font('Courier-Bold').fontSize(19).fillColor(COLORS.navy);
|
||||||
|
drawSpacedCentered(doc, code, cardX, inner, cardW, 3);
|
||||||
|
|
||||||
|
// ==================== Venue / ticket holder / note ====================
|
||||||
|
renderDetails(doc, blocks, note, detailsY, false);
|
||||||
|
|
||||||
|
// ==================== Footer ====================
|
||||||
|
doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy);
|
||||||
|
|
||||||
|
doc
|
||||||
|
.font('Courier')
|
||||||
|
.fontSize(7.5)
|
||||||
|
.fillColor(COLORS.footerMuted)
|
||||||
|
.text(ticket.id, MARGIN, footerY + FOOTER_H / 2 - 4, { width: CONTENT_W * 0.6, lineBreak: false });
|
||||||
|
|
||||||
|
doc
|
||||||
|
.font('Helvetica')
|
||||||
|
.fontSize(10)
|
||||||
|
.fillColor('#FFFFFF')
|
||||||
|
.text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDoc(): PDFKit.PDFDocument {
|
||||||
|
return new PDFDocument({ size: 'A4', margin: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collect(doc: PDFKit.PDFDocument): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function siteUrl(): { base: string; domain: string } {
|
||||||
|
const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
||||||
|
let domain = base;
|
||||||
|
try {
|
||||||
|
domain = new URL(base).host.replace(/^www\./, '');
|
||||||
|
} catch {
|
||||||
|
domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
return { base, domain };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a PDF ticket for a single ticket
|
* Generate a PDF ticket for a single ticket
|
||||||
*/
|
*/
|
||||||
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
export async function generateTicketPDF(ticket: TicketData): Promise<Buffer> {
|
||||||
return new Promise(async (resolve, reject) => {
|
return generateCombinedTicketsPDF([ticket]);
|
||||||
try {
|
|
||||||
const doc = new PDFDocument({
|
|
||||||
size: 'A4',
|
|
||||||
margin: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
||||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
|
||||||
doc.on('error', reject);
|
|
||||||
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
|
||||||
|
|
||||||
// Generate QR code with ticket URL
|
|
||||||
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
|
||||||
const qrBuffer = await generateQRCode(qrUrl);
|
|
||||||
|
|
||||||
// ==================== Header ====================
|
|
||||||
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
|
||||||
|
|
||||||
// Divider line
|
|
||||||
doc.moveDown(1);
|
|
||||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
|
||||||
doc.moveDown(1);
|
|
||||||
|
|
||||||
// ==================== Event Info ====================
|
|
||||||
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
|
|
||||||
// Date and time (using site timezone)
|
|
||||||
const tz = ticket.timezone || 'America/Asuncion';
|
|
||||||
doc.fontSize(14).fillColor('#333');
|
|
||||||
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
|
|
||||||
|
|
||||||
const startTime = formatTime(ticket.event.startDatetime, tz);
|
|
||||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
|
|
||||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
|
||||||
doc.text(timeRange, { align: 'center' });
|
|
||||||
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
|
||||||
|
|
||||||
// ==================== QR Code ====================
|
|
||||||
doc.moveDown(2);
|
|
||||||
|
|
||||||
// Center the QR code
|
|
||||||
const qrSize = 180;
|
|
||||||
const pageWidth = 595; // A4 width in points
|
|
||||||
const qrX = (pageWidth - qrSize) / 2;
|
|
||||||
|
|
||||||
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
|
||||||
doc.y += qrSize + 10;
|
|
||||||
|
|
||||||
// ==================== Attendee Info ====================
|
|
||||||
doc.moveDown(1);
|
|
||||||
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
|
||||||
|
|
||||||
if (ticket.attendeeEmail) {
|
|
||||||
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Ticket ID ====================
|
|
||||||
doc.moveDown(1);
|
|
||||||
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
|
||||||
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
|
||||||
|
|
||||||
// ==================== Footer ====================
|
|
||||||
doc.moveDown(2);
|
|
||||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
|
|
||||||
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
|
||||||
doc.moveDown(0.3);
|
|
||||||
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
|
||||||
|
|
||||||
doc.end();
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a combined PDF with multiple tickets
|
* Generate a combined PDF with multiple tickets (one page each)
|
||||||
*/
|
*/
|
||||||
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
|
export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise<Buffer> {
|
||||||
return new Promise(async (resolve, reject) => {
|
const doc = createDoc();
|
||||||
try {
|
const done = collect(doc);
|
||||||
const doc = new PDFDocument({
|
const { base, domain } = siteUrl();
|
||||||
size: 'A4',
|
|
||||||
margin: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
const chunks: Buffer[] = [];
|
try {
|
||||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
for (let i = 0; i < tickets.length; i++) {
|
||||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
const ticket = tickets[i];
|
||||||
doc.on('error', reject);
|
if (i > 0) doc.addPage();
|
||||||
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com';
|
const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`);
|
||||||
|
renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length);
|
||||||
for (let i = 0; i < tickets.length; i++) {
|
|
||||||
const ticket = tickets[i];
|
|
||||||
|
|
||||||
if (i > 0) {
|
|
||||||
doc.addPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate QR code
|
|
||||||
const qrUrl = `${frontendUrl}/ticket/${ticket.id}`;
|
|
||||||
const qrBuffer = await generateQRCode(qrUrl);
|
|
||||||
|
|
||||||
// ==================== Header ====================
|
|
||||||
doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' });
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' });
|
|
||||||
|
|
||||||
// Divider line
|
|
||||||
doc.moveDown(1);
|
|
||||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
|
||||||
doc.moveDown(1);
|
|
||||||
|
|
||||||
// ==================== Event Info ====================
|
|
||||||
doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' });
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
|
|
||||||
// Date and time (using site timezone)
|
|
||||||
const tz = ticket.timezone || 'America/Asuncion';
|
|
||||||
doc.fontSize(14).fillColor('#333');
|
|
||||||
doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' });
|
|
||||||
|
|
||||||
const startTime = formatTime(ticket.event.startDatetime, tz);
|
|
||||||
const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null;
|
|
||||||
const timeRange = endTime ? `${startTime} - ${endTime}` : startTime;
|
|
||||||
doc.text(timeRange, { align: 'center' });
|
|
||||||
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' });
|
|
||||||
|
|
||||||
// ==================== QR Code ====================
|
|
||||||
doc.moveDown(2);
|
|
||||||
|
|
||||||
const qrSize = 180;
|
|
||||||
const pageWidth = 595;
|
|
||||||
const qrX = (pageWidth - qrSize) / 2;
|
|
||||||
|
|
||||||
doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize });
|
|
||||||
doc.y += qrSize + 10;
|
|
||||||
|
|
||||||
// ==================== Attendee Info ====================
|
|
||||||
doc.moveDown(1);
|
|
||||||
doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' });
|
|
||||||
|
|
||||||
if (ticket.attendeeEmail) {
|
|
||||||
doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Ticket ID ====================
|
|
||||||
doc.moveDown(1);
|
|
||||||
doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' });
|
|
||||||
doc.text(`Code: ${ticket.qrCode}`, { align: 'center' });
|
|
||||||
|
|
||||||
// Ticket number for multi-ticket bookings
|
|
||||||
if (tickets.length > 1) {
|
|
||||||
doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Footer ====================
|
|
||||||
doc.moveDown(2);
|
|
||||||
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke();
|
|
||||||
doc.moveDown(0.5);
|
|
||||||
|
|
||||||
doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' });
|
|
||||||
doc.moveDown(0.3);
|
|
||||||
doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' });
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.end();
|
|
||||||
} catch (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
}
|
||||||
});
|
doc.end();
|
||||||
|
} catch (error) {
|
||||||
|
doc.end();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return done;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
resolvePresaleClosure,
|
||||||
|
isPresaleClosed,
|
||||||
|
DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||||
|
} from './presale.js';
|
||||||
|
|
||||||
|
const START = '2030-01-01T20:00:00.000Z';
|
||||||
|
const startMs = new Date(START).getTime();
|
||||||
|
const minutes = (n: number) => n * 60_000;
|
||||||
|
|
||||||
|
describe('resolvePresaleClosure', () => {
|
||||||
|
it('falls back to the built-in defaults when neither event nor settings specify anything', () => {
|
||||||
|
const r = resolvePresaleClosure({ startDatetime: START }, null);
|
||||||
|
expect(r.enabled).toBe(true);
|
||||||
|
expect(r.minutesBefore).toBe(DEFAULT_PRESALE_CLOSE_MINUTES);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(DEFAULT_PRESALE_CLOSE_MINUTES));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inherits from site settings when the event has null overrides', () => {
|
||||||
|
const r = resolvePresaleClosure(
|
||||||
|
{ startDatetime: START, presaleClosureEnabled: null, presaleCloseMinutesBefore: null },
|
||||||
|
{ presaleClosureEnabled: true, presaleCloseMinutesBefore: 30 }
|
||||||
|
);
|
||||||
|
expect(r.minutesBefore).toBe(30);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(30));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the event override the site settings', () => {
|
||||||
|
const r = resolvePresaleClosure(
|
||||||
|
{ startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 1440 },
|
||||||
|
{ presaleClosureEnabled: false, presaleCloseMinutesBefore: 30 }
|
||||||
|
);
|
||||||
|
expect(r.enabled).toBe(true);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs - minutes(1440));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns closesAt null when closure is disabled (site-wide or per event)', () => {
|
||||||
|
expect(resolvePresaleClosure({ startDatetime: START }, { presaleClosureEnabled: false }).closesAt).toBeNull();
|
||||||
|
expect(
|
||||||
|
resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: false }, { presaleClosureEnabled: true }).closesAt
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts PostgreSQL 0/1 integers and string minutes', () => {
|
||||||
|
const r = resolvePresaleClosure(
|
||||||
|
{ startDatetime: new Date(START), presaleClosureEnabled: 1, presaleCloseMinutesBefore: '45' },
|
||||||
|
{ presaleClosureEnabled: 0, presaleCloseMinutesBefore: 10 }
|
||||||
|
);
|
||||||
|
expect(r.enabled).toBe(true);
|
||||||
|
expect(r.minutesBefore).toBe(45);
|
||||||
|
const off = resolvePresaleClosure({ startDatetime: START, presaleClosureEnabled: 0 }, { presaleClosureEnabled: 1 });
|
||||||
|
expect(off.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes at the event start when minutes is 0', () => {
|
||||||
|
const r = resolvePresaleClosure({ startDatetime: START, presaleCloseMinutesBefore: 0 }, null);
|
||||||
|
expect(r.closesAt?.getTime()).toBe(startMs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPresaleClosed', () => {
|
||||||
|
const event = { startDatetime: START, presaleClosureEnabled: true, presaleCloseMinutesBefore: 60 };
|
||||||
|
|
||||||
|
it('is open before the cutoff and closed from the cutoff onwards', () => {
|
||||||
|
const cutoff = startMs - minutes(60);
|
||||||
|
expect(isPresaleClosed(event, null, cutoff - 1)).toBe(false);
|
||||||
|
expect(isPresaleClosed(event, null, cutoff)).toBe(true);
|
||||||
|
expect(isPresaleClosed(event, null, startMs + minutes(5))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never closes when closure is disabled', () => {
|
||||||
|
expect(isPresaleClosed({ ...event, presaleClosureEnabled: false }, null, startMs + minutes(60))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Pre-sale closure: online registration for an event closes a configurable
|
||||||
|
* number of minutes before the event starts.
|
||||||
|
*
|
||||||
|
* Each event may override the site-wide default; a `null`/`undefined` value on
|
||||||
|
* the event means "inherit from site_settings". Values arrive as booleans on
|
||||||
|
* SQLite and as 0/1 integers on PostgreSQL, so both are accepted here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_PRESALE_CLOSURE_ENABLED = true;
|
||||||
|
export const DEFAULT_PRESALE_CLOSE_MINUTES = 120;
|
||||||
|
|
||||||
|
export interface PresaleEventLike {
|
||||||
|
startDatetime: string | Date;
|
||||||
|
presaleClosureEnabled?: boolean | number | null;
|
||||||
|
presaleCloseMinutesBefore?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresaleSettingsLike {
|
||||||
|
presaleClosureEnabled?: boolean | number | null;
|
||||||
|
presaleCloseMinutesBefore?: number | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedPresaleClosure {
|
||||||
|
enabled: boolean;
|
||||||
|
minutesBefore: number;
|
||||||
|
/** When online registration closes, or null when closure is disabled. */
|
||||||
|
closesAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBool(value: boolean | number | null | undefined): boolean | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
return typeof value === 'number' ? value !== 0 : Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMinutes(value: number | string | null | undefined): number | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
const n = typeof value === 'string' ? parseInt(value, 10) : value;
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePresaleClosure(
|
||||||
|
event: PresaleEventLike,
|
||||||
|
settings?: PresaleSettingsLike | null
|
||||||
|
): ResolvedPresaleClosure {
|
||||||
|
const enabled =
|
||||||
|
toBool(event.presaleClosureEnabled) ??
|
||||||
|
toBool(settings?.presaleClosureEnabled) ??
|
||||||
|
DEFAULT_PRESALE_CLOSURE_ENABLED;
|
||||||
|
const minutesBefore =
|
||||||
|
toMinutes(event.presaleCloseMinutesBefore) ??
|
||||||
|
toMinutes(settings?.presaleCloseMinutesBefore) ??
|
||||||
|
DEFAULT_PRESALE_CLOSE_MINUTES;
|
||||||
|
|
||||||
|
if (!enabled) return { enabled, minutesBefore, closesAt: null };
|
||||||
|
|
||||||
|
const startMs = new Date(event.startDatetime).getTime();
|
||||||
|
if (!Number.isFinite(startMs)) return { enabled, minutesBefore, closesAt: null };
|
||||||
|
|
||||||
|
return { enabled, minutesBefore, closesAt: new Date(startMs - minutesBefore * 60_000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPresaleClosed(
|
||||||
|
event: PresaleEventLike,
|
||||||
|
settings?: PresaleSettingsLike | null,
|
||||||
|
nowMs: number = Date.now()
|
||||||
|
): boolean {
|
||||||
|
const { closesAt } = resolvePresaleClosure(event, settings);
|
||||||
|
return closesAt !== null && closesAt.getTime() <= nowMs;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveSalesState, publicSalesFields, isOnlineSalesClosed } from './salesState.js';
|
||||||
|
|
||||||
|
const START = '2030-01-01T23:00:00.000Z'; // 20:00 in Asunción
|
||||||
|
const END = '2030-01-02T02:00:00.000Z';
|
||||||
|
const startMs = new Date(START).getTime();
|
||||||
|
const minutes = (n: number) => n * 60_000;
|
||||||
|
|
||||||
|
// Pre-sale closes 120 minutes before the start (the site default).
|
||||||
|
const event = {
|
||||||
|
status: 'published',
|
||||||
|
startDatetime: START,
|
||||||
|
endDatetime: END,
|
||||||
|
externalBookingEnabled: false,
|
||||||
|
price: 21000,
|
||||||
|
walkInPrice: 30000 as number | null,
|
||||||
|
};
|
||||||
|
const settings = { presaleClosureEnabled: true, presaleCloseMinutesBefore: 120 };
|
||||||
|
const beforeClose = startMs - minutes(121);
|
||||||
|
const afterClose = startMs - minutes(60);
|
||||||
|
|
||||||
|
describe('resolveSalesState', () => {
|
||||||
|
it('is online while pre-sale is open and seats are left', () => {
|
||||||
|
expect(resolveSalesState(event, settings, 10, beforeClose)).toBe('online');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is door after pre-sale closes, until the event ends', () => {
|
||||||
|
expect(resolveSalesState(event, settings, 10, afterClose)).toBe('door');
|
||||||
|
expect(resolveSalesState(event, settings, 10, startMs + minutes(30))).toBe('door');
|
||||||
|
expect(resolveSalesState(event, settings, 10, new Date(END).getTime() - 1)).toBe('door');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is sold_out with no seats left, online or at the door', () => {
|
||||||
|
expect(resolveSalesState(event, settings, 0, beforeClose)).toBe('sold_out');
|
||||||
|
expect(resolveSalesState(event, settings, 0, afterClose)).toBe('sold_out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is ended once the end time passes, or the start time when there is no end', () => {
|
||||||
|
expect(resolveSalesState(event, settings, 10, new Date(END).getTime())).toBe('ended');
|
||||||
|
expect(resolveSalesState(event, settings, 0, new Date(END).getTime())).toBe('ended');
|
||||||
|
expect(resolveSalesState({ ...event, endDatetime: null }, settings, 10, startMs)).toBe('ended');
|
||||||
|
expect(resolveSalesState({ ...event, status: 'completed' }, settings, 10, beforeClose)).toBe('ended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is external for external booking events, with no door state', () => {
|
||||||
|
const external = { ...event, externalBookingEnabled: true };
|
||||||
|
expect(resolveSalesState(external, settings, 10, beforeClose)).toBe('external');
|
||||||
|
expect(resolveSalesState(external, settings, 10, afterClose)).toBe('external');
|
||||||
|
expect(resolveSalesState({ ...event, externalBookingEnabled: 1 }, settings, 10, afterClose)).toBe('external');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cancelled events cancelled', () => {
|
||||||
|
expect(resolveSalesState({ ...event, status: 'cancelled' }, settings, 10, afterClose)).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes online sales at the start when pre-sale closure is off', () => {
|
||||||
|
const noClosure = { ...event, presaleClosureEnabled: false };
|
||||||
|
expect(resolveSalesState(noClosure, settings, 10, startMs - 1)).toBe('online');
|
||||||
|
expect(resolveSalesState(noClosure, settings, 10, startMs)).toBe('door');
|
||||||
|
expect(isOnlineSalesClosed(noClosure, settings, startMs - 1)).toBe(false);
|
||||||
|
expect(isOnlineSalesClosed(noClosure, settings, startMs)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('publicSalesFields', () => {
|
||||||
|
it('door with walk_in_price set: doorPrice is the walk-in price', () => {
|
||||||
|
expect(publicSalesFields(event, settings, 48, afterClose)).toEqual({ salesState: 'door', doorPrice: 30000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('door with walk_in_price null: doorPrice falls back to the ticket price', () => {
|
||||||
|
expect(publicSalesFields({ ...event, walkInPrice: null }, settings, 48, afterClose))
|
||||||
|
.toEqual({ salesState: 'door', doorPrice: 21000 });
|
||||||
|
// Postgres decimals arrive as strings
|
||||||
|
expect(publicSalesFields({ ...event, price: '21000.00', walkInPrice: null }, settings, 48, afterClose).doorPrice)
|
||||||
|
.toBe(21000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('door with a free walk-in keeps 0, not the ticket price', () => {
|
||||||
|
expect(publicSalesFields({ ...event, walkInPrice: 0 }, settings, 48, afterClose).doorPrice).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits doorPrice in every other state', () => {
|
||||||
|
const cases: Array<[number, number, any]> = [
|
||||||
|
[10, beforeClose, event], // online
|
||||||
|
[0, afterClose, event], // sold_out
|
||||||
|
[10, new Date(END).getTime(), event], // ended
|
||||||
|
[10, afterClose, { ...event, externalBookingEnabled: true }], // external
|
||||||
|
[10, afterClose, { ...event, status: 'cancelled' }], // cancelled
|
||||||
|
];
|
||||||
|
for (const [spots, now, e] of cases) {
|
||||||
|
const fields = publicSalesFields(e, settings, spots, now);
|
||||||
|
expect(fields.salesState).not.toBe('door');
|
||||||
|
expect(fields).not.toHaveProperty('doorPrice');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Public sales state of an event: what the event page offers a visitor right now.
|
||||||
|
//
|
||||||
|
// online pre-sale open and seats left (book online)
|
||||||
|
// door online sales closed, the event has not ended and seats are left:
|
||||||
|
// people can still come and pay at the door until the event ends
|
||||||
|
// sold_out no seats left, online or at the door
|
||||||
|
// ended the event is over (end time passed, or status completed/archived)
|
||||||
|
// external bookings happen on an external site; no door state
|
||||||
|
// cancelled the event was cancelled
|
||||||
|
//
|
||||||
|
// This is the single source of truth for the public page, listings and JSON-LD.
|
||||||
|
// Online sales close at the pre-sale cutoff (lib/presale.ts) or, when pre-sale
|
||||||
|
// closure is off, when the event starts — the booking API enforces the same rule
|
||||||
|
// through isOnlineSalesClosed. The state flips on the clock without any edit to
|
||||||
|
// the event, so cached copies must be refreshed around presaleClosesAt.
|
||||||
|
|
||||||
|
import { isPresaleClosed, type PresaleEventLike, type PresaleSettingsLike } from './presale.js';
|
||||||
|
import { resolveWalkInPrice } from './walkInPrice.js';
|
||||||
|
|
||||||
|
export type SalesState = 'online' | 'door' | 'sold_out' | 'ended' | 'external' | 'cancelled';
|
||||||
|
|
||||||
|
export interface SalesStateEventLike extends PresaleEventLike {
|
||||||
|
status: string;
|
||||||
|
endDatetime?: string | Date | null;
|
||||||
|
externalBookingEnabled?: boolean | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the event ends: its end time, or its start time when no end is set (as in eventEndSweep). */
|
||||||
|
export function eventEndMs(event: { startDatetime: string | Date; endDatetime?: string | Date | null }): number {
|
||||||
|
return new Date(event.endDatetime || event.startDatetime).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once online booking is closed: pre-sale cutoff passed, or the event has started. */
|
||||||
|
export function isOnlineSalesClosed(
|
||||||
|
event: PresaleEventLike,
|
||||||
|
settings?: PresaleSettingsLike | null,
|
||||||
|
nowMs: number = Date.now()
|
||||||
|
): boolean {
|
||||||
|
return isPresaleClosed(event, settings, nowMs) || new Date(event.startDatetime).getTime() <= nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSalesState(
|
||||||
|
event: SalesStateEventLike,
|
||||||
|
settings: PresaleSettingsLike | null | undefined,
|
||||||
|
spotsLeft: number,
|
||||||
|
nowMs: number = Date.now()
|
||||||
|
): SalesState {
|
||||||
|
if (event.status === 'cancelled') return 'cancelled';
|
||||||
|
if (event.status === 'completed' || event.status === 'archived') return 'ended';
|
||||||
|
if (Boolean(event.externalBookingEnabled)) return 'external';
|
||||||
|
if (eventEndMs(event) <= nowMs) return 'ended';
|
||||||
|
if (spotsLeft <= 0) return 'sold_out';
|
||||||
|
if (isOnlineSalesClosed(event, settings, nowMs)) return 'door';
|
||||||
|
return 'online';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sales fields for a public event response. `doorPrice` (the resolved walk-in
|
||||||
|
* price: walk_in_price, else the ticket price) is present only in the `door`
|
||||||
|
* state, so the internal walk-in price never leaks while online sales are open.
|
||||||
|
* `event` is the raw row (it still carries walkInPrice).
|
||||||
|
*/
|
||||||
|
export function publicSalesFields(
|
||||||
|
event: SalesStateEventLike & { price: unknown; walkInPrice?: unknown },
|
||||||
|
settings: PresaleSettingsLike | null | undefined,
|
||||||
|
spotsLeft: number,
|
||||||
|
nowMs: number = Date.now()
|
||||||
|
): { salesState: SalesState; doorPrice?: number } {
|
||||||
|
const salesState = resolveSalesState(event, settings, spotsLeft, nowMs);
|
||||||
|
if (salesState !== 'door') return { salesState };
|
||||||
|
return { salesState, doorPrice: resolveWalkInPrice(event).unitPrice };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Engine-neutral transactional writes.
|
||||||
|
//
|
||||||
|
// better-sqlite3 transactions take a *synchronous* callback (awaiting inside one
|
||||||
|
// silently breaks atomicity), while node-postgres takes an async one. Rather than
|
||||||
|
// fork every multi-write route into two near-identical branches, callers build a
|
||||||
|
// plain list of operations and hand it here: the business logic stays in one
|
||||||
|
// place and only the six lines below know which driver is underneath.
|
||||||
|
|
||||||
|
import { db, isSqlite } from '../db/index.js';
|
||||||
|
|
||||||
|
export type TxOp =
|
||||||
|
| { kind: 'insert'; table: any; values: any }
|
||||||
|
| { kind: 'update'; table: any; values: any; where: any }
|
||||||
|
| { kind: 'delete'; table: any; where: any };
|
||||||
|
|
||||||
|
export const insertOp = (table: any, values: any): TxOp => ({ kind: 'insert', table, values });
|
||||||
|
export const updateOp = (table: any, values: any, where: any): TxOp => ({ kind: 'update', table, values, where });
|
||||||
|
export const deleteOp = (table: any, where: any): TxOp => ({ kind: 'delete', table, where });
|
||||||
|
|
||||||
|
/** Apply every op inside a single transaction; any throw rolls back all of them. */
|
||||||
|
export async function runOps(ops: TxOp[]): Promise<void> {
|
||||||
|
if (ops.length === 0) return;
|
||||||
|
|
||||||
|
if (isSqlite()) {
|
||||||
|
(db as any).transaction((tx: any) => {
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.kind === 'insert') tx.insert(op.table).values(op.values).run();
|
||||||
|
else if (op.kind === 'update') tx.update(op.table).set(op.values).where(op.where).run();
|
||||||
|
else tx.delete(op.table).where(op.where).run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await (db as any).transaction(async (tx: any) => {
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.kind === 'insert') await tx.insert(op.table).values(op.values);
|
||||||
|
else if (op.kind === 'update') await tx.update(op.table).set(op.values).where(op.where);
|
||||||
|
else await tx.delete(op.table).where(op.where);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,6 +22,19 @@ export function generateTicketCode(): string {
|
|||||||
return `TKT-${nanoid(8).toUpperCase()}`;
|
return `TKT-${nanoid(8).toUpperCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical form for `users.email`.
|
||||||
|
*
|
||||||
|
* Better Auth lowercases the address on every lookup and write it performs,
|
||||||
|
* but the `users.email` unique index is case-sensitive on both dialects. Any
|
||||||
|
* row written outside Better Auth (guest bookings, door sales, admin-added
|
||||||
|
* tickets) must therefore be normalized the same way, or the row becomes
|
||||||
|
* invisible to sign-in / Google linking and a duplicate person gets created.
|
||||||
|
*/
|
||||||
|
export function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current timestamp in the format appropriate for the database type.
|
* Get current timestamp in the format appropriate for the database type.
|
||||||
* - SQLite: returns ISO string
|
* - SQLite: returns ISO string
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveWalkInPrice, omitWalkInPrice, parseWalkInPrice, canSeeWalkInPrice } from './walkInPrice.js';
|
||||||
|
|
||||||
|
describe('resolveWalkInPrice', () => {
|
||||||
|
it('falls back to the ticket price when no walk-in price is set', () => {
|
||||||
|
expect(resolveWalkInPrice({ price: 21000, walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||||
|
expect(resolveWalkInPrice({ price: 21000 })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats 0 as a free walk-in, not as unset', () => {
|
||||||
|
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 0 })).toEqual({ unitPrice: 0, source: 'walk_in' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a set walk-in price', () => {
|
||||||
|
expect(resolveWalkInPrice({ price: 21000, walkInPrice: 25000 })).toEqual({ unitPrice: 25000, source: 'walk_in' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads Postgres decimal strings', () => {
|
||||||
|
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: '25000.00' })).toEqual({ unitPrice: 25000, source: 'walk_in' });
|
||||||
|
expect(resolveWalkInPrice({ price: '21000.00', walkInPrice: null })).toEqual({ unitPrice: 21000, source: 'ticket' });
|
||||||
|
expect(parseWalkInPrice('0.00')).toBe(0);
|
||||||
|
expect(parseWalkInPrice('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('omitWalkInPrice', () => {
|
||||||
|
it('drops the walk-in price and keeps everything else', () => {
|
||||||
|
const event = { id: 'e1', price: 21000, walkInPrice: 25000, currency: 'PYG' };
|
||||||
|
const result = omitWalkInPrice(event);
|
||||||
|
expect(result).not.toHaveProperty('walkInPrice');
|
||||||
|
expect(result).toEqual({ id: 'e1', price: 21000, currency: 'PYG' });
|
||||||
|
expect(event.walkInPrice).toBe(25000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes null through', () => {
|
||||||
|
expect(omitWalkInPrice(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canSeeWalkInPrice', () => {
|
||||||
|
it('is limited to event managers and door staff', () => {
|
||||||
|
expect(canSeeWalkInPrice('admin')).toBe(true);
|
||||||
|
expect(canSeeWalkInPrice('organizer')).toBe(true);
|
||||||
|
expect(canSeeWalkInPrice('staff')).toBe(true);
|
||||||
|
expect(canSeeWalkInPrice('marketing')).toBe(false);
|
||||||
|
expect(canSeeWalkInPrice('user')).toBe(false);
|
||||||
|
expect(canSeeWalkInPrice(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Walk-in (door) pricing.
|
||||||
|
//
|
||||||
|
// An event may set a separate price for people who buy at the door. It is an
|
||||||
|
// internal number: staff charge it on the door screen, but it never appears on
|
||||||
|
// the public event page, listings or JSON-LD, so every public serializer must
|
||||||
|
// drop it (see omitWalkInPrice).
|
||||||
|
//
|
||||||
|
// walkInPrice null -> not set, walk-ins pay the regular ticket price
|
||||||
|
// walkInPrice 0 -> free walk-in (a real value, distinct from null)
|
||||||
|
// walkInPrice n -> walk-ins pay n, in the event's currency
|
||||||
|
|
||||||
|
export type WalkInPriceSource = 'walk_in' | 'ticket';
|
||||||
|
|
||||||
|
/** Roles that may see an event's walk-in price: event managers and door staff. */
|
||||||
|
export const WALK_IN_PRICE_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||||
|
|
||||||
|
export function canSeeWalkInPrice(role: string | null | undefined): boolean {
|
||||||
|
return !!role && (WALK_IN_PRICE_ROLES as readonly string[]).includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Postgres decimals arrive as strings; null/undefined/garbage stay null. */
|
||||||
|
export function parseWalkInPrice(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const n = typeof value === 'string' ? parseFloat(value) : Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-ticket price a walk-in is charged, and where it came from. */
|
||||||
|
export function resolveWalkInPrice(event: { price: unknown; walkInPrice?: unknown }): {
|
||||||
|
unitPrice: number;
|
||||||
|
source: WalkInPriceSource;
|
||||||
|
} {
|
||||||
|
const walkIn = parseWalkInPrice(event.walkInPrice);
|
||||||
|
if (walkIn !== null) return { unitPrice: walkIn, source: 'walk_in' };
|
||||||
|
const price = typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price);
|
||||||
|
return { unitPrice: Number.isFinite(price) ? price : 0, source: 'ticket' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy of an event row without its walk-in price, for anything a non-staff caller can read. */
|
||||||
|
export function omitWalkInPrice<T extends Record<string, any> | null | undefined>(event: T): T {
|
||||||
|
if (!event) return event;
|
||||||
|
const { walkInPrice: _omitted, ...rest } = event as Record<string, any>;
|
||||||
|
return rest as T;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono';
|
|||||||
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js';
|
||||||
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
|
||||||
@@ -182,6 +183,17 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
|||||||
.groupBy((tickets as any).eventId)
|
.groupBy((tickets as any).eventId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Revenue is what was actually paid, not tickets × the current event price
|
||||||
|
// (which an admin can change after tickets have been sold).
|
||||||
|
const revenueRows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ eventId: (tickets as any).eventId, count: sql<number>`coalesce(sum(${(payments as any).amount}), 0)` })
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||||
|
.where(eq((payments as any).status, 'paid'))
|
||||||
|
.groupBy((tickets as any).eventId)
|
||||||
|
);
|
||||||
|
|
||||||
const toMap = (rows: any[]) => {
|
const toMap = (rows: any[]) => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number>();
|
||||||
for (const r of rows) m.set(r.eventId, Number(r.count) || 0);
|
for (const r of rows) m.set(r.eventId, Number(r.count) || 0);
|
||||||
@@ -190,6 +202,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
|||||||
const totalMap = toMap(totalRows);
|
const totalMap = toMap(totalRows);
|
||||||
const confirmedMap = toMap(confirmedRows);
|
const confirmedMap = toMap(confirmedRows);
|
||||||
const checkedInMap = toMap(checkedInRows);
|
const checkedInMap = toMap(checkedInRows);
|
||||||
|
const revenueMap = toMap(revenueRows);
|
||||||
|
|
||||||
const eventStats = allEvents.map((event: any) => {
|
const eventStats = allEvents.map((event: any) => {
|
||||||
const confirmedBookings = confirmedMap.get(event.id) || 0;
|
const confirmedBookings = confirmedMap.get(event.id) || 0;
|
||||||
@@ -201,7 +214,7 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
|
|||||||
totalBookings: totalMap.get(event.id) || 0,
|
totalBookings: totalMap.get(event.id) || 0,
|
||||||
confirmedBookings,
|
confirmedBookings,
|
||||||
checkedIn: checkedInMap.get(event.id) || 0,
|
checkedIn: checkedInMap.get(event.id) || 0,
|
||||||
revenue: confirmedBookings * event.price,
|
revenue: revenueMap.get(event.id) || 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -264,7 +277,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Export attendees for a specific event (admin) — CSV download
|
// Export attendees for a specific event (admin) — CSV download
|
||||||
adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), async (c) => {
|
adminRouter.get('/events/:eventId/attendees/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
const status = c.req.query('status') || 'all'; // confirmed | checked_in | confirmed_pending | all
|
||||||
const q = c.req.query('q') || '';
|
const q = c.req.query('q') || '';
|
||||||
@@ -368,14 +381,14 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Legacy alias — keep old path working
|
// Legacy alias — keep old path working
|
||||||
adminRouter.get('/events/:eventId/export', requireAuth(['admin']), async (c) => {
|
adminRouter.get('/events/:eventId/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
const newUrl = new URL(c.req.url);
|
const newUrl = new URL(c.req.url);
|
||||||
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
newUrl.pathname = newUrl.pathname.replace('/export', '/attendees/export');
|
||||||
return c.redirect(newUrl.toString(), 301);
|
return c.redirect(newUrl.toString(), 301);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
// Export tickets for a specific event (admin) — CSV download (confirmed/checked_in only)
|
||||||
adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async (c) => {
|
adminRouter.get('/events/:eventId/tickets/export', requireEventPermission('view_attendees_pii', { globalRoles: ['admin'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
const status = c.req.query('status') || 'all'; // confirmed | checked_in | all
|
||||||
const q = c.req.query('q') || '';
|
const q = c.req.query('q') || '';
|
||||||
@@ -519,6 +532,7 @@ adminRouter.get('/export/financial', requireAuth(['admin']), async (c) => {
|
|||||||
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
bancard: filteredPayments.filter((p: any) => p.provider === 'bancard' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
lightning: filteredPayments.filter((p: any) => p.provider === 'lightning' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
cash: filteredPayments.filter((p: any) => p.provider === 'cash' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
|
pos: filteredPayments.filter((p: any) => p.provider === 'pos' && p.status === 'paid').reduce((sum: number, p: any) => sum + p.amount, 0),
|
||||||
},
|
},
|
||||||
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
|
paidCount: filteredPayments.filter((p: any) => p.status === 'paid').length,
|
||||||
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
|
pendingCount: filteredPayments.filter((p: any) => p.status === 'pending').length,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { auth } from '../lib/betterAuth.js';
|
import { auth } from '../lib/betterAuth.js';
|
||||||
import { validatePassword } from '../lib/passwordPolicy.js';
|
import { validatePassword } from '../lib/passwordPolicy.js';
|
||||||
import { db, dbGet, users } from '../db/index.js';
|
import { db, dbGet, users } from '../db/index.js';
|
||||||
import { getNow, toDbBool } from '../lib/utils.js';
|
import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js';
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||||
|
|
||||||
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
|
// Custom auth flows that Better Auth doesn't provide out of the box. Mounted
|
||||||
@@ -83,8 +83,9 @@ authExt.get('/claim-eligibility', authExtRateLimit, async (c) => {
|
|||||||
return c.json({ canClaim: false });
|
return c.json({ canClaim: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalized to match how the row is stored (see lib/utils.ts normalizeEmail)
|
||||||
const user = await dbGet<any>(
|
const user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, email))
|
(db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email)))
|
||||||
);
|
);
|
||||||
|
|
||||||
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
|
const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended'
|
||||||
|
|||||||
@@ -1,18 +1,54 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, users, tickets, payments, events, invoices } from '../db/index.js';
|
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, eventMembers } from '../db/index.js';
|
||||||
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
import { requireAuth, getUserPasswordHash, hasGoogleAccount, validatePassword, type AuthUser } from '../lib/auth.js';
|
||||||
import { auth } from '../lib/betterAuth.js';
|
import { auth } from '../lib/betterAuth.js';
|
||||||
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
import { authSessions, authAccounts } from '../db/auth-schema.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
|
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
import { resolveMemberPermissions, EVENT_PERMISSIONS } from '../lib/eventPermissions.js';
|
||||||
|
|
||||||
const dashboard = new Hono();
|
const dashboard = new Hono();
|
||||||
|
|
||||||
// Apply authentication to all routes
|
// Apply authentication to all routes
|
||||||
dashboard.use('*', requireAuth());
|
dashboard.use('*', requireAuth());
|
||||||
|
|
||||||
|
// ==================== My Events (team memberships) ====================
|
||||||
|
|
||||||
|
// Events the user was added to as staff / collaborator / co-manager, with what
|
||||||
|
// they may do on each. Opens the scoped event page at /dashboard/events/:id.
|
||||||
|
dashboard.get('/my-events', async (c) => {
|
||||||
|
const user = (c as any).get('user') as AuthUser;
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ m: eventMembers, e: events })
|
||||||
|
.from(eventMembers)
|
||||||
|
.innerJoin(events, eq((eventMembers as any).eventId, (events as any).id))
|
||||||
|
.where(eq((eventMembers as any).userId, user.id))
|
||||||
|
);
|
||||||
|
const toIso = (v: any) => (v instanceof Date ? v.toISOString() : v);
|
||||||
|
return c.json({
|
||||||
|
events: rows
|
||||||
|
.map((r: any) => ({
|
||||||
|
event: {
|
||||||
|
id: r.e.id,
|
||||||
|
slug: r.e.slug,
|
||||||
|
title: r.e.title,
|
||||||
|
titleEs: r.e.titleEs,
|
||||||
|
startDatetime: toIso(r.e.startDatetime),
|
||||||
|
location: r.e.location,
|
||||||
|
status: r.e.status,
|
||||||
|
bannerUrl: r.e.bannerUrl,
|
||||||
|
},
|
||||||
|
rolePreset: r.m.rolePreset,
|
||||||
|
permissions: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(r.m.rolePreset, r.m.permissions).has(p)),
|
||||||
|
}))
|
||||||
|
.sort((a: any, b: any) => String(b.event.startDatetime).localeCompare(String(a.event.startDatetime))),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== Profile Routes ====================
|
// ==================== Profile Routes ====================
|
||||||
|
|
||||||
const updateProfileSchema = z.object({
|
const updateProfileSchema = z.object({
|
||||||
@@ -208,7 +244,7 @@ dashboard.get('/tickets/:id', async (c) => {
|
|||||||
return c.json({
|
return c.json({
|
||||||
ticket: {
|
ticket: {
|
||||||
...ticket,
|
...ticket,
|
||||||
event,
|
event: omitWalkInPrice(event as any),
|
||||||
payment,
|
payment,
|
||||||
invoice,
|
invoice,
|
||||||
},
|
},
|
||||||
@@ -272,7 +308,7 @@ dashboard.get('/next-event', async (c) => {
|
|||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
nextEvent: {
|
nextEvent: {
|
||||||
event: nextEvent,
|
event: omitWalkInPrice(nextEvent),
|
||||||
ticket: nextTicket,
|
ticket: nextTicket,
|
||||||
payment: nextPayment,
|
payment: nextPayment,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,691 @@
|
|||||||
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { mkdtempSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'door-test-'));
|
||||||
|
const dbPath = join(dir, 'test.db');
|
||||||
|
process.env.DB_TYPE = 'sqlite';
|
||||||
|
process.env.DATABASE_URL = dbPath;
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||||
|
process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
|
||||||
|
delete process.env.REDIS_URL;
|
||||||
|
|
||||||
|
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
||||||
|
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
|
||||||
|
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
|
||||||
|
|
||||||
|
// Who the next request is from. Session auth itself is Better Auth's concern and
|
||||||
|
// has its own integration suite; this mock keeps the *role* check real so the
|
||||||
|
// tests can prove which endpoints door staff may reach.
|
||||||
|
let currentUser: { id: string; name: string; role: string } = STAFF;
|
||||||
|
|
||||||
|
vi.mock('../lib/auth.js', () => ({
|
||||||
|
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||||
|
if (roles && !roles.includes(currentUser.role)) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
c.set('user', currentUser);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
getAuthUser: async () => currentUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Run one request as a given role, always restoring the default afterwards. */
|
||||||
|
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = currentUser;
|
||||||
|
currentUser = user;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
currentUser = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
|
||||||
|
vi.mock('../lib/email.js', () => ({
|
||||||
|
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
let sqlite: any;
|
||||||
|
|
||||||
|
const EVENT_ID = 'evt-door-1';
|
||||||
|
const PRICE = 60000;
|
||||||
|
|
||||||
|
/** POST helper that mirrors how the door screen calls the API. */
|
||||||
|
async function post(path: string, body: unknown) {
|
||||||
|
const res = await app.request(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(path: string) {
|
||||||
|
const res = await app.request(path);
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTicket(row: {
|
||||||
|
id: string;
|
||||||
|
first: string;
|
||||||
|
last?: string | null;
|
||||||
|
status: string;
|
||||||
|
paymentStatus: string;
|
||||||
|
phone?: string | null;
|
||||||
|
bookingId?: string | null;
|
||||||
|
qr?: string;
|
||||||
|
/** Seed the pre-sale payment a paid ticket was bought with, at this amount. */
|
||||||
|
paidAmount?: number;
|
||||||
|
}) {
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO tickets (id, booking_id, user_id, event_id, attendee_first_name, attendee_last_name,
|
||||||
|
attendee_email, attendee_phone, status, payment_status, is_guest, qr_code, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
row.id,
|
||||||
|
row.bookingId ?? null,
|
||||||
|
'seed-user',
|
||||||
|
EVENT_ID,
|
||||||
|
row.first,
|
||||||
|
row.last ?? null,
|
||||||
|
`${row.id}@test.py`,
|
||||||
|
row.phone ?? null,
|
||||||
|
row.status,
|
||||||
|
row.paymentStatus,
|
||||||
|
row.qr ?? `QR-${row.id}`,
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
if (row.paidAmount !== undefined) seedPayment(row.id, row.paidAmount, 'paid');
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedPayment(ticketId: string, amount: number, status: string, provider = 'bancard') {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO payments (id, ticket_id, provider, amount, currency, status, paid_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'PYG', ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(`pay-${ticketId}`, ticketId, provider, amount, status, status === 'paid' ? now : null, now, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||||
|
|
||||||
|
return (async () => {
|
||||||
|
const { Hono } = await import('hono');
|
||||||
|
const doorRoutes = (await import('./door.js')).default;
|
||||||
|
app = new Hono();
|
||||||
|
app.route('/api/events', doorRoutes);
|
||||||
|
|
||||||
|
const Database = (await import('better-sqlite3')).default;
|
||||||
|
sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'user', 0, 'unclaimed', ?, ?)`
|
||||||
|
)
|
||||||
|
.run('seed-user', 'seed@test.py', 'Seed User', now, now);
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'staff', 1, 'active', ?, ?)`
|
||||||
|
)
|
||||||
|
.run(STAFF.id, 'staff@test.py', STAFF.name, now, now);
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO events (id, title, description, start_datetime, location, price, currency, capacity, status, created_at, updated_at)
|
||||||
|
VALUES (?, 'Door Night', 'desc', ?, 'Asuncion', ?, 'PYG', 2, 'published', ?, ?)`
|
||||||
|
)
|
||||||
|
.run(EVENT_ID, now, PRICE, now, now);
|
||||||
|
|
||||||
|
seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567', paidAmount: PRICE });
|
||||||
|
seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
|
||||||
|
seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
|
||||||
|
seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' });
|
||||||
|
})();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
describe('door-attendees', () => {
|
||||||
|
it('returns everyone including cancelled, with group bookings flagged', async () => {
|
||||||
|
const { status, body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.event.price).toBe(PRICE);
|
||||||
|
expect(body.attendees).toHaveLength(4);
|
||||||
|
|
||||||
|
const cancelled = body.attendees.find((a: any) => a.ticketId === 'tkt-cancelled');
|
||||||
|
expect(cancelled.status).toBe('cancelled');
|
||||||
|
|
||||||
|
const grouped = body.attendees.find((a: any) => a.ticketId === 'tkt-unpaid');
|
||||||
|
expect(grouped.isGroupBooking).toBe(true);
|
||||||
|
expect(grouped.amountDue).toBe(PRICE);
|
||||||
|
|
||||||
|
const solo = body.attendees.find((a: any) => a.ticketId === 'tkt-paid');
|
||||||
|
expect(solo.isGroupBooking).toBe(false);
|
||||||
|
expect(solo.amountDue).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is sorted alphabetically so an empty search is scrollable', async () => {
|
||||||
|
const { body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
|
||||||
|
const names = body.attendees.map((a: any) => a.fullName);
|
||||||
|
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('door-checkin: existing ticket', () => {
|
||||||
|
it('checks in a paid attendee with no payment record touched', async () => {
|
||||||
|
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-paid',
|
||||||
|
entryMethod: 'search',
|
||||||
|
idempotencyKey: 'key-paid-checkin',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.attendee.checkedIn).toBe(true);
|
||||||
|
expect(body.attendee.checkinAt).toBeTruthy();
|
||||||
|
expect(body.attendee.checkedInBy).toBe(STAFF.name);
|
||||||
|
expect(body.payment).toBeNull();
|
||||||
|
|
||||||
|
const row = sqlite.prepare('SELECT status, checked_in_by_admin_id FROM tickets WHERE id = ?').get('tkt-paid');
|
||||||
|
expect(row.status).toBe('checked_in');
|
||||||
|
expect(row.checked_in_by_admin_id).toBe(STAFF.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replays an already-processed key instead of checking in twice', async () => {
|
||||||
|
const before = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
|
||||||
|
|
||||||
|
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-paid',
|
||||||
|
idempotencyKey: 'key-paid-checkin',
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.replayed).toBe(true);
|
||||||
|
|
||||||
|
const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
|
||||||
|
expect(after).toBe(before);
|
||||||
|
expect(sqlite.prepare("SELECT COUNT(*) n FROM payments WHERE ticket_id = ? AND source = 'door'").get('tkt-paid').n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => {
|
||||||
|
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-unpaid',
|
||||||
|
payment: { method: 'cash', amount: PRICE },
|
||||||
|
entryMethod: 'search',
|
||||||
|
idempotencyKey: 'key-unpaid-cash',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.attendee.paymentStatus).toBe('paid');
|
||||||
|
expect(body.attendee.checkedIn).toBe(true);
|
||||||
|
expect(body.payment).toMatchObject({ method: 'cash', amount: PRICE });
|
||||||
|
|
||||||
|
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid');
|
||||||
|
expect(payment.source).toBe('door');
|
||||||
|
expect(payment.method).toBe('cash');
|
||||||
|
expect(payment.provider).toBe('cash');
|
||||||
|
expect(payment.status).toBe('paid');
|
||||||
|
expect(payment.paid_by_admin_id).toBe(STAFF.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes a group payment at a multiple of the ticket price', async () => {
|
||||||
|
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-unpaid-2',
|
||||||
|
payment: { method: 'transfer', quantity: 2 },
|
||||||
|
idempotencyKey: 'key-unpaid-2-transfer',
|
||||||
|
});
|
||||||
|
expect(body.payment.amount).toBe(PRICE * 2);
|
||||||
|
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid-2');
|
||||||
|
expect(payment.provider).toBe('bank_transfer');
|
||||||
|
expect(payment.method).toBe('transfer');
|
||||||
|
expect(payment.amount).toBe(PRICE * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reactivates a cancelled ticket through the same payment flow', async () => {
|
||||||
|
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-cancelled',
|
||||||
|
payment: { method: 'bitcoin' },
|
||||||
|
idempotencyKey: 'key-cancelled-reactivate',
|
||||||
|
});
|
||||||
|
expect(body.attendee.status).toBe('checked_in');
|
||||||
|
expect(body.attendee.paymentStatus).toBe('paid');
|
||||||
|
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-cancelled');
|
||||||
|
// Bitcoin is recorded as already-paid Lightning: same trust model as cash,
|
||||||
|
// no invoice generated (see lib/doorPayments.ts).
|
||||||
|
expect(payment.provider).toBe('lightning');
|
||||||
|
expect(payment.method).toBe('bitcoin');
|
||||||
|
expect(payment.amount).toBe(PRICE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a ticket from another event', async () => {
|
||||||
|
const { status, body } = await post('/api/events/other-event/door-checkin', {
|
||||||
|
ticketId: 'tkt-paid',
|
||||||
|
idempotencyKey: 'key-wrong-event',
|
||||||
|
});
|
||||||
|
expect(status).toBe(404);
|
||||||
|
expect(body.error).toMatch(/Event not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('door-checkin: walk-ins', () => {
|
||||||
|
it('creates a cash walk-in confirmed, paid and checked in with no email', async () => {
|
||||||
|
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Walk' },
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
entryMethod: 'walkin',
|
||||||
|
idempotencyKey: 'key-walkin-cash',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.action).toBe('walkin');
|
||||||
|
expect(body.attendee.fullName).toBe('Walk');
|
||||||
|
expect(body.attendee.checkedIn).toBe(true);
|
||||||
|
expect(body.attendee.paymentStatus).toBe('paid');
|
||||||
|
expect(body.attendee.email).toBeNull();
|
||||||
|
|
||||||
|
const ticket = sqlite.prepare('SELECT * FROM tickets WHERE id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(ticket.status).toBe('checked_in');
|
||||||
|
expect(ticket.qr_code).toBeTruthy();
|
||||||
|
// A placeholder account keeps users.email unique without mailing anyone.
|
||||||
|
const account = sqlite.prepare('SELECT email FROM users WHERE id = ?').get(ticket.user_id);
|
||||||
|
expect(account.email).toMatch(/@doorentry\.local$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a guest walk-in as a zero-amount comp', async () => {
|
||||||
|
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Free', lastName: 'Guest' },
|
||||||
|
payment: { method: 'guest', amount: PRICE },
|
||||||
|
entryMethod: 'walkin',
|
||||||
|
idempotencyKey: 'key-walkin-guest',
|
||||||
|
});
|
||||||
|
expect(body.attendee.paymentStatus).toBe('comp');
|
||||||
|
expect(body.attendee.isGuest).toBe(true);
|
||||||
|
expect(body.payment.amount).toBe(0);
|
||||||
|
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(payment.amount).toBe(0);
|
||||||
|
expect(payment.method).toBe('guest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create a second ticket when the same walk-in key is retried', async () => {
|
||||||
|
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
|
||||||
|
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Walk' },
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
idempotencyKey: 'key-walkin-cash',
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.replayed).toBe(true);
|
||||||
|
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns rather than blocks once the event is over capacity', async () => {
|
||||||
|
// Capacity is 2 and several tickets already hold seats.
|
||||||
|
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Overflow' },
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
idempotencyKey: 'key-walkin-overflow',
|
||||||
|
});
|
||||||
|
expect(body.ok).toBe(true);
|
||||||
|
expect(body.warnings).toContain('at_capacity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undo', () => {
|
||||||
|
it('reverts a plain check-in to its previous state', async () => {
|
||||||
|
seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid', paidAmount: PRICE });
|
||||||
|
await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-undo',
|
||||||
|
idempotencyKey: 'key-undo-checkin',
|
||||||
|
});
|
||||||
|
expect(sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get('tkt-undo').status).toBe('checked_in');
|
||||||
|
|
||||||
|
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
|
||||||
|
idempotencyKey: 'key-undo-checkin',
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.reverted).toBe('existing');
|
||||||
|
|
||||||
|
const row = sqlite.prepare('SELECT status, checkin_at FROM tickets WHERE id = ?').get('tkt-undo');
|
||||||
|
expect(row.status).toBe('confirmed');
|
||||||
|
expect(row.checkin_at).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the payment it created and restores the unpaid balance', async () => {
|
||||||
|
seedTicket({ id: 'tkt-undo-pay', first: 'Undo', last: 'Pay', status: 'confirmed', paymentStatus: 'unpaid' });
|
||||||
|
await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-undo-pay',
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
idempotencyKey: 'key-undo-pay',
|
||||||
|
});
|
||||||
|
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(1);
|
||||||
|
|
||||||
|
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-pay' });
|
||||||
|
|
||||||
|
const row = sqlite.prepare('SELECT status, payment_status FROM tickets WHERE id = ?').get('tkt-undo-pay');
|
||||||
|
expect(row.status).toBe('confirmed');
|
||||||
|
expect(row.payment_status).toBe('unpaid');
|
||||||
|
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a walk-in it created', async () => {
|
||||||
|
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Mistake' },
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
idempotencyKey: 'key-undo-walkin',
|
||||||
|
});
|
||||||
|
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
|
||||||
|
|
||||||
|
const ticket = sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(ticket.status).toBe('cancelled');
|
||||||
|
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(payment.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is safe to call twice and rejects an unknown key', async () => {
|
||||||
|
const repeat = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
|
||||||
|
expect(repeat.body.alreadyUndone).toBe(true);
|
||||||
|
|
||||||
|
const unknown = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'never-happened' });
|
||||||
|
expect(unknown.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('door-summary access', () => {
|
||||||
|
it('is hidden from door staff — whole-event takings are not door information', async () => {
|
||||||
|
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
|
||||||
|
expect(status).toBe(403);
|
||||||
|
// The numbers must not leak in the body either: hiding the section in the UI
|
||||||
|
// alone would still expose them to anyone reading the network response.
|
||||||
|
expect(body).not.toHaveProperty('door');
|
||||||
|
expect(body).not.toHaveProperty('presale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is available to admin and organizer', async () => {
|
||||||
|
for (const role of [ADMIN, ORGANIZER]) {
|
||||||
|
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||||
|
expect(status, `${role.role} should see door takings`).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets door staff do their job — list, check in and undo', async () => {
|
||||||
|
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
|
||||||
|
|
||||||
|
// Comp, so this ticket stays out of the revenue totals asserted below and
|
||||||
|
// the two tests cannot drift into each other through the shared database.
|
||||||
|
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
|
||||||
|
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-role',
|
||||||
|
idempotencyKey: 'key-role-check',
|
||||||
|
});
|
||||||
|
expect(checkin.status).toBe(201);
|
||||||
|
|
||||||
|
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
|
||||||
|
idempotencyKey: 'key-role-check',
|
||||||
|
});
|
||||||
|
expect(undo.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('door-summary', () => {
|
||||||
|
it('totals door takings by tender and splits them from pre-sale', async () => {
|
||||||
|
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
|
||||||
|
// cancelled and no longer count).
|
||||||
|
expect(body.door.byMethod.cash.count).toBe(3);
|
||||||
|
expect(body.door.byMethod.cash.total).toBe(PRICE * 3);
|
||||||
|
expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 });
|
||||||
|
expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE });
|
||||||
|
expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 });
|
||||||
|
expect(body.door.total).toBe(PRICE * 6);
|
||||||
|
|
||||||
|
// Settled tickets with no door payment against them: tkt-paid, plus tkt-undo,
|
||||||
|
// whose door check-in was undone and which is a pre-paid ticket again.
|
||||||
|
expect(body.presale.count).toBe(2);
|
||||||
|
expect(body.presale.total).toBe(PRICE * 2);
|
||||||
|
expect(body.total).toBe(PRICE * 8);
|
||||||
|
|
||||||
|
expect(body.door.lines.length).toBe(body.door.count);
|
||||||
|
expect(body.door.lines[0]).toHaveProperty('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pre-sale revenue at what was paid when the ticket price changes', async () => {
|
||||||
|
sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(99000, EVENT_ID);
|
||||||
|
try {
|
||||||
|
const { body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||||
|
expect(body.presale).toEqual({ count: 2, total: PRICE * 2 });
|
||||||
|
expect(body.door.total).toBe(PRICE * 6);
|
||||||
|
} finally {
|
||||||
|
sqlite.prepare('UPDATE events SET price = ? WHERE id = ?').run(PRICE, EVENT_ID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Walk-in price & POS ====================
|
||||||
|
// Separate events so these cannot drift into the door-summary totals above.
|
||||||
|
|
||||||
|
const WALKIN_EVENT_ID = 'evt-door-walkin';
|
||||||
|
const WALKIN_PRICE = 25000;
|
||||||
|
const WALKIN_TICKET_PRICE = 21000;
|
||||||
|
const FREE_WALKIN_EVENT_ID = 'evt-door-free-walkin';
|
||||||
|
const NO_POS_EVENT_ID = 'evt-door-no-pos';
|
||||||
|
|
||||||
|
describe('walk-in pricing', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const insertEvent = sqlite.prepare(
|
||||||
|
`INSERT INTO events (id, title, description, start_datetime, location, price, walk_in_price, currency, capacity, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'desc', ?, 'Asuncion', ?, ?, 'PYG', 100, 'published', ?, ?)`
|
||||||
|
);
|
||||||
|
insertEvent.run(WALKIN_EVENT_ID, 'Walk-in Night', now, WALKIN_TICKET_PRICE, WALKIN_PRICE, now, now);
|
||||||
|
insertEvent.run(FREE_WALKIN_EVENT_ID, 'Free Door Night', now, WALKIN_TICKET_PRICE, 0, now, now);
|
||||||
|
insertEvent.run(NO_POS_EVENT_ID, 'No POS Night', now, WALKIN_TICKET_PRICE, null, now, now);
|
||||||
|
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, 'admin', 1, 'active', ?, ?)`
|
||||||
|
)
|
||||||
|
.run(ADMIN.id, 'admin@test.py', ADMIN.name, now, now);
|
||||||
|
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO event_payment_overrides (id, event_id, pos_enabled, created_at, updated_at)
|
||||||
|
VALUES ('ovr-no-pos', ?, 0, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(NO_POS_EVENT_ID, now, now);
|
||||||
|
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO tickets (id, user_id, event_id, attendee_first_name, status, payment_status, is_guest, qr_code, created_at)
|
||||||
|
VALUES ('tkt-walkin-event-unpaid', 'seed-user', ?, 'Pre', 'confirmed', 'unpaid', 0, 'QR-walkin-unpaid', ?)`
|
||||||
|
)
|
||||||
|
.run(WALKIN_EVENT_ID, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the walk-in unit price on the server for the door screen', async () => {
|
||||||
|
const withPrice = await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`);
|
||||||
|
expect(withPrice.body.event).toMatchObject({
|
||||||
|
price: WALKIN_TICKET_PRICE,
|
||||||
|
walkInPrice: WALKIN_PRICE,
|
||||||
|
walkInUnitPrice: WALKIN_PRICE,
|
||||||
|
walkInPriceSource: 'walk_in',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallback = await get(`/api/events/${EVENT_ID}/door-attendees`);
|
||||||
|
expect(fallback.body.event).toMatchObject({
|
||||||
|
walkInPrice: null,
|
||||||
|
walkInUnitPrice: PRICE,
|
||||||
|
walkInPriceSource: 'ticket',
|
||||||
|
});
|
||||||
|
|
||||||
|
const free = await get(`/api/events/${FREE_WALKIN_EVENT_ID}/door-attendees`);
|
||||||
|
expect(free.body.event).toMatchObject({ walkInPrice: 0, walkInUnitPrice: 0, walkInPriceSource: 'walk_in' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charges walk-ins the walk-in price and marks them as walk-in bookings', async () => {
|
||||||
|
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Door', lastName: 'Buyer' },
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
entryMethod: 'walkin',
|
||||||
|
idempotencyKey: 'key-walkin-price-cash',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.payment).toMatchObject({ method: 'cash', amount: WALKIN_PRICE, currency: 'PYG', amountOverridden: false });
|
||||||
|
|
||||||
|
const ticket = sqlite.prepare('SELECT booking_source FROM tickets WHERE id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(ticket.booking_source).toBe('walk_in');
|
||||||
|
const payment = sqlite.prepare('SELECT amount, currency, source FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(payment).toMatchObject({ amount: WALKIN_PRICE, currency: 'PYG', source: 'door' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a walk-in price of 0 as a free walk-in, not as "unset"', async () => {
|
||||||
|
const { body } = await post(`/api/events/${FREE_WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Free' },
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
idempotencyKey: 'key-walkin-free',
|
||||||
|
});
|
||||||
|
expect(body.payment.amount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiplies the resolved price by quantity', async () => {
|
||||||
|
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Group' },
|
||||||
|
payment: { method: 'cash', quantity: 3 },
|
||||||
|
idempotencyKey: 'key-walkin-price-group',
|
||||||
|
});
|
||||||
|
expect(body.payment.amount).toBe(WALKIN_PRICE * 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a client-sent amount without the override flag', async () => {
|
||||||
|
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Cheap' },
|
||||||
|
payment: { method: 'cash', amount: 1 },
|
||||||
|
idempotencyKey: 'key-walkin-client-amount',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.payment.amount).toBe(WALKIN_PRICE);
|
||||||
|
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(payment.amount).toBe(WALKIN_PRICE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an amount override from door staff and writes nothing', async () => {
|
||||||
|
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
|
||||||
|
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Discount' },
|
||||||
|
payment: { method: 'cash', amount: 1000, amountOverride: true },
|
||||||
|
idempotencyKey: 'key-walkin-staff-override',
|
||||||
|
});
|
||||||
|
expect(status).toBe(403);
|
||||||
|
expect(body.code).toBe('OVERRIDE_FORBIDDEN');
|
||||||
|
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin override the amount and records it in the audit log', async () => {
|
||||||
|
const { status, body } = await as(ADMIN, () => post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Friend' },
|
||||||
|
payment: { method: 'cash', amount: 10000, amountOverride: true },
|
||||||
|
idempotencyKey: 'key-walkin-admin-override',
|
||||||
|
}));
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.payment).toMatchObject({ amount: 10000, amountOverridden: true });
|
||||||
|
|
||||||
|
const log = sqlite
|
||||||
|
.prepare(`SELECT * FROM audit_logs WHERE action = 'door_amount_override' AND target_id = ?`)
|
||||||
|
.get(body.payment.id);
|
||||||
|
expect(log.user_id).toBe(ADMIN.id);
|
||||||
|
expect(JSON.parse(log.details)).toMatchObject({
|
||||||
|
eventId: WALKIN_EVENT_ID,
|
||||||
|
computedAmount: WALKIN_PRICE,
|
||||||
|
chargedAmount: 10000,
|
||||||
|
currency: 'PYG',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles an existing unpaid ticket at the ticket price, not the walk-in price', async () => {
|
||||||
|
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-walkin-event-unpaid',
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
idempotencyKey: 'key-walkin-event-existing',
|
||||||
|
});
|
||||||
|
expect(body.payment.amount).toBe(WALKIN_TICKET_PRICE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the charged amount when the walk-in price is edited afterwards', async () => {
|
||||||
|
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Snapshot' },
|
||||||
|
payment: { method: 'cash' },
|
||||||
|
idempotencyKey: 'key-walkin-snapshot',
|
||||||
|
});
|
||||||
|
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(99000, WALKIN_EVENT_ID);
|
||||||
|
try {
|
||||||
|
const payment = sqlite.prepare('SELECT amount FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(payment.amount).toBe(WALKIN_PRICE);
|
||||||
|
} finally {
|
||||||
|
sqlite.prepare('UPDATE events SET walk_in_price = ? WHERE id = ?').run(WALKIN_PRICE, WALKIN_EVENT_ID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POS tender', () => {
|
||||||
|
it('is offered on the door screen unless switched off for the event', async () => {
|
||||||
|
expect((await get(`/api/events/${WALKIN_EVENT_ID}/door-attendees`)).body.doorMethods)
|
||||||
|
.toEqual(['cash', 'bitcoin', 'transfer', 'pos', 'guest']);
|
||||||
|
expect((await get(`/api/events/${NO_POS_EVENT_ID}/door-attendees`)).body.doorMethods)
|
||||||
|
.toEqual(['cash', 'bitcoin', 'transfer', 'guest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a confirmed POS walk-in as a paid door payment at the walk-in price', async () => {
|
||||||
|
const { status, body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Card', lastName: 'Payer' },
|
||||||
|
payment: { method: 'pos' },
|
||||||
|
entryMethod: 'walkin',
|
||||||
|
idempotencyKey: 'key-walkin-pos',
|
||||||
|
});
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.attendee).toMatchObject({ checkedIn: true, paymentStatus: 'paid', doorMethod: 'pos' });
|
||||||
|
expect(body.payment).toMatchObject({ method: 'pos', amount: WALKIN_PRICE });
|
||||||
|
|
||||||
|
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(payment).toMatchObject({
|
||||||
|
provider: 'pos',
|
||||||
|
method: 'pos',
|
||||||
|
source: 'door',
|
||||||
|
status: 'paid',
|
||||||
|
amount: WALKIN_PRICE,
|
||||||
|
paid_by_admin_id: STAFF.id,
|
||||||
|
reference: 'Door — paid by POS',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows POS takings in the door summary', async () => {
|
||||||
|
const { body } = await as(ADMIN, () => get(`/api/events/${WALKIN_EVENT_ID}/door-summary`));
|
||||||
|
expect(body.door.byMethod.pos).toEqual({ count: 1, total: WALKIN_PRICE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be undone like any other walk-in', async () => {
|
||||||
|
const { body } = await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Declined' },
|
||||||
|
payment: { method: 'pos' },
|
||||||
|
idempotencyKey: 'key-walkin-pos-undo',
|
||||||
|
});
|
||||||
|
await post(`/api/events/${WALKIN_EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-walkin-pos-undo' });
|
||||||
|
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
|
||||||
|
expect(payment.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is rejected when POS is disabled for the event', async () => {
|
||||||
|
const { status, body } = await post(`/api/events/${NO_POS_EVENT_ID}/door-checkin`, {
|
||||||
|
attendee: { firstName: 'Nope' },
|
||||||
|
payment: { method: 'pos' },
|
||||||
|
idempotencyKey: 'key-walkin-pos-disabled',
|
||||||
|
});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body.code).toBe('METHOD_DISABLED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,746 @@
|
|||||||
|
// Door check-in screen (admin/scanner) API.
|
||||||
|
//
|
||||||
|
// At the door, check-in and ticket creation are the same action, so everything
|
||||||
|
// here is written for one-tap speed on a phone with unreliable venue wifi:
|
||||||
|
//
|
||||||
|
// GET /:eventId/door-attendees full attendee list, fetched once and searched
|
||||||
|
// client-side so typing never hits the network
|
||||||
|
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
|
||||||
|
// payment, or creates a walk-in, atomically
|
||||||
|
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
|
||||||
|
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
|
||||||
|
// split (admin/organizer only)
|
||||||
|
//
|
||||||
|
// Every write carries a client-generated idempotencyKey. The key is inserted in
|
||||||
|
// the same transaction as the writes, so a double tap or a retry after a timeout
|
||||||
|
// can never produce a second ticket, a second payment or a double check-in — the
|
||||||
|
// replay returns the original response instead.
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq, and, inArray, sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, auditLogs,
|
||||||
|
} from '../db/index.js';
|
||||||
|
import { requireEventPermission, eventFromParam, canSeeAttendeePii } from '../lib/eventPermissions.js';
|
||||||
|
import { generateId, generateTicketCode, getNow, toDbBool, toDbDate, normalizeEmail } from '../lib/utils.js';
|
||||||
|
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||||
|
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||||
|
import {
|
||||||
|
DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference,
|
||||||
|
paymentStatusForMethod, loadDoorMethods, type DoorPaymentMethod,
|
||||||
|
} from '../lib/doorPayments.js';
|
||||||
|
import { parseWalkInPrice, resolveWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
import emailService from '../lib/email.js';
|
||||||
|
|
||||||
|
const doorRouter = new Hono();
|
||||||
|
|
||||||
|
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||||
|
// Whole-event money is management information, not door information: door staff
|
||||||
|
// reconcile their own shift from the session feed the client keeps locally, and
|
||||||
|
// never see what the event took overall. Matches the existing convention for
|
||||||
|
// revenue aggregates (admin/export/financial, admin/analytics).
|
||||||
|
const REVENUE_ROLES = ['admin', 'organizer'] as const;
|
||||||
|
// The server prices every door charge from the event record. Only the roles the
|
||||||
|
// app treats as administrators may override that with a typed amount, and every
|
||||||
|
// override is written to audit_logs.
|
||||||
|
const AMOUNT_OVERRIDE_ROLES = ['admin', 'organizer'] as const;
|
||||||
|
const MAX_DOOR_QUANTITY = 50;
|
||||||
|
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
||||||
|
|
||||||
|
// ==================== Shared helpers ====================
|
||||||
|
|
||||||
|
const num = (v: any): number => {
|
||||||
|
const n = typeof v === 'string' ? parseFloat(v) : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const iso = (v: any): string | null => {
|
||||||
|
if (!v) return null;
|
||||||
|
return v instanceof Date ? v.toISOString() : String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
function fullName(ticket: any): string {
|
||||||
|
return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row shape the door screen renders. Returned both by the preload list and
|
||||||
|
* by every write, so the client can splice an updated attendee straight back
|
||||||
|
* into its in-memory list without a refetch.
|
||||||
|
*/
|
||||||
|
function toDoorAttendee(
|
||||||
|
ticket: any,
|
||||||
|
opts: { price: number; groupBookingIds: Set<string>; adminNames: Map<string, string>; doorMethod?: string | null } ,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ticketId: ticket.id,
|
||||||
|
firstName: ticket.attendeeFirstName,
|
||||||
|
lastName: ticket.attendeeLastName || null,
|
||||||
|
fullName: fullName(ticket),
|
||||||
|
email: ticket.attendeeEmail || null,
|
||||||
|
phone: ticket.attendeePhone || null,
|
||||||
|
status: ticket.status,
|
||||||
|
paymentStatus: ticket.paymentStatus,
|
||||||
|
isGuest: !!ticket.isGuest,
|
||||||
|
checkedIn: ticket.status === 'checked_in',
|
||||||
|
checkinAt: iso(ticket.checkinAt),
|
||||||
|
checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null,
|
||||||
|
bookingId: ticket.bookingId || null,
|
||||||
|
isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)),
|
||||||
|
amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0,
|
||||||
|
doorMethod: opts.doorMethod ?? null,
|
||||||
|
qrCode: ticket.qrCode || null,
|
||||||
|
createdAt: iso(ticket.createdAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEvent(eventId: string | undefined) {
|
||||||
|
if (!eventId) return null;
|
||||||
|
const event = await dbGet<any>(
|
||||||
|
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||||
|
);
|
||||||
|
if (!event) return null;
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
price: num(event.price),
|
||||||
|
walkInPrice: parseWalkInPrice(event.walkInPrice),
|
||||||
|
capacity: Number(event.capacity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Names of the admins/staff referenced by the given check-in rows, in one query. */
|
||||||
|
async function loadAdminNames(adminIds: string[]): Promise<Map<string, string>> {
|
||||||
|
const unique = [...new Set(adminIds.filter(Boolean))];
|
||||||
|
if (unique.length === 0) return new Map();
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ id: (users as any).id, name: (users as any).name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray((users as any).id, unique))
|
||||||
|
);
|
||||||
|
return new Map(rows.map((r: any) => [r.id, r.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seats currently held for an event, used only to warn (never to block) at the door. */
|
||||||
|
async function seatsHeld(eventId: string): Promise<number> {
|
||||||
|
const row = await dbGet<any>(seatHolderCountQuery(db, eventId));
|
||||||
|
return Number(row?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GET /:eventId/door-attendees ====================
|
||||||
|
// One payload, fetched on load and refreshed every ~30s by the client. Cancelled
|
||||||
|
// tickets are included on purpose: staff must be able to see and reactivate them.
|
||||||
|
|
||||||
|
doorRouter.get('/:eventId/door-attendees', requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
|
const event = await loadEvent(eventId);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId))
|
||||||
|
);
|
||||||
|
|
||||||
|
// A booking id shared by more than one ticket marks a group booking, which is
|
||||||
|
// the usual reason an otherwise-confirmed attendee still shows as unpaid.
|
||||||
|
const bookingCounts = new Map<string, number>();
|
||||||
|
for (const t of rows) {
|
||||||
|
if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1);
|
||||||
|
}
|
||||||
|
const groupBookingIds = new Set(
|
||||||
|
[...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId));
|
||||||
|
|
||||||
|
// Door tender per ticket, so a row already settled at the door shows how.
|
||||||
|
// Joined on the event rather than on a list of ticket ids: the id list would
|
||||||
|
// grow with the guest list and eventually blow the statement parameter limit.
|
||||||
|
const doorMethods = new Map<string, string>();
|
||||||
|
const doorPayments = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ ticketId: (payments as any).ticketId, method: (payments as any).method })
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||||
|
.where(and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((payments as any).source, 'door')
|
||||||
|
))
|
||||||
|
);
|
||||||
|
for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method);
|
||||||
|
|
||||||
|
const enabledMethods = await loadDoorMethods(event.id);
|
||||||
|
const walkIn = resolveWalkInPrice(event);
|
||||||
|
|
||||||
|
const attendees = rows
|
||||||
|
.map((t: any) => toDoorAttendee(t, {
|
||||||
|
price: event.price,
|
||||||
|
groupBookingIds,
|
||||||
|
adminNames,
|
||||||
|
doorMethod: doorMethods.get(t.id) || null,
|
||||||
|
}))
|
||||||
|
.map((a) => (canSeeAttendeePii(c) ? a : { ...a, email: null, phone: null }))
|
||||||
|
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
|
||||||
|
|
||||||
|
const checkedIn = attendees.filter((a) => a.checkedIn).length;
|
||||||
|
const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
title: event.title,
|
||||||
|
price: event.price,
|
||||||
|
// What one walk-in ticket costs, resolved server-side (walk-in price, or
|
||||||
|
// the ticket price when none is set) so the screen shows what will be charged.
|
||||||
|
walkInPrice: event.walkInPrice,
|
||||||
|
walkInUnitPrice: walkIn.unitPrice,
|
||||||
|
walkInPriceSource: walkIn.source,
|
||||||
|
currency: event.currency,
|
||||||
|
capacity: event.capacity,
|
||||||
|
},
|
||||||
|
doorMethods: enabledMethods,
|
||||||
|
attendees,
|
||||||
|
stats: { checkedIn, totalActive, capacity: event.capacity },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== POST /:eventId/door-checkin ====================
|
||||||
|
|
||||||
|
const doorCheckinSchema = z.object({
|
||||||
|
// Existing ticket to check in (and optionally settle), or…
|
||||||
|
ticketId: z.string().optional(),
|
||||||
|
// …a walk-in to create. Only a first name is ever required.
|
||||||
|
attendee: z.object({
|
||||||
|
firstName: z.string().trim().min(1).max(255),
|
||||||
|
lastName: z.string().trim().max(255).optional().or(z.literal('')),
|
||||||
|
phone: z.string().trim().max(50).optional().or(z.literal('')),
|
||||||
|
email: z.string().trim().email().optional().or(z.literal('')),
|
||||||
|
ruc: z.string().trim().max(15).optional().or(z.literal('')),
|
||||||
|
}).optional(),
|
||||||
|
payment: z.object({
|
||||||
|
method: z.enum(DOOR_PAYMENT_METHODS),
|
||||||
|
// How many tickets' worth is being paid (someone paying for their group).
|
||||||
|
// The server prices it: walk-in price for walk-ins, ticket price otherwise.
|
||||||
|
quantity: z.number().int().min(1).max(MAX_DOOR_QUANTITY).optional(),
|
||||||
|
// A typed amount is ignored unless amountOverride is set, which only
|
||||||
|
// admin/organizer may do.
|
||||||
|
amount: z.number().min(0).optional(),
|
||||||
|
amountOverride: z.boolean().optional(),
|
||||||
|
}).refine((p) => !p.amountOverride || typeof p.amount === 'number', {
|
||||||
|
message: 'amount is required when amountOverride is set',
|
||||||
|
path: ['amount'],
|
||||||
|
}).optional(),
|
||||||
|
// How the attendee reached this action, for the session feed.
|
||||||
|
entryMethod: z.enum(['scan', 'search', 'walkin']).optional(),
|
||||||
|
idempotencyKey: z.string().min(8).max(128),
|
||||||
|
}).refine((d) => !!d.ticketId || !!d.attendee, {
|
||||||
|
message: 'Either ticketId or attendee is required',
|
||||||
|
path: ['ticketId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Undo instructions recorded alongside each processed idempotency key. */
|
||||||
|
type UndoState =
|
||||||
|
| {
|
||||||
|
kind: 'created';
|
||||||
|
ticketId: string;
|
||||||
|
paymentId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'existing';
|
||||||
|
ticketId: string;
|
||||||
|
prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean };
|
||||||
|
createdPaymentId?: string;
|
||||||
|
prevPayment?: {
|
||||||
|
id: string; provider: string; amount: number; status: string; reference: string | null;
|
||||||
|
paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A replay of a key we already processed returns the original response verbatim. */
|
||||||
|
async function findProcessedKey(key: string) {
|
||||||
|
return dbGet<any>(
|
||||||
|
(db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
doorRouter.post(
|
||||||
|
'/:eventId/door-checkin',
|
||||||
|
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
|
||||||
|
zValidator('json', doorCheckinSchema),
|
||||||
|
async (c) => {
|
||||||
|
const eventId = c.req.param('eventId');
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const adminUser = (c as any).get('user');
|
||||||
|
|
||||||
|
const existingKey = await findProcessedKey(data.idempotencyKey);
|
||||||
|
if (existingKey) {
|
||||||
|
return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountOverride = !!data.payment?.amountOverride;
|
||||||
|
if (amountOverride && !(AMOUNT_OVERRIDE_ROLES as readonly string[]).includes(adminUser?.role)) {
|
||||||
|
return c.json({ error: 'Only an admin can override the door amount', code: 'OVERRIDE_FORBIDDEN' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await loadEvent(eventId);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
|
||||||
|
const method = data.payment?.method as DoorPaymentMethod | undefined;
|
||||||
|
if (method && !(await loadDoorMethods(event.id)).includes(method)) {
|
||||||
|
return c.json({ error: `${DOOR_TENDERS[method].label} is not enabled for this event`, code: 'METHOD_DISABLED' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const quantity = data.payment?.quantity ?? 1;
|
||||||
|
// Walk-ins pay the walk-in price (falling back to the ticket price); an
|
||||||
|
// existing unpaid ticket settles at the current ticket price (its pending
|
||||||
|
// payment is repriced whenever the event price changes).
|
||||||
|
const unitPrice = data.ticketId ? event.price : resolveWalkInPrice(event).unitPrice;
|
||||||
|
const computedAmount = unitPrice * quantity;
|
||||||
|
const requestedAmount = amountOverride ? data.payment!.amount! : computedAmount;
|
||||||
|
|
||||||
|
const ops: TxOp[] = [];
|
||||||
|
let undoState: UndoState;
|
||||||
|
let action: 'checkin' | 'walkin';
|
||||||
|
let ticketRow: any;
|
||||||
|
let paymentSummary: {
|
||||||
|
id: string; method: DoorPaymentMethod; amount: number; currency: string; amountOverridden: boolean;
|
||||||
|
} | null = null;
|
||||||
|
let emailTicketId: string | null = null;
|
||||||
|
|
||||||
|
if (data.ticketId) {
|
||||||
|
// ---- Existing ticket: settle (optionally) and check in ----
|
||||||
|
const ticket = await dbGet<any>(
|
||||||
|
(db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId))
|
||||||
|
);
|
||||||
|
if (!ticket) return c.json({ error: 'Ticket not found' }, 404);
|
||||||
|
if (ticket.eventId !== eventId) {
|
||||||
|
return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
action = 'checkin';
|
||||||
|
const prevTicket = {
|
||||||
|
status: ticket.status,
|
||||||
|
checkinAt: iso(ticket.checkinAt),
|
||||||
|
checkedInByAdminId: ticket.checkedInByAdminId || null,
|
||||||
|
paymentStatus: ticket.paymentStatus,
|
||||||
|
isGuest: !!ticket.isGuest,
|
||||||
|
};
|
||||||
|
const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket };
|
||||||
|
|
||||||
|
const ticketUpdate: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (method) {
|
||||||
|
const amount = amountForMethod(method, requestedAmount);
|
||||||
|
const tender = DOOR_TENDERS[method];
|
||||||
|
ticketUpdate.paymentStatus = paymentStatusForMethod(method);
|
||||||
|
if (method === 'guest') ticketUpdate.isGuest = toDbBool(true);
|
||||||
|
|
||||||
|
const existingPayment = await dbGet<any>(
|
||||||
|
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingPayment) {
|
||||||
|
undo.prevPayment = {
|
||||||
|
id: existingPayment.id,
|
||||||
|
provider: existingPayment.provider,
|
||||||
|
amount: num(existingPayment.amount),
|
||||||
|
status: existingPayment.status,
|
||||||
|
reference: existingPayment.reference || null,
|
||||||
|
paidAt: iso(existingPayment.paidAt),
|
||||||
|
paidByAdminId: existingPayment.paidByAdminId || null,
|
||||||
|
source: existingPayment.source || 'presale',
|
||||||
|
method: existingPayment.method || null,
|
||||||
|
};
|
||||||
|
ops.push(updateOp(payments, {
|
||||||
|
provider: tender.provider,
|
||||||
|
amount,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'paid',
|
||||||
|
reference: doorReference(method),
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: adminUser?.id || null,
|
||||||
|
source: 'door',
|
||||||
|
method,
|
||||||
|
updatedAt: now,
|
||||||
|
}, eq((payments as any).id, existingPayment.id)));
|
||||||
|
paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||||
|
} else {
|
||||||
|
const paymentId = generateId();
|
||||||
|
undo.createdPaymentId = paymentId;
|
||||||
|
ops.push(insertOp(payments, {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId: ticket.id,
|
||||||
|
provider: tender.provider,
|
||||||
|
amount,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'paid',
|
||||||
|
reference: doorReference(method),
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: adminUser?.id || null,
|
||||||
|
source: 'door',
|
||||||
|
method,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}));
|
||||||
|
paymentSummary = { id: paymentId, method, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check in. An already-checked-in ticket keeps its original timestamp so
|
||||||
|
// staff can still tell the person when they actually entered.
|
||||||
|
if (ticket.status !== 'checked_in') {
|
||||||
|
ticketUpdate.status = 'checked_in';
|
||||||
|
ticketUpdate.checkinAt = now;
|
||||||
|
ticketUpdate.checkedInByAdminId = adminUser?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(ticketUpdate).length > 0) {
|
||||||
|
ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
undoState = undo;
|
||||||
|
ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt };
|
||||||
|
} else {
|
||||||
|
// ---- Walk-in: born confirmed, settled and checked in, in one write ----
|
||||||
|
const attendee = data.attendee!;
|
||||||
|
action = 'walkin';
|
||||||
|
const tenderMethod: DoorPaymentMethod = method || 'cash';
|
||||||
|
const tender = DOOR_TENDERS[tenderMethod];
|
||||||
|
const amount = amountForMethod(tenderMethod, requestedAmount);
|
||||||
|
const hasEmail = !!(attendee.email && attendee.email.trim());
|
||||||
|
const firstNameValue = attendee.firstName.trim();
|
||||||
|
const lastNameValue = attendee.lastName?.trim() || null;
|
||||||
|
const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue;
|
||||||
|
|
||||||
|
// No email is the fast path; a placeholder keeps the users.email unique
|
||||||
|
// constraint satisfied without ever mailing anyone.
|
||||||
|
// Normalized: Better Auth lowercases every lookup it makes, and the
|
||||||
|
// users.email unique index is case-sensitive, so a mixed-case address
|
||||||
|
// written here would be invisible to sign-in and Google linking.
|
||||||
|
const accountEmail = normalizeEmail(
|
||||||
|
hasEmail
|
||||||
|
? attendee.email!
|
||||||
|
: `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`
|
||||||
|
);
|
||||||
|
|
||||||
|
let user = hasEmail
|
||||||
|
? await dbGet<any>((db as any).select().from(users).where(eq((users as any).email, accountEmail)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const userId = generateId();
|
||||||
|
user = { id: userId, email: accountEmail };
|
||||||
|
ops.push(insertOp(users, {
|
||||||
|
id: userId,
|
||||||
|
email: accountEmail,
|
||||||
|
password: null,
|
||||||
|
name: displayName,
|
||||||
|
phone: attendee.phone?.trim() || null,
|
||||||
|
role: 'user',
|
||||||
|
languagePreference: null,
|
||||||
|
isClaimed: toDbBool(false),
|
||||||
|
accountStatus: 'unclaimed',
|
||||||
|
emailVerified: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = generateId();
|
||||||
|
const paymentId = generateId();
|
||||||
|
const newTicket = {
|
||||||
|
id: ticketId,
|
||||||
|
bookingId: null,
|
||||||
|
userId: user.id,
|
||||||
|
eventId,
|
||||||
|
attendeeFirstName: firstNameValue,
|
||||||
|
attendeeLastName: lastNameValue,
|
||||||
|
attendeeEmail: hasEmail ? attendee.email!.trim() : null,
|
||||||
|
attendeePhone: attendee.phone?.trim() || null,
|
||||||
|
attendeeRuc: attendee.ruc?.trim() || null,
|
||||||
|
preferredLanguage: null,
|
||||||
|
status: 'checked_in',
|
||||||
|
paymentStatus: paymentStatusForMethod(tenderMethod),
|
||||||
|
isGuest: toDbBool(tenderMethod === 'guest'),
|
||||||
|
qrCode: generateTicketCode(),
|
||||||
|
checkinAt: now,
|
||||||
|
checkedInByAdminId: adminUser?.id || null,
|
||||||
|
adminNote: null,
|
||||||
|
bookingSource: 'walk_in',
|
||||||
|
createdAt: now,
|
||||||
|
};
|
||||||
|
ops.push(insertOp(tickets, newTicket));
|
||||||
|
ops.push(insertOp(payments, {
|
||||||
|
id: paymentId,
|
||||||
|
ticketId,
|
||||||
|
provider: tender.provider,
|
||||||
|
amount,
|
||||||
|
currency: event.currency,
|
||||||
|
status: 'paid',
|
||||||
|
reference: doorReference(tenderMethod),
|
||||||
|
paidAt: now,
|
||||||
|
paidByAdminId: adminUser?.id || null,
|
||||||
|
source: 'door',
|
||||||
|
method: tenderMethod,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency, amountOverridden: amountOverride && !tender.isComp };
|
||||||
|
undoState = { kind: 'created', ticketId, paymentId };
|
||||||
|
ticketRow = newTicket;
|
||||||
|
// Only mail people who actually gave an address; no QR for the rest.
|
||||||
|
if (hasEmail) emailTicketId = ticketId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written in the same transaction as the payment, so the log can never
|
||||||
|
// disagree with what was recorded.
|
||||||
|
if (paymentSummary?.amountOverridden) {
|
||||||
|
ops.push(insertOp(auditLogs, {
|
||||||
|
id: generateId(),
|
||||||
|
userId: adminUser?.id || null,
|
||||||
|
action: 'door_amount_override',
|
||||||
|
target: 'payment',
|
||||||
|
targetId: paymentSummary.id,
|
||||||
|
details: JSON.stringify({
|
||||||
|
eventId,
|
||||||
|
ticketId: ticketRow.id,
|
||||||
|
method: paymentSummary.method,
|
||||||
|
quantity,
|
||||||
|
computedAmount,
|
||||||
|
chargedAmount: paymentSummary.amount,
|
||||||
|
currency: event.currency,
|
||||||
|
}),
|
||||||
|
timestamp: now,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff at the door is the authority: a full event is a warning, never a block.
|
||||||
|
const held = await seatsHeld(eventId);
|
||||||
|
const atCapacity = event.capacity > 0 && held >= event.capacity;
|
||||||
|
|
||||||
|
const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]);
|
||||||
|
const responseBody = {
|
||||||
|
ok: true,
|
||||||
|
action,
|
||||||
|
attendee: toDoorAttendee(ticketRow, {
|
||||||
|
price: event.price,
|
||||||
|
groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []),
|
||||||
|
adminNames,
|
||||||
|
doorMethod: paymentSummary?.method || null,
|
||||||
|
}),
|
||||||
|
payment: paymentSummary,
|
||||||
|
warnings: atCapacity ? ['at_capacity'] : [],
|
||||||
|
idempotencyKey: data.idempotencyKey,
|
||||||
|
processedAt: nowIso,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The key row goes in with the writes, so two concurrent replays of the same
|
||||||
|
// key cannot both commit — the loser hits the primary-key conflict below.
|
||||||
|
ops.unshift(insertOp(idempotencyKeys, {
|
||||||
|
key: data.idempotencyKey,
|
||||||
|
scope: IDEMPOTENCY_SCOPE,
|
||||||
|
result: JSON.stringify(responseBody),
|
||||||
|
undoState: JSON.stringify(undoState),
|
||||||
|
undoneAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runOps(ops);
|
||||||
|
} catch (err: any) {
|
||||||
|
const replay = await findProcessedKey(data.idempotencyKey);
|
||||||
|
if (replay) {
|
||||||
|
return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentSummary?.amountOverridden) {
|
||||||
|
console.info(
|
||||||
|
`[Door] Amount override by ${adminUser?.id} (${adminUser?.role}) on event ${eventId}: ` +
|
||||||
|
`${paymentSummary.amount} ${event.currency} instead of ${computedAmount} (${paymentSummary.method})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailTicketId) {
|
||||||
|
emailService.sendBookingConfirmation(emailTicketId).catch((err) => {
|
||||||
|
console.error('[Email] Failed to send door walk-in confirmation:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(responseBody, 201);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== POST /:eventId/door-checkin/undo ====================
|
||||||
|
// Reverses exactly what the keyed action did — nothing more. This is what makes
|
||||||
|
// the door screen safe to run without a single confirm dialog.
|
||||||
|
|
||||||
|
doorRouter.post(
|
||||||
|
'/:eventId/door-checkin/undo',
|
||||||
|
requireEventPermission('check_in', { globalRoles: STAFF_ROLES, eventId: eventFromParam('eventId') }),
|
||||||
|
zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })),
|
||||||
|
async (c) => {
|
||||||
|
const { idempotencyKey } = c.req.valid('json');
|
||||||
|
|
||||||
|
const record = await findProcessedKey(idempotencyKey);
|
||||||
|
if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404);
|
||||||
|
if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true });
|
||||||
|
|
||||||
|
const undo = JSON.parse(record.undoState || 'null') as UndoState | null;
|
||||||
|
if (!undo) return c.json({ error: 'This action cannot be undone' }, 400);
|
||||||
|
|
||||||
|
const now = getNow();
|
||||||
|
const ops: TxOp[] = [];
|
||||||
|
|
||||||
|
if (undo.kind === 'created') {
|
||||||
|
// Walk-ins created here are cancelled, not deleted: the row stays as an
|
||||||
|
// audit trail and can be reactivated from the same screen.
|
||||||
|
ops.push(updateOp(tickets, {
|
||||||
|
status: 'cancelled',
|
||||||
|
checkinAt: null,
|
||||||
|
checkedInByAdminId: null,
|
||||||
|
}, eq((tickets as any).id, undo.ticketId)));
|
||||||
|
ops.push(updateOp(payments, {
|
||||||
|
status: 'cancelled',
|
||||||
|
paidAt: null,
|
||||||
|
updatedAt: now,
|
||||||
|
}, eq((payments as any).id, undo.paymentId)));
|
||||||
|
} else {
|
||||||
|
ops.push(updateOp(tickets, {
|
||||||
|
status: undo.prevTicket.status,
|
||||||
|
checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null,
|
||||||
|
checkedInByAdminId: undo.prevTicket.checkedInByAdminId,
|
||||||
|
paymentStatus: undo.prevTicket.paymentStatus,
|
||||||
|
isGuest: toDbBool(undo.prevTicket.isGuest),
|
||||||
|
}, eq((tickets as any).id, undo.ticketId)));
|
||||||
|
|
||||||
|
if (undo.createdPaymentId) {
|
||||||
|
ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId)));
|
||||||
|
} else if (undo.prevPayment) {
|
||||||
|
const prev = undo.prevPayment;
|
||||||
|
ops.push(updateOp(payments, {
|
||||||
|
provider: prev.provider,
|
||||||
|
amount: prev.amount,
|
||||||
|
status: prev.status,
|
||||||
|
reference: prev.reference,
|
||||||
|
paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null,
|
||||||
|
paidByAdminId: prev.paidByAdminId,
|
||||||
|
source: prev.source,
|
||||||
|
method: prev.method,
|
||||||
|
updatedAt: now,
|
||||||
|
}, eq((payments as any).id, prev.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey)));
|
||||||
|
|
||||||
|
await runOps(ops);
|
||||||
|
|
||||||
|
return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== GET /:eventId/door-summary ====================
|
||||||
|
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
||||||
|
// pre-sale/door split the event dashboard shows.
|
||||||
|
|
||||||
|
doorRouter.get('/:eventId/door-summary', requireEventPermission('view_payments', { globalRoles: REVENUE_ROLES, eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
|
const event = await loadEvent(eventId);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
|
||||||
|
// Door payments settled for this event, with the attendee attached so the
|
||||||
|
// session feed can show who each line belongs to.
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({
|
||||||
|
paymentId: (payments as any).id,
|
||||||
|
ticketId: (tickets as any).id,
|
||||||
|
method: (payments as any).method,
|
||||||
|
amount: (payments as any).amount,
|
||||||
|
paidAt: (payments as any).paidAt,
|
||||||
|
firstName: (tickets as any).attendeeFirstName,
|
||||||
|
lastName: (tickets as any).attendeeLastName,
|
||||||
|
ticketStatus: (tickets as any).status,
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id))
|
||||||
|
.where(and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((payments as any).source, 'door'),
|
||||||
|
eq((payments as any).status, 'paid')
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
const byMethod: Record<string, { count: number; total: number }> = {};
|
||||||
|
for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 };
|
||||||
|
|
||||||
|
let doorTotal = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = (r.method && byMethod[r.method]) ? r.method : 'cash';
|
||||||
|
const amount = num(r.amount);
|
||||||
|
byMethod[key].count += 1;
|
||||||
|
byMethod[key].total += amount;
|
||||||
|
doorTotal += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-sale revenue: settled tickets that weren't taken at the door, at the
|
||||||
|
// amount actually paid — never the current event price, which an admin can
|
||||||
|
// change after tickets have been sold.
|
||||||
|
const doorTicketIds = new Set(rows.map((r: any) => r.ticketId));
|
||||||
|
const settled = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ id: (tickets as any).id, amount: (payments as any).amount })
|
||||||
|
.from(tickets)
|
||||||
|
.leftJoin(payments, and(
|
||||||
|
eq((payments as any).ticketId, (tickets as any).id),
|
||||||
|
eq((payments as any).status, 'paid')
|
||||||
|
))
|
||||||
|
.where(and(
|
||||||
|
eq((tickets as any).eventId, eventId),
|
||||||
|
eq((tickets as any).paymentStatus, 'paid'),
|
||||||
|
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
|
||||||
|
))
|
||||||
|
);
|
||||||
|
const presaleIds = new Set<string>();
|
||||||
|
let presaleTotal = 0;
|
||||||
|
for (const t of settled) {
|
||||||
|
if (doorTicketIds.has(t.id)) continue;
|
||||||
|
presaleIds.add(t.id);
|
||||||
|
presaleTotal += num(t.amount);
|
||||||
|
}
|
||||||
|
const presaleCount = presaleIds.size;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
eventId,
|
||||||
|
currency: event.currency,
|
||||||
|
price: event.price,
|
||||||
|
door: {
|
||||||
|
count: rows.length,
|
||||||
|
total: doorTotal,
|
||||||
|
byMethod,
|
||||||
|
lines: rows
|
||||||
|
.map((r: any) => ({
|
||||||
|
paymentId: r.paymentId,
|
||||||
|
ticketId: r.ticketId,
|
||||||
|
name: `${r.firstName} ${r.lastName || ''}`.trim(),
|
||||||
|
method: r.method || 'cash',
|
||||||
|
amount: num(r.amount),
|
||||||
|
paidAt: iso(r.paidAt),
|
||||||
|
}))
|
||||||
|
.sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')),
|
||||||
|
},
|
||||||
|
presale: { count: presaleCount, total: presaleTotal },
|
||||||
|
total: presaleTotal + doorTotal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default doorRouter;
|
||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||||
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
|
import { requireEventPermission, eventFromParam, eventFromQuery } from '../lib/eventPermissions.js';
|
||||||
import { getNow, generateId } from '../lib/utils.js';
|
import { getNow, generateId } from '../lib/utils.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
import { getTemplateVariables, defaultTemplates } from '../lib/emailTemplates.js';
|
||||||
@@ -58,7 +59,7 @@ function safeParseVariables(raw: any): any[] {
|
|||||||
// ==================== Template Routes ====================
|
// ==================== Template Routes ====================
|
||||||
|
|
||||||
// Get all email templates
|
// Get all email templates
|
||||||
emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.get('/templates', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||||
const templates = await dbAll<any>(
|
const templates = await dbAll<any>(
|
||||||
(db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
|
(db as any).select().from(emailTemplates).orderBy(desc((emailTemplates as any).createdAt))
|
||||||
);
|
);
|
||||||
@@ -239,7 +240,7 @@ emailsRouter.get('/templates/:slug/variables', requireAuth(['admin', 'organizer'
|
|||||||
// ==================== Email Sending Routes ====================
|
// ==================== Email Sending Routes ====================
|
||||||
|
|
||||||
// Send email using template to event attendees (non-blocking, queued)
|
// Send email using template to event attendees (non-blocking, queued)
|
||||||
emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.post('/send/event/:eventId', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
const { eventId } = c.req.param();
|
const { eventId } = c.req.param();
|
||||||
const user = (c as any).get('user');
|
const user = (c as any).get('user');
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
@@ -286,7 +287,7 @@ emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidato
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Preview email (render template without sending)
|
// Preview email (render template without sending)
|
||||||
emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.post('/preview', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { templateSlug, variables, locale } = body;
|
const { templateSlug, variables, locale } = body;
|
||||||
|
|
||||||
@@ -327,7 +328,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
// ==================== Email Logs Routes ====================
|
// ==================== Email Logs Routes ====================
|
||||||
|
|
||||||
// Get email logs
|
// Get email logs
|
||||||
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.get('/logs', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const search = c.req.query('search');
|
const search = c.req.query('search');
|
||||||
@@ -420,7 +421,7 @@ emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get email stats
|
// Get email stats
|
||||||
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.get('/stats', requireEventPermission('email_attendees', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
|
let baseCondition = eventId ? eq((emailLogs as any).eventId, eventId) : undefined;
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { mkdtempSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||||
|
// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres
|
||||||
|
// URL to run the same suite in a throwaway schema (dropped afterwards).
|
||||||
|
const PG_URL = process.env.FINANCE_TEST_PG_URL;
|
||||||
|
const PG_SCHEMA = `fintest_${Date.now()}`;
|
||||||
|
if (PG_URL) {
|
||||||
|
process.env.DB_TYPE = 'postgres';
|
||||||
|
process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`;
|
||||||
|
} else {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'finance-test-'));
|
||||||
|
process.env.DB_TYPE = 'sqlite';
|
||||||
|
process.env.DATABASE_URL = join(dir, 'test.db');
|
||||||
|
}
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||||
|
process.env.BETTER_AUTH_SECRET = 'finance-test-secret-0123456789abcdef';
|
||||||
|
delete process.env.REDIS_URL;
|
||||||
|
|
||||||
|
type TestUser = { id: string; name: string; role: string; languagePreference?: string | null };
|
||||||
|
const ADMIN: TestUser = { id: randomUUID(), name: 'The Admin', role: 'admin' };
|
||||||
|
const ORGANIZER: TestUser = { id: randomUUID(), name: 'The Organizer', role: 'organizer' };
|
||||||
|
const COLLAB: TestUser = { id: randomUUID(), name: 'Pilates Studio', role: 'user' };
|
||||||
|
const COMANAGER: TestUser = { id: randomUUID(), name: 'Co Manager', role: 'user' };
|
||||||
|
const DOOR: TestUser = { id: randomUUID(), name: 'Door Helper', role: 'user' };
|
||||||
|
const STRANGER: TestUser = { id: randomUUID(), name: 'Stranger', role: 'user' };
|
||||||
|
|
||||||
|
// Session auth is Better Auth's concern and has its own suite; this keeps the
|
||||||
|
// role and membership checks real.
|
||||||
|
let currentUser: TestUser = ADMIN;
|
||||||
|
vi.mock('../lib/auth.js', () => ({
|
||||||
|
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||||
|
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
c.set('user', currentUser);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
getAuthUser: async () => currentUser,
|
||||||
|
}));
|
||||||
|
vi.mock('../lib/email.js', () => ({ default: {} }));
|
||||||
|
|
||||||
|
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = currentUser;
|
||||||
|
currentUser = user;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
currentUser = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
let dbm: any;
|
||||||
|
|
||||||
|
const EVENT_ID = randomUUID();
|
||||||
|
const OTHER_EVENT_ID = randomUUID();
|
||||||
|
const SEED_USER_ID = randomUUID();
|
||||||
|
const PRICE = 100000;
|
||||||
|
|
||||||
|
async function call(method: string, path: string, body?: unknown) {
|
||||||
|
const res = await app.request(path, {
|
||||||
|
method,
|
||||||
|
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const type = res.headers.get('content-type') || '';
|
||||||
|
return { status: res.status, type, body: type.includes('json') ? await res.json() : Buffer.from(await res.arrayBuffer()) };
|
||||||
|
}
|
||||||
|
const get = (p: string) => call('GET', p);
|
||||||
|
const post = (p: string, b: unknown = {}) => call('POST', p, b);
|
||||||
|
const put = (p: string, b: unknown) => call('PUT', p, b);
|
||||||
|
|
||||||
|
// Seeded through drizzle so the same rows work on both engines.
|
||||||
|
async function seedTicket(label: string, eventId: string, opts: { status: string; paymentStatus: string; pay?: { amount: number; provider: string; source?: string; status?: string } }) {
|
||||||
|
const { db, tickets, payments } = dbm;
|
||||||
|
const now = dbm.getNow();
|
||||||
|
const id = randomUUID();
|
||||||
|
await db.insert(tickets).values({
|
||||||
|
id, userId: SEED_USER_ID, eventId, attendeeFirstName: `Guest ${label}`, attendeeLastName: 'Test',
|
||||||
|
attendeeEmail: `${label}@test.py`, attendeePhone: '+595 981 000 000', status: opts.status,
|
||||||
|
paymentStatus: opts.paymentStatus, isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now,
|
||||||
|
});
|
||||||
|
if (opts.pay) {
|
||||||
|
await db.insert(payments).values({
|
||||||
|
id: randomUUID(), ticketId: id, provider: opts.pay.provider, amount: opts.pay.amount, currency: 'PYG',
|
||||||
|
status: opts.pay.status || 'paid', source: opts.pay.source || 'presale', paidAt: now, createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' });
|
||||||
|
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||||
|
|
||||||
|
return (async () => {
|
||||||
|
const { Hono } = await import('hono');
|
||||||
|
app = new Hono();
|
||||||
|
app.route('/api/events', (await import('./door.js')).default);
|
||||||
|
app.route('/api/events', (await import('./eventFinance.js')).default);
|
||||||
|
app.route('/api/events', (await import('./events.js')).default);
|
||||||
|
app.route('/api/finance', (await import('./finance.js')).default);
|
||||||
|
app.route('/api/dashboard', (await import('./dashboard.js')).default);
|
||||||
|
|
||||||
|
dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')) };
|
||||||
|
const { db, users, events } = dbm;
|
||||||
|
const now = dbm.getNow();
|
||||||
|
for (const u of [ADMIN, ORGANIZER, COLLAB, COMANAGER, DOOR, STRANGER, { id: SEED_USER_ID, name: 'Seed', role: 'user' }]) {
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: u.id, email: `${u.name.toLowerCase().replace(/ /g, '.')}@test.py`, name: u.name, role: u.role,
|
||||||
|
isClaimed: dbm.toDbBool(true), accountStatus: 'active', createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [id, title] of [[EVENT_ID, 'Morning Club: Pilates Edition'], [OTHER_EVENT_ID, 'Some Other Event']]) {
|
||||||
|
await db.insert(events).values({
|
||||||
|
id, title, description: 'desc', startDatetime: now, location: 'Studio Uno', price: PRICE, currency: 'PYG',
|
||||||
|
capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10 pre-sale TPago tickets (8 checked in), 2 cash walk-ins at the door, 1 refund.
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await seedTicket(`t${i}`, EVENT_ID, { status: i < 8 ? 'checked_in' : 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
|
||||||
|
}
|
||||||
|
await seedTicket('door1', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||||
|
await seedTicket('door2', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||||
|
await seedTicket('refunded', EVENT_ID, { status: 'cancelled', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago', status: 'refunded' } });
|
||||||
|
await seedTicket('other1', OTHER_EVENT_ID, { status: 'confirmed', paymentStatus: 'paid', pay: { amount: PRICE, provider: 'tpago' } });
|
||||||
|
})();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('event finance access', () => {
|
||||||
|
it('lets admins in and keeps organizers out until they are granted access', async () => {
|
||||||
|
expect((await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
|
||||||
|
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(403);
|
||||||
|
expect((await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
|
||||||
|
const perms = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/my-permissions`));
|
||||||
|
expect(perms.body.permissions).toContain('view_payments');
|
||||||
|
expect(perms.body.permissions).not.toContain('view_finance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an admin add team members, and writes the audit log', async () => {
|
||||||
|
const add = (userId: string, rolePreset: string, permissions?: Record<string, boolean>) =>
|
||||||
|
as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId, rolePreset, permissions }));
|
||||||
|
expect((await add(COLLAB.id, 'collaborator')).status).toBe(201);
|
||||||
|
expect((await add(COMANAGER.id, 'co_manager')).status).toBe(201);
|
||||||
|
expect((await add(DOOR.id, 'staff')).status).toBe(201);
|
||||||
|
expect((await add(COLLAB.id, 'staff')).status).toBe(409);
|
||||||
|
|
||||||
|
const candidates = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members/candidates?q=stran`));
|
||||||
|
expect(candidates.body.users.map((u: any) => u.id)).toEqual([STRANGER.id]);
|
||||||
|
|
||||||
|
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
|
||||||
|
expect(log.body.entries.filter((e: any) => e.entityType === 'member' && e.action === 'create')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a collaborator only their events and permitted routes', async () => {
|
||||||
|
const mine = await as(COLLAB, () => get('/api/dashboard/my-events'));
|
||||||
|
expect(mine.status).toBe(200);
|
||||||
|
expect(mine.body.events.map((e: any) => e.event.id)).toEqual([EVENT_ID]);
|
||||||
|
expect(mine.body.events[0].permissions).toEqual(['view_overview', 'view_finance']);
|
||||||
|
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`))).status).toBe(200);
|
||||||
|
// Other events' data
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${OTHER_EVENT_ID}/attendees`))).status).toBe(403);
|
||||||
|
// Routes on their own event that the preset does not grant
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/attendees`))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/door-summary`))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/members`))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => get(`/api/events/${EVENT_ID}/audit-log`))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'x', unitAmount: 1 }))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}`, { title: 'Hacked' }))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => get('/api/finance/overview'))).status).toBe(403);
|
||||||
|
expect((await as(COLLAB, () => get('/api/finance/settings/expense-templates'))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows staff members attendee names without contact details', async () => {
|
||||||
|
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.attendees.length).toBeGreaterThan(0);
|
||||||
|
expect(res.body.attendees.every((a: any) => a.attendeeEmail === null && a.attendeePhone === null)).toBe(true);
|
||||||
|
const door = await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`));
|
||||||
|
expect(door.status).toBe(200);
|
||||||
|
expect(door.body.attendees.every((a: any) => a.email === null)).toBe(true);
|
||||||
|
// Admins still get everything
|
||||||
|
const full = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||||
|
expect(full.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies per-member overrides in both directions', async () => {
|
||||||
|
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
|
||||||
|
const door = list.body.members.find((m: any) => m.userId === DOOR.id);
|
||||||
|
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${door.id}`, { permissions: { view_attendees_pii: true, check_in: false } }));
|
||||||
|
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/door-attendees`))).status).toBe(403);
|
||||||
|
const res = await as(DOOR, () => get(`/api/events/${EVENT_ID}/attendees`));
|
||||||
|
expect(res.body.attendees.some((a: any) => a.attendeeEmail)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Morning Club: Pilates Edition finance flow', () => {
|
||||||
|
let packId = '';
|
||||||
|
let partnerId = '';
|
||||||
|
|
||||||
|
it('builds a template pack in settings', async () => {
|
||||||
|
const cats = await as(ADMIN, () => get('/api/finance/settings/expense-categories'));
|
||||||
|
const venue = cats.body.categories.find((c: any) => c.nameEn === 'Venue');
|
||||||
|
const make = (body: any) => as(ADMIN, () => post('/api/finance/settings/expense-templates', body));
|
||||||
|
const studio = await make({ name: 'Studio minimum spend', categoryId: venue.id, calcType: 'minimum_spend', amount: 30000, minimumAmount: 500000 });
|
||||||
|
const mats = await make({ name: 'Mat rental', calcType: 'per_checked_in', amount: 5000 });
|
||||||
|
const instructor = await make({ name: 'Instructor', calcType: 'fixed', amount: 300000 });
|
||||||
|
const promo = await make({ name: 'Promo share', calcType: 'percent_of_revenue', percentBp: 500 });
|
||||||
|
expect([studio, mats, instructor, promo].map((r) => r.status)).toEqual([201, 201, 201, 201]);
|
||||||
|
|
||||||
|
const pack = await as(ADMIN, () => post('/api/finance/settings/expense-template-packs', {
|
||||||
|
name: 'Morning Club pack', templateIds: [studio.body.template.id, mats.body.template.id, instructor.body.template.id, promo.body.template.id],
|
||||||
|
}));
|
||||||
|
expect(pack.status).toBe(201);
|
||||||
|
packId = pack.body.pack.id;
|
||||||
|
expect(pack.body.pack.templateIds).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the pack to the event with amounts from current counts', async () => {
|
||||||
|
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses/apply-template`, { packId }));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.expenses.map((e: any) => [e.description, e.computedAmount])).toEqual([
|
||||||
|
['Studio minimum spend', 500000], // 10 checked in x 30k = 300k < 500k minimum
|
||||||
|
['Mat rental', 50000],
|
||||||
|
['Instructor', 300000],
|
||||||
|
['Promo share', 62000], // 5% of 1,240,000 sales after the refund
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a partner at a percent of profit and computes the split', async () => {
|
||||||
|
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, {
|
||||||
|
userId: COLLAB.id, roleLabel: 'Studio', shareType: 'percent_profit', percentBp: 3000, lossRule: 'proportional',
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
partnerId = res.body.partner.id;
|
||||||
|
|
||||||
|
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||||
|
const s = fin.body.summary;
|
||||||
|
expect(s.revenue.gross).toBe(1340000);
|
||||||
|
expect(s.revenue.refunds).toBe(100000);
|
||||||
|
expect(s.revenue.presale).toBe(1100000);
|
||||||
|
expect(s.revenue.door).toBe(240000);
|
||||||
|
expect(s.expenses.total).toBe(912000);
|
||||||
|
expect(s.profit).toBe(1240000 - 912000);
|
||||||
|
expect(s.split.partners[0].share).toBe(Math.round(328000 * 0.3));
|
||||||
|
expect(s.split.organization).toBe(328000 - 98400);
|
||||||
|
expect(s.breakEven.tickets).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the collaborator their own share but not the full split', async () => {
|
||||||
|
const fin = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||||
|
expect(fin.body.viewer.fullSplit).toBe(false);
|
||||||
|
expect(fin.body.summary.split.organization).toBeNull();
|
||||||
|
expect(fin.body.summary.split.partners.map((p: any) => p.partnerId)).toEqual([partnerId]);
|
||||||
|
expect(fin.body.summary.waterfall.some((w: any) => w.key === 'organization')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an organizer in once they are granted finance on this event', async () => {
|
||||||
|
await as(ADMIN, () => post(`/api/events/${EVENT_ID}/members`, { userId: ORGANIZER.id, rolePreset: 'collaborator', permissions: { view_full_split: true } }));
|
||||||
|
const fin = await as(ORGANIZER, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||||
|
expect(fin.status).toBe(200);
|
||||||
|
expect(fin.body.viewer.fullSplit).toBe(true);
|
||||||
|
expect((await as(ORGANIZER, () => get(`/api/events/${OTHER_EVENT_ID}/finance`))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalizes, freezes the numbers and blocks edits', async () => {
|
||||||
|
expect((await as(COLLAB, () => post(`/api/events/${EVENT_ID}/finance/finalize`))).status).toBe(403);
|
||||||
|
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/finance/finalize`));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// A late walk-in no longer moves the finalized numbers
|
||||||
|
await seedTicket('late', EVENT_ID, { status: 'checked_in', paymentStatus: 'paid', pay: { amount: 120000, provider: 'cash', source: 'door' } });
|
||||||
|
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||||
|
expect(fin.body.status).toBe('finalized');
|
||||||
|
expect(fin.body.live).toBe(false);
|
||||||
|
expect(fin.body.summary.revenue.gross).toBe(1340000);
|
||||||
|
|
||||||
|
const blocked = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Late cost', unitAmount: 1000 }));
|
||||||
|
expect(blocked.status).toBe(409);
|
||||||
|
expect(blocked.body.code).toBe('FINANCE_FINALIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports the partner statement to the partner and not to others', async () => {
|
||||||
|
const pdf = await as(COLLAB, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement?locale=es`));
|
||||||
|
expect(pdf.status).toBe(200);
|
||||||
|
expect(pdf.type).toBe('application/pdf');
|
||||||
|
expect((pdf.body as Buffer).subarray(0, 4).toString()).toBe('%PDF');
|
||||||
|
|
||||||
|
const other = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners`, { externalName: 'Someone', shareType: 'fixed', fixedAmount: 1 }));
|
||||||
|
expect(other.status).toBe(409); // finalized
|
||||||
|
expect((await as(DOOR, () => get(`/api/events/${EVENT_ID}/partners/${partnerId}/statement`))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the payout and moves the event to paid out', async () => {
|
||||||
|
const res = await as(ADMIN, () => post(`/api/events/${EVENT_ID}/partners/${partnerId}/mark-paid`, { paid: true, payoutMethod: 'transfer' }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('paid_out');
|
||||||
|
expect(res.body.partner.payoutStatus).toBe('paid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only lets admins and co-managers unfinalize, and logs it', async () => {
|
||||||
|
expect((await as(ORGANIZER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(403);
|
||||||
|
expect((await as(COMANAGER, () => post(`/api/events/${EVENT_ID}/finance/unfinalize`))).status).toBe(200);
|
||||||
|
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||||
|
expect(fin.body.status).toBe('open');
|
||||||
|
expect(fin.body.summary.revenue.gross).toBe(1460000); // the late walk-in now counts
|
||||||
|
const log = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/audit-log`));
|
||||||
|
const actions = log.body.entries.filter((e: any) => e.entityType === 'finance_state').map((e: any) => e.action);
|
||||||
|
expect(actions).toEqual(expect.arrayContaining(['finalize', 'paid_out', 'unfinalize']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits edit_own_expenses_only members to their own rows', async () => {
|
||||||
|
const list = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/members`));
|
||||||
|
const collab = list.body.members.find((m: any) => m.userId === COLLAB.id);
|
||||||
|
await as(ADMIN, () => put(`/api/events/${EVENT_ID}/members/${collab.id}`, { permissions: { edit_own_expenses_only: true } }));
|
||||||
|
const own = await as(COLLAB, () => post(`/api/events/${EVENT_ID}/expenses`, { description: 'Flowers', unitAmount: 40000, status: 'paid', paidByPartnerId: partnerId }));
|
||||||
|
expect(own.status).toBe(201);
|
||||||
|
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${own.body.expense.id}`, { unitAmount: 45000 }))).status).toBe(200);
|
||||||
|
const fin = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/finance`));
|
||||||
|
const adminRow = fin.body.expenses.find((e: any) => e.createdBy === ADMIN.id);
|
||||||
|
expect((await as(COLLAB, () => put(`/api/events/${EVENT_ID}/expenses/${adminRow.id}`, { unitAmount: 1 }))).status).toBe(403);
|
||||||
|
// The reimbursement shows up on the partner line
|
||||||
|
expect(fin.body.summary.split.partners[0].reimbursement).toBe(45000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the event in the cross-event overview', async () => {
|
||||||
|
await as(ADMIN, () => put(`/api/events/${EVENT_ID}`, { series: 'Morning Club' }));
|
||||||
|
const res = await as(ADMIN, () => get('/api/finance/overview?series=Morning%20Club'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.events.map((e: any) => e.id)).toEqual([EVENT_ID]);
|
||||||
|
expect(res.body.bySeries[0].series).toBe('Morning Club');
|
||||||
|
expect(res.body.byPartner[0].name).toBe('Pilates Studio');
|
||||||
|
expect(res.body.filters.series).toEqual(['Morning Club']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cross-event overview: ready to close', () => {
|
||||||
|
const PAST_QUIET = 'evt-past-quiet';
|
||||||
|
const FUTURE = 'evt-future';
|
||||||
|
const PAST_DRAFT = 'evt-past-draft';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { db, events } = dbm;
|
||||||
|
const now = dbm.getNow();
|
||||||
|
const at = (iso: string) => dbm.toDbDate(iso);
|
||||||
|
for (const [id, title, start, status] of [
|
||||||
|
[PAST_QUIET, 'Quiet past event', '2026-01-10T20:00:00Z', 'published'],
|
||||||
|
[FUTURE, 'Future event', '2099-01-10T20:00:00Z', 'published'],
|
||||||
|
[PAST_DRAFT, 'Past draft', '2026-01-11T20:00:00Z', 'draft'],
|
||||||
|
] as const) {
|
||||||
|
await db.insert(events).values({
|
||||||
|
id, title, description: 'desc', startDatetime: at(start), location: 'Studio Uno', price: PRICE, currency: 'PYG',
|
||||||
|
capacity: 40, status, externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists past events with open books even with no money, and keeps them out of the totals', async () => {
|
||||||
|
const res = await as(ADMIN, () => get('/api/finance/overview'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ready = res.body.readyToClose.map((e: any) => e.id);
|
||||||
|
expect(ready).toContain(PAST_QUIET);
|
||||||
|
expect(ready).not.toContain(FUTURE);
|
||||||
|
expect(ready).not.toContain(PAST_DRAFT);
|
||||||
|
expect(res.body.events.map((e: any) => e.id)).not.toContain(PAST_QUIET);
|
||||||
|
// Longest-waiting first
|
||||||
|
const starts = res.body.readyToClose.map((e: any) => e.startDatetime);
|
||||||
|
expect([...starts].sort()).toEqual(starts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the venue filter', async () => {
|
||||||
|
const res = await as(ADMIN, () => get('/api/finance/overview?venue=Nowhere'));
|
||||||
|
expect(res.body.readyToClose).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,936 @@
|
|||||||
|
// Event finance, partners and team access, all scoped to one event and
|
||||||
|
// mounted under /api/events:
|
||||||
|
//
|
||||||
|
// GET /:id/my-permissions what the current user may do here
|
||||||
|
// GET /:id/finance P&L summary, chart data and the rows behind it
|
||||||
|
// POST /:id/finance/finalize | unfinalize freeze / reopen the numbers
|
||||||
|
// CRUD /:id/expenses (+ /apply-template) costs, manual or from templates / packs
|
||||||
|
// CRUD /:id/other-income sponsors, venue kickbacks, ...
|
||||||
|
// CRUD /:id/partners (+ /:pid/mark-paid, /:pid/statement PDF)
|
||||||
|
// CRUD /:id/members (+ /candidates) per-event team access
|
||||||
|
// GET /:id/audit-log
|
||||||
|
//
|
||||||
|
// Every route is guarded by requireEventPermission; only global admins pass on
|
||||||
|
// every event. Every write commits together with its finance_audit_log row.
|
||||||
|
|
||||||
|
import { Hono, type Context } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { and, desc, eq, inArray, notInArray, or, sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
db, dbAll, dbGet, users, eventExpenses, eventOtherIncome, eventPartners, eventFinanceState, eventMembers,
|
||||||
|
expenseCategories, expenseTemplates, expenseTemplatePackItems, financeAuditLog,
|
||||||
|
} from '../db/index.js';
|
||||||
|
import { requireAuth, type AuthUser } from '../lib/auth.js';
|
||||||
|
import {
|
||||||
|
requireEventPermission, getEventAccess, getEffectivePermissions, canUnfinalize, resolveMemberPermissions,
|
||||||
|
parseOverrides, EVENT_PERMISSIONS, ROLE_PRESETS, type EventPermission,
|
||||||
|
} from '../lib/eventPermissions.js';
|
||||||
|
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
|
||||||
|
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||||
|
import { financeAuditOp } from '../lib/finance/audit.js';
|
||||||
|
import {
|
||||||
|
getEventFinance, getEvent, getFinanceState, serializeExpense, serializeIncome, serializePartner, iso, pyg, bool,
|
||||||
|
loadPartners, type SerializedExpense,
|
||||||
|
} from '../lib/finance/load.js';
|
||||||
|
import { expenseAmount, isAutoCalc, CALC_TYPES, SHARE_TYPES, LOSS_RULES } from '../lib/finance/calculate.js';
|
||||||
|
import { generatePartnerStatementPDF } from '../lib/finance/statementPdf.js';
|
||||||
|
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
|
||||||
|
const financeRouter = new Hono();
|
||||||
|
|
||||||
|
const validationHook = (result: any, c: any) => {
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
|
||||||
|
return c.json({ error: errors }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_PYG = 2_000_000_000;
|
||||||
|
const money = z.number().int().min(0).max(MAX_PYG);
|
||||||
|
const bp = z.number().int().min(0).max(10000);
|
||||||
|
// Receipts come from the media upload (/uploads/...) or an external link; never javascript: etc.
|
||||||
|
const receiptUrl = z.string().max(500).regex(/^(https?:\/\/|\/uploads\/)/, 'must be an http(s) or /uploads/ URL');
|
||||||
|
|
||||||
|
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
|
||||||
|
const can = (c: Context, key: EventPermission) => !!getEventAccess(c)?.permissions.has(key);
|
||||||
|
|
||||||
|
async function requireEvent(c: Context) {
|
||||||
|
const event = await getEvent(c.req.param('id')!);
|
||||||
|
return event || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 409 while the numbers are frozen. */
|
||||||
|
async function assertOpen(c: Context, eventId: string) {
|
||||||
|
const state = await getFinanceState(eventId);
|
||||||
|
if (state.status !== 'open') {
|
||||||
|
return c.json({ error: 'Finance is finalized for this event. Unfinalize it to make changes.', code: 'FINANCE_FINALIZED' }, 409);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts and sales the auto-calculated expenses follow right now. */
|
||||||
|
async function liveContext(eventId: string, event: any) {
|
||||||
|
const fin = await getEventFinance(eventId, event);
|
||||||
|
return {
|
||||||
|
ticketsSold: fin!.result.counts.ticketsSold,
|
||||||
|
checkedIn: fin!.result.counts.checkedIn,
|
||||||
|
sales: fin!.result.revenue.sales,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function partnerBelongsToEvent(partnerId: string, eventId: string) {
|
||||||
|
const row = await dbGet<any>(
|
||||||
|
(db as any).select({ id: (eventPartners as any).id }).from(eventPartners)
|
||||||
|
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
|
||||||
|
);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function categoryExists(categoryId: string) {
|
||||||
|
const row = await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId)));
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Permissions for the UI ====================
|
||||||
|
|
||||||
|
financeRouter.get('/:id/my-permissions', requireAuth(), async (c) => {
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const access = await getEffectivePermissions(currentUser(c), event.id);
|
||||||
|
return c.json({
|
||||||
|
eventId: event.id,
|
||||||
|
global: access.global,
|
||||||
|
role: access.role,
|
||||||
|
rolePreset: access.membership?.rolePreset ?? null,
|
||||||
|
permissions: EVENT_PERMISSIONS.filter((p) => access.permissions.has(p)),
|
||||||
|
canUnfinalize: canUnfinalize(access),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Summary ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collaborators without view_full_split see the event's P&L and their own
|
||||||
|
* partner line only: other partners and the organization's remainder are removed.
|
||||||
|
*/
|
||||||
|
function scopeToOwnShare<T extends NonNullable<Awaited<ReturnType<typeof getEventFinance>>>>(fin: T, userId: string) {
|
||||||
|
const own = new Set(fin.partners.filter((p) => p.userId === userId).map((p) => p.id));
|
||||||
|
return {
|
||||||
|
...fin,
|
||||||
|
partners: fin.partners.filter((p) => own.has(p.id)),
|
||||||
|
expenses: fin.expenses.map((e) => (e.paidByPartnerId && !own.has(e.paidByPartnerId) ? { ...e, paidByPartnerId: 'other' } : e)),
|
||||||
|
result: {
|
||||||
|
...fin.result,
|
||||||
|
split: {
|
||||||
|
distributable: null,
|
||||||
|
organization: null,
|
||||||
|
partners: fin.result.split.partners.filter((p) => own.has(p.partnerId)),
|
||||||
|
},
|
||||||
|
waterfall: fin.result.waterfall.filter((w) =>
|
||||||
|
w.key === 'organization' ? false : w.key.startsWith('partner:') ? own.has(w.key.slice(8)) : true),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
financeRouter.get('/:id/finance', requireEventPermission('view_finance'), async (c) => {
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const fin = await getEventFinance(event.id, event);
|
||||||
|
if (!fin) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
|
||||||
|
const lines = new Map(fin.result.expenses.lines.map((l) => [l.id, l]));
|
||||||
|
const withAmounts = {
|
||||||
|
...fin,
|
||||||
|
expenses: fin.expenses.map((e) => ({
|
||||||
|
...e,
|
||||||
|
amount: lines.get(e.id)?.amount ?? e.computedAmount,
|
||||||
|
liveQuantity: lines.get(e.id)?.quantity ?? e.quantity,
|
||||||
|
auto: lines.get(e.id)?.auto ?? false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const fullSplit = can(c, 'view_full_split');
|
||||||
|
const scoped = fullSplit ? withAmounts : scopeToOwnShare(withAmounts, currentUser(c).id);
|
||||||
|
|
||||||
|
const categories = await dbAll<any>((db as any).select().from(expenseCategories));
|
||||||
|
const access = getEventAccess(c)!;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
event: omitWalkInPrice({
|
||||||
|
id: event.id, title: event.title, titleEs: event.titleEs, startDatetime: iso(event.startDatetime),
|
||||||
|
endDatetime: event.endDatetime ? iso(event.endDatetime) : null,
|
||||||
|
location: event.location, series: event.series ?? null, price: pyg(event.price), currency: event.currency,
|
||||||
|
capacity: event.capacity,
|
||||||
|
}),
|
||||||
|
status: fin.state.status,
|
||||||
|
finalizedAt: fin.state.finalizedAt,
|
||||||
|
live: fin.live,
|
||||||
|
computedAt: fin.computedAt,
|
||||||
|
summary: scoped.result,
|
||||||
|
expenses: scoped.expenses,
|
||||||
|
otherIncome: scoped.otherIncome,
|
||||||
|
partners: scoped.partners,
|
||||||
|
categories: categories
|
||||||
|
.map((cat: any) => ({ id: cat.id, nameEn: cat.nameEn, nameEs: cat.nameEs, color: cat.color, sortOrder: pyg(cat.sortOrder), archived: bool(cat.archived) }))
|
||||||
|
.sort((a: any, b: any) => a.sortOrder - b.sortOrder),
|
||||||
|
viewer: {
|
||||||
|
fullSplit,
|
||||||
|
canEditExpenses: access.permissions.has('edit_expenses'),
|
||||||
|
canEditOwnExpenses: access.permissions.has('edit_own_expenses_only'),
|
||||||
|
canManageSplit: fullSplit && access.permissions.has('edit_expenses'),
|
||||||
|
canUnfinalize: canUnfinalize(access),
|
||||||
|
userId: currentUser(c).id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Expenses ====================
|
||||||
|
|
||||||
|
const expenseFields = {
|
||||||
|
description: z.string().trim().min(1).max(300),
|
||||||
|
categoryId: z.string().nullable().optional(),
|
||||||
|
calcType: z.enum(CALC_TYPES),
|
||||||
|
quantity: z.number().int().min(0).max(1_000_000),
|
||||||
|
unitAmount: money,
|
||||||
|
percentBp: bp,
|
||||||
|
minimumAmount: money,
|
||||||
|
computedAmount: money.optional(),
|
||||||
|
isLocked: z.boolean(),
|
||||||
|
status: z.enum(['planned', 'paid']),
|
||||||
|
paidByPartnerId: z.string().nullable().optional(),
|
||||||
|
receiptUrl: receiptUrl.nullable().optional(),
|
||||||
|
expenseDate: z.string().nullable().optional(),
|
||||||
|
};
|
||||||
|
const createExpenseSchema = z.object({
|
||||||
|
...expenseFields,
|
||||||
|
calcType: expenseFields.calcType.default('fixed'),
|
||||||
|
quantity: expenseFields.quantity.default(1),
|
||||||
|
unitAmount: money.default(0),
|
||||||
|
percentBp: bp.default(0),
|
||||||
|
minimumAmount: money.default(0),
|
||||||
|
isLocked: z.boolean().default(false),
|
||||||
|
status: expenseFields.status.default('planned'),
|
||||||
|
});
|
||||||
|
const updateExpenseSchema = z.object(expenseFields).partial();
|
||||||
|
|
||||||
|
const EXPENSE_EDITORS = ['edit_expenses', 'edit_own_expenses_only'] as const;
|
||||||
|
|
||||||
|
/** Members limited to their own expenses may only touch rows they created. */
|
||||||
|
function canEditRow(c: Context, row: any) {
|
||||||
|
return can(c, 'edit_expenses') || row.createdBy === currentUser(c).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveContext = { ticketsSold: number; checkedIn: number; sales: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantity and amount to store for a row. Unlocked rows follow the live counts.
|
||||||
|
* A locked row keeps its frozen amount; locking freezes the amount it shows at
|
||||||
|
* that moment unless one is typed in (`typed`).
|
||||||
|
*/
|
||||||
|
function storedAmount(row: any, ctx: LiveContext, opts: { typed?: number; wasLocked?: boolean; frozen?: { quantity: number; amount: number } } = {}) {
|
||||||
|
const live = expenseAmount({ ...row, isLocked: false }, ctx);
|
||||||
|
const quantity = isAutoCalc(row.calcType) ? live.quantity : row.quantity;
|
||||||
|
if (!row.isLocked) return { quantity, computedAmount: live.amount };
|
||||||
|
if (opts.typed !== undefined) return { quantity, computedAmount: opts.typed };
|
||||||
|
if (opts.wasLocked && opts.frozen) return { quantity: opts.frozen.quantity, computedAmount: opts.frozen.amount };
|
||||||
|
return { quantity, computedAmount: live.amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateExpenseRefs(c: Context, eventId: string, data: { categoryId?: string | null; paidByPartnerId?: string | null }) {
|
||||||
|
if (data.categoryId && !(await categoryExists(data.categoryId))) {
|
||||||
|
return c.json({ error: 'Unknown expense category' }, 400);
|
||||||
|
}
|
||||||
|
if (data.paidByPartnerId && !(await partnerBelongsToEvent(data.paidByPartnerId, eventId))) {
|
||||||
|
return c.json({ error: 'paidByPartnerId must be a partner of this event' }, 400);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
financeRouter.post('/:id/expenses', requireEventPermission(EXPENSE_EDITORS), zValidator('json', createExpenseSchema, validationHook), async (c) => {
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const closed = await assertOpen(c, event.id);
|
||||||
|
if (closed) return closed;
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const bad = await validateExpenseRefs(c, event.id, data);
|
||||||
|
if (bad) return bad;
|
||||||
|
|
||||||
|
const user = currentUser(c);
|
||||||
|
const now = getNow();
|
||||||
|
const ctx = await liveContext(event.id, event);
|
||||||
|
const amounts = storedAmount(data, ctx, { typed: data.computedAmount });
|
||||||
|
const values = {
|
||||||
|
id: generateId(),
|
||||||
|
eventId: event.id,
|
||||||
|
categoryId: data.categoryId || null,
|
||||||
|
templateId: null,
|
||||||
|
description: data.description,
|
||||||
|
calcType: data.calcType,
|
||||||
|
quantity: amounts.quantity,
|
||||||
|
unitAmount: data.unitAmount,
|
||||||
|
percentBp: data.percentBp,
|
||||||
|
minimumAmount: data.minimumAmount,
|
||||||
|
computedAmount: amounts.computedAmount,
|
||||||
|
isLocked: toDbBool(data.isLocked),
|
||||||
|
status: data.status,
|
||||||
|
paidByPartnerId: data.paidByPartnerId || null,
|
||||||
|
receiptUrl: data.receiptUrl || null,
|
||||||
|
expenseDate: data.expenseDate ? toDbDate(data.expenseDate) : null,
|
||||||
|
createdBy: user.id,
|
||||||
|
updatedBy: user.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await runOps([
|
||||||
|
insertOp(eventExpenses, values),
|
||||||
|
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: values.id, action: 'create', after: serializeExpense(values) }),
|
||||||
|
]);
|
||||||
|
return c.json({ expense: serializeExpense(values) }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.put('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), zValidator('json', updateExpenseSchema, validationHook), async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const event = await getEvent(eventId);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const closed = await assertOpen(c, eventId);
|
||||||
|
if (closed) return closed;
|
||||||
|
const existing = await dbGet<any>(
|
||||||
|
(db as any).select().from(eventExpenses)
|
||||||
|
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
|
||||||
|
);
|
||||||
|
if (!existing) return c.json({ error: 'Expense not found' }, 404);
|
||||||
|
if (!canEditRow(c, existing)) return c.json({ error: 'You can only edit expenses you added', code: 'EVENT_PERMISSION' }, 403);
|
||||||
|
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const bad = await validateExpenseRefs(c, eventId, data);
|
||||||
|
if (bad) return bad;
|
||||||
|
|
||||||
|
const before = serializeExpense(existing);
|
||||||
|
const merged = { ...before, ...Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)) } as any;
|
||||||
|
const ctx = await liveContext(eventId, event);
|
||||||
|
const amounts = storedAmount(merged, ctx, {
|
||||||
|
typed: data.computedAmount,
|
||||||
|
wasLocked: before.isLocked,
|
||||||
|
frozen: { quantity: before.quantity, amount: before.computedAmount },
|
||||||
|
});
|
||||||
|
const user = currentUser(c);
|
||||||
|
const updates: Record<string, any> = {
|
||||||
|
description: merged.description,
|
||||||
|
categoryId: merged.categoryId || null,
|
||||||
|
calcType: merged.calcType,
|
||||||
|
quantity: amounts.quantity,
|
||||||
|
unitAmount: merged.unitAmount,
|
||||||
|
percentBp: merged.percentBp,
|
||||||
|
minimumAmount: merged.minimumAmount,
|
||||||
|
computedAmount: amounts.computedAmount,
|
||||||
|
isLocked: toDbBool(!!merged.isLocked),
|
||||||
|
status: merged.status,
|
||||||
|
paidByPartnerId: merged.paidByPartnerId || null,
|
||||||
|
receiptUrl: merged.receiptUrl || null,
|
||||||
|
expenseDate: merged.expenseDate ? toDbDate(merged.expenseDate) : null,
|
||||||
|
updatedBy: user.id,
|
||||||
|
updatedAt: getNow(),
|
||||||
|
};
|
||||||
|
const after = serializeExpense({ ...existing, ...updates });
|
||||||
|
await runOps([
|
||||||
|
updateOp(eventExpenses, updates, eq((eventExpenses as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'update', before, after }),
|
||||||
|
]);
|
||||||
|
return c.json({ expense: after });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.delete('/:id/expenses/:expenseId', requireEventPermission(EXPENSE_EDITORS), async (c) => {
|
||||||
|
const eventId = c.req.param('id')!;
|
||||||
|
const closed = await assertOpen(c, eventId);
|
||||||
|
if (closed) return closed;
|
||||||
|
const existing = await dbGet<any>(
|
||||||
|
(db as any).select().from(eventExpenses)
|
||||||
|
.where(and(eq((eventExpenses as any).id, c.req.param('expenseId')), eq((eventExpenses as any).eventId, eventId)))
|
||||||
|
);
|
||||||
|
if (!existing) return c.json({ error: 'Expense not found' }, 404);
|
||||||
|
if (!canEditRow(c, existing)) return c.json({ error: 'You can only delete expenses you added', code: 'EVENT_PERMISSION' }, 403);
|
||||||
|
const user = currentUser(c);
|
||||||
|
await runOps([
|
||||||
|
deleteOp(eventExpenses, eq((eventExpenses as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'expense', entityId: existing.id, action: 'delete', before: serializeExpense(existing) }),
|
||||||
|
]);
|
||||||
|
return c.json({ message: 'Expense deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyTemplateSchema = z.object({
|
||||||
|
templateId: z.string().optional(),
|
||||||
|
packId: z.string().optional(),
|
||||||
|
}).refine((d) => !!d.templateId !== !!d.packId, { message: 'Provide exactly one of templateId or packId' });
|
||||||
|
|
||||||
|
financeRouter.post('/:id/expenses/apply-template', requireEventPermission(EXPENSE_EDITORS), zValidator('json', applyTemplateSchema, validationHook), async (c) => {
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const closed = await assertOpen(c, event.id);
|
||||||
|
if (closed) return closed;
|
||||||
|
const { templateId, packId } = c.req.valid('json');
|
||||||
|
|
||||||
|
let templateIds: string[];
|
||||||
|
if (packId) {
|
||||||
|
const items = await dbAll<any>((db as any).select().from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).packId, packId)));
|
||||||
|
if (items.length === 0) return c.json({ error: 'Template pack not found or empty' }, 404);
|
||||||
|
templateIds = items.sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId);
|
||||||
|
} else {
|
||||||
|
templateIds = [templateId!];
|
||||||
|
}
|
||||||
|
const templates = await dbAll<any>((db as any).select().from(expenseTemplates).where(inArray((expenseTemplates as any).id, templateIds)));
|
||||||
|
const byId = new Map(templates.filter((t: any) => !bool(t.archived)).map((t: any) => [t.id, t]));
|
||||||
|
const ordered = templateIds.map((id) => byId.get(id)).filter(Boolean) as any[];
|
||||||
|
if (ordered.length === 0) return c.json({ error: 'Template not found' }, 404);
|
||||||
|
|
||||||
|
const user = currentUser(c);
|
||||||
|
const now = getNow();
|
||||||
|
const ctx = await liveContext(event.id, event);
|
||||||
|
const ops: TxOp[] = [];
|
||||||
|
const created: SerializedExpense[] = [];
|
||||||
|
for (const t of ordered) {
|
||||||
|
const row: any = {
|
||||||
|
id: generateId(),
|
||||||
|
eventId: event.id,
|
||||||
|
categoryId: t.categoryId || null,
|
||||||
|
templateId: t.id,
|
||||||
|
description: t.name,
|
||||||
|
calcType: t.calcType,
|
||||||
|
quantity: 1,
|
||||||
|
unitAmount: pyg(t.amount),
|
||||||
|
percentBp: pyg(t.percentBp),
|
||||||
|
minimumAmount: pyg(t.minimumAmount),
|
||||||
|
computedAmount: 0,
|
||||||
|
isLocked: toDbBool(false),
|
||||||
|
status: 'planned',
|
||||||
|
paidByPartnerId: null,
|
||||||
|
receiptUrl: null,
|
||||||
|
expenseDate: null,
|
||||||
|
createdBy: user.id,
|
||||||
|
updatedBy: user.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
const amounts = storedAmount(row, ctx);
|
||||||
|
row.quantity = amounts.quantity;
|
||||||
|
row.computedAmount = amounts.computedAmount;
|
||||||
|
ops.push(insertOp(eventExpenses, row));
|
||||||
|
const serialized = serializeExpense(row);
|
||||||
|
created.push(serialized);
|
||||||
|
ops.push(financeAuditOp({
|
||||||
|
eventId: event.id, actorUserId: user.id, entityType: 'expense', entityId: row.id,
|
||||||
|
action: packId ? 'apply_pack' : 'apply_template', after: { ...serialized, packId: packId ?? null },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
await runOps(ops);
|
||||||
|
return c.json({ expenses: created }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Other income ====================
|
||||||
|
|
||||||
|
const incomeSchema = z.object({ description: z.string().trim().min(1).max(300), amount: money });
|
||||||
|
|
||||||
|
financeRouter.post('/:id/other-income', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema, validationHook), async (c) => {
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const closed = await assertOpen(c, event.id);
|
||||||
|
if (closed) return closed;
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const user = currentUser(c);
|
||||||
|
const now = getNow();
|
||||||
|
const values = { id: generateId(), eventId: event.id, description: data.description, amount: data.amount, createdBy: user.id, createdAt: now, updatedAt: now };
|
||||||
|
await runOps([
|
||||||
|
insertOp(eventOtherIncome, values),
|
||||||
|
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'other_income', entityId: values.id, action: 'create', after: serializeIncome(values) }),
|
||||||
|
]);
|
||||||
|
return c.json({ income: serializeIncome(values) }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.put('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), zValidator('json', incomeSchema.partial(), validationHook), async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const closed = await assertOpen(c, eventId);
|
||||||
|
if (closed) return closed;
|
||||||
|
const existing = await dbGet<any>(
|
||||||
|
(db as any).select().from(eventOtherIncome)
|
||||||
|
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
|
||||||
|
);
|
||||||
|
if (!existing) return c.json({ error: 'Income not found' }, 404);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||||
|
if (data.description !== undefined) updates.description = data.description;
|
||||||
|
if (data.amount !== undefined) updates.amount = data.amount;
|
||||||
|
const user = currentUser(c);
|
||||||
|
const after = serializeIncome({ ...existing, ...updates });
|
||||||
|
await runOps([
|
||||||
|
updateOp(eventOtherIncome, updates, eq((eventOtherIncome as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'update', before: serializeIncome(existing), after }),
|
||||||
|
]);
|
||||||
|
return c.json({ income: after });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.delete('/:id/other-income/:incomeId', requireEventPermission('edit_expenses'), async (c) => {
|
||||||
|
const eventId = c.req.param('id')!;
|
||||||
|
const closed = await assertOpen(c, eventId);
|
||||||
|
if (closed) return closed;
|
||||||
|
const existing = await dbGet<any>(
|
||||||
|
(db as any).select().from(eventOtherIncome)
|
||||||
|
.where(and(eq((eventOtherIncome as any).id, c.req.param('incomeId')), eq((eventOtherIncome as any).eventId, eventId)))
|
||||||
|
);
|
||||||
|
if (!existing) return c.json({ error: 'Income not found' }, 404);
|
||||||
|
const user = currentUser(c);
|
||||||
|
await runOps([
|
||||||
|
deleteOp(eventOtherIncome, eq((eventOtherIncome as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'other_income', entityId: existing.id, action: 'delete', before: serializeIncome(existing) }),
|
||||||
|
]);
|
||||||
|
return c.json({ message: 'Income deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Partners ====================
|
||||||
|
|
||||||
|
const partnerFields = {
|
||||||
|
userId: z.string().nullable().optional(),
|
||||||
|
externalName: z.string().trim().max(200).nullable().optional(),
|
||||||
|
roleLabel: z.string().trim().max(100).nullable().optional(),
|
||||||
|
shareType: z.enum(SHARE_TYPES),
|
||||||
|
percentBp: bp,
|
||||||
|
fixedAmount: money,
|
||||||
|
thresholdAmount: money,
|
||||||
|
lossRule: z.enum(LOSS_RULES),
|
||||||
|
lossCapAmount: money,
|
||||||
|
};
|
||||||
|
const createPartnerSchema = z.object({
|
||||||
|
...partnerFields,
|
||||||
|
percentBp: bp.default(0),
|
||||||
|
fixedAmount: money.default(0),
|
||||||
|
thresholdAmount: money.default(0),
|
||||||
|
lossRule: partnerFields.lossRule.default('none'),
|
||||||
|
lossCapAmount: money.default(0),
|
||||||
|
}).refine((d) => !!d.userId || !!d.externalName, { message: 'A partner needs a linked user or a name' });
|
||||||
|
const updatePartnerSchema = z.object(partnerFields).partial();
|
||||||
|
|
||||||
|
/** Editing the split needs both the expense editor and the full-split view. */
|
||||||
|
function canManageSplit(c: Context) {
|
||||||
|
return can(c, 'edit_expenses') && can(c, 'view_full_split');
|
||||||
|
}
|
||||||
|
const splitForbidden = (c: Context) =>
|
||||||
|
c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
|
||||||
|
|
||||||
|
async function userExists(userId: string) {
|
||||||
|
return !!(await dbGet<any>((db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).id, userId))));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPartner(eventId: string, partnerId: string) {
|
||||||
|
return dbGet<any>(
|
||||||
|
(db as any).select().from(eventPartners)
|
||||||
|
.where(and(eq((eventPartners as any).id, partnerId), eq((eventPartners as any).eventId, eventId)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users that can be linked to a partner (so the partner can log in and see their statement).
|
||||||
|
financeRouter.get('/:id/partners/candidates', requireEventPermission('edit_expenses'), async (c) => {
|
||||||
|
if (!canManageSplit(c)) return splitForbidden(c);
|
||||||
|
const q = (c.req.query('q') || '').trim().toLowerCase();
|
||||||
|
if (q.length < 2) return c.json({ users: [] });
|
||||||
|
return c.json({ users: await searchUsers(q, []) });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.post('/:id/partners', requireEventPermission('edit_expenses'), zValidator('json', createPartnerSchema, validationHook), async (c) => {
|
||||||
|
if (!canManageSplit(c)) return splitForbidden(c);
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const closed = await assertOpen(c, event.id);
|
||||||
|
if (closed) return closed;
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
|
||||||
|
const user = currentUser(c);
|
||||||
|
const now = getNow();
|
||||||
|
const values = {
|
||||||
|
id: generateId(),
|
||||||
|
eventId: event.id,
|
||||||
|
userId: data.userId || null,
|
||||||
|
externalName: data.externalName || null,
|
||||||
|
roleLabel: data.roleLabel || null,
|
||||||
|
shareType: data.shareType,
|
||||||
|
percentBp: data.percentBp,
|
||||||
|
fixedAmount: data.fixedAmount,
|
||||||
|
thresholdAmount: data.thresholdAmount,
|
||||||
|
lossRule: data.lossRule,
|
||||||
|
lossCapAmount: data.lossCapAmount,
|
||||||
|
payoutStatus: 'pending',
|
||||||
|
payoutDate: null,
|
||||||
|
payoutMethod: null,
|
||||||
|
payoutNote: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await runOps([
|
||||||
|
insertOp(eventPartners, values),
|
||||||
|
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'partner', entityId: values.id, action: 'create', after: serializePartner(values) }),
|
||||||
|
]);
|
||||||
|
const partner = (await loadPartners(event.id)).find((p) => p.id === values.id);
|
||||||
|
return c.json({ partner }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.put('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), zValidator('json', updatePartnerSchema, validationHook), async (c) => {
|
||||||
|
if (!canManageSplit(c)) return splitForbidden(c);
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const closed = await assertOpen(c, eventId);
|
||||||
|
if (closed) return closed;
|
||||||
|
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||||
|
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
if (data.userId && !(await userExists(data.userId))) return c.json({ error: 'User not found' }, 400);
|
||||||
|
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||||
|
for (const [k, v] of Object.entries(data)) if (v !== undefined) updates[k] = v === '' ? null : v;
|
||||||
|
const merged = { ...existing, ...updates };
|
||||||
|
if (!merged.userId && !merged.externalName) return c.json({ error: 'A partner needs a linked user or a name' }, 400);
|
||||||
|
const user = currentUser(c);
|
||||||
|
await runOps([
|
||||||
|
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'update', before: serializePartner(existing), after: serializePartner(merged) }),
|
||||||
|
]);
|
||||||
|
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
|
||||||
|
return c.json({ partner });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.delete('/:id/partners/:partnerId', requireEventPermission('edit_expenses'), async (c) => {
|
||||||
|
if (!canManageSplit(c)) return splitForbidden(c);
|
||||||
|
const eventId = c.req.param('id')!;
|
||||||
|
const closed = await assertOpen(c, eventId);
|
||||||
|
if (closed) return closed;
|
||||||
|
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||||
|
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||||
|
const fronted = await dbGet<any>(
|
||||||
|
(db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).paidByPartnerId, existing.id))
|
||||||
|
);
|
||||||
|
if (fronted) {
|
||||||
|
return c.json({ error: 'This partner paid for expenses. Change who paid those expenses first.', code: 'PARTNER_HAS_EXPENSES' }, 409);
|
||||||
|
}
|
||||||
|
const user = currentUser(c);
|
||||||
|
await runOps([
|
||||||
|
deleteOp(eventPartners, eq((eventPartners as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: 'delete', before: serializePartner(existing) }),
|
||||||
|
]);
|
||||||
|
return c.json({ message: 'Partner removed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const markPaidSchema = z.object({
|
||||||
|
paid: z.boolean().default(true),
|
||||||
|
payoutDate: z.string().nullable().optional(),
|
||||||
|
payoutMethod: z.string().trim().max(50).nullable().optional(),
|
||||||
|
payoutNote: z.string().trim().max(1000).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.post('/:id/partners/:partnerId/mark-paid', requireEventPermission('edit_expenses'), zValidator('json', markPaidSchema, validationHook), async (c) => {
|
||||||
|
if (!canManageSplit(c)) return splitForbidden(c);
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const existing = await loadPartner(eventId, c.req.param('partnerId'));
|
||||||
|
if (!existing) return c.json({ error: 'Partner not found' }, 404);
|
||||||
|
const state = await getFinanceState(eventId);
|
||||||
|
if (state.status === 'open') {
|
||||||
|
return c.json({ error: 'Finalize the event before recording payouts, so they match the frozen numbers.', code: 'FINANCE_NOT_FINALIZED' }, 409);
|
||||||
|
}
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const now = getNow();
|
||||||
|
const updates = data.paid
|
||||||
|
? {
|
||||||
|
payoutStatus: 'paid',
|
||||||
|
payoutDate: data.payoutDate ? toDbDate(data.payoutDate) : now,
|
||||||
|
payoutMethod: data.payoutMethod || null,
|
||||||
|
payoutNote: data.payoutNote || null,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: { payoutStatus: 'pending', payoutDate: null, payoutMethod: null, payoutNote: data.payoutNote ?? existing.payoutNote ?? null, updatedAt: now };
|
||||||
|
|
||||||
|
const user = currentUser(c);
|
||||||
|
const ops: TxOp[] = [
|
||||||
|
updateOp(eventPartners, updates, eq((eventPartners as any).id, existing.id)),
|
||||||
|
financeAuditOp({
|
||||||
|
eventId, actorUserId: user.id, entityType: 'partner', entityId: existing.id, action: data.paid ? 'mark_paid' : 'mark_unpaid',
|
||||||
|
before: serializePartner(existing), after: serializePartner({ ...existing, ...updates }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
// The event is paid out once every partner is.
|
||||||
|
const all = await loadPartners(eventId);
|
||||||
|
const allPaid = all.every((p) => (p.id === existing.id ? data.paid : p.payoutStatus === 'paid'));
|
||||||
|
const nextStatus = allPaid ? 'paid_out' : 'finalized';
|
||||||
|
if (nextStatus !== state.status) {
|
||||||
|
ops.push(updateOp(eventFinanceState, { status: nextStatus, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)));
|
||||||
|
ops.push(financeAuditOp({ eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: nextStatus, before: { status: state.status }, after: { status: nextStatus } }));
|
||||||
|
}
|
||||||
|
await runOps(ops);
|
||||||
|
const partner = (await loadPartners(eventId)).find((p) => p.id === existing.id);
|
||||||
|
return c.json({ partner, status: nextStatus });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.get('/:id/partners/:partnerId/statement', requireEventPermission('view_finance'), async (c) => {
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const fin = await getEventFinance(event.id, event);
|
||||||
|
const partner = fin!.partners.find((p) => p.id === c.req.param('partnerId'));
|
||||||
|
if (!partner) return c.json({ error: 'Partner not found' }, 404);
|
||||||
|
if (!can(c, 'view_full_split') && partner.userId !== currentUser(c).id) {
|
||||||
|
return c.json({ error: 'Forbidden', code: 'EVENT_PERMISSION', permission: 'view_full_split' }, 403);
|
||||||
|
}
|
||||||
|
const localeParam = c.req.query('locale');
|
||||||
|
const locale = localeParam === 'es' || (!localeParam && currentUser(c).languagePreference === 'es') ? 'es' : 'en';
|
||||||
|
const amounts = new Map(fin!.result.expenses.lines.map((l) => [l.id, l.amount]));
|
||||||
|
const pdf = await generatePartnerStatementPDF({
|
||||||
|
locale,
|
||||||
|
event: { title: (locale === 'es' && event.titleEs) || event.title, startDatetime: event.startDatetime, location: event.location },
|
||||||
|
finalized: fin!.state.status !== 'open',
|
||||||
|
result: fin!.result,
|
||||||
|
partner,
|
||||||
|
line: fin!.result.split.partners.find((p) => p.partnerId === partner.id),
|
||||||
|
frontedExpenses: fin!.expenses.filter((e) => e.paidByPartnerId === partner.id && e.status === 'paid'),
|
||||||
|
expenseAmounts: amounts,
|
||||||
|
});
|
||||||
|
const safeName = partner.name.replace(/[^a-zA-Z0-9-_]+/g, '-').replace(/^-+|-+$/g, '') || 'partner';
|
||||||
|
const slug = (event.slug || event.id).replace(/[^a-zA-Z0-9-_]+/g, '-');
|
||||||
|
return new Response(new Uint8Array(pdf), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="statement-${slug}-${safeName}.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Finalize ====================
|
||||||
|
|
||||||
|
financeRouter.post('/:id/finance/finalize', requireEventPermission('edit_expenses'), async (c) => {
|
||||||
|
if (!canManageSplit(c)) return splitForbidden(c);
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const fin = await getEventFinance(event.id, event);
|
||||||
|
if (fin!.state.status !== 'open') return c.json({ error: 'Already finalized', code: 'FINANCE_FINALIZED' }, 409);
|
||||||
|
|
||||||
|
const user = currentUser(c);
|
||||||
|
const now = getNow();
|
||||||
|
const snapshot = { version: 1 as const, computedAt: new Date().toISOString(), result: fin!.result };
|
||||||
|
const ops: TxOp[] = [];
|
||||||
|
// Persist the amounts auto rows had at finalize, so the rows read the same as the snapshot.
|
||||||
|
const lines = new Map(fin!.result.expenses.lines.map((l) => [l.id, l]));
|
||||||
|
for (const e of fin!.expenses) {
|
||||||
|
const line = lines.get(e.id);
|
||||||
|
if (line && (line.amount !== e.computedAmount || line.quantity !== e.quantity)) {
|
||||||
|
ops.push(updateOp(eventExpenses, { computedAmount: line.amount, quantity: line.quantity }, eq((eventExpenses as any).id, e.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stateValues = { status: 'finalized', finalizedAt: now, finalizedBy: user.id, snapshotJson: JSON.stringify(snapshot), updatedAt: now };
|
||||||
|
ops.push(fin!.state.exists
|
||||||
|
? updateOp(eventFinanceState, stateValues, eq((eventFinanceState as any).eventId, event.id))
|
||||||
|
: insertOp(eventFinanceState, { eventId: event.id, ...stateValues }));
|
||||||
|
ops.push(financeAuditOp({
|
||||||
|
eventId: event.id, actorUserId: user.id, entityType: 'finance_state', entityId: event.id, action: 'finalize',
|
||||||
|
before: { status: 'open' }, after: { status: 'finalized', profit: snapshot.result.profit, split: snapshot.result.split },
|
||||||
|
}));
|
||||||
|
await runOps(ops);
|
||||||
|
return c.json({ status: 'finalized', finalizedAt: iso(now) });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.post('/:id/finance/unfinalize', requireEventPermission('view_finance'), async (c) => {
|
||||||
|
const access = getEventAccess(c)!;
|
||||||
|
if (!canUnfinalize(access)) {
|
||||||
|
return c.json({ error: 'Only admins and co-managers can unfinalize', code: 'EVENT_PERMISSION' }, 403);
|
||||||
|
}
|
||||||
|
const eventId = c.req.param('id')!;
|
||||||
|
const state = await getFinanceState(eventId);
|
||||||
|
if (state.status === 'open') return c.json({ error: 'Not finalized' }, 409);
|
||||||
|
const user = currentUser(c);
|
||||||
|
const now = getNow();
|
||||||
|
await runOps([
|
||||||
|
updateOp(eventFinanceState, { status: 'open', finalizedAt: null, finalizedBy: null, snapshotJson: null, updatedAt: now }, eq((eventFinanceState as any).eventId, eventId)),
|
||||||
|
financeAuditOp({
|
||||||
|
eventId, actorUserId: user.id, entityType: 'finance_state', entityId: eventId, action: 'unfinalize',
|
||||||
|
before: { status: state.status, finalizedAt: state.finalizedAt, finalizedBy: state.finalizedBy, snapshot: state.snapshot },
|
||||||
|
after: { status: 'open' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return c.json({ status: 'open' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Audit log ====================
|
||||||
|
|
||||||
|
financeRouter.get('/:id/audit-log', requireEventPermission('view_full_split'), async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const limit = Math.min(200, Math.max(1, parseInt(c.req.query('limit') || '100', 10) || 100));
|
||||||
|
const offset = Math.max(0, parseInt(c.req.query('offset') || '0', 10) || 0);
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({
|
||||||
|
id: (financeAuditLog as any).id,
|
||||||
|
actorUserId: (financeAuditLog as any).actorUserId,
|
||||||
|
actorName: (users as any).name,
|
||||||
|
entityType: (financeAuditLog as any).entityType,
|
||||||
|
entityId: (financeAuditLog as any).entityId,
|
||||||
|
action: (financeAuditLog as any).action,
|
||||||
|
beforeJson: (financeAuditLog as any).beforeJson,
|
||||||
|
afterJson: (financeAuditLog as any).afterJson,
|
||||||
|
createdAt: (financeAuditLog as any).createdAt,
|
||||||
|
})
|
||||||
|
.from(financeAuditLog)
|
||||||
|
.leftJoin(users, eq((financeAuditLog as any).actorUserId, (users as any).id))
|
||||||
|
.where(eq((financeAuditLog as any).eventId, eventId))
|
||||||
|
.orderBy(desc((financeAuditLog as any).createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
);
|
||||||
|
const parse = (s: string | null) => { if (!s) return null; try { return JSON.parse(s); } catch { return null; } };
|
||||||
|
return c.json({
|
||||||
|
entries: rows.map((r: any) => ({
|
||||||
|
id: r.id, actorUserId: r.actorUserId, actorName: r.actorName ?? null, entityType: r.entityType, entityId: r.entityId,
|
||||||
|
action: r.action, before: parse(r.beforeJson), after: parse(r.afterJson), createdAt: iso(r.createdAt),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Team members ====================
|
||||||
|
|
||||||
|
const permissionOverrides = z.record(z.enum(EVENT_PERMISSIONS), z.boolean());
|
||||||
|
const createMemberSchema = z.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
rolePreset: z.enum(ROLE_PRESETS),
|
||||||
|
permissions: permissionOverrides.optional(),
|
||||||
|
});
|
||||||
|
const updateMemberSchema = z.object({
|
||||||
|
rolePreset: z.enum(ROLE_PRESETS).optional(),
|
||||||
|
permissions: permissionOverrides.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function serializeMember(m: any, u: any) {
|
||||||
|
const overrides = parseOverrides(m.permissions);
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
eventId: m.eventId,
|
||||||
|
userId: m.userId,
|
||||||
|
name: u?.name ?? null,
|
||||||
|
email: u?.email ?? null,
|
||||||
|
globalRole: u?.role ?? null,
|
||||||
|
rolePreset: m.rolePreset,
|
||||||
|
permissions: overrides,
|
||||||
|
effective: EVENT_PERMISSIONS.filter((p) => resolveMemberPermissions(m.rolePreset, overrides).has(p)),
|
||||||
|
createdAt: iso(m.createdAt),
|
||||||
|
updatedAt: iso(m.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMember(eventId: string, memberId: string) {
|
||||||
|
return dbGet<any>(
|
||||||
|
(db as any).select().from(eventMembers)
|
||||||
|
.where(and(eq((eventMembers as any).id, memberId), eq((eventMembers as any).eventId, eventId)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUser(userId: string) {
|
||||||
|
return dbGet<any>(
|
||||||
|
(db as any).select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||||
|
.from(users).where(eq((users as any).id, userId))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
financeRouter.get('/:id/members', requireEventPermission('manage_team'), async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const rows = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ m: eventMembers, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||||
|
.from(eventMembers)
|
||||||
|
.leftJoin(users, eq((eventMembers as any).userId, (users as any).id))
|
||||||
|
.where(eq((eventMembers as any).eventId, eventId))
|
||||||
|
);
|
||||||
|
return c.json({
|
||||||
|
members: rows
|
||||||
|
.map((r: any) => serializeMember(r.m, { name: r.name, email: r.email, role: r.role }))
|
||||||
|
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Active users matching q by name or email, excluding some ids. */
|
||||||
|
async function searchUsers(q: string, excludeIds: string[]) {
|
||||||
|
const like = `%${q.replace(/[%_]/g, '')}%`;
|
||||||
|
const conditions: any[] = [
|
||||||
|
or(sql`lower(${(users as any).name}) like ${like}`, sql`lower(${(users as any).email}) like ${like}`),
|
||||||
|
eq((users as any).accountStatus, 'active'),
|
||||||
|
];
|
||||||
|
if (excludeIds.length > 0) conditions.push(notInArray((users as any).id, excludeIds));
|
||||||
|
return dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select({ id: (users as any).id, name: (users as any).name, email: (users as any).email, role: (users as any).role })
|
||||||
|
.from(users)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.limit(10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
financeRouter.get('/:id/members/candidates', requireEventPermission('manage_team'), async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const q = (c.req.query('q') || '').trim().toLowerCase();
|
||||||
|
if (q.length < 2) return c.json({ users: [] });
|
||||||
|
const existing = await dbAll<any>(
|
||||||
|
(db as any).select({ userId: (eventMembers as any).userId }).from(eventMembers).where(eq((eventMembers as any).eventId, eventId))
|
||||||
|
);
|
||||||
|
return c.json({ users: await searchUsers(q, existing.map((e: any) => e.userId)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.post('/:id/members', requireEventPermission('manage_team'), zValidator('json', createMemberSchema, validationHook), async (c) => {
|
||||||
|
const event = await requireEvent(c);
|
||||||
|
if (!event) return c.json({ error: 'Event not found' }, 404);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const target = await loadUser(data.userId);
|
||||||
|
if (!target) return c.json({ error: 'User not found' }, 400);
|
||||||
|
const dupe = await dbGet<any>(
|
||||||
|
(db as any).select({ id: (eventMembers as any).id }).from(eventMembers)
|
||||||
|
.where(and(eq((eventMembers as any).eventId, event.id), eq((eventMembers as any).userId, data.userId)))
|
||||||
|
);
|
||||||
|
if (dupe) return c.json({ error: 'This user is already on the team', code: 'ALREADY_MEMBER' }, 409);
|
||||||
|
const user = currentUser(c);
|
||||||
|
const now = getNow();
|
||||||
|
const values = {
|
||||||
|
id: generateId(),
|
||||||
|
eventId: event.id,
|
||||||
|
userId: data.userId,
|
||||||
|
rolePreset: data.rolePreset,
|
||||||
|
permissions: JSON.stringify(data.permissions || {}),
|
||||||
|
createdBy: user.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
const member = serializeMember(values, target);
|
||||||
|
await runOps([
|
||||||
|
insertOp(eventMembers, values),
|
||||||
|
financeAuditOp({ eventId: event.id, actorUserId: user.id, entityType: 'member', entityId: values.id, action: 'create', after: member }),
|
||||||
|
]);
|
||||||
|
return c.json({ member }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.put('/:id/members/:memberId', requireEventPermission('manage_team'), zValidator('json', updateMemberSchema, validationHook), async (c) => {
|
||||||
|
const eventId = c.req.param('id');
|
||||||
|
const existing = await loadMember(eventId, c.req.param('memberId'));
|
||||||
|
if (!existing) return c.json({ error: 'Member not found' }, 404);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||||
|
if (data.rolePreset) updates.rolePreset = data.rolePreset;
|
||||||
|
if (data.permissions) updates.permissions = JSON.stringify(data.permissions);
|
||||||
|
const target = await loadUser(existing.userId);
|
||||||
|
const before = serializeMember(existing, target);
|
||||||
|
const after = serializeMember({ ...existing, ...updates }, target);
|
||||||
|
const user = currentUser(c);
|
||||||
|
await runOps([
|
||||||
|
updateOp(eventMembers, updates, eq((eventMembers as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'update', before, after }),
|
||||||
|
]);
|
||||||
|
return c.json({ member: after });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeRouter.delete('/:id/members/:memberId', requireEventPermission('manage_team'), async (c) => {
|
||||||
|
const eventId = c.req.param('id')!;
|
||||||
|
const existing = await loadMember(eventId, c.req.param('memberId'));
|
||||||
|
if (!existing) return c.json({ error: 'Member not found' }, 404);
|
||||||
|
const user = currentUser(c);
|
||||||
|
const target = await loadUser(existing.userId);
|
||||||
|
await runOps([
|
||||||
|
deleteOp(eventMembers, eq((eventMembers as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId, actorUserId: user.id, entityType: 'member', entityId: existing.id, action: 'delete', before: serializeMember(existing, target) }),
|
||||||
|
]);
|
||||||
|
return c.json({ message: 'Member removed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default financeRouter;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { mkdtempSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
// Changing an event's ticket price must never rewrite what was already paid,
|
||||||
|
// but anyone who booked and hasn't paid yet owes the new price. These tests pin
|
||||||
|
// which payments follow a price change and which keep their amount.
|
||||||
|
|
||||||
|
// Runs on a temp SQLite file by default. Set FINANCE_TEST_PG_URL to a Postgres
|
||||||
|
// URL to run the same suite in a throwaway schema (dropped afterwards).
|
||||||
|
const PG_URL = process.env.FINANCE_TEST_PG_URL;
|
||||||
|
const PG_SCHEMA = `pricetest_${Date.now()}`;
|
||||||
|
if (PG_URL) {
|
||||||
|
process.env.DB_TYPE = 'postgres';
|
||||||
|
process.env.DATABASE_URL = `${PG_URL}${PG_URL.includes('?') ? '&' : '?'}options=-c%20search_path%3D${PG_SCHEMA}`;
|
||||||
|
} else {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'events-price-test-'));
|
||||||
|
process.env.DB_TYPE = 'sqlite';
|
||||||
|
process.env.DATABASE_URL = join(dir, 'test.db');
|
||||||
|
}
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||||
|
process.env.BETTER_AUTH_SECRET = 'events-price-secret-0123456789abcdef';
|
||||||
|
delete process.env.REDIS_URL;
|
||||||
|
delete process.env.REVALIDATE_SECRET;
|
||||||
|
|
||||||
|
const ADMIN = { id: randomUUID(), name: 'The Admin', role: 'admin' };
|
||||||
|
|
||||||
|
vi.mock('../lib/auth.js', () => ({
|
||||||
|
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||||
|
if (roles && !roles.includes(ADMIN.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
c.set('user', ADMIN);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
getAuthUser: async () => ADMIN,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
let dbm: any;
|
||||||
|
|
||||||
|
const SEED_USER_ID = randomUUID();
|
||||||
|
const EVENT_ID = randomUUID();
|
||||||
|
const OTHER_EVENT_ID = randomUUID();
|
||||||
|
const OLD_PRICE = 50000;
|
||||||
|
const NEW_PRICE = 70000;
|
||||||
|
// Ticket id by label, so assertions read by name.
|
||||||
|
const ticketIds: Record<string, string> = {};
|
||||||
|
|
||||||
|
async function put(path: string, body: unknown) {
|
||||||
|
const res = await app.request(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One ticket with its payment row, the way a booking leaves them. Seeded through drizzle so it works on both engines. */
|
||||||
|
async function seedBooking(label: string, eventId: string, paymentStatus: string, provider = 'bancard') {
|
||||||
|
const { db, tickets, payments } = dbm;
|
||||||
|
const now = dbm.getNow();
|
||||||
|
const id = randomUUID();
|
||||||
|
ticketIds[label] = id;
|
||||||
|
const paid = paymentStatus === 'paid';
|
||||||
|
await db.insert(tickets).values({
|
||||||
|
id, userId: SEED_USER_ID, eventId, attendeeFirstName: label, status: paid ? 'confirmed' : 'pending',
|
||||||
|
paymentStatus: paid ? 'paid' : 'unpaid', isGuest: dbm.toDbBool(false), qrCode: `QR-${label}`, createdAt: now,
|
||||||
|
});
|
||||||
|
await db.insert(payments).values({
|
||||||
|
id: randomUUID(), ticketId: id, provider, amount: OLD_PRICE, currency: 'PYG',
|
||||||
|
status: paymentStatus, paidAt: paid ? now : null, createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function amountOf(label: string): Promise<number> {
|
||||||
|
const { db, dbGet, payments, eq } = dbm;
|
||||||
|
const row = await dbGet(db.select({ amount: payments.amount }).from(payments).where(eq(payments.ticketId, ticketIds[label])));
|
||||||
|
return Number(row.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `CREATE SCHEMA ${PG_SCHEMA}`], { stdio: 'pipe' });
|
||||||
|
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||||
|
|
||||||
|
return (async () => {
|
||||||
|
const { Hono } = await import('hono');
|
||||||
|
app = new Hono();
|
||||||
|
app.route('/api/events', (await import('./events.js')).default);
|
||||||
|
|
||||||
|
dbm = { ...(await import('../db/index.js')), ...(await import('../lib/utils.js')), ...(await import('drizzle-orm')) };
|
||||||
|
const { db, users, events } = dbm;
|
||||||
|
const now = dbm.getNow();
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: SEED_USER_ID, email: 'seed@test.py', name: 'Seed', role: 'user',
|
||||||
|
isClaimed: dbm.toDbBool(false), accountStatus: 'unclaimed', createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
for (const id of [EVENT_ID, OTHER_EVENT_ID]) {
|
||||||
|
await db.insert(events).values({
|
||||||
|
id, title: `Event ${id}`, description: 'desc', startDatetime: now, location: 'Asuncion', price: OLD_PRICE,
|
||||||
|
currency: 'PYG', capacity: 40, status: 'published', externalBookingEnabled: dbm.toDbBool(false), createdAt: now, updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await seedBooking('paid', EVENT_ID, 'paid');
|
||||||
|
await seedBooking('refunded', EVENT_ID, 'refunded');
|
||||||
|
await seedBooking('pending', EVENT_ID, 'pending');
|
||||||
|
await seedBooking('pending-tpago', EVENT_ID, 'pending', 'tpago');
|
||||||
|
await seedBooking('claimed', EVENT_ID, 'pending_approval', 'bank_transfer');
|
||||||
|
await seedBooking('on-hold', EVENT_ID, 'on_hold');
|
||||||
|
await seedBooking('lightning', EVENT_ID, 'pending', 'lightning');
|
||||||
|
await seedBooking('other-event', OTHER_EVENT_ID, 'pending');
|
||||||
|
})();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (PG_URL) execFileSync('psql', [PG_URL, '-qc', `DROP SCHEMA ${PG_SCHEMA} CASCADE`], { stdio: 'pipe' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changing the ticket price', () => {
|
||||||
|
it('leaves every payment alone when the price is not part of the edit', async () => {
|
||||||
|
const { status } = await put(`/api/events/${EVENT_ID}`, { location: 'Encarnación' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(await amountOf('pending')).toBe(OLD_PRICE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves every payment alone when the price is resent unchanged', async () => {
|
||||||
|
const { status } = await put(`/api/events/${EVENT_ID}`, { price: OLD_PRICE, currency: 'PYG' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(await amountOf('pending')).toBe(OLD_PRICE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reprices open payments and keeps what was already paid', async () => {
|
||||||
|
const { status, body } = await put(`/api/events/${EVENT_ID}`, { price: NEW_PRICE });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body.event.price).toBe(NEW_PRICE);
|
||||||
|
|
||||||
|
// Not yet paid: owe the current price.
|
||||||
|
expect(await amountOf('pending')).toBe(NEW_PRICE);
|
||||||
|
expect(await amountOf('pending-tpago')).toBe(NEW_PRICE);
|
||||||
|
|
||||||
|
// History stays as it happened.
|
||||||
|
expect(await amountOf('paid')).toBe(OLD_PRICE);
|
||||||
|
expect(await amountOf('refunded')).toBe(OLD_PRICE);
|
||||||
|
// The customer already sent the old amount / an admin is reviewing it.
|
||||||
|
expect(await amountOf('claimed')).toBe(OLD_PRICE);
|
||||||
|
expect(await amountOf('on-hold')).toBe(OLD_PRICE);
|
||||||
|
// The Lightning invoice was issued for a fixed amount.
|
||||||
|
expect(await amountOf('lightning')).toBe(OLD_PRICE);
|
||||||
|
// Other events are untouched.
|
||||||
|
expect(await amountOf('other-event')).toBe(OLD_PRICE);
|
||||||
|
});
|
||||||
|
});
|
||||||
+214
-58
@@ -2,12 +2,18 @@ import { Hono } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
|
import { db, dbGet, dbAll, events, eventSlugAliases, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings, isPostgres } from '../db/index.js';
|
||||||
import { eq, desc, and, gte, sql } from 'drizzle-orm';
|
import { eq, ne, desc, and, gte, inArray, sql } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
|
import { requireEventPermission, canSeeAttendeePii, redactAttendee } from '../lib/eventPermissions.js';
|
||||||
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js';
|
||||||
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
import { slugify, uniqueSlug } from '../lib/slugify.js';
|
||||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
import { eventSeatBreakdownQuery } from '../lib/capacity.js';
|
||||||
|
import { resolvePresaleClosure } from '../lib/presale.js';
|
||||||
|
import { canSeeWalkInPrice, parseWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
import { publicSalesFields } from '../lib/salesState.js';
|
||||||
|
import { loadDoorMethods } from '../lib/doorPayments.js';
|
||||||
|
import { runOps, updateOp, type TxOp } from '../lib/txOps.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,17 +25,52 @@ interface UserContext {
|
|||||||
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
const eventsRouter = new Hono<{ Variables: { user: UserContext } }>();
|
||||||
|
|
||||||
// Helper to normalize event data for API response
|
// Helper to normalize event data for API response
|
||||||
// PostgreSQL decimal returns strings, booleans are stored as integers
|
// PostgreSQL decimal returns strings, booleans are stored as integers.
|
||||||
function normalizeEvent(event: any) {
|
// `settings` is the site_settings row; when given, the effective pre-sale
|
||||||
|
// cutoff (`presaleClosesAt`, ISO or null) is computed so the frontend and the
|
||||||
|
// booking API agree on when registration closes.
|
||||||
|
// The walk-in (door) price is internal: it is dropped unless the caller is
|
||||||
|
// admin/organizer/staff and `includeWalkInPrice` is set.
|
||||||
|
function normalizeEvent(event: any, settings?: any, opts: { includeWalkInPrice?: boolean } = {}) {
|
||||||
if (!event) return event;
|
if (!event) return event;
|
||||||
return {
|
const { walkInPrice, ...publicFields } = event;
|
||||||
...event,
|
const normalized = {
|
||||||
|
...publicFields,
|
||||||
|
...(opts.includeWalkInPrice ? { walkInPrice: parseWalkInPrice(walkInPrice) } : {}),
|
||||||
// Convert price from string/decimal to clean number
|
// Convert price from string/decimal to clean number
|
||||||
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
price: typeof event.price === 'string' ? parseFloat(event.price) : Number(event.price),
|
||||||
// Convert capacity from string to number if needed
|
// Convert capacity from string to number if needed
|
||||||
capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
|
capacity: typeof event.capacity === 'string' ? parseInt(event.capacity, 10) : Number(event.capacity),
|
||||||
// Convert boolean integers to actual booleans for frontend
|
// Convert boolean integers to actual booleans for frontend
|
||||||
externalBookingEnabled: Boolean(event.externalBookingEnabled),
|
externalBookingEnabled: Boolean(event.externalBookingEnabled),
|
||||||
|
// Pre-sale overrides: null means "inherit the site default"
|
||||||
|
presaleClosureEnabled: event.presaleClosureEnabled == null ? null : Boolean(event.presaleClosureEnabled),
|
||||||
|
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore == null ? null : Number(event.presaleCloseMinutesBefore),
|
||||||
|
};
|
||||||
|
if (settings !== undefined) {
|
||||||
|
const { closesAt } = resolvePresaleClosure(normalized, settings);
|
||||||
|
return { ...normalized, presaleClosesAt: closesAt ? closesAt.toISOString() : null };
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seat counts plus the public sales state for a normalized event. `raw` is the
|
||||||
|
// DB row: doorPrice is resolved from its walk-in price, and only in the `door`
|
||||||
|
// state (see lib/salesState.ts).
|
||||||
|
function withSeatsAndSales(
|
||||||
|
raw: any,
|
||||||
|
normalized: any,
|
||||||
|
settings: any,
|
||||||
|
counts: { paid: number; claimed: number },
|
||||||
|
nowMs: number = Date.now()
|
||||||
|
) {
|
||||||
|
const availableSeats = calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed);
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
bookedCount: counts.paid,
|
||||||
|
claimedCount: counts.claimed,
|
||||||
|
availableSeats,
|
||||||
|
...publicSalesFields(raw, settings, availableSeats, nowMs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +143,29 @@ const parsePrice = (val: unknown): number => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Walk-in price: empty/null means "not set" (fall back to price) and must stay
|
||||||
|
// null, never 0 — 0 is a real value meaning a free walk-in. Unparseable input
|
||||||
|
// fails validation instead of silently becoming 0 the way parsePrice does.
|
||||||
|
const walkInPriceSchema = z.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((val) => {
|
||||||
|
if (val === null) return null;
|
||||||
|
if (typeof val === 'number') return val;
|
||||||
|
const trimmed = val.trim();
|
||||||
|
if (trimmed === '') return null;
|
||||||
|
return Number(trimmed.replace(',', '.'));
|
||||||
|
})
|
||||||
|
.pipe(z.number().min(0, 'Walk-in price cannot be negative').nullable())
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
// PYG has no minor unit, so a PYG walk-in price must be a whole number.
|
||||||
|
function walkInPriceError(walkInPrice: number | null | undefined, currency: string | null | undefined): string | null {
|
||||||
|
if (walkInPrice == null) return null;
|
||||||
|
if ((currency || 'PYG') === 'PYG' && !Number.isInteger(walkInPrice)) {
|
||||||
|
return 'walkInPrice: Walk-in price must be a whole number for PYG';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to normalize boolean (handles true/false and 0/1)
|
// Helper to normalize boolean (handles true/false and 0/1)
|
||||||
const normalizeBoolean = (val: unknown): boolean => {
|
const normalizeBoolean = (val: unknown): boolean => {
|
||||||
if (typeof val === 'boolean') return val;
|
if (typeof val === 'boolean') return val;
|
||||||
@@ -125,6 +189,9 @@ const baseEventSchema = z.object({
|
|||||||
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
locationUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
// Accept price as number or string (handles "45000" and "41,44" formats)
|
// Accept price as number or string (handles "45000" and "41,44" formats)
|
||||||
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
||||||
|
walkInPrice: walkInPriceSchema,
|
||||||
|
// Groups recurring events for the finance overview ("" clears it)
|
||||||
|
series: z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? null : v), z.string().trim().max(100).nullable()).optional(),
|
||||||
currency: z.string().default('PYG'),
|
currency: z.string().default('PYG'),
|
||||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||||
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||||
@@ -138,8 +205,30 @@ const baseEventSchema = z.object({
|
|||||||
// External booking support - accept boolean or number (0/1 from DB)
|
// External booking support - accept boolean or number (0/1 from DB)
|
||||||
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
|
||||||
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
|
||||||
|
// Pre-sale closure overrides - null/omitted means "inherit the site default"
|
||||||
|
presaleClosureEnabled: z.union([z.boolean(), z.number(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : normalizeBoolean(v)))
|
||||||
|
.optional(),
|
||||||
|
presaleCloseMinutesBefore: z.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === null) return null;
|
||||||
|
const n = typeof v === 'string' ? parseInt(v, 10) : v;
|
||||||
|
return Number.isFinite(n) ? Math.floor(n) : NaN;
|
||||||
|
})
|
||||||
|
.pipe(z.number().int().min(0, 'Pre-sale closure time cannot be negative').nullable())
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When pre-sale closure is explicitly enabled on an event, the cutoff must be set too.
|
||||||
|
const presaleRefine = {
|
||||||
|
check: (data: { presaleClosureEnabled?: boolean | null; presaleCloseMinutesBefore?: number | null }) =>
|
||||||
|
data.presaleClosureEnabled !== true || typeof data.presaleCloseMinutesBefore === 'number',
|
||||||
|
options: {
|
||||||
|
message: 'Pre-sale closure time is required when pre-sale closure is enabled',
|
||||||
|
path: ['presaleCloseMinutesBefore'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const createEventSchema = baseEventSchema.refine(
|
const createEventSchema = baseEventSchema.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// If external booking is enabled, URL must be provided and must start with https://
|
// If external booking is enabled, URL must be provided and must start with https://
|
||||||
@@ -152,7 +241,7 @@ const createEventSchema = baseEventSchema.refine(
|
|||||||
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
||||||
path: ['externalBookingUrl'],
|
path: ['externalBookingUrl'],
|
||||||
}
|
}
|
||||||
);
|
).refine(presaleRefine.check, presaleRefine.options);
|
||||||
|
|
||||||
const updateEventSchema = baseEventSchema.partial().refine(
|
const updateEventSchema = baseEventSchema.partial().refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
@@ -166,18 +255,26 @@ const updateEventSchema = baseEventSchema.partial().refine(
|
|||||||
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
message: 'External booking URL is required and must be a valid HTTPS link when external booking is enabled',
|
||||||
path: ['externalBookingUrl'],
|
path: ['externalBookingUrl'],
|
||||||
}
|
}
|
||||||
);
|
).refine(presaleRefine.check, presaleRefine.options);
|
||||||
|
|
||||||
// Get all events (public)
|
// Get all events (public)
|
||||||
eventsRouter.get('/', async (c) => {
|
eventsRouter.get('/', async (c) => {
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
const upcoming = c.req.query('upcoming');
|
const upcoming = c.req.query('upcoming');
|
||||||
|
// Pagination is opt-in: callers that pass neither page nor pageSize (public
|
||||||
|
// pages, admin filter dropdowns) still get the full list.
|
||||||
|
const pageParam = c.req.query('page');
|
||||||
|
const pageSizeParam = c.req.query('pageSize');
|
||||||
|
const paginated = pageParam !== undefined || pageSizeParam !== undefined;
|
||||||
|
const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1);
|
||||||
|
const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200);
|
||||||
|
|
||||||
// Only privileged users may see non-public events (drafts, archived, etc.).
|
// Only privileged users may see non-public events (drafts, archived, etc.).
|
||||||
// Anonymous/regular callers are restricted to published events regardless of
|
// Anonymous/regular callers are restricted to published events regardless of
|
||||||
// any client-supplied status filter, so drafts cannot leak.
|
// any client-supplied status filter, so drafts cannot leak.
|
||||||
const authUser: any = await getAuthUser(c);
|
const authUser: any = await getAuthUser(c);
|
||||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||||
|
const includeWalkInPrice = canSeeWalkInPrice(authUser?.role);
|
||||||
|
|
||||||
const conditions: any[] = [];
|
const conditions: any[] = [];
|
||||||
|
|
||||||
@@ -195,19 +292,35 @@ eventsRouter.get('/', async (c) => {
|
|||||||
conditions.push(eq((events as any).status, 'published'));
|
conditions.push(eq((events as any).status, 'published'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length === 0
|
||||||
|
? undefined
|
||||||
|
: conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||||
|
|
||||||
let query = (db as any).select().from(events);
|
let query = (db as any).select().from(events);
|
||||||
if (conditions.length > 0) {
|
if (whereClause) query = query.where(whereClause);
|
||||||
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
|
query = query.orderBy(desc((events as any).startDatetime));
|
||||||
|
|
||||||
|
let total: number | undefined;
|
||||||
|
if (paginated) {
|
||||||
|
let countQuery = (db as any).select({ count: sql`count(*)` }).from(events);
|
||||||
|
if (whereClause) countQuery = countQuery.where(whereClause);
|
||||||
|
const totalRow = await dbGet<any>(countQuery);
|
||||||
|
total = Number(totalRow?.count || 0);
|
||||||
|
query = query.limit(pageSize).offset((page - 1) * pageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbAll<any>(query.orderBy(desc((events as any).startDatetime)));
|
const result = await dbAll<any>(query);
|
||||||
|
|
||||||
// Single grouped query for seat counts across all events (avoids N+1: previously
|
// Single grouped query for seat counts across all events (avoids N+1: previously
|
||||||
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
// this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in);
|
||||||
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
// claimedCount = "I've paid" claims awaiting admin verification. Both hold seats,
|
||||||
// so availableSeats subtracts them together — the same formula the booking-creation
|
// so availableSeats subtracts them together — the same formula the booking-creation
|
||||||
// capacity check enforces (lib/capacity.ts).
|
// capacity check enforces (lib/capacity.ts).
|
||||||
const countRows = await dbAll<any>(eventSeatBreakdownQuery(db));
|
// Scoped to the returned events so a page of 25 does not scan every ticket.
|
||||||
|
const eventIds = result.map((event: any) => event.id);
|
||||||
|
const countRows = eventIds.length > 0
|
||||||
|
? await dbAll<any>(eventSeatBreakdownQuery(db, eventIds))
|
||||||
|
: [];
|
||||||
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
const countByEvent = new Map<string, { paid: number; claimed: number }>();
|
||||||
for (const row of countRows) {
|
for (const row of countRows) {
|
||||||
countByEvent.set(row.eventId, {
|
countByEvent.set(row.eventId, {
|
||||||
@@ -216,18 +329,21 @@ eventsRouter.get('/', async (c) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsWithCounts = result.map((event: any) => {
|
const siteSettingsRow = await getSiteSettingsRow();
|
||||||
const normalized = normalizeEvent(event);
|
const nowMs = Date.now();
|
||||||
const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 };
|
const eventsWithCounts = result.map((event: any) =>
|
||||||
return {
|
withSeatsAndSales(
|
||||||
...normalized,
|
event,
|
||||||
bookedCount: counts.paid,
|
normalizeEvent(event, siteSettingsRow, { includeWalkInPrice }),
|
||||||
claimedCount: counts.claimed,
|
siteSettingsRow,
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
countByEvent.get(event.id) || { paid: 0, claimed: 0 },
|
||||||
};
|
nowMs,
|
||||||
});
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({ events: eventsWithCounts });
|
return paginated
|
||||||
|
? c.json({ events: eventsWithCounts, total, page, pageSize })
|
||||||
|
: c.json({ events: eventsWithCounts });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single event (public) - resolves by id, canonical slug, or historical alias
|
// Get single event (public) - resolves by id, canonical slug, or historical alias
|
||||||
@@ -239,31 +355,43 @@ eventsRouter.get('/:id', async (c) => {
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authUser: any = await getAuthUser(c);
|
||||||
|
|
||||||
// Draft events are only visible to privileged users (admin preview); hide from public.
|
// Draft events are only visible to privileged users (admin preview); hide from public.
|
||||||
if ((event as any).status === 'draft') {
|
if ((event as any).status === 'draft') {
|
||||||
const authUser: any = await getAuthUser(c);
|
|
||||||
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
|
||||||
if (!isPrivileged) {
|
if (!isPrivileged) {
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeEvent(event);
|
const settings = await getSiteSettingsRow();
|
||||||
|
const normalized = normalizeEvent(event, settings, {
|
||||||
|
includeWalkInPrice: canSeeWalkInPrice(authUser?.role),
|
||||||
|
});
|
||||||
const counts = await getEventSeatCounts(event.id);
|
const counts = await getEventSeatCounts(event.id);
|
||||||
|
const publicEvent = withSeatsAndSales(event, normalized, settings, counts);
|
||||||
|
// Door tenders (never the comp "guest" one) for the page's "pay at the door" line.
|
||||||
|
const doorPaymentMethods = publicEvent.salesState === 'door'
|
||||||
|
? (await loadDoorMethods(event.id)).filter((m) => m !== 'guest')
|
||||||
|
: undefined;
|
||||||
|
// serverTime lets the page schedule its refresh at presaleClosesAt even when
|
||||||
|
// the visitor's clock is off.
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: { ...publicEvent, ...(doorPaymentMethods ? { doorPaymentMethods } : {}) },
|
||||||
...normalized,
|
serverTime: new Date().toISOString(),
|
||||||
bookedCount: counts.paid,
|
|
||||||
claimedCount: counts.claimed,
|
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getSiteTimezone(): Promise<string> {
|
// Single site_settings row (or null when none has been created yet)
|
||||||
|
async function getSiteSettingsRow(): Promise<any | null> {
|
||||||
const settings = await dbGet<any>(
|
const settings = await dbGet<any>(
|
||||||
(db as any).select().from(siteSettings).limit(1)
|
(db as any).select().from(siteSettings).limit(1)
|
||||||
);
|
);
|
||||||
|
return settings || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteTimezoneOf(settings: any | null): string {
|
||||||
return settings?.timezone || 'America/Asuncion';
|
return settings?.timezone || 'America/Asuncion';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,13 +427,8 @@ async function getNextChronologicalUpcoming(): Promise<any | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const counts = await getEventSeatCounts(event.id);
|
const counts = await getEventSeatCounts(event.id);
|
||||||
const normalized = normalizeEvent(event);
|
const settings = await getSiteSettingsRow();
|
||||||
return {
|
return withSeatsAndSales(event, normalizeEvent(event, settings), settings, counts);
|
||||||
...normalized,
|
|
||||||
bookedCount: counts.paid,
|
|
||||||
claimedCount: counts.claimed,
|
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
|
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
|
||||||
@@ -368,13 +491,9 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
// If we have a valid featured event, return it
|
// If we have a valid featured event, return it
|
||||||
if (featuredEvent) {
|
if (featuredEvent) {
|
||||||
const counts = await getEventSeatCounts(featuredEvent.id);
|
const counts = await getEventSeatCounts(featuredEvent.id);
|
||||||
const normalized = normalizeEvent(featuredEvent);
|
|
||||||
return c.json({
|
return c.json({
|
||||||
event: {
|
event: {
|
||||||
...normalized,
|
...withSeatsAndSales(featuredEvent, normalizeEvent(featuredEvent, settings), settings, counts),
|
||||||
bookedCount: counts.paid,
|
|
||||||
claimedCount: counts.claimed,
|
|
||||||
availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed),
|
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -396,7 +515,11 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
const tz = await getSiteTimezone();
|
const siteSettingsRow = await getSiteSettingsRow();
|
||||||
|
const tz = siteTimezoneOf(siteSettingsRow);
|
||||||
|
|
||||||
|
const walkInError = walkInPriceError(data.walkInPrice, data.currency);
|
||||||
|
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||||
|
|
||||||
// Convert data for database compatibility
|
// Convert data for database compatibility
|
||||||
const dbData = convertBooleansForDb(data);
|
const dbData = convertBooleansForDb(data);
|
||||||
@@ -421,11 +544,11 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c
|
|||||||
revalidateFrontendCache();
|
revalidateFrontendCache();
|
||||||
|
|
||||||
// Return normalized event data
|
// Return normalized event data
|
||||||
return c.json({ event: normalizeEvent(newEvent) }, 201);
|
return c.json({ event: normalizeEvent(newEvent, siteSettingsRow, { includeWalkInPrice: true }) }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update event (admin/organizer only)
|
// Update event (admin/organizer only)
|
||||||
eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', updateEventSchema, validationHook), async (c) => {
|
eventsRouter.put('/:id', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), zValidator('json', updateEventSchema, validationHook), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
@@ -436,8 +559,17 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.walkInPrice !== undefined || data.currency !== undefined) {
|
||||||
|
const walkInError = walkInPriceError(
|
||||||
|
data.walkInPrice !== undefined ? data.walkInPrice : parseWalkInPrice(existing.walkInPrice),
|
||||||
|
data.currency ?? existing.currency,
|
||||||
|
);
|
||||||
|
if (walkInError) return c.json({ error: walkInError }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
const tz = await getSiteTimezone();
|
const siteSettingsRow = await getSiteSettingsRow();
|
||||||
|
const tz = siteTimezoneOf(siteSettingsRow);
|
||||||
// Convert data for database compatibility
|
// Convert data for database compatibility
|
||||||
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
const updateData: Record<string, any> = { ...convertBooleansForDb(data), updatedAt: now };
|
||||||
// Slug changes are handled explicitly below to manage aliases
|
// Slug changes are handled explicitly below to manage aliases
|
||||||
@@ -484,10 +616,30 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
|||||||
updateData.slug = newSlug;
|
updateData.slug = newSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
await (db as any)
|
const ops: TxOp[] = [updateOp(events, updateData, eq((events as any).id, id))];
|
||||||
.update(events)
|
|
||||||
.set(updateData)
|
// Anyone who booked but hasn't paid yet owes the current price, so open
|
||||||
.where(eq((events as any).id, id));
|
// payments follow a price/currency change. Settled payments keep what was
|
||||||
|
// actually paid. Excluded: pending_approval (the customer already sent the
|
||||||
|
// old amount), on_hold (under review) and Lightning (the invoice is fixed).
|
||||||
|
const newPrice = data.price !== undefined ? data.price : Number(existing.price);
|
||||||
|
const newCurrency = data.currency ?? existing.currency;
|
||||||
|
if (newPrice !== Number(existing.price) || newCurrency !== existing.currency) {
|
||||||
|
ops.push(updateOp(
|
||||||
|
payments,
|
||||||
|
{ amount: newPrice, currency: newCurrency, updatedAt: now },
|
||||||
|
and(
|
||||||
|
eq((payments as any).status, 'pending'),
|
||||||
|
ne((payments as any).provider, 'lightning'),
|
||||||
|
inArray(
|
||||||
|
(payments as any).ticketId,
|
||||||
|
(db as any).select({ id: (tickets as any).id }).from(tickets).where(eq((tickets as any).eventId, id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
await runOps(ops);
|
||||||
|
|
||||||
const updated = await dbGet(
|
const updated = await dbGet(
|
||||||
(db as any).select().from(events).where(eq((events as any).id, id))
|
(db as any).select().from(events).where(eq((events as any).id, id))
|
||||||
@@ -496,7 +648,7 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json',
|
|||||||
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
// Revalidate sitemap when an event is updated (status/dates may have changed)
|
||||||
revalidateFrontendCache();
|
revalidateFrontendCache();
|
||||||
|
|
||||||
return c.json({ event: normalizeEvent(updated) });
|
return c.json({ event: normalizeEvent(updated, siteSettingsRow, { includeWalkInPrice: true }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete event (admin only)
|
// Delete event (admin only)
|
||||||
@@ -562,17 +714,17 @@ eventsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get event attendees (admin/organizer only)
|
// Get event attendees (admin/organizer only)
|
||||||
eventsRouter.get('/:id/attendees', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
eventsRouter.get('/:id/attendees', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer', 'staff'] }), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
|
||||||
const attendees = await dbAll(
|
const attendees = await dbAll<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
.from(tickets)
|
.from(tickets)
|
||||||
.where(eq((tickets as any).eventId, id))
|
.where(eq((tickets as any).eventId, id))
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json({ attendees });
|
return c.json({ attendees: canSeeAttendeePii(c) ? attendees : attendees.map(redactAttendee) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Duplicate event (admin/organizer only)
|
// Duplicate event (admin/organizer only)
|
||||||
@@ -607,23 +759,27 @@ eventsRouter.post('/:id/duplicate', requireAuth(['admin', 'organizer']), async (
|
|||||||
location: existing.location,
|
location: existing.location,
|
||||||
locationUrl: existing.locationUrl,
|
locationUrl: existing.locationUrl,
|
||||||
price: existing.price,
|
price: existing.price,
|
||||||
|
walkInPrice: existing.walkInPrice ?? null,
|
||||||
currency: existing.currency,
|
currency: existing.currency,
|
||||||
capacity: existing.capacity,
|
capacity: existing.capacity,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
bannerUrl: existing.bannerUrl,
|
bannerUrl: existing.bannerUrl,
|
||||||
externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
|
externalBookingEnabled: existing.externalBookingEnabled ?? 0, // Already in DB format (0/1)
|
||||||
externalBookingUrl: existing.externalBookingUrl,
|
externalBookingUrl: existing.externalBookingUrl,
|
||||||
|
presaleClosureEnabled: existing.presaleClosureEnabled ?? null, // Already in DB format (0/1/null)
|
||||||
|
presaleCloseMinutesBefore: existing.presaleCloseMinutesBefore ?? null,
|
||||||
|
series: existing.series ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await (db as any).insert(events).values(duplicatedEvent);
|
await (db as any).insert(events).values(duplicatedEvent);
|
||||||
|
|
||||||
return c.json({ event: normalizeEvent(duplicatedEvent), message: 'Event duplicated successfully' }, 201);
|
return c.json({ event: normalizeEvent(duplicatedEvent, undefined, { includeWalkInPrice: true }), message: 'Event duplicated successfully' }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// List slug aliases for an event (admin/organizer only)
|
// List slug aliases for an event (admin/organizer only)
|
||||||
eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async (c) => {
|
eventsRouter.get('/:id/slug-aliases', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
|
||||||
const existing = await dbGet<any>(
|
const existing = await dbGet<any>(
|
||||||
@@ -644,7 +800,7 @@ eventsRouter.get('/:id/slug-aliases', requireAuth(['admin', 'organizer']), async
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove a slug alias from an event (admin/organizer only)
|
// Remove a slug alias from an event (admin/organizer only)
|
||||||
eventsRouter.delete('/:id/slug-aliases/:slug', requireAuth(['admin', 'organizer']), async (c) => {
|
eventsRouter.delete('/:id/slug-aliases/:slug', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'] }), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const slug = c.req.param('slug');
|
const slug = c.req.param('slug');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { mkdtempSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// The walk-in price is internal: admins set it, door staff charge it, and no
|
||||||
|
// public event response may carry it. These tests pin both halves.
|
||||||
|
|
||||||
|
// Env must be pinned before the db singleton is imported (dotenv never overrides).
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'events-walkin-test-'));
|
||||||
|
const dbPath = join(dir, 'test.db');
|
||||||
|
process.env.DB_TYPE = 'sqlite';
|
||||||
|
process.env.DATABASE_URL = dbPath;
|
||||||
|
process.env.FRONTEND_URL = 'http://localhost:3002';
|
||||||
|
process.env.BETTER_AUTH_SECRET = 'events-test-secret-0123456789abcdef';
|
||||||
|
delete process.env.REDIS_URL;
|
||||||
|
delete process.env.REVALIDATE_SECRET;
|
||||||
|
|
||||||
|
type TestUser = { id: string; name: string; role: string } | null;
|
||||||
|
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
|
||||||
|
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
||||||
|
const MEMBER = { id: 'member-user-id', name: 'Member', role: 'user' };
|
||||||
|
|
||||||
|
// Anonymous by default, like the public site and its server-side fetches.
|
||||||
|
let currentUser: TestUser = null;
|
||||||
|
|
||||||
|
vi.mock('../lib/auth.js', () => ({
|
||||||
|
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||||
|
if (!currentUser) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
if (roles && !roles.includes(currentUser.role)) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
c.set('user', currentUser);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
getAuthUser: async () => currentUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function as<T>(user: TestUser, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = currentUser;
|
||||||
|
currentUser = user;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
currentUser = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: any;
|
||||||
|
let sqlite: any;
|
||||||
|
|
||||||
|
async function request(method: string, path: string, body?: unknown) {
|
||||||
|
const res = await app.request(path, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseEvent = {
|
||||||
|
title: 'Walk-in Test',
|
||||||
|
description: 'desc',
|
||||||
|
startDatetime: '2099-01-10T20:00',
|
||||||
|
location: 'Asuncion',
|
||||||
|
price: 21000,
|
||||||
|
currency: 'PYG',
|
||||||
|
capacity: 40,
|
||||||
|
status: 'published',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
|
||||||
|
|
||||||
|
return (async () => {
|
||||||
|
const { Hono } = await import('hono');
|
||||||
|
const eventsRoutes = (await import('./events.js')).default;
|
||||||
|
app = new Hono();
|
||||||
|
app.route('/api/events', eventsRoutes);
|
||||||
|
|
||||||
|
const Database = (await import('better-sqlite3')).default;
|
||||||
|
sqlite = new Database(dbPath);
|
||||||
|
})();
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
describe('admin event form: walk-in price', () => {
|
||||||
|
it('saves an empty walk-in price as null, not 0', async () => {
|
||||||
|
const { status, body } = await as(ADMIN, () =>
|
||||||
|
request('POST', '/api/events', { ...baseEvent, title: 'Empty walk-in', walkInPrice: '' })
|
||||||
|
);
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body.event.walkInPrice).toBeNull();
|
||||||
|
expect(sqlite.prepare('SELECT walk_in_price FROM events WHERE id = ?').get(body.event.id).walk_in_price).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves 0 as a free walk-in and a number as-is', async () => {
|
||||||
|
const free = await as(ADMIN, () =>
|
||||||
|
request('POST', '/api/events', { ...baseEvent, title: 'Free walk-in', walkInPrice: 0 })
|
||||||
|
);
|
||||||
|
expect(free.body.event.walkInPrice).toBe(0);
|
||||||
|
|
||||||
|
const priced = await as(ADMIN, () =>
|
||||||
|
request('POST', '/api/events', { ...baseEvent, title: 'Priced walk-in', walkInPrice: '25000' })
|
||||||
|
);
|
||||||
|
expect(priced.body.event.walkInPrice).toBe(25000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative, non-numeric and fractional PYG walk-in prices', async () => {
|
||||||
|
for (const walkInPrice of [-1, 'abc', 25000.5]) {
|
||||||
|
const { status } = await as(ADMIN, () =>
|
||||||
|
request('POST', '/api/events', { ...baseEvent, title: 'Bad walk-in', walkInPrice })
|
||||||
|
);
|
||||||
|
expect(status, `walkInPrice ${walkInPrice}`).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates and clears the walk-in price, and duplicates carry it over', async () => {
|
||||||
|
const created = await as(ADMIN, () =>
|
||||||
|
request('POST', '/api/events', { ...baseEvent, title: 'Editable walk-in', walkInPrice: 25000 })
|
||||||
|
);
|
||||||
|
const id = created.body.event.id;
|
||||||
|
|
||||||
|
const updated = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 30000 }));
|
||||||
|
expect(updated.body.event.walkInPrice).toBe(30000);
|
||||||
|
|
||||||
|
const copy = await as(ADMIN, () => request('POST', `/api/events/${id}/duplicate`));
|
||||||
|
expect(copy.status).toBe(201);
|
||||||
|
expect(copy.body.event.walkInPrice).toBe(30000);
|
||||||
|
|
||||||
|
const cleared = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: null }));
|
||||||
|
expect(cleared.body.event.walkInPrice).toBeNull();
|
||||||
|
|
||||||
|
// Omitting the field leaves it untouched.
|
||||||
|
await as(ADMIN, () => request('PUT', `/api/events/${id}`, { walkInPrice: 0 }));
|
||||||
|
const untouched = await as(ADMIN, () => request('PUT', `/api/events/${id}`, { capacity: 45 }));
|
||||||
|
expect(untouched.body.event.walkInPrice).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('public event responses', () => {
|
||||||
|
let slug: string;
|
||||||
|
let id: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { body } = await as(ADMIN, () =>
|
||||||
|
request('POST', '/api/events', { ...baseEvent, title: 'Secret Door Price', walkInPrice: 25000 })
|
||||||
|
);
|
||||||
|
slug = body.event.slug;
|
||||||
|
id = body.event.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never include the walk-in price for anonymous or regular users', async () => {
|
||||||
|
for (const user of [null, MEMBER]) {
|
||||||
|
await as(user, async () => {
|
||||||
|
const single = await request('GET', `/api/events/${slug}`);
|
||||||
|
expect(single.status).toBe(200);
|
||||||
|
expect(single.body.event).not.toHaveProperty('walkInPrice');
|
||||||
|
expect(single.body.event.price).toBe(21000);
|
||||||
|
|
||||||
|
const list = await request('GET', '/api/events');
|
||||||
|
const listed = list.body.events.find((e: any) => e.id === id);
|
||||||
|
expect(listed).toBeTruthy();
|
||||||
|
expect(listed).not.toHaveProperty('walkInPrice');
|
||||||
|
|
||||||
|
const next = await request('GET', '/api/events/next/upcoming');
|
||||||
|
expect(next.body.event).not.toHaveProperty('walkInPrice');
|
||||||
|
|
||||||
|
// Belt and braces: the value must not appear anywhere in the payload.
|
||||||
|
expect(JSON.stringify(single.body)).not.toContain('25000');
|
||||||
|
expect(JSON.stringify(list.body)).not.toContain('25000');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes it for admin and door staff', async () => {
|
||||||
|
for (const user of [ADMIN, STAFF]) {
|
||||||
|
const single = await as(user, () => request('GET', `/api/events/${id}`));
|
||||||
|
expect(single.body.event.walkInPrice).toBe(25000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('public sales state and door price', () => {
|
||||||
|
const hours = (n: number) => new Date(Date.now() + n * 3_600_000).toISOString();
|
||||||
|
|
||||||
|
async function createEvent(fields: Record<string, unknown>) {
|
||||||
|
const { status, body } = await as(ADMIN, () =>
|
||||||
|
request('POST', '/api/events', { ...baseEvent, ...fields })
|
||||||
|
);
|
||||||
|
expect(status).toBe(201);
|
||||||
|
return body.event as { id: string; slug: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('adds doorPrice only in the door state and never exposes the raw walk-in price', async () => {
|
||||||
|
// Starts in 1h, pre-sale closed 2h before start (site default): door state.
|
||||||
|
const door = await createEvent({
|
||||||
|
title: 'Door State', walkInPrice: 31000, startDatetime: hours(1), endDatetime: hours(4),
|
||||||
|
});
|
||||||
|
// Starts in 3 days: still online.
|
||||||
|
const online = await createEvent({
|
||||||
|
title: 'Online State', walkInPrice: 31000, startDatetime: hours(72), endDatetime: hours(75),
|
||||||
|
});
|
||||||
|
// Already over.
|
||||||
|
const ended = await createEvent({
|
||||||
|
title: 'Ended State', walkInPrice: 31000, startDatetime: hours(-4), endDatetime: hours(-1),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of [null, MEMBER]) {
|
||||||
|
await as(user, async () => {
|
||||||
|
const single = await request('GET', `/api/events/${door.slug}`);
|
||||||
|
expect(single.body.event.salesState).toBe('door');
|
||||||
|
expect(single.body.event.doorPrice).toBe(31000);
|
||||||
|
expect(single.body.event.presaleClosesAt).toEqual(expect.any(String));
|
||||||
|
expect(single.body.event.availableSeats).toBe(40);
|
||||||
|
expect(single.body.event.doorPaymentMethods).toEqual(['cash', 'bitcoin', 'transfer', 'pos']);
|
||||||
|
expect(single.body.event).not.toHaveProperty('walkInPrice');
|
||||||
|
expect(typeof single.body.serverTime).toBe('string');
|
||||||
|
|
||||||
|
for (const other of [online, ended]) {
|
||||||
|
const res = await request('GET', `/api/events/${other.slug}`);
|
||||||
|
expect(res.body.event.salesState).toBe(other === online ? 'online' : 'ended');
|
||||||
|
expect(res.body.event).not.toHaveProperty('doorPrice');
|
||||||
|
expect(res.body.event).not.toHaveProperty('doorPaymentMethods');
|
||||||
|
expect(res.body.event).not.toHaveProperty('walkInPrice');
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('31000');
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await request('GET', '/api/events');
|
||||||
|
const byId = new Map(list.body.events.map((e: any) => [e.id, e]));
|
||||||
|
expect((byId.get(door.id) as any).salesState).toBe('door');
|
||||||
|
expect((byId.get(door.id) as any).doorPrice).toBe(31000);
|
||||||
|
expect((byId.get(online.id) as any)).not.toHaveProperty('doorPrice');
|
||||||
|
for (const e of list.body.events) expect(e).not.toHaveProperty('walkInPrice');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the ticket price when no walk-in price is set', async () => {
|
||||||
|
const door = await createEvent({
|
||||||
|
title: 'Door Fallback', walkInPrice: null, startDatetime: hours(1), endDatetime: hours(4),
|
||||||
|
});
|
||||||
|
const res = await request('GET', `/api/events/${door.slug}`);
|
||||||
|
expect(res.body.event.salesState).toBe('door');
|
||||||
|
expect(res.body.event.doorPrice).toBe(21000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
// Global finance: expense categories, templates, template packs, payment
|
||||||
|
// method fees (Site Settings → Expense Templates) and the cross-event overview.
|
||||||
|
// Mounted at /api/finance.
|
||||||
|
//
|
||||||
|
// Writes are admin only. The read endpoints for categories/templates/packs are
|
||||||
|
// also open to members who can add expenses on the event given as ?eventId=,
|
||||||
|
// so the "Apply template" pickers work for them.
|
||||||
|
|
||||||
|
import { Hono, type Context } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { and, eq, gte, inArray, lte } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
db, dbAll, dbGet, events, expenseCategories, expenseTemplates, expenseTemplatePacks, expenseTemplatePackItems,
|
||||||
|
eventExpenses, paymentMethodFees,
|
||||||
|
} from '../db/index.js';
|
||||||
|
import { requireAuth, type AuthUser } from '../lib/auth.js';
|
||||||
|
import { requireEventPermission, eventFromQuery } from '../lib/eventPermissions.js';
|
||||||
|
import { generateId, getNow, toDbBool, toDbDate } from '../lib/utils.js';
|
||||||
|
import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js';
|
||||||
|
import { financeAuditOp } from '../lib/finance/audit.js';
|
||||||
|
import { getEventFinance, iso, pyg, bool, loadFeeRules } from '../lib/finance/load.js';
|
||||||
|
import { CALC_TYPES } from '../lib/finance/calculate.js';
|
||||||
|
|
||||||
|
const financeGlobalRouter = new Hono();
|
||||||
|
|
||||||
|
const validationHook = (result: any, c: any) => {
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues.map((i: any) => `${i.path.join('.')}: ${i.message}`).join(', ');
|
||||||
|
return c.json({ error: errors }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const money = z.number().int().min(0).max(2_000_000_000);
|
||||||
|
const bp = z.number().int().min(0).max(10000);
|
||||||
|
const currentUser = (c: Context) => (c as any).get('user') as AuthUser;
|
||||||
|
const ADMIN = requireAuth(['admin']);
|
||||||
|
const TEMPLATE_READERS = requireEventPermission(['edit_expenses', 'edit_own_expenses_only'], { eventId: eventFromQuery() });
|
||||||
|
|
||||||
|
// ==================== Categories ====================
|
||||||
|
|
||||||
|
const serializeCategory = (r: any) => ({
|
||||||
|
id: r.id, nameEn: r.nameEn, nameEs: r.nameEs, color: r.color, sortOrder: pyg(r.sortOrder), archived: bool(r.archived),
|
||||||
|
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const categorySchema = z.object({
|
||||||
|
nameEn: z.string().trim().min(1).max(100),
|
||||||
|
nameEs: z.string().trim().min(1).max(100),
|
||||||
|
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#6B7280'),
|
||||||
|
sortOrder: z.number().int().min(0).max(10000).default(0),
|
||||||
|
archived: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.get('/settings/expense-categories', TEMPLATE_READERS, async (c) => {
|
||||||
|
const rows = await dbAll<any>((db as any).select().from(expenseCategories));
|
||||||
|
return c.json({ categories: rows.map(serializeCategory).sort((a, b) => a.sortOrder - b.sortOrder) });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.post('/settings/expense-categories', ADMIN, zValidator('json', categorySchema, validationHook), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const now = getNow();
|
||||||
|
const values = { id: generateId(), ...data, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
|
||||||
|
await runOps([
|
||||||
|
insertOp(expenseCategories, values),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: values.id, action: 'create', after: serializeCategory(values) }),
|
||||||
|
]);
|
||||||
|
return c.json({ category: serializeCategory(values) }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.put('/settings/expense-categories/:id', ADMIN, zValidator('json', categorySchema.partial(), validationHook), async (c) => {
|
||||||
|
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, c.req.param('id'))));
|
||||||
|
if (!existing) return c.json({ error: 'Category not found' }, 404);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
|
||||||
|
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||||
|
const after = serializeCategory({ ...existing, ...updates });
|
||||||
|
await runOps([
|
||||||
|
updateOp(expenseCategories, updates, eq((expenseCategories as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: existing.id, action: 'update', before: serializeCategory(existing), after }),
|
||||||
|
]);
|
||||||
|
return c.json({ category: after });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.delete('/settings/expense-categories/:id', ADMIN, async (c) => {
|
||||||
|
const id = c.req.param('id')!;
|
||||||
|
const existing = await dbGet<any>((db as any).select().from(expenseCategories).where(eq((expenseCategories as any).id, id)));
|
||||||
|
if (!existing) return c.json({ error: 'Category not found' }, 404);
|
||||||
|
const [usedByExpense, usedByTemplate] = await Promise.all([
|
||||||
|
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).categoryId, id))),
|
||||||
|
dbGet<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(eq((expenseTemplates as any).categoryId, id))),
|
||||||
|
]);
|
||||||
|
if (usedByExpense || usedByTemplate) {
|
||||||
|
return c.json({ error: 'This category is in use. Archive it instead.', code: 'IN_USE' }, 409);
|
||||||
|
}
|
||||||
|
await runOps([
|
||||||
|
deleteOp(expenseCategories, eq((expenseCategories as any).id, id)),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_category', entityId: id, action: 'delete', before: serializeCategory(existing) }),
|
||||||
|
]);
|
||||||
|
return c.json({ message: 'Category deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Templates ====================
|
||||||
|
|
||||||
|
const serializeTemplate = (r: any) => ({
|
||||||
|
id: r.id, name: r.name, categoryId: r.categoryId ?? null, description: r.description ?? null, calcType: r.calcType,
|
||||||
|
amount: pyg(r.amount), percentBp: pyg(r.percentBp), minimumAmount: pyg(r.minimumAmount), archived: bool(r.archived),
|
||||||
|
createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
categoryId: z.string().nullable().optional(),
|
||||||
|
description: z.string().trim().max(1000).nullable().optional(),
|
||||||
|
calcType: z.enum(CALC_TYPES),
|
||||||
|
amount: money.default(0),
|
||||||
|
percentBp: bp.default(0),
|
||||||
|
minimumAmount: money.default(0),
|
||||||
|
archived: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function validCategory(categoryId: string | null | undefined) {
|
||||||
|
if (!categoryId) return true;
|
||||||
|
return !!(await dbGet<any>((db as any).select({ id: (expenseCategories as any).id }).from(expenseCategories).where(eq((expenseCategories as any).id, categoryId))));
|
||||||
|
}
|
||||||
|
|
||||||
|
financeGlobalRouter.get('/settings/expense-templates', TEMPLATE_READERS, async (c) => {
|
||||||
|
const rows = await dbAll<any>((db as any).select().from(expenseTemplates));
|
||||||
|
return c.json({ templates: rows.map(serializeTemplate).sort((a, b) => a.name.localeCompare(b.name)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.post('/settings/expense-templates', ADMIN, zValidator('json', templateSchema, validationHook), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
|
||||||
|
const now = getNow();
|
||||||
|
const values = {
|
||||||
|
id: generateId(), ...data, categoryId: data.categoryId || null, description: data.description || null,
|
||||||
|
archived: toDbBool(data.archived), createdAt: now, updatedAt: now,
|
||||||
|
};
|
||||||
|
await runOps([
|
||||||
|
insertOp(expenseTemplates, values),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: values.id, action: 'create', after: serializeTemplate(values) }),
|
||||||
|
]);
|
||||||
|
return c.json({ template: serializeTemplate(values) }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.put('/settings/expense-templates/:id', ADMIN, zValidator('json', templateSchema.partial(), validationHook), async (c) => {
|
||||||
|
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, c.req.param('id'))));
|
||||||
|
if (!existing) return c.json({ error: 'Template not found' }, 404);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
if (!(await validCategory(data.categoryId))) return c.json({ error: 'Unknown expense category' }, 400);
|
||||||
|
const updates: Record<string, any> = { ...data, updatedAt: getNow() };
|
||||||
|
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||||
|
if (data.categoryId === '') updates.categoryId = null;
|
||||||
|
const after = serializeTemplate({ ...existing, ...updates });
|
||||||
|
await runOps([
|
||||||
|
updateOp(expenseTemplates, updates, eq((expenseTemplates as any).id, existing.id)),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: existing.id, action: 'update', before: serializeTemplate(existing), after }),
|
||||||
|
]);
|
||||||
|
return c.json({ template: after });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.delete('/settings/expense-templates/:id', ADMIN, async (c) => {
|
||||||
|
const id = c.req.param('id')!;
|
||||||
|
const existing = await dbGet<any>((db as any).select().from(expenseTemplates).where(eq((expenseTemplates as any).id, id)));
|
||||||
|
if (!existing) return c.json({ error: 'Template not found' }, 404);
|
||||||
|
const [usedByExpense, usedByPack] = await Promise.all([
|
||||||
|
dbGet<any>((db as any).select({ id: (eventExpenses as any).id }).from(eventExpenses).where(eq((eventExpenses as any).templateId, id))),
|
||||||
|
dbGet<any>((db as any).select({ id: (expenseTemplatePackItems as any).id }).from(expenseTemplatePackItems).where(eq((expenseTemplatePackItems as any).templateId, id))),
|
||||||
|
]);
|
||||||
|
if (usedByExpense || usedByPack) {
|
||||||
|
return c.json({ error: 'This template has been used or is in a pack. Archive it instead.', code: 'IN_USE' }, 409);
|
||||||
|
}
|
||||||
|
await runOps([
|
||||||
|
deleteOp(expenseTemplates, eq((expenseTemplates as any).id, id)),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template', entityId: id, action: 'delete', before: serializeTemplate(existing) }),
|
||||||
|
]);
|
||||||
|
return c.json({ message: 'Template deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Template packs ====================
|
||||||
|
|
||||||
|
const packSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
description: z.string().trim().max(1000).nullable().optional(),
|
||||||
|
archived: z.boolean().default(false),
|
||||||
|
templateIds: z.array(z.string()).max(50).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPacks() {
|
||||||
|
const [packs, items] = await Promise.all([
|
||||||
|
dbAll<any>((db as any).select().from(expenseTemplatePacks)),
|
||||||
|
dbAll<any>((db as any).select().from(expenseTemplatePackItems)),
|
||||||
|
]);
|
||||||
|
return packs
|
||||||
|
.map((p: any) => ({
|
||||||
|
id: p.id, name: p.name, description: p.description ?? null, archived: bool(p.archived),
|
||||||
|
templateIds: items.filter((i: any) => i.packId === p.id).sort((a: any, b: any) => pyg(a.sortOrder) - pyg(b.sortOrder)).map((i: any) => i.templateId),
|
||||||
|
createdAt: iso(p.createdAt), updatedAt: iso(p.updatedAt),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validTemplates(ids: string[]) {
|
||||||
|
if (ids.length === 0) return true;
|
||||||
|
const rows = await dbAll<any>((db as any).select({ id: (expenseTemplates as any).id }).from(expenseTemplates).where(inArray((expenseTemplates as any).id, ids)));
|
||||||
|
return rows.length === new Set(ids).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemOps = (packId: string, templateIds: string[]): TxOp[] =>
|
||||||
|
[...new Set(templateIds)].map((templateId, i) => insertOp(expenseTemplatePackItems, { id: generateId(), packId, templateId, sortOrder: i }));
|
||||||
|
|
||||||
|
financeGlobalRouter.get('/settings/expense-template-packs', TEMPLATE_READERS, async (c) => {
|
||||||
|
return c.json({ packs: await loadPacks() });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.post('/settings/expense-template-packs', ADMIN, zValidator('json', packSchema, validationHook), async (c) => {
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
if (!(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
|
||||||
|
const now = getNow();
|
||||||
|
const values = { id: generateId(), name: data.name, description: data.description || null, archived: toDbBool(data.archived), createdAt: now, updatedAt: now };
|
||||||
|
await runOps([
|
||||||
|
insertOp(expenseTemplatePacks, values),
|
||||||
|
...itemOps(values.id, data.templateIds),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: values.id, action: 'create', after: { ...data } }),
|
||||||
|
]);
|
||||||
|
const pack = (await loadPacks()).find((p) => p.id === values.id);
|
||||||
|
return c.json({ pack }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.put('/settings/expense-template-packs/:id', ADMIN, zValidator('json', packSchema.partial(), validationHook), async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const before = (await loadPacks()).find((p) => p.id === id);
|
||||||
|
if (!before) return c.json({ error: 'Pack not found' }, 404);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
if (data.templateIds && !(await validTemplates(data.templateIds))) return c.json({ error: 'Unknown template in pack' }, 400);
|
||||||
|
const updates: Record<string, any> = { updatedAt: getNow() };
|
||||||
|
if (data.name !== undefined) updates.name = data.name;
|
||||||
|
if (data.description !== undefined) updates.description = data.description || null;
|
||||||
|
if (data.archived !== undefined) updates.archived = toDbBool(data.archived);
|
||||||
|
const ops: TxOp[] = [updateOp(expenseTemplatePacks, updates, eq((expenseTemplatePacks as any).id, id))];
|
||||||
|
if (data.templateIds) {
|
||||||
|
ops.push(deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)));
|
||||||
|
ops.push(...itemOps(id, data.templateIds));
|
||||||
|
}
|
||||||
|
ops.push(financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'update', before, after: { ...before, ...data } }));
|
||||||
|
await runOps(ops);
|
||||||
|
const pack = (await loadPacks()).find((p) => p.id === id);
|
||||||
|
return c.json({ pack });
|
||||||
|
});
|
||||||
|
|
||||||
|
financeGlobalRouter.delete('/settings/expense-template-packs/:id', ADMIN, async (c) => {
|
||||||
|
const id = c.req.param('id')!;
|
||||||
|
const before = (await loadPacks()).find((p) => p.id === id);
|
||||||
|
if (!before) return c.json({ error: 'Pack not found' }, 404);
|
||||||
|
await runOps([
|
||||||
|
deleteOp(expenseTemplatePackItems, eq((expenseTemplatePackItems as any).packId, id)),
|
||||||
|
deleteOp(expenseTemplatePacks, eq((expenseTemplatePacks as any).id, id)),
|
||||||
|
financeAuditOp({ eventId: null, actorUserId: currentUser(c).id, entityType: 'expense_template_pack', entityId: id, action: 'delete', before }),
|
||||||
|
]);
|
||||||
|
return c.json({ message: 'Pack deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Payment method fees ====================
|
||||||
|
|
||||||
|
financeGlobalRouter.get('/settings/payment-fees', ADMIN, async (c) => {
|
||||||
|
return c.json({ fees: await loadFeeRules() });
|
||||||
|
});
|
||||||
|
|
||||||
|
const feeSchema = z.object({ percentBp: bp, fixedAmount: money });
|
||||||
|
const FEE_METHODS = ['tpago', 'bank_transfer', 'lightning', 'cash', 'pos', 'bancard'] as const;
|
||||||
|
|
||||||
|
financeGlobalRouter.put('/settings/payment-fees/:method', ADMIN, zValidator('json', feeSchema, validationHook), async (c) => {
|
||||||
|
const method = c.req.param('method');
|
||||||
|
if (!(FEE_METHODS as readonly string[]).includes(method)) return c.json({ error: 'Unknown payment method' }, 400);
|
||||||
|
const data = c.req.valid('json');
|
||||||
|
const existing = await dbGet<any>((db as any).select().from(paymentMethodFees).where(eq((paymentMethodFees as any).method, method)));
|
||||||
|
const user = currentUser(c);
|
||||||
|
const values = { percentBp: data.percentBp, fixedAmount: data.fixedAmount, updatedAt: getNow(), updatedBy: user.id };
|
||||||
|
await runOps([
|
||||||
|
existing
|
||||||
|
? updateOp(paymentMethodFees, values, eq((paymentMethodFees as any).method, method))
|
||||||
|
: insertOp(paymentMethodFees, { method, ...values }),
|
||||||
|
financeAuditOp({
|
||||||
|
eventId: null, actorUserId: user.id, entityType: 'payment_fee', entityId: method, action: existing ? 'update' : 'create',
|
||||||
|
before: existing ? { percentBp: pyg(existing.percentBp), fixedAmount: pyg(existing.fixedAmount) } : null, after: data,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return c.json({ fee: { method, ...data } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== Cross-event overview ====================
|
||||||
|
|
||||||
|
type Totals = { events: number; gross: number; fees: number; net: number; expenses: number; profit: number };
|
||||||
|
const emptyTotals = (): Totals => ({ events: 0, gross: 0, fees: 0, net: 0, expenses: 0, profit: 0 });
|
||||||
|
const addTo = (t: Totals, row: Omit<Totals, 'events'>) => {
|
||||||
|
t.events += 1; t.gross += row.gross; t.fees += row.fees; t.net += row.net; t.expenses += row.expenses; t.profit += row.profit;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profit per event, per series, per venue and payouts per partner. Finalized
|
||||||
|
* events use their snapshot. `readyToClose` lists past events whose books are
|
||||||
|
* still open (with or without any money recorded), longest-waiting first. Filters: from / to (ISO dates on the event start),
|
||||||
|
* series, venue (exact location text), partner (user id or external name).
|
||||||
|
*/
|
||||||
|
financeGlobalRouter.get('/overview', ADMIN, async (c) => {
|
||||||
|
const isDate = (v?: string) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined);
|
||||||
|
const from = isDate(c.req.query('from'));
|
||||||
|
const to = isDate(c.req.query('to'));
|
||||||
|
const seriesFilter = c.req.query('series');
|
||||||
|
const venueFilter = c.req.query('venue');
|
||||||
|
const partnerFilter = c.req.query('partner');
|
||||||
|
|
||||||
|
const conditions: any[] = [];
|
||||||
|
if (from) conditions.push(gte((events as any).startDatetime, toDbDate(from)));
|
||||||
|
if (to) conditions.push(lte((events as any).startDatetime, toDbDate(`${to}T23:59:59.999Z`)));
|
||||||
|
const allEvents = await dbAll<any>(
|
||||||
|
(db as any).select().from(events).where(conditions.length ? and(...conditions) : undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows: any[] = [];
|
||||||
|
const seriesOptions = new Set<string>();
|
||||||
|
const venueOptions = new Set<string>();
|
||||||
|
const partnerOptions = new Map<string, string>();
|
||||||
|
const bySeries = new Map<string, Totals>();
|
||||||
|
const byVenue = new Map<string, Totals>();
|
||||||
|
const byPartner = new Map<string, { key: string; name: string; userId: string | null; events: number; share: number; reimbursement: number; payout: number; paid: number; pending: number }>();
|
||||||
|
const totals = emptyTotals();
|
||||||
|
// Past events whose books are still open, including ones with no money in
|
||||||
|
// or out yet (those are left out of `events` and the totals).
|
||||||
|
const readyToClose: any[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const ev of allEvents) {
|
||||||
|
if (ev.status === 'draft') continue;
|
||||||
|
const fin = await getEventFinance(ev.id, ev);
|
||||||
|
if (!fin) continue;
|
||||||
|
const r = fin.result;
|
||||||
|
const hasMoney = !(r.revenue.gross === 0 && r.expenses.total === 0 && r.revenue.otherIncome === 0 && fin.partners.length === 0);
|
||||||
|
const ended = new Date(ev.endDatetime || ev.startDatetime).getTime() <= now;
|
||||||
|
const ready = fin.state.status === 'open' && ended;
|
||||||
|
// Nothing to report for events with no money in or out, unless they still need closing.
|
||||||
|
if (!hasMoney && !ready) continue;
|
||||||
|
|
||||||
|
const series = ev.series || null;
|
||||||
|
const venue = (ev.location || '').trim();
|
||||||
|
const partnerKeys = fin.partners.map((p) => p.userId || `name:${p.name}`);
|
||||||
|
if (hasMoney) {
|
||||||
|
if (series) seriesOptions.add(series);
|
||||||
|
if (venue) venueOptions.add(venue);
|
||||||
|
fin.partners.forEach((p, i) => partnerOptions.set(partnerKeys[i], p.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesFilter && (seriesFilter === '__none__' ? series !== null : series !== seriesFilter)) continue;
|
||||||
|
if (venueFilter && venue !== venueFilter) continue;
|
||||||
|
if (partnerFilter && !partnerKeys.includes(partnerFilter)) continue;
|
||||||
|
|
||||||
|
const row = { gross: r.revenue.gross, fees: r.revenue.fees, net: r.revenue.net, expenses: r.expenses.total, profit: r.profit };
|
||||||
|
const eventRow = {
|
||||||
|
id: ev.id, title: ev.title, titleEs: ev.titleEs ?? null, startDatetime: iso(ev.startDatetime),
|
||||||
|
endDatetime: ev.endDatetime ? iso(ev.endDatetime) : null, series, location: venue,
|
||||||
|
status: ev.status, financeStatus: fin.state.status, ticketsSold: r.counts.ticketsSold, ...row,
|
||||||
|
organization: r.split.organization,
|
||||||
|
};
|
||||||
|
if (ready) readyToClose.push(eventRow);
|
||||||
|
if (!hasMoney) continue;
|
||||||
|
rows.push(eventRow);
|
||||||
|
addTo(totals, row);
|
||||||
|
const sKey = series || '';
|
||||||
|
if (!bySeries.has(sKey)) bySeries.set(sKey, emptyTotals());
|
||||||
|
addTo(bySeries.get(sKey)!, row);
|
||||||
|
if (!byVenue.has(venue)) byVenue.set(venue, emptyTotals());
|
||||||
|
addTo(byVenue.get(venue)!, row);
|
||||||
|
|
||||||
|
fin.partners.forEach((p, i) => {
|
||||||
|
const key = partnerKeys[i];
|
||||||
|
const line = r.split.partners.find((x) => x.partnerId === p.id);
|
||||||
|
const agg = byPartner.get(key) || { key, name: p.name, userId: p.userId, events: 0, share: 0, reimbursement: 0, payout: 0, paid: 0, pending: 0 };
|
||||||
|
agg.events += 1;
|
||||||
|
agg.share += line?.share ?? 0;
|
||||||
|
agg.reimbursement += line?.reimbursement ?? 0;
|
||||||
|
agg.payout += line?.payout ?? 0;
|
||||||
|
if (p.payoutStatus === 'paid') agg.paid += line?.payout ?? 0; else agg.pending += line?.payout ?? 0;
|
||||||
|
byPartner.set(key, agg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.sort((a, b) => (b.startDatetime || '').localeCompare(a.startDatetime || ''));
|
||||||
|
// Longest-waiting first.
|
||||||
|
readyToClose.sort((a, b) => (a.startDatetime || '').localeCompare(b.startDatetime || ''));
|
||||||
|
const sortByProfit = <T extends { profit: number }>(xs: T[]) => xs.sort((a, b) => b.profit - a.profit);
|
||||||
|
return c.json({
|
||||||
|
totals,
|
||||||
|
events: rows,
|
||||||
|
readyToClose,
|
||||||
|
bySeries: sortByProfit([...bySeries.entries()].map(([series, t]) => ({ series: series || null, ...t }))),
|
||||||
|
byVenue: sortByProfit([...byVenue.entries()].map(([venue, t]) => ({ venue, ...t }))),
|
||||||
|
byPartner: [...byPartner.values()].sort((a, b) => b.payout - a.payout),
|
||||||
|
filters: {
|
||||||
|
series: [...seriesOptions].sort(),
|
||||||
|
venues: [...venueOptions].sort(),
|
||||||
|
partners: [...partnerOptions.entries()].map(([key, name]) => ({ key, name })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default financeGlobalRouter;
|
||||||
@@ -4,6 +4,7 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
|
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
|
import { requireEventPermission, eventFromParam } from '../lib/eventPermissions.js';
|
||||||
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
|
||||||
|
|
||||||
const paymentOptionsRouter = new Hono();
|
const paymentOptionsRouter = new Hono();
|
||||||
@@ -36,6 +37,8 @@ const updatePaymentOptionsSchema = z.object({
|
|||||||
cashEnabled: booleanOrNumber.optional(),
|
cashEnabled: booleanOrNumber.optional(),
|
||||||
cashInstructions: z.string().optional().nullable(),
|
cashInstructions: z.string().optional().nullable(),
|
||||||
cashInstructionsEs: z.string().optional().nullable(),
|
cashInstructionsEs: z.string().optional().nullable(),
|
||||||
|
// POS terminal on the door screen
|
||||||
|
posEnabled: booleanOrNumber.optional(),
|
||||||
// Booking settings
|
// Booking settings
|
||||||
allowDuplicateBookings: booleanOrNumber.optional(),
|
allowDuplicateBookings: booleanOrNumber.optional(),
|
||||||
});
|
});
|
||||||
@@ -79,6 +82,7 @@ const updateEventOverridesSchema = z.object({
|
|||||||
cashEnabled: booleanOrNumber.optional().nullable(),
|
cashEnabled: booleanOrNumber.optional().nullable(),
|
||||||
cashInstructions: z.string().optional().nullable(),
|
cashInstructions: z.string().optional().nullable(),
|
||||||
cashInstructionsEs: z.string().optional().nullable(),
|
cashInstructionsEs: z.string().optional().nullable(),
|
||||||
|
posEnabled: booleanOrNumber.optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get global payment options
|
// Get global payment options
|
||||||
@@ -111,6 +115,7 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
|
|||||||
cashEnabled: true,
|
cashEnabled: true,
|
||||||
cashInstructions: null,
|
cashInstructions: null,
|
||||||
cashInstructionsEs: null,
|
cashInstructionsEs: null,
|
||||||
|
posEnabled: true,
|
||||||
allowDuplicateBookings: false,
|
allowDuplicateBookings: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -219,6 +224,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
cashEnabled: true,
|
cashEnabled: true,
|
||||||
cashInstructions: null,
|
cashInstructions: null,
|
||||||
cashInstructionsEs: null,
|
cashInstructionsEs: null,
|
||||||
|
posEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const global = globalOptions || defaults;
|
const global = globalOptions || defaults;
|
||||||
@@ -245,6 +251,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
|
cashEnabled: overrides?.cashEnabled ?? global.cashEnabled,
|
||||||
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
|
cashInstructions: overrides?.cashInstructions ?? global.cashInstructions,
|
||||||
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
|
||||||
|
posEnabled: overrides?.posEnabled ?? global.posEnabled ?? true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Full bank/TPago credentials are only returned when the caller proves they hold
|
// Full bank/TPago credentials are only returned when the caller proves they hold
|
||||||
@@ -270,7 +277,7 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get event payment overrides (admin only)
|
// Get event payment overrides (admin only)
|
||||||
paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
paymentOptionsRouter.get('/event/:eventId/overrides', requireEventPermission('view_payments', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
const overrides = await dbGet<any>(
|
const overrides = await dbGet<any>(
|
||||||
@@ -281,7 +288,7 @@ paymentOptionsRouter.get('/event/:eventId/overrides', requireAuth(['admin', 'org
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update event payment overrides
|
// Update event payment overrides
|
||||||
paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), zValidator('json', updateEventOverridesSchema), async (c) => {
|
paymentOptionsRouter.put('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), zValidator('json', updateEventOverridesSchema), async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
@@ -333,7 +340,7 @@ paymentOptionsRouter.put('/event/:eventId/overrides', requireAuth(['admin', 'org
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete event payment overrides (revert to global)
|
// Delete event payment overrides (revert to global)
|
||||||
paymentOptionsRouter.delete('/event/:eventId/overrides', requireAuth(['admin', 'organizer']), async (c) => {
|
paymentOptionsRouter.delete('/event/:eventId/overrides', requireEventPermission('edit_event', { globalRoles: ['admin', 'organizer'], eventId: eventFromParam('eventId') }), async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await emailService.sendPaymentReminder(id);
|
const result = await emailService.sendPaymentReminder(payment.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { eq, and, gte } from 'drizzle-orm';
|
|||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
import { generateId, getNow, toDbBool } from '../lib/utils.js';
|
||||||
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
import { revalidateFrontendCache } from '../lib/revalidate.js';
|
||||||
|
import { DEFAULT_PRESALE_CLOSURE_ENABLED, DEFAULT_PRESALE_CLOSE_MINUTES } from '../lib/presale.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,8 +44,26 @@ const updateSiteSettingsSchema = z.object({
|
|||||||
maintenanceMode: z.boolean().optional(),
|
maintenanceMode: z.boolean().optional(),
|
||||||
maintenanceMessage: z.string().optional().nullable(),
|
maintenanceMessage: z.string().optional().nullable(),
|
||||||
maintenanceMessageEs: z.string().optional().nullable(),
|
maintenanceMessageEs: z.string().optional().nullable(),
|
||||||
|
// Pre-sale closure defaults inherited by events that don't override them
|
||||||
|
presaleClosureEnabled: z.boolean().optional(),
|
||||||
|
presaleCloseMinutesBefore: z.number().int().min(0).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Booleans are stored as 0/1 integers on PostgreSQL; hand the frontend real booleans.
|
||||||
|
function normalizeSettings(row: any) {
|
||||||
|
if (!row) return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
maintenanceMode: Boolean(row.maintenanceMode),
|
||||||
|
presaleClosureEnabled: row.presaleClosureEnabled == null
|
||||||
|
? DEFAULT_PRESALE_CLOSURE_ENABLED
|
||||||
|
: Boolean(row.presaleClosureEnabled),
|
||||||
|
presaleCloseMinutesBefore: row.presaleCloseMinutesBefore == null
|
||||||
|
? DEFAULT_PRESALE_CLOSE_MINUTES
|
||||||
|
: Number(row.presaleCloseMinutesBefore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Get site settings (public - needed for frontend timezone)
|
// Get site settings (public - needed for frontend timezone)
|
||||||
siteSettingsRouter.get('/', async (c) => {
|
siteSettingsRouter.get('/', async (c) => {
|
||||||
const settings = await dbGet(
|
const settings = await dbGet(
|
||||||
@@ -69,11 +88,13 @@ siteSettingsRouter.get('/', async (c) => {
|
|||||||
maintenanceMode: false,
|
maintenanceMode: false,
|
||||||
maintenanceMessage: null,
|
maintenanceMessage: null,
|
||||||
maintenanceMessageEs: null,
|
maintenanceMessageEs: null,
|
||||||
|
presaleClosureEnabled: DEFAULT_PRESALE_CLOSURE_ENABLED,
|
||||||
|
presaleCloseMinutesBefore: DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ settings });
|
return c.json({ settings: normalizeSettings(settings) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get available timezones
|
// Get available timezones
|
||||||
@@ -145,13 +166,15 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
|||||||
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
maintenanceMode: toDbBool(data.maintenanceMode || false),
|
||||||
maintenanceMessage: data.maintenanceMessage || null,
|
maintenanceMessage: data.maintenanceMessage || null,
|
||||||
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
maintenanceMessageEs: data.maintenanceMessageEs || null,
|
||||||
|
presaleClosureEnabled: toDbBool(data.presaleClosureEnabled ?? DEFAULT_PRESALE_CLOSURE_ENABLED),
|
||||||
|
presaleCloseMinutesBefore: data.presaleCloseMinutesBefore ?? DEFAULT_PRESALE_CLOSE_MINUTES,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
updatedBy: user.id,
|
updatedBy: user.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
await (db as any).insert(siteSettings).values(newSettings);
|
await (db as any).insert(siteSettings).values(newSettings);
|
||||||
|
|
||||||
return c.json({ settings: newSettings, message: 'Settings created successfully' }, 201);
|
return c.json({ settings: normalizeSettings(newSettings), message: 'Settings created successfully' }, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate featured event if provided
|
// Validate featured event if provided
|
||||||
@@ -174,6 +197,9 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
|||||||
if (typeof data.maintenanceMode === 'boolean') {
|
if (typeof data.maintenanceMode === 'boolean') {
|
||||||
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
|
updateData.maintenanceMode = toDbBool(data.maintenanceMode);
|
||||||
}
|
}
|
||||||
|
if (typeof data.presaleClosureEnabled === 'boolean') {
|
||||||
|
updateData.presaleClosureEnabled = toDbBool(data.presaleClosureEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
await (db as any)
|
await (db as any)
|
||||||
.update(siteSettings)
|
.update(siteSettings)
|
||||||
@@ -184,12 +210,17 @@ siteSettingsRouter.put('/', requireAuth(['admin']), zValidator('json', updateSit
|
|||||||
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
(db as any).select().from(siteSettings).where(eq((siteSettings as any).id, existing.id))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Revalidate frontend cache if featured event changed
|
// Revalidate frontend cache if featured event changed or the pre-sale
|
||||||
if (data.featuredEventId !== undefined) {
|
// defaults changed (public event pages embed the computed cutoff).
|
||||||
|
if (
|
||||||
|
data.featuredEventId !== undefined ||
|
||||||
|
data.presaleClosureEnabled !== undefined ||
|
||||||
|
data.presaleCloseMinutesBefore !== undefined
|
||||||
|
) {
|
||||||
revalidateFrontendCache();
|
revalidateFrontendCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ settings: updated, message: 'Settings updated successfully' });
|
return c.json({ settings: normalizeSettings(updated), message: 'Settings updated successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set featured event (admin only) - convenience endpoint for event editor
|
// Set featured event (admin only) - convenience endpoint for event editor
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import { z } from 'zod';
|
|||||||
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
|
||||||
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
import { eq, and, or, sql, inArray } from 'drizzle-orm';
|
||||||
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
import { requireAuth, getAuthUser } from '../lib/auth.js';
|
||||||
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
import {
|
||||||
|
requireEventPermission, eventFromQuery, eventFromBody, eventFromTicketParam, canSeeAttendeePii, redactAttendee,
|
||||||
|
} from '../lib/eventPermissions.js';
|
||||||
|
import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
|
||||||
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
|
import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js';
|
||||||
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
import { rateLimitMiddleware } from '../lib/rateLimit.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
|
||||||
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js';
|
||||||
import { seatHolderCountQuery } from '../lib/capacity.js';
|
import { seatHolderCountQuery } from '../lib/capacity.js';
|
||||||
|
import { isOnlineSalesClosed } from '../lib/salesState.js';
|
||||||
|
|
||||||
const ticketsRouter = new Hono();
|
const ticketsRouter = new Hono();
|
||||||
|
|
||||||
@@ -111,6 +115,17 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
return c.json({ error: 'Event is not available for booking' }, 400);
|
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-sale closure: online registration stops N minutes before the event
|
||||||
|
// starts (per-event override, else the site-wide default), and at the latest
|
||||||
|
// when it starts — the same rule as the public salesState (lib/salesState.ts).
|
||||||
|
// Staff/door and admin ticket creation use separate endpoints and are not gated.
|
||||||
|
const siteSettingsRow = await dbGet<any>(
|
||||||
|
(db as any).select().from(siteSettings).limit(1)
|
||||||
|
);
|
||||||
|
if (isOnlineSalesClosed(event, siteSettingsRow)) {
|
||||||
|
return c.json({ error: 'Registration for this event is closed' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate the requested payment method is actually enabled for this event
|
// Validate the requested payment method is actually enabled for this event
|
||||||
// (merge global options with any event-level overrides; override wins when not null)
|
// (merge global options with any event-level overrides; override wins when not null)
|
||||||
const globalPaymentOptions = await dbGet<any>(
|
const globalPaymentOptions = await dbGet<any>(
|
||||||
@@ -148,9 +163,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create user
|
// Find or create user. The account row is keyed on the normalized address so
|
||||||
|
// it stays reachable from Better Auth (which lowercases every lookup) —
|
||||||
|
// tickets.attendeeEmail below keeps the address exactly as the buyer typed it.
|
||||||
|
const accountEmail = normalizeEmail(data.email);
|
||||||
let user = await dbGet<any>(
|
let user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, data.email))
|
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
@@ -163,7 +181,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
const userId = generateId();
|
const userId = generateId();
|
||||||
user = {
|
user = {
|
||||||
id: userId,
|
id: userId,
|
||||||
email: data.email,
|
email: accountEmail,
|
||||||
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
|
password: null, // No password for guest bookings; set on claim (Better Auth credential account)
|
||||||
name: fullName,
|
name: fullName,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
@@ -548,20 +566,24 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => {
|
|||||||
);
|
);
|
||||||
const timezone = settings?.timezone || 'America/Asuncion';
|
const timezone = settings?.timezone || 'America/Asuncion';
|
||||||
|
|
||||||
const ticketsData = confirmedTickets.map((ticket: any) => ({
|
const ticketsData = confirmedTickets.map((ticket: any) => {
|
||||||
id: ticket.id,
|
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
|
||||||
qrCode: ticket.qrCode,
|
return {
|
||||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
id: ticket.id,
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
qrCode: ticket.qrCode,
|
||||||
event: {
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
title: event.title,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
startDatetime: event.startDatetime,
|
event: {
|
||||||
endDatetime: event.endDatetime,
|
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
|
||||||
location: event.location,
|
startDatetime: event.startDatetime,
|
||||||
locationUrl: event.locationUrl,
|
endDatetime: event.endDatetime,
|
||||||
},
|
location: event.location,
|
||||||
timezone,
|
locationUrl: event.locationUrl,
|
||||||
}));
|
},
|
||||||
|
timezone,
|
||||||
|
locale,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
|
const pdfBuffer = await generateCombinedTicketsPDF(ticketsData);
|
||||||
|
|
||||||
@@ -625,19 +647,22 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
|||||||
);
|
);
|
||||||
const timezone = settings?.timezone || 'America/Asuncion';
|
const timezone = settings?.timezone || 'America/Asuncion';
|
||||||
|
|
||||||
|
const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en';
|
||||||
|
|
||||||
const pdfBuffer = await generateTicketPDF({
|
const pdfBuffer = await generateTicketPDF({
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
qrCode: ticket.qrCode,
|
qrCode: ticket.qrCode,
|
||||||
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(),
|
||||||
attendeeEmail: ticket.attendeeEmail,
|
attendeeEmail: ticket.attendeeEmail,
|
||||||
event: {
|
event: {
|
||||||
title: event.title,
|
title: locale === 'es' && event.titleEs ? event.titleEs : event.title,
|
||||||
startDatetime: event.startDatetime,
|
startDatetime: event.startDatetime,
|
||||||
endDatetime: event.endDatetime,
|
endDatetime: event.endDatetime,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
locationUrl: event.locationUrl,
|
locationUrl: event.locationUrl,
|
||||||
},
|
},
|
||||||
timezone,
|
timezone,
|
||||||
|
locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set response headers for PDF download
|
// Set response headers for PDF download
|
||||||
@@ -654,7 +679,7 @@ ticketsRouter.get('/:id/pdf', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get event check-in stats for scanner (lightweight endpoint for staff)
|
// Get event check-in stats for scanner (lightweight endpoint for staff)
|
||||||
ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.get('/stats/checkin', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromQuery() }), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
if (!eventId) {
|
if (!eventId) {
|
||||||
@@ -705,7 +730,7 @@ ticketsRouter.get('/stats/checkin', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Live search tickets (GET - for scanner live search)
|
// Live search tickets (GET - for scanner live search)
|
||||||
ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.get('/search', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromQuery() }), async (c) => {
|
||||||
const q = c.req.query('q')?.trim() || '';
|
const q = c.req.query('q')?.trim() || '';
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
|
|
||||||
@@ -824,7 +849,7 @@ ticketsRouter.get('/:id', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update ticket status (admin/organizer)
|
// Update ticket status (admin/organizer)
|
||||||
ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateTicketSchema), async (c) => {
|
ticketsRouter.put('/:id', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), zValidator('json', updateTicketSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
@@ -857,7 +882,7 @@ ticketsRouter.put('/:id', requireAuth(['admin', 'organizer', 'staff']), zValidat
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Search tickets by name/email (for scanner manual search)
|
// Search tickets by name/email (for scanner manual search)
|
||||||
ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/search', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
const { query, eventId } = body;
|
const { query, eventId } = body;
|
||||||
|
|
||||||
@@ -916,7 +941,7 @@ ticketsRouter.post('/search', requireAuth(['admin', 'organizer', 'staff']), asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Validate ticket by QR code (for scanner)
|
// Validate ticket by QR code (for scanner)
|
||||||
ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/validate', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
const { code, eventId } = body;
|
const { code, eventId } = body;
|
||||||
|
|
||||||
@@ -1021,7 +1046,7 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check-in ticket
|
// Check-in ticket
|
||||||
ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/:id/checkin', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const adminUser = (c as any).get('user');
|
const adminUser = (c as any).get('user');
|
||||||
|
|
||||||
@@ -1076,7 +1101,7 @@ ticketsRouter.post('/:id/checkin', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
|
|
||||||
// Mark payment as received (for cash payments - admin only)
|
// Mark payment as received (for cash payments - admin only)
|
||||||
// Supports multi-ticket bookings - confirms all tickets in the booking
|
// Supports multi-ticket bookings - confirms all tickets in the booking
|
||||||
ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/:id/mark-paid', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const user = (c as any).get('user');
|
const user = (c as any).get('user');
|
||||||
|
|
||||||
@@ -1164,7 +1189,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
|
|
||||||
// Send confirmation emails asynchronously (don't block the response)
|
// Send confirmation emails asynchronously (don't block the response)
|
||||||
Promise.all([
|
Promise.all([
|
||||||
emailService.sendBookingConfirmation(id),
|
emailService.sendBookingConfirmation(ticket.id),
|
||||||
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
||||||
]).catch(err => {
|
]).catch(err => {
|
||||||
console.error('[Email] Failed to send confirmation emails:', err);
|
console.error('[Email] Failed to send confirmation emails:', err);
|
||||||
@@ -1345,7 +1370,7 @@ ticketsRouter.post('/:id/cancel', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove check-in (reset to confirmed)
|
// Remove check-in (reset to confirmed)
|
||||||
ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'staff']), async (c) => {
|
ticketsRouter.post('/:id/remove-checkin', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
|
|
||||||
const ticket = await dbGet<any>(
|
const ticket = await dbGet<any>(
|
||||||
@@ -1373,7 +1398,7 @@ ticketsRouter.post('/:id/remove-checkin', requireAuth(['admin', 'organizer', 'st
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update admin note
|
// Update admin note
|
||||||
ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', updateNoteSchema), async (c) => {
|
ticketsRouter.post('/:id/note', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromTicketParam() }), zValidator('json', updateNoteSchema), async (c) => {
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const { note } = c.req.valid('json');
|
const { note } = c.req.valid('json');
|
||||||
|
|
||||||
@@ -1398,7 +1423,7 @@ ticketsRouter.post('/:id/note', requireAuth(['admin', 'organizer', 'staff']), zV
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Admin create ticket (at the door)
|
// Admin create ticket (at the door)
|
||||||
ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', adminCreateTicketSchema), async (c) => {
|
ticketsRouter.post('/admin/create', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), zValidator('json', adminCreateTicketSchema), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
// Get event
|
// Get event
|
||||||
@@ -1418,9 +1443,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
? data.email.trim()
|
? data.email.trim()
|
||||||
: `door-${generateId()}@doorentry.local`;
|
: `door-${generateId()}@doorentry.local`;
|
||||||
|
|
||||||
// Find or create user
|
// Find or create user (see the note on `accountEmail` in the booking route)
|
||||||
|
const accountEmail = normalizeEmail(attendeeEmail);
|
||||||
let user = await dbGet<any>(
|
let user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
|
||||||
);
|
);
|
||||||
|
|
||||||
const adminFullName = data.lastName && data.lastName.trim()
|
const adminFullName = data.lastName && data.lastName.trim()
|
||||||
@@ -1431,7 +1457,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
const userId = generateId();
|
const userId = generateId();
|
||||||
user = {
|
user = {
|
||||||
id: userId,
|
id: userId,
|
||||||
email: attendeeEmail,
|
email: accountEmail,
|
||||||
password: null,
|
password: null,
|
||||||
name: adminFullName,
|
name: adminFullName,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
@@ -1486,6 +1512,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
qrCode,
|
qrCode,
|
||||||
checkinAt: data.autoCheckin ? now : null,
|
checkinAt: data.autoCheckin ? now : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
|
bookingSource: 'admin',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1529,14 +1556,18 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff'])
|
|||||||
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
|
// Unified admin add-attendee endpoint backing the single Add Ticket modal.
|
||||||
// type drives payment handling:
|
// type drives payment handling:
|
||||||
// paid — email required; paid cash payment; confirmation email + QR sent
|
// paid — email required; paid cash payment; confirmation email + QR sent
|
||||||
|
// door — paid in cash at the door; all fields optional; counts toward revenue;
|
||||||
|
// confirmation email only when an email is provided
|
||||||
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
|
// unpaid — QR issued with balance due (collect at door); pending tpago payment;
|
||||||
// pay-link (Bancard/TPago) email sent when an email is provided
|
// pay-link (Bancard/TPago) email sent when an email is provided
|
||||||
// guest — free comp ticket, not counted in revenue; confirmation email only
|
// guest — free comp ticket, not counted in revenue; confirmation email only
|
||||||
// when an email is provided
|
// when an email is provided
|
||||||
ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({
|
ticketsRouter.post('/admin/add', requireEventPermission('check_in', { globalRoles: ['admin', 'organizer', 'staff'], eventId: eventFromBody() }), zValidator('json', z.object({
|
||||||
eventId: z.string(),
|
eventId: z.string(),
|
||||||
type: z.enum(['paid', 'unpaid', 'guest']),
|
type: z.enum(['paid', 'door', 'unpaid', 'guest']),
|
||||||
firstName: z.string().min(1),
|
// Door walk-ins can be logged with nothing filled in, so firstName is only
|
||||||
|
// required for the other types
|
||||||
|
firstName: z.string().optional().or(z.literal('')),
|
||||||
lastName: z.string().optional().or(z.literal('')),
|
lastName: z.string().optional().or(z.literal('')),
|
||||||
email: z.string().email().optional().or(z.literal('')),
|
email: z.string().email().optional().or(z.literal('')),
|
||||||
phone: z.string().optional().or(z.literal('')),
|
phone: z.string().optional().or(z.literal('')),
|
||||||
@@ -1546,6 +1577,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
|
}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), {
|
||||||
message: 'Email is required for paid tickets',
|
message: 'Email is required for paid tickets',
|
||||||
path: ['email'],
|
path: ['email'],
|
||||||
|
}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), {
|
||||||
|
message: 'First name is required',
|
||||||
|
path: ['firstName'],
|
||||||
})), async (c) => {
|
})), async (c) => {
|
||||||
const data = c.req.valid('json');
|
const data = c.req.valid('json');
|
||||||
|
|
||||||
@@ -1565,20 +1599,23 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
? data.email!.trim()
|
? data.email!.trim()
|
||||||
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
|
: `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`;
|
||||||
|
|
||||||
|
// Nameless door walk-ins still need a display name on the ticket
|
||||||
|
const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in';
|
||||||
const fullName = data.lastName && data.lastName.trim()
|
const fullName = data.lastName && data.lastName.trim()
|
||||||
? `${data.firstName} ${data.lastName}`.trim()
|
? `${firstName} ${data.lastName.trim()}`
|
||||||
: data.firstName;
|
: firstName;
|
||||||
|
|
||||||
// Find or create user
|
// Find or create user (see the note on `accountEmail` in the booking route)
|
||||||
|
const accountEmail = normalizeEmail(attendeeEmail);
|
||||||
let user = await dbGet<any>(
|
let user = await dbGet<any>(
|
||||||
(db as any).select().from(users).where(eq((users as any).email, attendeeEmail))
|
(db as any).select().from(users).where(eq((users as any).email, accountEmail))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const userId = generateId();
|
const userId = generateId();
|
||||||
user = {
|
user = {
|
||||||
id: userId,
|
id: userId,
|
||||||
email: attendeeEmail,
|
email: accountEmail,
|
||||||
password: null,
|
password: null,
|
||||||
name: fullName,
|
name: fullName,
|
||||||
phone: data.phone || null,
|
phone: data.phone || null,
|
||||||
@@ -1613,13 +1650,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
|
|
||||||
const ticketId = generateId();
|
const ticketId = generateId();
|
||||||
const qrCode = generateTicketCode();
|
const qrCode = generateTicketCode();
|
||||||
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid';
|
const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid';
|
||||||
|
|
||||||
const newTicket = {
|
const newTicket = {
|
||||||
id: ticketId,
|
id: ticketId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
eventId: data.eventId,
|
eventId: data.eventId,
|
||||||
attendeeFirstName: data.firstName,
|
attendeeFirstName: firstName,
|
||||||
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null,
|
||||||
attendeeEmail: hasEmail ? data.email!.trim() : null,
|
attendeeEmail: hasEmail ? data.email!.trim() : null,
|
||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
@@ -1631,12 +1668,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
checkinAt: data.checkinNow ? now : null,
|
checkinAt: data.checkinNow ? now : null,
|
||||||
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
|
bookingSource: 'admin',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await (db as any).insert(tickets).values(newTicket);
|
await (db as any).insert(tickets).values(newTicket);
|
||||||
|
|
||||||
// Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid
|
// Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid
|
||||||
const paymentId = generateId();
|
const paymentId = generateId();
|
||||||
const newPayment = data.type === 'unpaid'
|
const newPayment = data.type === 'unpaid'
|
||||||
? {
|
? {
|
||||||
@@ -1659,7 +1697,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
amount: data.type === 'guest' ? 0 : event.price,
|
amount: data.type === 'guest' ? 0 : event.price,
|
||||||
currency: event.currency,
|
currency: event.currency,
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket',
|
reference: data.type === 'guest'
|
||||||
|
? 'Guest invite'
|
||||||
|
: data.type === 'door'
|
||||||
|
? 'Paid at door'
|
||||||
|
: 'Manual ticket',
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
paidByAdminId: adminUser?.id || null,
|
paidByAdminId: adminUser?.id || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -1668,8 +1710,8 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
|
|
||||||
await (db as any).insert(payments).values(newPayment);
|
await (db as any).insert(payments).values(newPayment);
|
||||||
|
|
||||||
// Emails (asynchronous): paid always confirms; guest confirms when an email
|
// Emails (asynchronous): paid always confirms; door/guest confirm only when an
|
||||||
// exists; unpaid sends the TPago (Bancard) pay-link instructions instead
|
// email exists; unpaid sends the TPago (Bancard) pay-link instructions instead
|
||||||
if (data.type === 'unpaid') {
|
if (data.type === 'unpaid') {
|
||||||
if (hasEmail) {
|
if (hasEmail) {
|
||||||
emailService.sendPaymentInstructions(ticketId).then(result => {
|
emailService.sendPaymentInstructions(ticketId).then(result => {
|
||||||
@@ -1692,6 +1734,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
|
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
paid: 'Ticket created — confirmation email sent',
|
paid: 'Ticket created — confirmation email sent',
|
||||||
|
door: hasEmail
|
||||||
|
? 'Ticket created — paid at the door, confirmation email sent'
|
||||||
|
: 'Ticket created — paid at the door',
|
||||||
unpaid: hasEmail
|
unpaid: hasEmail
|
||||||
? 'Unpaid ticket created — payment link sent'
|
? 'Unpaid ticket created — payment link sent'
|
||||||
: 'Unpaid ticket created — collect payment at the door',
|
: 'Unpaid ticket created — collect payment at the door',
|
||||||
@@ -1715,7 +1760,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get all tickets (admin) - includes payment for each ticket
|
// Get all tickets (admin) - includes payment for each ticket
|
||||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
ticketsRouter.get('/', requireEventPermission('view_attendees_names', { globalRoles: ['admin', 'organizer'], eventId: eventFromQuery() }), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
|
|
||||||
@@ -1746,8 +1791,9 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showPii = canSeeAttendeePii(c);
|
||||||
const ticketsWithPayment = ticketsList.map((t: any) => ({
|
const ticketsWithPayment = ticketsList.map((t: any) => ({
|
||||||
...t,
|
...(showPii ? t : redactAttendee(t)),
|
||||||
payment: paymentByTicketId[t.id] || null,
|
payment: paymentByTicketId[t.id] || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { eq, desc, sql, and, gte, lte } from 'drizzle-orm';
|
|||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { authSessions } from '../db/auth-schema.js';
|
import { authSessions } from '../db/auth-schema.js';
|
||||||
import { getNow, toDbDate } from '../lib/utils.js';
|
import { getNow, toDbDate } from '../lib/utils.js';
|
||||||
|
import { omitWalkInPrice } from '../lib/walkInPrice.js';
|
||||||
|
|
||||||
interface UserContext {
|
interface UserContext {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -249,7 +250,7 @@ usersRouter.get('/:id/history', requireAuth(['admin', 'organizer', 'staff', 'mar
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...ticket,
|
...ticket,
|
||||||
event,
|
event: omitWalkInPrice(event as any),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Spanglish Community - Photo Gallery API
|
||||||
|
# photos.spanglishcommunity.com
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name photos.spanglishcommunity.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://photos.spanglishcommunity.com$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name photos.spanglishcommunity.com;
|
||||||
|
|
||||||
|
# Photos can be larger than typical JSON payloads
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Security
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/spanglish_photo_access.log;
|
||||||
|
error_log /var/log/nginx/spanglish_photo_error.log;
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
set $cors_origin "";
|
||||||
|
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||||
|
|
||||||
|
# Ensure 413 returns JSON + CORS
|
||||||
|
error_page 413 = @payload_too_large;
|
||||||
|
location @payload_too_large {
|
||||||
|
default_type application/json;
|
||||||
|
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure 429 (rate limited) returns JSON + CORS
|
||||||
|
error_page 429 = @rate_limited;
|
||||||
|
location @rate_limited {
|
||||||
|
default_type application/json;
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
return 429 '{"error":"Too many requests. Please slow down."}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
limit_req zone=spanglish_photo_limit burst=40 nodelay;
|
||||||
|
|
||||||
|
# Preflight
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||||
|
add_header 'Access-Control-Max-Age' 86400 always;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_pass http://spanglish_photo_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||||
|
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
|
||||||
|
# Buffer large image uploads to disk rather than memory
|
||||||
|
proxy_request_buffering on;
|
||||||
|
proxy_max_temp_file_size 1024m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Spanglish Community - Backend API
|
||||||
|
# api.spanglishcommunity.com
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name api.spanglishcommunity.com;
|
||||||
|
|
||||||
|
# ACME
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Force HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://api.spanglishcommunity.com$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name api.spanglishcommunity.com;
|
||||||
|
|
||||||
|
# Upload size limit (avoid nginx 413 on media uploads)
|
||||||
|
# Keep this >= backend MEDIA_MAX_UPLOAD_MB (default 10MB).
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Security (API)
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/spanglish_api_access.log;
|
||||||
|
error_log /var/log/nginx/spanglish_api_error.log;
|
||||||
|
|
||||||
|
# CORS Configuration (set once, used everywhere)
|
||||||
|
set $cors_origin "";
|
||||||
|
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add CORS headers to all responses (including nginx-generated errors)
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||||
|
|
||||||
|
# Ensure 413 returns JSON + CORS (browser otherwise reports "CORS blocked")
|
||||||
|
error_page 413 = @payload_too_large;
|
||||||
|
location @payload_too_large {
|
||||||
|
default_type application/json;
|
||||||
|
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Photo gallery service (photo-api, port 3003). ^~ wins over the "/"
|
||||||
|
# prefix below, so /api/photos/* reaches the Go photo-api instead of the
|
||||||
|
# Node backend (which has no photo routes and would 404). The admin UI
|
||||||
|
# calls this cross-origin via NEXT_PUBLIC_API_URL, so preflight + CORS
|
||||||
|
# must be handled here just like location / below.
|
||||||
|
location ^~ /api/photos/ {
|
||||||
|
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||||
|
|
||||||
|
# Handle preflight OPTIONS requests (add_header inside if{} does NOT
|
||||||
|
# inherit server-level headers, so repeat all CORS headers here).
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||||
|
add_header 'Access-Control-Max-Age' 86400 always;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_pass http://spanglish_photo_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Strip CORS headers from the service (nginx handles CORS here)
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||||
|
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||||
|
|
||||||
|
# Photo batches can be large; allow bigger bodies + unbuffered upload.
|
||||||
|
client_max_body_size 100m;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
limit_req zone=spanglish_api_limit burst=50 nodelay;
|
||||||
|
|
||||||
|
# Handle preflight OPTIONS requests
|
||||||
|
# NOTE: add_header inside if{} does NOT inherit server-level headers,
|
||||||
|
# so we must repeat all CORS headers here.
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||||
|
add_header 'Access-Control-Max-Age' 86400 always;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_pass http://spanglish_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Strip CORS headers from backend (nginx handles CORS at server level)
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||||
|
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Spanglish Community - Frontend
|
||||||
|
# spanglishcommunity.com / www
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||||
|
|
||||||
|
# ACME
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Force HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://spanglishcommunity.com$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name spanglishcommunity.com www.spanglishcommunity.com;
|
||||||
|
|
||||||
|
# Upload size limit (covers same-origin /api uploads via this vhost)
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
ssl_certificate /etc/letsencrypt/live/spanglishcommunity.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/spanglishcommunity.com/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Security
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/spanglish_frontend_access.log;
|
||||||
|
error_log /var/log/nginx/spanglish_frontend_error.log;
|
||||||
|
|
||||||
|
# Proxy /api/photos to the photo-api (Go service, port 3003). ^~ wins over
|
||||||
|
# the /api prefix below so same-origin image/gallery requests reach the
|
||||||
|
# photo-api instead of the Node backend (which has no photo routes -> 404).
|
||||||
|
location ^~ /api/photos/ {
|
||||||
|
proxy_pass http://spanglish_photo_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Photo uploads/batches can be large; allow bigger bodies.
|
||||||
|
client_max_body_size 100m;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
|
||||||
|
# Let the photo-api set Cache-Control per image visibility (public vs.
|
||||||
|
# token-gated) rather than forcing a cache policy here.
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy /api to backend
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://spanglish_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy /uploads to backend
|
||||||
|
location /uploads {
|
||||||
|
proxy_pass http://spanglish_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Cache static files
|
||||||
|
proxy_cache_valid 200 1d;
|
||||||
|
expires 1d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend App
|
||||||
|
location / {
|
||||||
|
proxy_pass http://spanglish_frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket / HMR
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Spanglish Community - Photo Gallery API
|
||||||
|
# photos.spanglishcommunity.com
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name photos.spanglishcommunity.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://photos.spanglishcommunity.com$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name photos.spanglishcommunity.com;
|
||||||
|
|
||||||
|
# Photos can be larger than typical JSON payloads
|
||||||
|
client_max_body_size 25m;
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
ssl_certificate /etc/letsencrypt/live/photos.spanglishcommunity.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/photos.spanglishcommunity.com/privkey.pem;
|
||||||
|
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Security
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
access_log /var/log/nginx/spanglish_photo_access.log;
|
||||||
|
error_log /var/log/nginx/spanglish_photo_error.log;
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
set $cors_origin "";
|
||||||
|
if ($http_origin ~* "^https://(www\.)?spanglishcommunity\.com$") {
|
||||||
|
set $cors_origin $http_origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||||
|
|
||||||
|
# Ensure 413 returns JSON + CORS
|
||||||
|
error_page 413 = @payload_too_large;
|
||||||
|
location @payload_too_large {
|
||||||
|
default_type application/json;
|
||||||
|
return 413 '{"error":"Payload too large (413). Please upload a smaller file."}';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure 429 (rate limited) returns JSON + CORS
|
||||||
|
error_page 429 = @rate_limited;
|
||||||
|
location @rate_limited {
|
||||||
|
default_type application/json;
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
return 429 '{"error":"Too many requests. Please slow down."}';
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
limit_req zone=spanglish_photo_limit burst=40 nodelay;
|
||||||
|
|
||||||
|
# Preflight
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin' $cors_origin always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
|
||||||
|
add_header 'Access-Control-Max-Age' 86400 always;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy_pass http://spanglish_photo_api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Origin';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Methods';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Headers';
|
||||||
|
proxy_hide_header 'Access-Control-Allow-Credentials';
|
||||||
|
proxy_hide_header 'Access-Control-Expose-Headers';
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
|
||||||
|
# Buffer large image uploads to disk rather than memory
|
||||||
|
proxy_request_buffering on;
|
||||||
|
proxy_max_temp_file_size 1024m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
upstream spanglish_frontend {
|
||||||
|
server 127.0.0.1:3019;
|
||||||
|
}
|
||||||
|
upstream spanglish_backend {
|
||||||
|
server 127.0.0.1:3018;
|
||||||
|
}
|
||||||
|
upstream spanglish_photo_api {
|
||||||
|
server 127.0.0.1:3003;
|
||||||
|
}
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=spanglish_photo_limit:10m rate=20r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=spanglish_api_limit:10m rate=30r/s;
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"dev": "dotenv -e .env -- next dev",
|
"dev": "dotenv -e .env -- next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "dotenv -e .env -- next start",
|
"start": "dotenv -e .env -- next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.1.4",
|
"@heroicons/react": "^2.1.4",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.5.2"
|
"typescript": "^5.5.2",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut, isPresaleClosed } from '@/lib/utils';
|
||||||
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
import { isSafeExternalUrl } from '@/lib/safeRedirect';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import type {
|
import type {
|
||||||
@@ -108,6 +108,14 @@ export default function BookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-sale closure: the booking API rejects after the cutoff, so
|
||||||
|
// bounce back to the event page instead of showing a dead form.
|
||||||
|
if (isPresaleClosed(eventRes.event)) {
|
||||||
|
toast.error(t('events.details.registrationClosed'));
|
||||||
|
router.push(`/events/${eventRes.event.slug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Server-authoritative availability — same formula the booking API
|
// Server-authoritative availability — same formula the booking API
|
||||||
// enforces, so a sold-out event is caught here, not at submit time.
|
// enforces, so a sold-out event is caught here, not at submit time.
|
||||||
if (isEventSoldOut(eventRes.event)) {
|
if (isEventSoldOut(eventRes.event)) {
|
||||||
@@ -364,11 +372,18 @@ export default function BookingPage() {
|
|||||||
toast.success(t('booking.success.message'));
|
toast.success(t('booking.success.message'));
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const message = String(error?.message || '');
|
||||||
|
// Pre-sale closed while the form was open: send the user back to the
|
||||||
|
// event page, which now shows the "Registration Closed" state.
|
||||||
|
if (/registration .*closed/i.test(message)) {
|
||||||
|
toast.error(t('events.details.registrationClosed'));
|
||||||
|
if (event?.slug) router.push(`/events/${event.slug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
toast.error(error.message || t('booking.form.errors.bookingFailed'));
|
||||||
// Capacity race on the last seats: refresh availability so the page
|
// Capacity race on the last seats: refresh availability so the page
|
||||||
// reflects reality (sold-out block / lower quantity cap) instead of the
|
// reflects reality (sold-out block / lower quantity cap) instead of the
|
||||||
// stale counts loaded when the form was opened.
|
// stale counts loaded when the form was opened.
|
||||||
const message = String(error?.message || '');
|
|
||||||
if (/sold out|seats available/i.test(message)) {
|
if (/sold out|seats available/i.test(message)) {
|
||||||
try {
|
try {
|
||||||
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
|
const { event: freshEvent } = await eventsApi.getById(params.eventId as string);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { CalendarIcon, MapPinIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import type { MyEvent } from '@/lib/api';
|
||||||
|
|
||||||
|
/** Events the user was added to as staff, collaborator or co-manager. */
|
||||||
|
export default function MyEventsTab({ events }: { events: MyEvent[] }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
if (events.length === 0) {
|
||||||
|
return <p className="text-sm text-gray-500 py-8 text-center">{t('dashboard.myEvents.empty')}</p>;
|
||||||
|
}
|
||||||
|
const fmt = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{events.map(({ event, rolePreset }) => (
|
||||||
|
<li key={event.id}>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/events/${event.id}`}
|
||||||
|
className="flex items-center gap-4 rounded-card bg-white p-4 shadow-card transition-shadow hover:shadow-card-hover"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-primary-dark truncate">{(locale === 'es' && event.titleEs) || event.title}</p>
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||||
|
<span className="inline-flex items-center gap-1"><CalendarIcon className="h-3.5 w-3.5" />{fmt(event.startDatetime)}</span>
|
||||||
|
<span className="inline-flex items-center gap-1 min-w-0"><MapPinIcon className="h-3.5 w-3.5 flex-shrink-0" /><span className="truncate">{event.location}</span></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 whitespace-nowrap">
|
||||||
|
{t(`admin.rolePresets.${rolePreset}`)}
|
||||||
|
</span>
|
||||||
|
<ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-gray-400" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
|
|||||||
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
|
lightning: { en: 'Lightning (Bitcoin)', es: 'Lightning (Bitcoin)' },
|
||||||
cash: { en: 'Cash', es: 'Efectivo' },
|
cash: { en: 'Cash', es: 'Efectivo' },
|
||||||
bancard: { en: 'Card', es: 'Tarjeta' },
|
bancard: { en: 'Card', es: 'Tarjeta' },
|
||||||
|
pos: { en: 'POS', es: 'POS' },
|
||||||
};
|
};
|
||||||
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
|
return labels[provider]?.[locale === 'es' ? 'es' : 'en'] || provider;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { PrivacyProvider } from '@/context/PrivacyContext';
|
||||||
|
import { EventDetailView } from '@/app/admin/events/[id]/_components/EventDetailView';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single event page for team members (staff / collaborators / co-managers
|
||||||
|
* added in the event's Team tab). Same view as the admin page; tabs and data
|
||||||
|
* follow the member's permissions on this event only.
|
||||||
|
*/
|
||||||
|
export default function MemberEventPage() {
|
||||||
|
const params = useParams();
|
||||||
|
return (
|
||||||
|
<PrivacyProvider>
|
||||||
|
<div className="section-padding min-h-[70vh]">
|
||||||
|
<div className="container-page">
|
||||||
|
<EventDetailView eventId={params.id as string} backHref="/dashboard?tab=events" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PrivacyProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import {
|
import {
|
||||||
dashboardApi,
|
dashboardApi,
|
||||||
|
financeApi,
|
||||||
NextEventInfo,
|
NextEventInfo,
|
||||||
UserTicket,
|
UserTicket,
|
||||||
UserPayment,
|
UserPayment,
|
||||||
|
type MyEvent,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { CardListSkeleton } from '@/components/ui/Skeleton';
|
import { CardListSkeleton } from '@/components/ui/Skeleton';
|
||||||
@@ -17,20 +19,28 @@ import OverviewTab from './components/OverviewTab';
|
|||||||
import TicketsTab from './components/TicketsTab';
|
import TicketsTab from './components/TicketsTab';
|
||||||
import PaymentsTab from './components/PaymentsTab';
|
import PaymentsTab from './components/PaymentsTab';
|
||||||
import AccountTab from './components/AccountTab';
|
import AccountTab from './components/AccountTab';
|
||||||
|
import MyEventsTab from './components/MyEventsTab';
|
||||||
|
|
||||||
type Tab = 'overview' | 'tickets' | 'payments' | 'account';
|
type Tab = 'overview' | 'tickets' | 'payments' | 'events' | 'account';
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { locale } = useLanguage();
|
const { locale, t } = useLanguage();
|
||||||
const { user, isLoading: authLoading } = useAuth();
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
const [nextEvent, setNextEvent] = useState<NextEventInfo | null>(null);
|
||||||
const [tickets, setTickets] = useState<UserTicket[]>([]);
|
const [tickets, setTickets] = useState<UserTicket[]>([]);
|
||||||
const [payments, setPayments] = useState<UserPayment[]>([]);
|
const [payments, setPayments] = useState<UserPayment[]>([]);
|
||||||
|
const [myEvents, setMyEvents] = useState<MyEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// ?tab=events (the back link from a team event page) opens My Events.
|
||||||
|
useEffect(() => {
|
||||||
|
const tab = new URLSearchParams(window.location.search).get('tab');
|
||||||
|
if (tab === 'events') setActiveTab('events');
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
router.push('/login');
|
router.push('/login');
|
||||||
@@ -47,14 +57,17 @@ export default function DashboardPage() {
|
|||||||
const loadDashboardData = async () => {
|
const loadDashboardData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [nextEventRes, ticketsRes, paymentsRes] = await Promise.all([
|
const [nextEventRes, ticketsRes, paymentsRes, myEventsRes] = await Promise.all([
|
||||||
dashboardApi.getNextEvent(),
|
dashboardApi.getNextEvent(),
|
||||||
dashboardApi.getTickets(),
|
dashboardApi.getTickets(),
|
||||||
dashboardApi.getPayments(),
|
dashboardApi.getPayments(),
|
||||||
|
// Team memberships are optional extra; never fail the dashboard over them.
|
||||||
|
financeApi.myEvents().catch(() => ({ events: [] as MyEvent[] })),
|
||||||
]);
|
]);
|
||||||
setNextEvent(nextEventRes.nextEvent);
|
setNextEvent(nextEventRes.nextEvent);
|
||||||
setTickets(ticketsRes.tickets);
|
setTickets(ticketsRes.tickets);
|
||||||
setPayments(paymentsRes.payments);
|
setPayments(paymentsRes.payments);
|
||||||
|
setMyEvents(myEventsRes.events);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard:', error);
|
console.error('Failed to load dashboard:', error);
|
||||||
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
|
toast.error(locale === 'es' ? 'Error al cargar el panel' : 'Failed to load dashboard data');
|
||||||
@@ -67,6 +80,8 @@ export default function DashboardPage() {
|
|||||||
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
|
{ id: 'overview', label: { en: 'Overview', es: 'Resumen' } },
|
||||||
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
|
{ id: 'tickets', label: { en: 'Tickets', es: 'Entradas' } },
|
||||||
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
|
{ id: 'payments', label: { en: 'Payments', es: 'Pagos' } },
|
||||||
|
// Only for people on at least one event team
|
||||||
|
...(myEvents.length > 0 ? [{ id: 'events' as Tab, label: { en: t('dashboard.myEvents.tab'), es: t('dashboard.myEvents.tab') } }] : []),
|
||||||
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
|
{ id: 'account', label: { en: 'Account', es: 'Cuenta' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -125,6 +140,7 @@ export default function DashboardPage() {
|
|||||||
{activeTab === 'payments' && (
|
{activeTab === 'payments' && (
|
||||||
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
|
<PaymentsTab payments={payments} language={locale} onChange={loadDashboardData} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === 'events' && <MyEventsTab events={myEvents} />}
|
||||||
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
|
{activeTab === 'account' && <AccountTab onUpdate={loadDashboardData} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
|
|||||||
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
|
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// An event that has started but still sells at the door stays under Upcoming.
|
||||||
|
const isUpcoming = (e: Event) =>
|
||||||
|
new Date(e.startDatetime) >= now || e.salesState === 'door';
|
||||||
const upcomingEvents = initialEvents.filter(e =>
|
const upcomingEvents = initialEvents.filter(e =>
|
||||||
e.status === 'published' && new Date(e.startDatetime) >= now
|
e.status === 'published' && isUpcoming(e)
|
||||||
);
|
);
|
||||||
const pastEvents = initialEvents.filter(e =>
|
const pastEvents = initialEvents.filter(e =>
|
||||||
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
|
e.status === 'completed' || (e.status === 'published' && !isUpcoming(e))
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
|
||||||
@@ -34,9 +37,16 @@ export default function EventsClient({ initialEvents }: { initialEvents: Event[]
|
|||||||
if (event.status === 'cancelled') {
|
if (event.status === 'cancelled') {
|
||||||
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
|
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
|
||||||
}
|
}
|
||||||
if (event.availableSeats === 0) {
|
if (event.salesState === 'sold_out' || event.availableSeats === 0) {
|
||||||
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
|
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
|
||||||
}
|
}
|
||||||
|
if (event.salesState === 'door') {
|
||||||
|
return (
|
||||||
|
<span className="badge bg-primary-yellow/20 text-primary-dark whitespace-nowrap">
|
||||||
|
{t('events.door.badge')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut, isPresaleClosed, parseDate, formatDurationWords, nextSalesStateChangeMs } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import ShareButtons from '@/components/ShareButtons';
|
import ShareButtons from '@/components/ShareButtons';
|
||||||
@@ -24,6 +24,10 @@ interface EventDetailClientProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TICKETS_PER_PERSON = 5;
|
const MAX_TICKETS_PER_PERSON = 5;
|
||||||
|
// setTimeout overflows past ~24.8 days; farther flips are caught on a later visit.
|
||||||
|
const MAX_TIMER_MS = 2_147_483_647;
|
||||||
|
// Display order of door tenders ("card" is the POS terminal; never a provider name).
|
||||||
|
const DOOR_METHOD_ORDER = ['cash', 'pos', 'bitcoin', 'transfer'] as const;
|
||||||
|
|
||||||
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
@@ -36,13 +40,50 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Refresh event data on client for real-time availability
|
// Server clock minus ours, so the refresh below fires on the server's schedule.
|
||||||
useEffect(() => {
|
const [clockOffsetMs, setClockOffsetMs] = useState(0);
|
||||||
|
|
||||||
|
const refreshEvent = useCallback(() => {
|
||||||
|
const sentAt = Date.now();
|
||||||
eventsApi.getById(eventId)
|
eventsApi.getById(eventId)
|
||||||
.then(({ event }) => setEvent(event))
|
.then(({ event, serverTime }) => {
|
||||||
|
setEvent(event);
|
||||||
|
if (serverTime) {
|
||||||
|
const receivedAt = Date.now();
|
||||||
|
setClockOffsetMs(parseDate(serverTime).getTime() - (sentAt + receivedAt) / 2);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
|
// Refresh event data on client for real-time availability. The server-rendered
|
||||||
|
// copy can be up to a minute old (fetch revalidate), so this also corrects a
|
||||||
|
// stale sales state right after load.
|
||||||
|
useEffect(() => {
|
||||||
|
refreshEvent();
|
||||||
|
}, [refreshEvent]);
|
||||||
|
|
||||||
|
// salesState flips on the clock (online → door at presaleClosesAt, door →
|
||||||
|
// ended at the end time) without any edit to the event: refetch at that moment
|
||||||
|
// so an open page stops offering online booking. Overdue flips retry after 3s.
|
||||||
|
useEffect(() => {
|
||||||
|
const changeAt = nextSalesStateChangeMs(event);
|
||||||
|
if (changeAt === null) return;
|
||||||
|
const delay = Math.max(changeAt - (Date.now() + clockOffsetMs) + 1000, 3000);
|
||||||
|
if (delay > MAX_TIMER_MS) return;
|
||||||
|
const timer = setTimeout(refreshEvent, delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [event, clockOffsetMs, refreshEvent]);
|
||||||
|
|
||||||
|
// Phones suspend timers in background tabs; catch up when the page is shown again.
|
||||||
|
useEffect(() => {
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible') refreshEvent();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
}, [refreshEvent]);
|
||||||
|
|
||||||
// Server-authoritative availability (paid + claimed seats count; abandoned
|
// Server-authoritative availability (paid + claimed seats count; abandoned
|
||||||
// pending bookings don't) — matches the booking API's sold-out check exactly.
|
// pending bookings don't) — matches the booking API's sold-out check exactly.
|
||||||
const spotsLeft = eventSpotsLeft(event);
|
const spotsLeft = eventSpotsLeft(event);
|
||||||
@@ -67,12 +108,78 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
|
|
||||||
const isCancelled = event.status === 'cancelled';
|
const isCancelled = event.status === 'cancelled';
|
||||||
|
// Server-computed sales state (backend lib/salesState.ts). External booking
|
||||||
|
// events, and any cached copy from before salesState existed, keep the
|
||||||
|
// original client-side rules below.
|
||||||
|
const salesState = event.salesState;
|
||||||
|
const legacyRules = !salesState || salesState === 'external';
|
||||||
|
const isDoorSales = salesState === 'door' && typeof event.doorPrice === 'number';
|
||||||
|
const hasEnded = salesState === 'ended';
|
||||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||||
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
const isPastEvent = legacyRules ? (mounted ? new Date(event.startDatetime) < new Date() : false) : hasEnded;
|
||||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
|
// Pre-sale closure (server-computed cutoff); same mount guard as isPastEvent
|
||||||
|
const presaleClosed = legacyRules ? (mounted ? isPresaleClosed(event) : false) : salesState === 'door';
|
||||||
|
const canBook = legacyRules
|
||||||
|
? !isSoldOut && !isCancelled && !isPastEvent && !presaleClosed && (event.status === 'published' || event.status === 'unlisted')
|
||||||
|
: salesState === 'online';
|
||||||
|
// Effective lead time (event override or site default), derived from the server cutoff
|
||||||
|
const presaleLeadMinutes = event.presaleClosesAt
|
||||||
|
? Math.max(0, Math.round((parseDate(event.startDatetime).getTime() - parseDate(event.presaleClosesAt).getTime()) / 60_000))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const spotsLeftText = spotsLeft === 1
|
||||||
|
? t('events.door.spotsLeftOne')
|
||||||
|
: t('events.door.spotsLeft', { n: spotsLeft });
|
||||||
|
const doorMethodsText = DOOR_METHOD_ORDER
|
||||||
|
.filter((method) => event.doorPaymentMethods?.includes(method))
|
||||||
|
.map((method) => t(`events.door.methods.${method}`))
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
// Online sales closed but seats left: people can still come and pay at the
|
||||||
|
// door. Informational only — there is nothing to click.
|
||||||
|
const DoorSalesBlock = () => {
|
||||||
|
const doorPrice = event.doorPrice ?? event.price;
|
||||||
|
return (
|
||||||
|
<div className="rounded-card bg-primary-yellow/15 border border-primary-yellow/40 px-5 py-6 text-center">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
{t('events.door.onlineClosed')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-primary-dark">
|
||||||
|
{t('events.door.headline')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-4xl font-bold text-primary-dark">
|
||||||
|
{doorPrice === 0 ? t('events.details.free') : formatPrice(doorPrice, event.currency)}
|
||||||
|
</p>
|
||||||
|
{doorPrice > 0 && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{t('events.door.perPerson')}</p>
|
||||||
|
)}
|
||||||
|
{doorPrice > event.price && event.price > 0 && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
{t('events.door.onlinePriceWas', { price: formatPrice(event.price, event.currency) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-sm font-medium text-primary-dark">{spotsLeftText}</p>
|
||||||
|
{doorMethodsText && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{t('events.door.accepts', { methods: doorMethodsText })}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-500" suppressHydrationWarning>
|
||||||
|
{t('events.door.doorsOpen', { time: fmtTime(event.startDatetime) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Booking card content - reused for mobile and desktop positions
|
// Booking card content - reused for mobile and desktop positions
|
||||||
const BookingCardContent = () => (
|
const BookingCardContent = () => {
|
||||||
|
if (isDoorSales) return <DoorSalesBlock />;
|
||||||
|
if (hasEnded) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-card bg-secondary-gray px-5 py-6 text-center">
|
||||||
|
<p className="text-lg font-semibold text-primary-dark">{t('events.details.eventHasEnded')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
<p className="text-sm text-gray-500">{t('events.details.price')}</p>
|
||||||
@@ -143,19 +250,32 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
<Button className="w-full" size="lg" disabled>
|
<Button className="w-full" size="lg" disabled>
|
||||||
{isPastEvent
|
{isPastEvent
|
||||||
? t('events.details.eventEnded')
|
? t('events.details.eventEnded')
|
||||||
|
: presaleClosed
|
||||||
|
? t('events.details.registrationClosed')
|
||||||
: isSoldOut
|
: isSoldOut
|
||||||
? t('events.details.soldOut')
|
? t('events.details.soldOut')
|
||||||
: t('events.details.cancelled')}
|
: t('events.details.cancelled')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canBook && !event.externalBookingEnabled && presaleLeadMinutes !== null && (
|
||||||
|
<p className="mt-3 text-center text-xs text-gray-400">
|
||||||
|
{presaleLeadMinutes > 0
|
||||||
|
? t('events.details.presaleClosesBefore', {
|
||||||
|
duration: formatDurationWords(presaleLeadMinutes, locale as 'en' | 'es'),
|
||||||
|
})
|
||||||
|
: t('events.details.presaleClosesAtStart')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{!event.externalBookingEnabled && (
|
{!event.externalBookingEnabled && (
|
||||||
<p className="mt-4 text-center text-sm text-gray-500">
|
<p className="mt-4 text-center text-sm text-gray-500">
|
||||||
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding">
|
<div className="section-padding">
|
||||||
@@ -254,7 +374,9 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
<p className="font-medium text-sm">{t('events.details.capacity')}</p>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}
|
{isDoorSales
|
||||||
|
? t('events.door.spotsLeftAtDoor', { n: spotsLeft })
|
||||||
|
: `${spotsLeft} / ${event.capacity} ${t('events.details.spotsLeft')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
|
||||||
|
|
||||||
|
// Shown while the server component fetches the event gallery.
|
||||||
|
export default function Loading() {
|
||||||
|
return <GallerySkeleton />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { notFound, permanentRedirect } from 'next/navigation';
|
import { notFound, permanentRedirect } from 'next/navigation';
|
||||||
import EventDetailClient from './EventDetailClient';
|
import EventDetailClient from './EventDetailClient';
|
||||||
|
import type { EventSalesState } from '@/lib/api';
|
||||||
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
@@ -25,6 +26,8 @@ interface Event {
|
|||||||
bannerUrl?: string;
|
bannerUrl?: string;
|
||||||
availableSeats?: number;
|
availableSeats?: number;
|
||||||
bookedCount?: number;
|
bookedCount?: number;
|
||||||
|
presaleClosesAt?: string | null;
|
||||||
|
salesState?: EventSalesState;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -113,18 +116,23 @@ function generateEventJsonLd(event: Event) {
|
|||||||
name: 'Spanglish',
|
name: 'Spanglish',
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
},
|
},
|
||||||
|
// The offer is always the online price; the door price is never published
|
||||||
|
// here. In the `door` state seats can still be bought (at the door), so it
|
||||||
|
// stays InStock and drops the pre-sale validThrough, which is in the past.
|
||||||
offers: {
|
offers: {
|
||||||
'@type': 'Offer',
|
'@type': 'Offer',
|
||||||
price: event.price,
|
price: event.price,
|
||||||
priceCurrency: event.currency,
|
priceCurrency: event.currency,
|
||||||
availability:
|
availability:
|
||||||
|
event.salesState === 'sold_out' ||
|
||||||
(typeof event.availableSeats === 'number'
|
(typeof event.availableSeats === 'number'
|
||||||
? event.availableSeats
|
? event.availableSeats
|
||||||
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0
|
: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) <= 0
|
||||||
? 'https://schema.org/InStock'
|
? 'https://schema.org/SoldOut'
|
||||||
: 'https://schema.org/SoldOut',
|
: 'https://schema.org/InStock',
|
||||||
url: `${siteUrl}/events/${event.slug}`,
|
url: `${siteUrl}/events/${event.slug}`,
|
||||||
validFrom: new Date().toISOString(),
|
validFrom: new Date().toISOString(),
|
||||||
|
...(event.presaleClosesAt && event.salesState !== 'door' ? { validThrough: event.presaleClosesAt } : {}),
|
||||||
},
|
},
|
||||||
image: event.bannerUrl
|
image: event.bannerUrl
|
||||||
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
|
||||||
|
|||||||
@@ -7,11 +7,18 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { ImageGridSkeleton } from '@/components/ui/Skeleton';
|
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
|
||||||
|
import PhotoTile from '@/components/gallery/PhotoTile';
|
||||||
|
import {
|
||||||
|
GalleryContainer,
|
||||||
|
GalleryHeroFrame,
|
||||||
|
MasonryGrid,
|
||||||
|
} from '@/components/gallery/GalleryLayout';
|
||||||
|
import { useDownloads } from '@/components/gallery/useDownloads';
|
||||||
|
import SaveSheet, { SavePhoto, useMobileSave } from '@/components/gallery/SaveSheet';
|
||||||
import Lightbox from '@/components/Lightbox';
|
import Lightbox from '@/components/Lightbox';
|
||||||
import LoginModal from '@/components/LoginModal';
|
import LoginModal from '@/components/LoginModal';
|
||||||
import {
|
import {
|
||||||
ArrowDownTrayIcon,
|
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
CameraIcon,
|
CameraIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
@@ -42,6 +49,35 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
const [denied, setDenied] = useState<DeniedState>(null);
|
const [denied, setDenied] = useState<DeniedState>(null);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const [loginOpen, setLoginOpen] = useState(false);
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
const downloads = useDownloads(es);
|
||||||
|
const downloadLabels = {
|
||||||
|
download: es ? 'Descargar' : 'Download',
|
||||||
|
downloading: es ? 'Descargando…' : 'Downloading…',
|
||||||
|
};
|
||||||
|
|
||||||
|
// On a phone a download cannot reach the photo library, so the button
|
||||||
|
// opens a sheet offering the shareable preview instead (SaveSheet).
|
||||||
|
// Desktop keeps downloading the original straight away.
|
||||||
|
const mobileSave = useMobileSave();
|
||||||
|
const [savePhoto, setSavePhoto] = useState<SavePhoto | null>(null);
|
||||||
|
|
||||||
|
const downloadPhoto = (photo: Photo) => {
|
||||||
|
if (mobileSave) {
|
||||||
|
setSavePhoto({
|
||||||
|
id: photo.id,
|
||||||
|
previewUrl: photo.urls.download || photo.urls.preview || photo.urls.original,
|
||||||
|
originalUrl: photo.urls.downloadOriginal || photo.urls.original,
|
||||||
|
previewSize: photo.previewSizeBytes,
|
||||||
|
originalSize: photo.sizeBytes,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloads.start({
|
||||||
|
id: photo.id,
|
||||||
|
url: photo.urls.original,
|
||||||
|
filename: photo.originalFilename,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Server-rendered public galleries need no client fetch. Everything else
|
// Server-rendered public galleries need no client fetch. Everything else
|
||||||
// (link/ticket/private) is fetched here with the share token and/or the
|
// (link/ticket/private) is fetched here with the share token and/or the
|
||||||
@@ -78,14 +114,10 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
};
|
};
|
||||||
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
|
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
|
||||||
|
|
||||||
|
// Mirrors the hero + masonry layout below, so the real page drops straight
|
||||||
|
// into the placeholder's geometry instead of replacing it.
|
||||||
if (loading || (authLoading && !initial)) {
|
if (loading || (authLoading && !initial)) {
|
||||||
return (
|
return <GallerySkeleton count={gallery?.photoCount || undefined} />;
|
||||||
<div className="section-padding">
|
|
||||||
<div className="container-page">
|
|
||||||
<ImageGridSkeleton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gate pages for restricted galleries. After a successful login in the
|
// Gate pages for restricted galleries. After a successful login in the
|
||||||
@@ -222,10 +254,12 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
}
|
}
|
||||||
|
|
||||||
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||||||
|
// previewUrl is the download endpoint's preview: rendering and saving from
|
||||||
|
// the same URL is what lets "Save photo" be answered by the HTTP cache.
|
||||||
const lightboxItems = readyPhotos.map((p) => ({
|
const lightboxItems = readyPhotos.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
previewUrl: p.urls.preview || p.urls.original,
|
previewUrl: p.urls.download || p.urls.preview || p.urls.original,
|
||||||
downloadUrl: p.urls.original,
|
downloadUrl: p.urls.downloadOriginal || p.urls.original,
|
||||||
filename: p.originalFilename,
|
filename: p.originalFilename,
|
||||||
thumbUrl: p.urls.thumb,
|
thumbUrl: p.urls.thumb,
|
||||||
}));
|
}));
|
||||||
@@ -250,51 +284,56 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
// first photo. Falls back to a navy gradient with no image.
|
// first photo. Falls back to a navy gradient with no image.
|
||||||
const coverPhoto =
|
const coverPhoto =
|
||||||
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
||||||
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
|
// Same preview URL the lightbox uses, so the cover photo's bytes are
|
||||||
|
// fetched once for both.
|
||||||
|
const heroUrl = coverPhoto
|
||||||
|
? coverPhoto.urls.download || coverPhoto.urls.preview || coverPhoto.urls.thumb
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Hero */}
|
{/* Hero — same frame the skeleton renders (GalleryLayout). */}
|
||||||
<div className="relative bg-brand-navy overflow-hidden">
|
<GalleryHeroFrame
|
||||||
{heroUrl && (
|
backdrop={
|
||||||
<>
|
heroUrl ? (
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<>
|
||||||
<img
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
src={heroUrl}
|
<img
|
||||||
alt=""
|
src={heroUrl}
|
||||||
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
alt=""
|
||||||
/>
|
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
|
/>
|
||||||
</>
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
|
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||||
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
|
||||||
{title}
|
<CameraIcon className="w-4 h-4" />
|
||||||
</h1>
|
{readyPhotos.length} {es ? 'fotos' : 'photos'}
|
||||||
{description && (
|
</span>
|
||||||
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
|
{gallery.event && (
|
||||||
|
<Link
|
||||||
|
href={`/events/${gallery.event.slug}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
|
||||||
|
>
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
{eventTitle}
|
||||||
|
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
|
|
||||||
<CameraIcon className="w-4 h-4" />
|
|
||||||
{readyPhotos.length} {es ? 'fotos' : 'photos'}
|
|
||||||
</span>
|
|
||||||
{gallery.event && (
|
|
||||||
<Link
|
|
||||||
href={`/events/${gallery.event.slug}`}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
|
|
||||||
>
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
{eventTitle}
|
|
||||||
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GalleryHeroFrame>
|
||||||
|
|
||||||
{/* Masonry grid */}
|
{/* Masonry grid */}
|
||||||
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
|
<GalleryContainer>
|
||||||
{readyPhotos.length === 0 ? (
|
{readyPhotos.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||||
@@ -303,46 +342,19 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
|
<MasonryGrid>
|
||||||
{readyPhotos.map((photo, i) => (
|
{readyPhotos.map((photo, i) => (
|
||||||
<div
|
<PhotoTile
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
role="button"
|
photo={photo}
|
||||||
tabIndex={0}
|
eager={i < 8}
|
||||||
onClick={() => setLightboxIndex(i)}
|
onOpen={() => setLightboxIndex(i)}
|
||||||
onKeyDown={(e) => {
|
onDownload={() => downloadPhoto(photo)}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
downloading={downloads.isPending(photo.id)}
|
||||||
e.preventDefault();
|
labels={downloadLabels}
|
||||||
setLightboxIndex(i);
|
/>
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
||||||
style={
|
|
||||||
photo.width && photo.height
|
|
||||||
? { aspectRatio: `${photo.width} / ${photo.height}` }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={photo.urls.thumb}
|
|
||||||
alt=""
|
|
||||||
loading={i < 8 ? 'eager' : 'lazy'}
|
|
||||||
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
|
||||||
<a
|
|
||||||
href={photo.urls.original}
|
|
||||||
download={photo.originalFilename || true}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
|
|
||||||
aria-label={es ? 'Descargar' : 'Download'}
|
|
||||||
>
|
|
||||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</MasonryGrid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lightboxIndex !== null && (
|
{lightboxIndex !== null && (
|
||||||
@@ -351,9 +363,21 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
|
|||||||
index={lightboxIndex}
|
index={lightboxIndex}
|
||||||
onClose={() => setLightboxIndex(null)}
|
onClose={() => setLightboxIndex(null)}
|
||||||
onNavigate={setLightboxIndex}
|
onNavigate={setLightboxIndex}
|
||||||
|
onDownload={(it) => {
|
||||||
|
const photo = readyPhotos.find((p) => p.id === it.id);
|
||||||
|
if (photo) downloadPhoto(photo);
|
||||||
|
}}
|
||||||
|
downloadingId={
|
||||||
|
lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null
|
||||||
|
}
|
||||||
|
downloadLabels={downloadLabels}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* Shared by both entry points: the tile button and the lightbox's.
|
||||||
|
Fetches nothing until it is open. */}
|
||||||
|
<SaveSheet photo={savePhoto} onClose={() => setSavePhoto(null)} />
|
||||||
|
</GalleryContainer>
|
||||||
|
|
||||||
{/* Call to action: send attendees to their dashboard, everyone else to
|
{/* Call to action: send attendees to their dashboard, everyone else to
|
||||||
the next event. Auth state comes from the same useAuth() the gate
|
the next event. Auth state comes from the same useAuth() the gate
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
|
||||||
|
|
||||||
|
// Shown while the server component fetches the gallery, so the first paint is
|
||||||
|
// already the gallery's layout rather than an empty page.
|
||||||
|
export default function Loading() {
|
||||||
|
return <GallerySkeleton />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||||
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
@@ -8,6 +8,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
|
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
|
||||||
import {
|
import {
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@@ -51,6 +52,8 @@ export default function AdminBookingsPage() {
|
|||||||
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -174,6 +177,7 @@ export default function AdminBookingsPage() {
|
|||||||
lightning: 'Lightning',
|
lightning: 'Lightning',
|
||||||
tpago: 'TPago',
|
tpago: 'TPago',
|
||||||
bancard: 'Bancard',
|
bancard: 'Bancard',
|
||||||
|
pos: 'POS',
|
||||||
};
|
};
|
||||||
return labels[provider] || provider;
|
return labels[provider] || provider;
|
||||||
};
|
};
|
||||||
@@ -203,6 +207,19 @@ export default function AdminBookingsPage() {
|
|||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bookings are paginated client-side: the page already loads every ticket so
|
||||||
|
// that the stat cards, the group-booking totals and the sibling payment-method
|
||||||
|
// lookup can see the whole set, and those would break on a server-side slice.
|
||||||
|
const filterKey = JSON.stringify([selectedEvent, selectedStatus, selectedPaymentStatus, searchQuery]);
|
||||||
|
const prevFilterKey = useRef(filterKey);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFilterKey.current !== filterKey) {
|
||||||
|
prevFilterKey.current = filterKey;
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [filterKey]);
|
||||||
|
const pagedTickets = usePaginatedList(sortedTickets, page, pageSize, setPage);
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tickets.length,
|
total: tickets.length,
|
||||||
pending: tickets.filter(t => t.status === 'pending').length,
|
pending: tickets.filter(t => t.status === 'pending').length,
|
||||||
@@ -408,7 +425,7 @@ export default function AdminBookingsPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sortedTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const bookingInfo = getBookingInfo(ticket);
|
const bookingInfo = getBookingInfo(ticket);
|
||||||
return (
|
return (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||||
@@ -427,7 +444,10 @@ export default function AdminBookingsPage() {
|
|||||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||||
{ticket.payment?.status || 'pending'}
|
{ticket.payment?.status || 'pending'}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(getDisplayProvider(ticket))}</p>
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{getPaymentMethodLabel(getDisplayProvider(ticket))}
|
||||||
|
{ticket.bookingSource === 'walk_in' && (locale === 'es' ? ' · En puerta' : ' · Walk-in')}
|
||||||
|
</p>
|
||||||
{ticket.payment && (
|
{ticket.payment && (
|
||||||
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
||||||
)}
|
)}
|
||||||
@@ -502,7 +522,7 @@ export default function AdminBookingsPage() {
|
|||||||
No bookings found.
|
No bookings found.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sortedTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const bookingInfo = getBookingInfo(ticket);
|
const bookingInfo = getBookingInfo(ticket);
|
||||||
const primary = getPrimaryAction(ticket);
|
const primary = getPrimaryAction(ticket);
|
||||||
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
|
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
|
||||||
@@ -580,6 +600,15 @@ export default function AdminBookingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
id="bookings"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={sortedTickets.length}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile Filter BottomSheet */}
|
{/* Mobile Filter BottomSheet */}
|
||||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
|
// Loading placeholders for the single event page, one per region, so each part
|
||||||
|
// of the page can show as soon as its own data is in: the header once the event
|
||||||
|
// loads, the tab bar once permissions load, stats and tab bodies once tickets load.
|
||||||
|
|
||||||
|
/** Title, date line and action buttons; sits beside the real back arrow. */
|
||||||
|
export function EventHeaderSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 min-w-0 pt-1" aria-hidden="true">
|
||||||
|
<Skeleton className="h-7 w-2/3 md:w-80" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-40" />
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0" aria-hidden="true">
|
||||||
|
<Skeleton className="h-9 w-28 rounded-btn" />
|
||||||
|
<Skeleton className="h-9 w-20 rounded-btn" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventMetaChipsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]" aria-hidden="true">
|
||||||
|
<Skeleton className="h-6 w-40 rounded-full" />
|
||||||
|
<Skeleton className="h-6 w-32 rounded-full" />
|
||||||
|
<Skeleton className="h-6 w-20 rounded-full" />
|
||||||
|
<Skeleton className="h-6 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop 4-card stats row and the collapsed mobile stats bar. */
|
||||||
|
export function EventStatsSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hidden md:grid grid-cols-4 gap-3 mb-4" aria-hidden="true">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
|
||||||
|
<Skeleton className="w-8 h-8 rounded-full flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-5 w-16" />
|
||||||
|
<Skeleton className="mt-1 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="md:hidden mb-3" aria-hidden="true">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]">
|
||||||
|
<Skeleton className="h-3 w-10" />
|
||||||
|
<Skeleton className="h-4 w-14" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop tab strip (top of the content card) and the mobile segmented bar. */
|
||||||
|
export function EventTabBarSkeleton() {
|
||||||
|
const widths = ['w-20', 'w-24', 'w-16', 'w-14', 'w-20'];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200 px-4" aria-hidden="true">
|
||||||
|
<div className="flex gap-4 py-3">
|
||||||
|
{widths.map((w, i) => <Skeleton key={i} className={`h-5 ${w}`} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:hidden -mx-4 px-3 py-1.5 bg-gray-100" aria-hidden="true">
|
||||||
|
<div className="flex gap-1.5 overflow-hidden">
|
||||||
|
{widths.map((w, i) => <Skeleton key={i} className={`h-[44px] ${w} flex-shrink-0 bg-white/80`} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toolbar plus list rows, sized for the attendee/ticket lists inside a tab. */
|
||||||
|
export function EventTabBodySkeleton() {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup>
|
||||||
|
<div className="flex items-center gap-2 mb-4" aria-hidden="true">
|
||||||
|
<Skeleton className="h-10 flex-1 md:max-w-xs rounded-btn" />
|
||||||
|
<Skeleton className="h-10 w-24 rounded-btn hidden md:block" />
|
||||||
|
<Skeleton className="h-10 w-28 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100" aria-hidden="true">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 py-3">
|
||||||
|
<Skeleton className="w-9 h-9 rounded-full flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className={i % 2 === 0 ? 'h-4 w-1/3' : 'h-4 w-1/4'} />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-1/2 md:w-1/3" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-20 rounded-full hidden sm:block" />
|
||||||
|
<Skeleton className="h-8 w-20 rounded-btn" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wraps a tab body skeleton in the same card the real tab content sits in. */
|
||||||
|
export function EventTabContentSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
|
||||||
|
<EventTabBodySkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back arrow row shared by the loading shell and the real header. */
|
||||||
|
export function EventBackButton({ href }: { href: string }) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="mt-1 flex-shrink-0">
|
||||||
|
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<ArrowLeftIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-page shell before the event itself has loaded. The back arrow is real. */
|
||||||
|
export function EventDetailSkeleton({ backHref }: { backHref: string }) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-full overflow-x-hidden">
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<EventBackButton href={backHref} />
|
||||||
|
<EventHeaderSkeleton />
|
||||||
|
</div>
|
||||||
|
<EventMetaChipsSkeleton />
|
||||||
|
<EventStatsSkeleton />
|
||||||
|
<EventTabBarSkeleton />
|
||||||
|
<EventTabContentSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,875 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, financeApi, Ticket, type EventPermission } from '@/lib/api';
|
||||||
|
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
|
import {
|
||||||
|
CalendarIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
CurrencyDollarIcon,
|
||||||
|
UsersIcon,
|
||||||
|
TicketIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
PencilIcon,
|
||||||
|
EyeIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
EllipsisVerticalIcon,
|
||||||
|
ArrowUturnLeftIcon,
|
||||||
|
BanknotesIcon,
|
||||||
|
UserPlusIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
|
import type {
|
||||||
|
TabType,
|
||||||
|
AttendeeStatusFilter,
|
||||||
|
TicketStatusFilter,
|
||||||
|
RecipientFilter,
|
||||||
|
AddTicketType,
|
||||||
|
AddTicketFormState,
|
||||||
|
PrimaryAction,
|
||||||
|
} from '../_types';
|
||||||
|
import { formatCurrency, downloadBlob } from '../_utils/format';
|
||||||
|
import { useEventDetailData } from '../_hooks/useEventDetailData';
|
||||||
|
import { usePaymentOverrides } from '../_hooks/usePaymentOverrides';
|
||||||
|
import { OverviewTab } from '../_tabs/OverviewTab';
|
||||||
|
import { AttendeesTab } from '../_tabs/AttendeesTab';
|
||||||
|
import { TicketsTab } from '../_tabs/TicketsTab';
|
||||||
|
import { EmailTab } from '../_tabs/EmailTab';
|
||||||
|
import { PaymentsTab } from '../_tabs/PaymentsTab';
|
||||||
|
import { TeamTab } from '../_tabs/TeamTab';
|
||||||
|
import { FinanceTab } from '../_finance/FinanceTab';
|
||||||
|
import { EventModals } from '../_modals/EventModals';
|
||||||
|
import { AddTicketModal } from '../_modals/AddTicketModal';
|
||||||
|
import EventFormModal from '../../_components/EventFormModal';
|
||||||
|
import {
|
||||||
|
EventDetailSkeleton,
|
||||||
|
EventBackButton,
|
||||||
|
EventStatsSkeleton,
|
||||||
|
EventTabBarSkeleton,
|
||||||
|
EventTabBodySkeleton,
|
||||||
|
} from './EventDetailSkeleton';
|
||||||
|
|
||||||
|
/** Which permission each tab needs (see backend lib/eventPermissions.ts). */
|
||||||
|
const TAB_PERMISSION: Record<TabType, EventPermission> = {
|
||||||
|
overview: 'view_overview',
|
||||||
|
attendees: 'view_attendees_names',
|
||||||
|
tickets: 'view_attendees_names',
|
||||||
|
email: 'email_attendees',
|
||||||
|
payments: 'view_payments',
|
||||||
|
finance: 'view_finance',
|
||||||
|
team: 'manage_team',
|
||||||
|
};
|
||||||
|
const TAB_ORDER: TabType[] = ['overview', 'attendees', 'tickets', 'email', 'payments', 'finance', 'team'];
|
||||||
|
/** Tabs rendered from the tickets/templates/door data loaded by useEventDetailData
|
||||||
|
* (Overview only needs the event, and skeletons its own seat counts). */
|
||||||
|
const TABS_NEEDING_DETAILS: TabType[] = ['attendees', 'tickets', 'email', 'payments'];
|
||||||
|
|
||||||
|
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
|
||||||
|
type: 'paid',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
adminNote: '',
|
||||||
|
checkinNow: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single event page. Admin and organizer see it at /admin/events/[id];
|
||||||
|
* team members (event_members) see the same view at /dashboard/events/[id].
|
||||||
|
* Tabs, header actions and data loads follow the viewer's permissions on this
|
||||||
|
* event; the server enforces the same permissions on every call.
|
||||||
|
*/
|
||||||
|
export function EventDetailView({ eventId, backHref }: { eventId: string; backHref: string }) {
|
||||||
|
const { locale, t } = useLanguage();
|
||||||
|
|
||||||
|
const [permissions, setPermissions] = useState<Set<EventPermission> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
financeApi
|
||||||
|
.myPermissions(eventId)
|
||||||
|
.then((res) => setPermissions(new Set(res.permissions)))
|
||||||
|
.catch(() => setPermissions(new Set()));
|
||||||
|
}, [eventId]);
|
||||||
|
const can = (p: EventPermission) => !!permissions?.has(p);
|
||||||
|
|
||||||
|
const { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId, permissions);
|
||||||
|
const [activeTab, setActiveTabState] = useState<TabType>('overview');
|
||||||
|
// The open tab lives in the URL (?tab=finance) so reloads, the back button
|
||||||
|
// and shared links land on it. Switching tabs drops sub-tab params (?fin=).
|
||||||
|
const setActiveTab = (tab: TabType) => {
|
||||||
|
setActiveTabState(tab);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (tab === 'overview') url.searchParams.delete('tab'); else url.searchParams.set('tab', tab);
|
||||||
|
if (tab !== 'finance') url.searchParams.delete('fin');
|
||||||
|
window.history.replaceState(window.history.state, '', url);
|
||||||
|
};
|
||||||
|
// Land on the requested tab if allowed, otherwise the first tab the viewer may open.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!permissions) return;
|
||||||
|
const requested = new URLSearchParams(window.location.search).get('tab') as TabType | null;
|
||||||
|
if (requested && TAB_ORDER.includes(requested) && permissions.has(TAB_PERMISSION[requested])) {
|
||||||
|
setActiveTabState(requested);
|
||||||
|
} else if (!permissions.has(TAB_PERMISSION[activeTab])) {
|
||||||
|
const first = TAB_ORDER.find((k) => permissions.has(TAB_PERMISSION[k]));
|
||||||
|
if (first) setActiveTabState(first);
|
||||||
|
}
|
||||||
|
}, [permissions]);
|
||||||
|
// Keep the active tab visible in the horizontally scrolling mobile strip.
|
||||||
|
const mobileTabsRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Scroll only the strip itself: scrollIntoView would also shift the page sideways.
|
||||||
|
useEffect(() => {
|
||||||
|
const strip = mobileTabsRef.current;
|
||||||
|
const el = strip?.querySelector<HTMLElement>(`[data-tab="${activeTab}"]`);
|
||||||
|
if (strip && el) strip.scrollLeft = el.offsetLeft - (strip.clientWidth - el.clientWidth) / 2;
|
||||||
|
}, [activeTab, eventLoading, permissions]);
|
||||||
|
|
||||||
|
// Email state
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||||||
|
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('confirmed');
|
||||||
|
const [customMessage, setCustomMessage] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Attendees tab state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const showStats = !privacyMode;
|
||||||
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||||
|
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
// Unified Add Ticket modal (paid / door / unpaid / guest via segmented control)
|
||||||
|
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
|
||||||
|
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const openAddTicket = (type: AddTicketType) => {
|
||||||
|
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
|
||||||
|
setShowAddTicketModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
||||||
|
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
|
||||||
|
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
|
||||||
|
const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop
|
||||||
|
const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
// Add Ticket — separate desktop dropdown vs mobile bottom sheet
|
||||||
|
const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop
|
||||||
|
const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB
|
||||||
|
|
||||||
|
// Tickets tab state
|
||||||
|
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
||||||
|
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>('all');
|
||||||
|
|
||||||
|
// Payment options state + handlers
|
||||||
|
const payments = usePaymentOverrides(eventId, locale);
|
||||||
|
|
||||||
|
// Edit event modal (opens in place instead of redirecting to the list page)
|
||||||
|
const [showEditForm, setShowEditForm] = useState(false);
|
||||||
|
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
siteSettingsApi
|
||||||
|
.get()
|
||||||
|
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Mobile-specific state
|
||||||
|
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Tab bar ref for sticky
|
||||||
|
const tabBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'payments') {
|
||||||
|
payments.loadPaymentOptions();
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
|
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
|
||||||
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
|
|
||||||
|
const getTicketsByStatus = (status: string) => {
|
||||||
|
return tickets.filter(t => t.status === status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFilteredRecipientCount = () => {
|
||||||
|
if (recipientFilter === 'all') return tickets.length;
|
||||||
|
return getTicketsByStatus(recipientFilter).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkPaid = async (ticketId: string) => {
|
||||||
|
try {
|
||||||
|
await ticketsApi.markPaid(ticketId);
|
||||||
|
toast.success('Payment marked as received');
|
||||||
|
loadEventData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to mark payment');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckin = async (ticketId: string) => {
|
||||||
|
try {
|
||||||
|
await ticketsApi.checkin(ticketId);
|
||||||
|
toast.success('Attendee checked in');
|
||||||
|
loadEventData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to check in');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReactivate = async (ticket: Ticket) => {
|
||||||
|
if (!ticket.payment?.id) return;
|
||||||
|
try {
|
||||||
|
await paymentsApi.reactivate(ticket.payment.id);
|
||||||
|
toast.success('Booking reactivated');
|
||||||
|
loadEventData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to reactivate booking');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCheckin = async (ticketId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
|
||||||
|
try {
|
||||||
|
await ticketsApi.removeCheckin(ticketId);
|
||||||
|
toast.success('Check-in removed');
|
||||||
|
loadEventData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to remove check-in');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenNoteModal = (ticket: Ticket) => {
|
||||||
|
setSelectedTicket(ticket);
|
||||||
|
setNoteText(ticket.adminNote || '');
|
||||||
|
setShowNoteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNote = async () => {
|
||||||
|
if (!selectedTicket) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await ticketsApi.updateNote(selectedTicket.id, noteText);
|
||||||
|
toast.success('Note saved');
|
||||||
|
setShowNoteModal(false);
|
||||||
|
setSelectedTicket(null);
|
||||||
|
setNoteText('');
|
||||||
|
loadEventData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to save note');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTicket = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!event) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await ticketsApi.adminAdd({
|
||||||
|
eventId: event.id,
|
||||||
|
type: addTicketForm.type,
|
||||||
|
firstName: addTicketForm.firstName || undefined,
|
||||||
|
lastName: addTicketForm.lastName || undefined,
|
||||||
|
email: addTicketForm.email || undefined,
|
||||||
|
phone: addTicketForm.phone || undefined,
|
||||||
|
checkinNow: addTicketForm.checkinNow,
|
||||||
|
adminNote: addTicketForm.adminNote || undefined,
|
||||||
|
});
|
||||||
|
toast.success(res.message || 'Ticket created');
|
||||||
|
setShowAddTicketModal(false);
|
||||||
|
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
|
||||||
|
loadEventData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to add ticket');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
|
||||||
|
if (!event) return;
|
||||||
|
setExporting(true);
|
||||||
|
setShowExportDropdown(false);
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined });
|
||||||
|
downloadBlob(blob, filename);
|
||||||
|
toast.success('Export downloaded');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to export attendees');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => {
|
||||||
|
if (!event) return;
|
||||||
|
setExporting(true);
|
||||||
|
setShowTicketExportDropdown(false);
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined });
|
||||||
|
downloadBlob(blob, filename);
|
||||||
|
toast.success('Export downloaded');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to export tickets');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtered tickets for attendees tab
|
||||||
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
|
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
fullName.includes(query) ||
|
||||||
|
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
|
||||||
|
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
|
||||||
|
ticket.id.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
|
||||||
|
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
|
||||||
|
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
|
||||||
|
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
|
||||||
|
if (ticketSearchQuery) {
|
||||||
|
const query = ticketSearchQuery.toLowerCase();
|
||||||
|
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
fullName.includes(query) ||
|
||||||
|
ticket.id.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePreviewEmail = async () => {
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
toast.error('Please select a template');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await emailsApi.preview({
|
||||||
|
templateSlug: selectedTemplate,
|
||||||
|
variables: {
|
||||||
|
attendeeName: 'John Doe',
|
||||||
|
attendeeEmail: 'john@example.com',
|
||||||
|
ticketId: 'TKT-PREVIEW',
|
||||||
|
eventTitle: event?.title || '',
|
||||||
|
eventDate: event ? formatDate(event.startDatetime) : '',
|
||||||
|
eventTime: event ? fmtTime(event.startDatetime) : '',
|
||||||
|
eventLocation: event?.location || '',
|
||||||
|
eventLocationUrl: event?.locationUrl || '',
|
||||||
|
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
||||||
|
customMessage: customMessage || 'Your custom message will appear here.',
|
||||||
|
},
|
||||||
|
locale,
|
||||||
|
}, eventId);
|
||||||
|
setPreviewHtml(res.bodyHtml);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to preview email');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmail = async () => {
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
toast.error('Please select a template');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientCount = getFilteredRecipientCount();
|
||||||
|
if (recipientCount === 0) {
|
||||||
|
toast.error('No recipients match the selected filter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await emailsApi.sendToEvent(eventId, {
|
||||||
|
templateSlug: selectedTemplate,
|
||||||
|
recipientFilter,
|
||||||
|
customVariables: customMessage ? { customMessage } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
||||||
|
} else {
|
||||||
|
toast.error(res.error || 'Failed to queue emails');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || 'Failed to send emails');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The header renders as soon as the event is in; the tab bar waits for
|
||||||
|
// permissions and the ticket-derived parts (stats, counts, tab bodies) wait
|
||||||
|
// for the details, each behind its own skeleton.
|
||||||
|
if (eventLoading) {
|
||||||
|
return <EventDetailSkeleton backHref={backHref} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500">Event not found</p>
|
||||||
|
<Link href={backHref}>
|
||||||
|
<Button variant="outline" className="mt-4">
|
||||||
|
Back to Events
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedCount = getTicketsByStatus('confirmed').length;
|
||||||
|
const pendingCount = getTicketsByStatus('pending').length;
|
||||||
|
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||||||
|
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||||||
|
const onHoldCount = getTicketsByStatus('on_hold').length;
|
||||||
|
// Revenue comes only from the door summary, which adds up what was actually
|
||||||
|
// paid. Never derive it from ticket count × event.price: the price can change
|
||||||
|
// after tickets have been sold.
|
||||||
|
const presaleRevenue = doorSummary?.presale.total ?? 0;
|
||||||
|
const doorRevenue = doorSummary?.door.total ?? 0;
|
||||||
|
const revenue = presaleRevenue + doorRevenue;
|
||||||
|
// Header money follows the UI language's thousands separator.
|
||||||
|
const money = (amount: number) => formatCurrency(amount, event.currency, locale);
|
||||||
|
const revenueLabel = doorSummary ? money(revenue) : '—';
|
||||||
|
// "confirmed" tickets become "checked_in" at the door, so this counts the
|
||||||
|
// guests who have a ticket and have not arrived yet (not all confirmed ones).
|
||||||
|
const notCheckedInLabel = t('admin.eventStats.notCheckedIn');
|
||||||
|
|
||||||
|
const allTabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
||||||
|
{ key: 'overview', label: t('admin.eventTabs.overview'), icon: CalendarIcon },
|
||||||
|
{ key: 'attendees', label: t('admin.eventTabs.attendees'), icon: UserGroupIcon, count: detailsLoading ? undefined : tickets.length },
|
||||||
|
{ key: 'tickets', label: t('admin.eventTabs.tickets'), icon: TicketIcon, count: detailsLoading ? undefined : confirmedTickets.length },
|
||||||
|
{ key: 'email', label: t('admin.eventTabs.email'), icon: EnvelopeIcon },
|
||||||
|
{ key: 'payments', label: t('admin.eventTabs.payments'), icon: CreditCardIcon },
|
||||||
|
{ key: 'finance', label: t('admin.eventTabs.finance'), icon: BanknotesIcon },
|
||||||
|
{ key: 'team', label: t('admin.eventTabs.team'), icon: UserPlusIcon },
|
||||||
|
];
|
||||||
|
const tabs = allTabs.filter((tab) => can(TAB_PERMISSION[tab.key]));
|
||||||
|
// Event-wide money is only shown to viewers who can see payments.
|
||||||
|
const showRevenue = can('view_payments');
|
||||||
|
|
||||||
|
// ========== Primary action for a ticket ==========
|
||||||
|
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
|
||||||
|
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
|
||||||
|
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||||||
|
}
|
||||||
|
if (ticket.status === 'confirmed') {
|
||||||
|
// Unpaid tickets resolve their balance first; check-in stays available via scanner
|
||||||
|
if (ticket.paymentStatus === 'unpaid') {
|
||||||
|
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
||||||
|
}
|
||||||
|
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
|
||||||
|
}
|
||||||
|
if (ticket.status === 'checked_in') {
|
||||||
|
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-full overflow-x-hidden">
|
||||||
|
{/* ============= HEADER ============= */}
|
||||||
|
<div className="flex items-start gap-3 mb-3">
|
||||||
|
<EventBackButton href={backHref} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
|
||||||
|
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}</p>
|
||||||
|
</div>
|
||||||
|
{/* Desktop header actions */}
|
||||||
|
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Link href={`/events/${event.slug}`} target="_blank">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||||
|
View Public
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{can('edit_event') && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
|
||||||
|
<PencilIcon className="w-4 h-4 mr-1.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Mobile header overflow menu */}
|
||||||
|
<div className="md:hidden flex-shrink-0">
|
||||||
|
<Dropdown
|
||||||
|
open={mobileHeaderMenuOpen}
|
||||||
|
onOpenChange={setMobileHeaderMenuOpen}
|
||||||
|
trigger={
|
||||||
|
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
|
<EllipsisVerticalIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
||||||
|
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
||||||
|
</DropdownItem>
|
||||||
|
{can('edit_event') && (
|
||||||
|
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
||||||
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============= COMPACT META CHIPS (desktop) ============= */}
|
||||||
|
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
|
<CalendarIcon className="w-3.5 h-3.5" />
|
||||||
|
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
|
<MapPinIcon className="w-3.5 h-3.5" />
|
||||||
|
{event.location}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
|
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
||||||
|
{event.price === 0 ? t('admin.eventStats.free') : money(event.price)}
|
||||||
|
</span>
|
||||||
|
{showStats && !detailsLoading && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||||
|
<UsersIcon className="w-3.5 h-3.5" />
|
||||||
|
{confirmedCount + checkedInCount}/{event.capacity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============= STATS ROW ============= */}
|
||||||
|
{showStats && detailsLoading && <EventStatsSkeleton />}
|
||||||
|
{!detailsLoading && (<>
|
||||||
|
{/* Desktop: always-visible compact 4-card row */}
|
||||||
|
<div className="hidden md:block mb-4">
|
||||||
|
{showStats && (
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: t('admin.eventStats.capacity'), value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
|
||||||
|
{ label: notCheckedInLabel, value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
|
||||||
|
{ label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
|
||||||
|
...(!showRevenue ? [] : [{
|
||||||
|
label: t('admin.eventStats.revenue'),
|
||||||
|
value: revenueLabel,
|
||||||
|
icon: CurrencyDollarIcon,
|
||||||
|
color: 'bg-gray-50 text-gray-600',
|
||||||
|
detail: doorSummary
|
||||||
|
? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) })
|
||||||
|
: undefined,
|
||||||
|
}]),
|
||||||
|
].map((stat) => (
|
||||||
|
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
|
||||||
|
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
||||||
|
<stat.icon className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: collapsible stats */}
|
||||||
|
<div className="md:hidden mb-3">
|
||||||
|
{showStats && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-x-auto">
|
||||||
|
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">{t('admin.eventStats.stats')}</span>
|
||||||
|
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
|
||||||
|
<span className="text-xs text-gray-400">|</span>
|
||||||
|
<span className="text-sm whitespace-nowrap">{t('admin.eventStats.inShort', { count: checkedInCount })}</span>
|
||||||
|
{showRevenue && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-400">|</span>
|
||||||
|
<span className="text-sm whitespace-nowrap">{revenueLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
{mobileStatsExpanded && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ label: t('admin.eventStats.capacity'), value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
|
||||||
|
{ label: notCheckedInLabel, value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
|
||||||
|
{ label: t('admin.eventStats.checkedIn'), value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
|
||||||
|
...(!showRevenue ? [] : [{
|
||||||
|
label: t('admin.eventStats.revenue'),
|
||||||
|
value: revenueLabel,
|
||||||
|
icon: CurrencyDollarIcon,
|
||||||
|
color: 'text-gray-600 bg-gray-50',
|
||||||
|
detail: doorSummary
|
||||||
|
? t('admin.eventStats.presaleDoor', { presale: money(presaleRevenue), door: money(doorRevenue) })
|
||||||
|
: undefined,
|
||||||
|
}]),
|
||||||
|
].map((stat) => (
|
||||||
|
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
|
||||||
|
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
||||||
|
<stat.icon className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ============= TAB BAR ============= */}
|
||||||
|
{!permissions ? <EventTabBarSkeleton /> : (<>
|
||||||
|
{/* Desktop: tab bar inside a card top-section */}
|
||||||
|
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
|
||||||
|
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
|
||||||
|
<nav className="flex gap-1">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={clsx(
|
||||||
|
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'text-primary-dark'
|
||||||
|
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon className="w-4 h-4" />
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
|
||||||
|
{activeTab === tab.key && (
|
||||||
|
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: segmented tab bar */}
|
||||||
|
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
|
||||||
|
<div ref={mobileTabsRef} className="overflow-x-auto scrollbar-hide">
|
||||||
|
<div className="flex gap-0.5 min-w-max">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
data-tab={tab.key}
|
||||||
|
aria-current={activeTab === tab.key ? 'page' : undefined}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-2 rounded-lg text-sm font-medium transition-all whitespace-nowrap min-h-[44px] flex items-center gap-1',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-white shadow-sm text-primary-dark'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<tab.icon className="w-3.5 h-3.5" />
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
|
||||||
|
{/* ============= TAB CONTENT ============= */}
|
||||||
|
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
|
||||||
|
{/* Overview, Finance and Team render straight away; the rest read
|
||||||
|
tickets, templates or door takings, so they wait for the details. */}
|
||||||
|
{detailsLoading && TABS_NEEDING_DETAILS.includes(activeTab) ? (
|
||||||
|
<EventTabBodySkeleton />
|
||||||
|
) : (<>
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<OverviewTab
|
||||||
|
event={event}
|
||||||
|
formatDate={formatDate}
|
||||||
|
fmtTime={fmtTime}
|
||||||
|
formatCurrency={(amount, currency) => formatCurrency(amount, currency, locale)}
|
||||||
|
confirmedCount={confirmedCount}
|
||||||
|
checkedInCount={checkedInCount}
|
||||||
|
countsLoading={detailsLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'attendees' && (
|
||||||
|
<AttendeesTab
|
||||||
|
locale={locale}
|
||||||
|
tickets={tickets}
|
||||||
|
filteredTickets={filteredTickets}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
setStatusFilter={setStatusFilter}
|
||||||
|
pendingCount={pendingCount}
|
||||||
|
confirmedCount={confirmedCount}
|
||||||
|
checkedInCount={checkedInCount}
|
||||||
|
cancelledCount={cancelledCount}
|
||||||
|
onHoldCount={onHoldCount}
|
||||||
|
exporting={exporting}
|
||||||
|
showExportDropdown={showExportDropdown}
|
||||||
|
setShowExportDropdown={setShowExportDropdown}
|
||||||
|
showAddTicketDropdown={showAddTicketDropdown}
|
||||||
|
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
||||||
|
handleExportAttendees={handleExportAttendees}
|
||||||
|
openAddTicket={openAddTicket}
|
||||||
|
setMobileFilterOpen={setMobileFilterOpen}
|
||||||
|
setShowExportSheet={setShowExportSheet}
|
||||||
|
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||||
|
getPrimaryAction={getPrimaryAction}
|
||||||
|
handleOpenNoteModal={handleOpenNoteModal}
|
||||||
|
handleReactivate={handleReactivate}
|
||||||
|
handleMarkPaid={handleMarkPaid}
|
||||||
|
handleCheckin={handleCheckin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'tickets' && (
|
||||||
|
<TicketsTab
|
||||||
|
locale={locale}
|
||||||
|
confirmedTickets={confirmedTickets}
|
||||||
|
filteredConfirmedTickets={filteredConfirmedTickets}
|
||||||
|
ticketSearchQuery={ticketSearchQuery}
|
||||||
|
setTicketSearchQuery={setTicketSearchQuery}
|
||||||
|
ticketStatusFilter={ticketStatusFilter}
|
||||||
|
setTicketStatusFilter={setTicketStatusFilter}
|
||||||
|
confirmedCount={confirmedCount}
|
||||||
|
checkedInCount={checkedInCount}
|
||||||
|
exporting={exporting}
|
||||||
|
showTicketExportDropdown={showTicketExportDropdown}
|
||||||
|
setShowTicketExportDropdown={setShowTicketExportDropdown}
|
||||||
|
handleExportTickets={handleExportTickets}
|
||||||
|
handleCheckin={handleCheckin}
|
||||||
|
handleRemoveCheckin={handleRemoveCheckin}
|
||||||
|
setShowTicketExportSheet={setShowTicketExportSheet}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'email' && (
|
||||||
|
<EmailTab
|
||||||
|
templates={templates}
|
||||||
|
selectedTemplate={selectedTemplate}
|
||||||
|
setSelectedTemplate={setSelectedTemplate}
|
||||||
|
recipientFilter={recipientFilter}
|
||||||
|
setRecipientFilter={setRecipientFilter}
|
||||||
|
customMessage={customMessage}
|
||||||
|
setCustomMessage={setCustomMessage}
|
||||||
|
sending={sending}
|
||||||
|
handlePreviewEmail={handlePreviewEmail}
|
||||||
|
handleSendEmail={handleSendEmail}
|
||||||
|
getFilteredRecipientCount={getFilteredRecipientCount}
|
||||||
|
ticketsCount={tickets.length}
|
||||||
|
confirmedCount={confirmedCount}
|
||||||
|
pendingCount={pendingCount}
|
||||||
|
checkedInCount={checkedInCount}
|
||||||
|
cancelledCount={cancelledCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'payments' && (
|
||||||
|
<PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'finance' && <FinanceTab eventId={eventId} />}
|
||||||
|
|
||||||
|
{activeTab === 'team' && <TeamTab eventId={eventId} />}
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ============= MODALS ============= */}
|
||||||
|
<EventModals
|
||||||
|
ticketsCount={tickets.length}
|
||||||
|
pendingCount={pendingCount}
|
||||||
|
confirmedCount={confirmedCount}
|
||||||
|
checkedInCount={checkedInCount}
|
||||||
|
cancelledCount={cancelledCount}
|
||||||
|
onHoldCount={onHoldCount}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
setStatusFilter={setStatusFilter}
|
||||||
|
mobileFilterOpen={mobileFilterOpen}
|
||||||
|
setMobileFilterOpen={setMobileFilterOpen}
|
||||||
|
showAddTicketSheet={showAddTicketSheet}
|
||||||
|
setShowAddTicketSheet={setShowAddTicketSheet}
|
||||||
|
openAddTicket={openAddTicket}
|
||||||
|
showExportSheet={showExportSheet}
|
||||||
|
setShowExportSheet={setShowExportSheet}
|
||||||
|
handleExportAttendees={handleExportAttendees}
|
||||||
|
showTicketExportSheet={showTicketExportSheet}
|
||||||
|
setShowTicketExportSheet={setShowTicketExportSheet}
|
||||||
|
handleExportTickets={handleExportTickets}
|
||||||
|
submitting={submitting}
|
||||||
|
showNoteModal={showNoteModal}
|
||||||
|
setShowNoteModal={setShowNoteModal}
|
||||||
|
selectedTicket={selectedTicket}
|
||||||
|
setSelectedTicket={setSelectedTicket}
|
||||||
|
noteText={noteText}
|
||||||
|
setNoteText={setNoteText}
|
||||||
|
handleSaveNote={handleSaveNote}
|
||||||
|
previewHtml={previewHtml}
|
||||||
|
setPreviewHtml={setPreviewHtml}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddTicketModal
|
||||||
|
open={showAddTicketModal}
|
||||||
|
onClose={() => setShowAddTicketModal(false)}
|
||||||
|
form={addTicketForm}
|
||||||
|
setForm={setAddTicketForm}
|
||||||
|
onSubmit={handleAddTicket}
|
||||||
|
submitting={submitting}
|
||||||
|
eventPriceLabel={event.price === 0 ? 'Free' : money(event.price)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EventFormModal
|
||||||
|
open={showEditForm}
|
||||||
|
event={event}
|
||||||
|
featuredEventId={featuredEventId}
|
||||||
|
onFeaturedChange={setFeaturedEventId}
|
||||||
|
onClose={() => setShowEditForm(false)}
|
||||||
|
onSaved={() => { setShowEditForm(false); loadEventData(); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
PlusIcon, PencilIcon, TrashIcon, LockClosedIcon, LockOpenIcon, RectangleStackIcon,
|
||||||
|
PaperClipIcon, ArrowUpTrayIcon, CheckCircleIcon, ClockIcon, FunnelIcon, ShoppingBagIcon, BoltIcon, SparklesIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
|
import { useMoney } from '@/lib/useMoney';
|
||||||
|
import {
|
||||||
|
financeApi, mediaApi, CALC_TYPES,
|
||||||
|
type CalcType, type EventExpense, type EventFinance, type ExpenseInput, type ExpenseTemplate, type ExpenseTemplatePack, type OtherIncome,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Modal, Field, PygInput, DateInput, Money, Pill, Segmented, EmptyState, inputClass, iconButtonClass, todayIso } from './ui';
|
||||||
|
import { bpToPercent, percentToBp } from './format';
|
||||||
|
import { previewExpense } from './calc';
|
||||||
|
import { suggestCategory, tCount } from './derive';
|
||||||
|
|
||||||
|
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
type Fmt = ReturnType<typeof useMoney>;
|
||||||
|
|
||||||
|
const isAuto = (c: CalcType) => c !== 'fixed';
|
||||||
|
|
||||||
|
/** One-line explanation of how a row's amount came about ("7 tickets × 5.000 PYG"). */
|
||||||
|
function formula(e: EventExpense, t: T, m: Fmt): string {
|
||||||
|
const q = e.liveQuantity ?? e.quantity;
|
||||||
|
switch (e.calcType) {
|
||||||
|
case 'fixed': return e.quantity === 1 ? '' : `${m.num(e.quantity)} × ${m.pyg(e.unitAmount)}`;
|
||||||
|
case 'per_ticket_sold': return t('admin.finance.formula.perTicket', { count: m.num(q), amount: m.pyg(e.unitAmount) });
|
||||||
|
case 'per_checked_in': return t('admin.finance.formula.perGuest', { count: m.num(q), amount: m.pyg(e.unitAmount) });
|
||||||
|
case 'percent_of_revenue': return t('admin.finance.formula.percent', { percent: m.pct(e.percentBp) });
|
||||||
|
case 'minimum_spend': return t('admin.finance.formula.minimum', { minimum: m.pyg(e.minimumAmount), count: m.num(q), amount: m.pyg(e.unitAmount) });
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sticky action bar at the bottom of a modal, so Save stays reachable on phones. */
|
||||||
|
function ModalActions({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Expense form ====================
|
||||||
|
|
||||||
|
interface ExpenseFormState {
|
||||||
|
description: string;
|
||||||
|
categoryId: string;
|
||||||
|
calcType: CalcType;
|
||||||
|
/** Fixed: the whole amount. Per ticket / per guest / minimum spend: the amount per unit. */
|
||||||
|
unitAmount: number;
|
||||||
|
percent: string;
|
||||||
|
minimumAmount: number;
|
||||||
|
isLocked: boolean;
|
||||||
|
computedAmount: number;
|
||||||
|
status: 'planned' | 'paid';
|
||||||
|
paidByPartnerId: string;
|
||||||
|
receiptUrl: string;
|
||||||
|
expenseDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = (): ExpenseFormState => ({
|
||||||
|
description: '', categoryId: '', calcType: 'fixed', unitAmount: 0, percent: '', minimumAmount: 0,
|
||||||
|
isLocked: false, computedAmount: 0, status: 'planned', paidByPartnerId: '', receiptUrl: '', expenseDate: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function fromExpense(e: EventExpense): ExpenseFormState {
|
||||||
|
return {
|
||||||
|
description: e.description, categoryId: e.categoryId || '', calcType: e.calcType,
|
||||||
|
// Older fixed rows may carry a quantity; the form edits their total, which saves as quantity 1.
|
||||||
|
unitAmount: e.calcType === 'fixed' ? e.unitAmount * e.quantity : e.unitAmount,
|
||||||
|
percent: e.percentBp ? bpToPercent(e.percentBp) : '', minimumAmount: e.minimumAmount,
|
||||||
|
isLocked: e.isLocked, computedAmount: e.amount ?? e.computedAmount, status: e.status,
|
||||||
|
paidByPartnerId: e.paidByPartnerId && e.paidByPartnerId !== 'other' ? e.paidByPartnerId : '',
|
||||||
|
receiptUrl: e.receiptUrl || '', expenseDate: e.expenseDate ? e.expenseDate.slice(0, 10) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpenseModal({ open, onClose, eventId, data, expense, onSaved }: {
|
||||||
|
open: boolean; onClose: () => void; eventId: string; data: EventFinance; expense: EventExpense | null; onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const m = useMoney();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [form, setForm] = useState<ExpenseFormState>(emptyForm());
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
// Category follows the description until someone picks one by hand.
|
||||||
|
const [categoryAuto, setCategoryAuto] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
// The media upload is admin/organizer only; others can paste a link.
|
||||||
|
const canUpload = user?.role === 'admin' || user?.role === 'organizer';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(expense ? fromExpense(expense) : emptyForm());
|
||||||
|
setTouched(false);
|
||||||
|
setCategoryAuto(!expense || !expense.categoryId);
|
||||||
|
}
|
||||||
|
}, [open, expense]);
|
||||||
|
|
||||||
|
const set = <K extends keyof ExpenseFormState>(k: K, v: ExpenseFormState[K]) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
const categories = data.categories.filter((c) => !c.archived || c.id === form.categoryId);
|
||||||
|
const ct = form.calcType;
|
||||||
|
const counts = data.summary.counts;
|
||||||
|
const sales = data.summary.revenue.sales;
|
||||||
|
const preview = previewExpense(
|
||||||
|
{ calcType: ct, quantity: 1, unitAmount: form.unitAmount, percentBp: percentToBp(form.percent), minimumAmount: form.minimumAmount },
|
||||||
|
{ ticketsSold: counts.ticketsSold, checkedIn: counts.checkedIn, sales },
|
||||||
|
);
|
||||||
|
const locked = isAuto(ct) && form.isLocked;
|
||||||
|
const total = locked ? form.computedAmount : preview.amount;
|
||||||
|
const descriptionError = touched && !form.description.trim() ? t('admin.finance.expenses.descriptionRequired') : undefined;
|
||||||
|
|
||||||
|
const setDescription = (v: string) => {
|
||||||
|
setForm((f) => {
|
||||||
|
if (!categoryAuto) return { ...f, description: v };
|
||||||
|
return { ...f, description: v, categoryId: suggestCategory(v, data.categories) || '' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const setStatus = (v: 'planned' | 'paid') => {
|
||||||
|
setForm((f) => ({ ...f, status: v, expenseDate: v === 'paid' && !f.expenseDate ? todayIso() : f.expenseDate }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await mediaApi.upload(file, expense?.id, 'expense');
|
||||||
|
set('receiptUrl', res.url);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (!form.description.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const payload: ExpenseInput = {
|
||||||
|
description: form.description.trim(),
|
||||||
|
categoryId: form.categoryId || null,
|
||||||
|
calcType: ct,
|
||||||
|
quantity: ct === 'fixed' ? 1 : undefined,
|
||||||
|
unitAmount: ct === 'percent_of_revenue' ? 0 : form.unitAmount,
|
||||||
|
percentBp: ct === 'percent_of_revenue' ? percentToBp(form.percent) : 0,
|
||||||
|
minimumAmount: ct === 'minimum_spend' ? form.minimumAmount : 0,
|
||||||
|
isLocked: locked,
|
||||||
|
computedAmount: locked ? form.computedAmount : undefined,
|
||||||
|
status: form.status,
|
||||||
|
paidByPartnerId: form.paidByPartnerId || null,
|
||||||
|
receiptUrl: form.receiptUrl || null,
|
||||||
|
expenseDate: form.expenseDate || null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (expense) await financeApi.updateExpense(eventId, expense.id, payload);
|
||||||
|
else await financeApi.createExpense(eventId, payload);
|
||||||
|
toast.success(t(expense ? 'admin.finance.expenses.saved' : 'admin.finance.expenses.created'));
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// "5.000 PYG × 4 tickets = 20.000 PYG" with this event's real numbers.
|
||||||
|
const previewLine = (() => {
|
||||||
|
if (locked) return t('admin.finance.preview.locked', { total: m.pyg(total) });
|
||||||
|
const totalText = m.pyg(total);
|
||||||
|
switch (ct) {
|
||||||
|
case 'per_ticket_sold': return tCount(t, 'admin.finance.preview.perTicket', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
|
||||||
|
case 'per_checked_in': return tCount(t, 'admin.finance.preview.perGuest', preview.count, { count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
|
||||||
|
case 'percent_of_revenue': return t('admin.finance.preview.percent', { percent: m.pct(percentToBp(form.percent)), sales: m.pyg(Math.max(0, sales)), total: totalText });
|
||||||
|
case 'minimum_spend': return t('admin.finance.preview.minimum', { minimum: m.pyg(form.minimumAmount), count: m.num(preview.count), amount: m.pyg(form.unitAmount), total: totalText });
|
||||||
|
default: return t('admin.finance.preview.fixed', { total: totalText });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const amountLabel = {
|
||||||
|
fixed: 'admin.finance.expenses.amount',
|
||||||
|
per_ticket_sold: 'admin.finance.expenses.amountPerTicket',
|
||||||
|
per_checked_in: 'admin.finance.expenses.amountPerGuest',
|
||||||
|
percent_of_revenue: 'admin.finance.expenses.percentOfRevenue',
|
||||||
|
minimum_spend: 'admin.finance.expenses.minimumAmount',
|
||||||
|
}[ct];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t(expense ? 'admin.finance.expenses.edit' : 'admin.finance.expenses.new')} wide>
|
||||||
|
<Field label={t('admin.finance.expenses.description')} required error={descriptionError}>
|
||||||
|
<input
|
||||||
|
className={clsx(inputClass, descriptionError && 'border-red-400')}
|
||||||
|
value={form.description}
|
||||||
|
maxLength={300}
|
||||||
|
placeholder={t('admin.finance.expenses.descriptionPlaceholder')}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
onBlur={() => setTouched(true)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<Field label={t(amountLabel)}>
|
||||||
|
{ct === 'percent_of_revenue' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} onChange={(e) => set('percent', e.target.value)} placeholder="10" />
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
) : ct === 'minimum_spend' ? (
|
||||||
|
<PygInput value={form.minimumAmount} onChange={(n) => set('minimumAmount', n)} />
|
||||||
|
) : (
|
||||||
|
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.expenses.calcType')} hint={t(`admin.finance.calcHints.${ct}`)}>
|
||||||
|
<select className={inputClass} value={ct} onChange={(e) => set('calcType', e.target.value as CalcType)}>
|
||||||
|
{CALC_TYPES.map((c) => <option key={c} value={c}>{t(`admin.finance.calcTypes.${c}`)}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{ct === 'minimum_spend' && (
|
||||||
|
<Field label={t('admin.finance.expenses.amountPerGuest')}>
|
||||||
|
<PygInput value={form.unitAmount} onChange={(n) => set('unitAmount', n)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live result: what this line costs with today's numbers */}
|
||||||
|
<div className="rounded-btn bg-gray-50 border border-gray-100 px-3 py-2.5">
|
||||||
|
<p className="text-sm text-gray-700 tabular-nums">{previewLine}</p>
|
||||||
|
{isAuto(ct) && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-200 space-y-2">
|
||||||
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.isLocked} onChange={(e) => { set('isLocked', e.target.checked); if (e.target.checked) set('computedAmount', preview.amount); }} className="w-4 h-4 mt-0.5 accent-primary-yellow" />
|
||||||
|
<span>
|
||||||
|
<span className="font-medium">{t('admin.finance.expenses.lock')}</span>
|
||||||
|
<span className="block text-xs text-gray-500">{t('admin.finance.expenses.lockHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{form.isLocked && (
|
||||||
|
<Field label={t('admin.finance.expenses.lockedAmount')}>
|
||||||
|
<PygInput value={form.computedAmount} onChange={(n) => set('computedAmount', n)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<Field
|
||||||
|
label={t('admin.finance.expenses.category')}
|
||||||
|
hint={categoryAuto && form.categoryId ? t('admin.finance.expenses.categorySuggested') : undefined}
|
||||||
|
>
|
||||||
|
<select className={inputClass} value={form.categoryId} onChange={(e) => { setCategoryAuto(false); set('categoryId', e.target.value); }}>
|
||||||
|
<option value="">{t('admin.finance.charts.uncategorized')}</option>
|
||||||
|
{categories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.expenses.status')}>
|
||||||
|
<Segmented<'planned' | 'paid'>
|
||||||
|
label={t('admin.finance.expenses.status')}
|
||||||
|
value={form.status}
|
||||||
|
onChange={setStatus}
|
||||||
|
options={[
|
||||||
|
{ key: 'planned', label: t('admin.finance.expenseStatus.planned'), icon: <ClockIcon className="w-4 h-4" aria-hidden /> },
|
||||||
|
{ key: 'paid', label: t('admin.finance.expenseStatus.paid'), icon: <CheckCircleIcon className="w-4 h-4" aria-hidden /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{form.status === 'paid' && (
|
||||||
|
<>
|
||||||
|
<Field label={t('admin.finance.expenses.date')}>
|
||||||
|
<DateInput value={form.expenseDate} onChange={(v) => set('expenseDate', v)} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.expenses.paidBy')} hint={data.partners.length > 0 ? t('admin.finance.expenses.paidByHint') : undefined}>
|
||||||
|
<select className={inputClass} value={form.paidByPartnerId} onChange={(e) => set('paidByPartnerId', e.target.value)}>
|
||||||
|
<option value="">{t('admin.finance.expenses.organization')}</option>
|
||||||
|
{data.partners.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Field label={t('admin.finance.expenses.receipt')} hint={canUpload ? undefined : t('admin.finance.expenses.receiptAdminOnly')}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className={inputClass} type="url" inputMode="url" value={form.receiptUrl} placeholder="https://" onChange={(e) => set('receiptUrl', e.target.value)} />
|
||||||
|
{canUpload && (
|
||||||
|
<label className={clsx('flex-shrink-0 inline-flex items-center gap-1.5 px-3 rounded-btn border border-secondary-light-gray cursor-pointer hover:bg-gray-50 min-h-[44px] text-sm font-medium focus-within:ring-2 focus-within:ring-primary-yellow', uploading && 'opacity-50 pointer-events-none')}>
|
||||||
|
<ArrowUpTrayIcon className="w-4 h-4" aria-hidden />
|
||||||
|
{uploading ? t('common.loading') : t('admin.finance.expenses.upload')}
|
||||||
|
<input type="file" accept="image/*" className="sr-only" onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalActions>
|
||||||
|
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
|
||||||
|
</ModalActions>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Income form ====================
|
||||||
|
|
||||||
|
function IncomeModal({ open, income, onClose, eventId, onSaved }: {
|
||||||
|
open: boolean; income: OtherIncome | null; onClose: () => void; eventId: string; onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [amount, setAmount] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) { setDescription(income?.description || ''); setAmount(income?.amount || 0); }
|
||||||
|
}, [open, income]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (income) await financeApi.updateIncome(eventId, income.id, { description, amount });
|
||||||
|
else await financeApi.createIncome(eventId, { description, amount });
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t(income ? 'admin.finance.otherIncome.edit' : 'admin.finance.otherIncome.add')}
|
||||||
|
subtitle={t('admin.finance.otherIncome.hint')}
|
||||||
|
>
|
||||||
|
<Field label={t('admin.finance.expenses.description')} required>
|
||||||
|
<input className={inputClass} value={description} maxLength={300} placeholder={t('admin.finance.otherIncome.placeholder')} onChange={(e) => setDescription(e.target.value)} autoFocus />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.expenses.amount')} required>
|
||||||
|
<PygInput value={amount} onChange={setAmount} />
|
||||||
|
</Field>
|
||||||
|
<ModalActions>
|
||||||
|
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={save} isLoading={saving} disabled={!description.trim()} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
|
||||||
|
</ModalActions>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Use a template (packs + single templates) ====================
|
||||||
|
|
||||||
|
function TemplateModal({ open, onClose, eventId, onApplied }: { open: boolean; onClose: () => void; eventId: string; onApplied: () => void }) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const m = useMoney();
|
||||||
|
const [templates, setTemplates] = useState<ExpenseTemplate[]>([]);
|
||||||
|
const [packs, setPacks] = useState<ExpenseTemplatePack[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([financeApi.getTemplates(eventId), financeApi.getPacks(eventId)])
|
||||||
|
.then(([tpl, pk]) => { setTemplates(tpl.templates.filter((x) => !x.archived)); setPacks(pk.packs.filter((x) => !x.archived)); })
|
||||||
|
.catch((error) => toast.error(error.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open, eventId]);
|
||||||
|
|
||||||
|
const apply = async (body: { templateId?: string; packId?: string }, key: string) => {
|
||||||
|
setBusy(key);
|
||||||
|
try {
|
||||||
|
const res = await financeApi.applyTemplate(eventId, body);
|
||||||
|
toast.success(t('admin.finance.expenses.applied', { count: res.expenses.length }));
|
||||||
|
onApplied();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const templateName = (id: string) => templates.find((x) => x.id === id)?.name;
|
||||||
|
const summary = (tpl: ExpenseTemplate) => {
|
||||||
|
const kind = t(`admin.finance.calcTypes.${tpl.calcType}`);
|
||||||
|
if (tpl.calcType === 'percent_of_revenue') return `${kind} · ${m.pct(tpl.percentBp)}`;
|
||||||
|
if (tpl.calcType === 'minimum_spend') return `${kind} · ${m.pyg(tpl.amount)} · min ${m.pyg(tpl.minimumAmount)}`;
|
||||||
|
return `${kind} · ${m.pyg(tpl.amount)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t('admin.finance.expenses.useTemplate')} subtitle={t('admin.finance.expenses.useTemplateHint')}>
|
||||||
|
{loading ? <p className="text-sm text-gray-500">{t('common.loading')}</p> : templates.length === 0 && packs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">{t('admin.finance.expenses.noTemplates')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{packs.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.packsHeading')}</h3>
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
|
||||||
|
{packs.map((p) => (
|
||||||
|
<li key={p.id} className="px-3 py-2.5 flex items-center gap-3">
|
||||||
|
<RectangleStackIcon className="w-5 h-5 text-gray-400 flex-shrink-0" aria-hidden />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{p.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{p.templateIds.map(templateName).filter(Boolean).join(' · ')}</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" isLoading={busy === p.id} onClick={() => apply({ packId: p.id }, p.id)} className="min-h-[40px]">
|
||||||
|
{t('admin.finance.expenses.addAll', { count: p.templateIds.length })}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{t('admin.finance.expenses.templatesHeading')}</h3>
|
||||||
|
<ul className="divide-y divide-gray-100 border border-gray-100 rounded-btn">
|
||||||
|
{templates.map((tpl) => (
|
||||||
|
<li key={tpl.id} className="px-3 py-2.5 flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{tpl.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{summary(tpl)}</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" isLoading={busy === tpl.id} onClick={() => apply({ templateId: tpl.id }, tpl.id)} className="min-h-[40px]">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.expenses.addOne')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== View ====================
|
||||||
|
|
||||||
|
type LedgerRow = { kind: 'expense'; e: EventExpense } | { kind: 'income'; i: OtherIncome };
|
||||||
|
|
||||||
|
/** Costs and other income in one ledger: income rows read +amount in green. */
|
||||||
|
export function ExpensesView({ eventId, data, onChange }: { eventId: string; data: EventFinance; onChange: () => void }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const m = useMoney();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [editing, setEditing] = useState<EventExpense | null>(null);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [income, setIncome] = useState<OtherIncome | null>(null);
|
||||||
|
const [incomeOpen, setIncomeOpen] = useState(false);
|
||||||
|
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const open = data.status === 'open';
|
||||||
|
const { canEditExpenses, canEditOwnExpenses, userId } = data.viewer;
|
||||||
|
const canAdd = open && (canEditExpenses || canEditOwnExpenses);
|
||||||
|
const canEditIncome = open && canEditExpenses;
|
||||||
|
const canEditRow = (e: EventExpense) => open && (canEditExpenses || (canEditOwnExpenses && e.createdBy === userId));
|
||||||
|
|
||||||
|
const catById = useMemo(() => new Map(data.categories.map((c) => [c.id, c])), [data.categories]);
|
||||||
|
const usedCategories = data.categories.filter((c) => data.expenses.some((e) => e.categoryId === c.id));
|
||||||
|
const partnerName = (id: string | null) => {
|
||||||
|
if (!id) return t('admin.finance.expenses.organization');
|
||||||
|
if (id === 'other') return t('admin.finance.expenses.otherPartner');
|
||||||
|
return data.partners.find((p) => p.id === id)?.name || t('admin.finance.expenses.otherPartner');
|
||||||
|
};
|
||||||
|
const expenses = data.expenses.filter((e) => !category || (category === '__none__' ? !e.categoryId : e.categoryId === category));
|
||||||
|
// A category filter narrows to costs; income has no category.
|
||||||
|
const rows: LedgerRow[] = [
|
||||||
|
...expenses.map((e) => ({ kind: 'expense' as const, e })),
|
||||||
|
...(category ? [] : data.otherIncome.map((i) => ({ kind: 'income' as const, i }))),
|
||||||
|
];
|
||||||
|
const amountOf = (e: EventExpense) => e.amount ?? e.computedAmount;
|
||||||
|
const costTotal = expenses.reduce((s, e) => s + amountOf(e), 0);
|
||||||
|
const paid = expenses.filter((e) => e.status === 'paid').reduce((s, e) => s + amountOf(e), 0);
|
||||||
|
const incomeTotal = data.otherIncome.reduce((s, r) => s + r.amount, 0);
|
||||||
|
|
||||||
|
const patch = async (e: EventExpense, body: ExpenseInput) => {
|
||||||
|
setBusyId(e.id);
|
||||||
|
try {
|
||||||
|
await financeApi.updateExpense(eventId, e.id, body);
|
||||||
|
onChange();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const remove = async (e: EventExpense) => {
|
||||||
|
if (!confirm(t('admin.finance.expenses.confirmDeleteNamed', { name: e.description }))) return;
|
||||||
|
try {
|
||||||
|
await financeApi.deleteExpense(eventId, e.id);
|
||||||
|
toast.success(t('admin.finance.expenses.deleted'));
|
||||||
|
onChange();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeIncome = async (row: OtherIncome) => {
|
||||||
|
if (!confirm(t('admin.finance.otherIncome.confirmDelete', { name: row.description }))) return;
|
||||||
|
try {
|
||||||
|
await financeApi.deleteIncome(eventId, row.id);
|
||||||
|
onChange();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const openNew = () => { setEditing(null); setFormOpen(true); };
|
||||||
|
const openEdit = (e: EventExpense) => { setEditing(e); setFormOpen(true); };
|
||||||
|
const openIncome = (row: OtherIncome | null) => { setIncome(row); setIncomeOpen(true); };
|
||||||
|
|
||||||
|
const CategoryTag = ({ id }: { id: string | null }) => {
|
||||||
|
const c = id ? catById.get(id) : null;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: c?.color || '#D1D5DB' }} aria-hidden />
|
||||||
|
{c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status is a labeled toggle with an icon, not a color-only badge.
|
||||||
|
const StatusToggle = ({ e }: { e: EventExpense }) => {
|
||||||
|
const isPaid = e.status === 'paid';
|
||||||
|
const editable = canEditRow(e);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={!editable || busyId === e.id}
|
||||||
|
onClick={() => patch(e, isPaid ? { status: 'planned' } : { status: 'paid', expenseDate: e.expenseDate || todayIso() })}
|
||||||
|
title={editable ? t(isPaid ? 'admin.finance.expenses.markPlanned' : 'admin.finance.expenses.markPaid') : undefined}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 min-h-[32px] text-xs font-medium whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||||
|
isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800',
|
||||||
|
editable ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-gray-300' : 'cursor-default',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
|
||||||
|
{t(`admin.finance.expenseStatus.${e.status}`)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const IncomeTag = () => (
|
||||||
|
<Pill tone="green"><SparklesIcon className="w-3 h-3 mr-0.5" aria-hidden />{t('admin.finance.ledger.income')}</Pill>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LockButton = ({ e }: { e: EventExpense }) => isAuto(e.calcType) ? (
|
||||||
|
<button
|
||||||
|
disabled={!canEditRow(e) || busyId === e.id}
|
||||||
|
onClick={() => patch(e, { isLocked: !e.isLocked })}
|
||||||
|
title={t(e.isLocked ? 'admin.finance.expenses.unlockHint' : 'admin.finance.expenses.lockHint')}
|
||||||
|
className={clsx(iconButtonClass, e.isLocked ? 'text-primary-dark' : 'text-gray-400')}
|
||||||
|
aria-label={t('admin.finance.expenses.lock')}
|
||||||
|
aria-pressed={e.isLocked}
|
||||||
|
>
|
||||||
|
{e.isLocked ? <LockClosedIcon className="w-4 h-4" /> : <LockOpenIcon className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const Meta = ({ e }: { e: EventExpense }) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1">
|
||||||
|
<CategoryTag id={e.categoryId} />
|
||||||
|
{isAuto(e.calcType) && (
|
||||||
|
<Pill tone={e.isLocked ? 'gray' : 'blue'}>
|
||||||
|
{e.isLocked ? <LockClosedIcon className="w-3 h-3 mr-0.5" aria-hidden /> : <BoltIcon className="w-3 h-3 mr-0.5" aria-hidden />}
|
||||||
|
{t(e.isLocked ? 'admin.finance.expenses.locked' : 'admin.finance.expenses.auto')}
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
{e.receiptUrl && (
|
||||||
|
<a href={e.receiptUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 text-xs text-blue-700 hover:underline">
|
||||||
|
<PaperClipIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.viewReceipt')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modals = (
|
||||||
|
<>
|
||||||
|
<ExpenseModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} data={data} expense={editing} onSaved={onChange} />
|
||||||
|
<IncomeModal open={incomeOpen} income={income} onClose={() => setIncomeOpen(false)} eventId={eventId} onSaved={onChange} />
|
||||||
|
<TemplateModal open={templatesOpen} onClose={() => setTemplatesOpen(false)} eventId={eventId} onApplied={onChange} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const addButtons = (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setTemplatesOpen(true)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
|
||||||
|
<RectangleStackIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.useTemplate')}
|
||||||
|
</Button>
|
||||||
|
{canEditIncome && (
|
||||||
|
<Button variant="outline" onClick={() => openIncome(null)} className="min-h-[44px] whitespace-nowrap !px-3 md:!px-5 !text-sm md:!text-base">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.otherIncome.add')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={openNew} className="min-h-[44px] col-span-2 lg:col-span-1">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.expenses.add')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.expenses.length === 0 && data.otherIncome.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<EmptyState icon={<ShoppingBagIcon className="w-5 h-5" />} title={t('admin.finance.ledger.emptyTitle')} body={t('admin.finance.expenses.empty')}>
|
||||||
|
{canAdd && addButtons}
|
||||||
|
</EmptyState>
|
||||||
|
{modals}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomeAmount = (i: OtherIncome) => <Money value={i.amount} signed className={clsx('font-semibold whitespace-nowrap', !privacyMode && 'text-green-700')} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Totals + actions */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||||
|
<dl className={clsx('grid gap-2 flex-1 lg:max-w-2xl grid-cols-2', incomeTotal > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3')}>
|
||||||
|
{[
|
||||||
|
{ label: t('admin.finance.ledger.costs'), value: costTotal, strong: true },
|
||||||
|
{ label: t('admin.finance.expenseStatus.paid'), value: paid },
|
||||||
|
{ label: t('admin.finance.expenseStatus.planned'), value: costTotal - paid },
|
||||||
|
...(incomeTotal > 0 ? [{ label: t('admin.finance.otherIncome.title'), value: incomeTotal, income: true }] : []),
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className={clsx('bg-white rounded-card shadow-card px-3 py-2 min-w-0', incomeTotal === 0 && 'strong' in s && 'col-span-2 sm:col-span-1')}>
|
||||||
|
<dt className="text-xs text-gray-500 truncate">{s.label}</dt>
|
||||||
|
<dd>
|
||||||
|
<Money
|
||||||
|
value={s.value}
|
||||||
|
signed={'income' in s}
|
||||||
|
className={clsx('text-sm md:text-base whitespace-nowrap', 'strong' in s && s.strong ? 'font-bold' : 'font-semibold', 'income' in s && !privacyMode && 'text-green-700')}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{canAdd && <div className="grid grid-cols-2 lg:flex gap-2 lg:ml-auto">{addButtons}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{usedCategories.length > 1 && (
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<FunnelIcon className="w-4 h-4" aria-hidden />
|
||||||
|
<span className="sr-only">{t('admin.finance.expenses.category')}</span>
|
||||||
|
<select
|
||||||
|
className="px-3 min-h-[40px] text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow cursor-pointer"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.finance.ledger.allRows')}</option>
|
||||||
|
{usedCategories.map((c) => <option key={c.id} value={c.id}>{locale === 'es' ? c.nameEs : c.nameEn}</option>)}
|
||||||
|
{data.expenses.some((e) => !e.categoryId) && <option value="__none__">{t('admin.finance.charts.uncategorized')}</option>}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{open && !canEditExpenses && canEditOwnExpenses && <p className="text-xs text-gray-500">{t('admin.finance.expenses.ownOnly')}</p>}
|
||||||
|
{!open && <p className="text-xs text-gray-500 inline-flex items-center gap-1"><LockClosedIcon className="w-3.5 h-3.5" aria-hidden />{t('admin.finance.expenses.frozen')}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop table: amount sits on the right, next to the row actions */}
|
||||||
|
<div className="hidden md:block bg-white rounded-card shadow-card overflow-hidden">
|
||||||
|
<table className="w-full text-sm table-fixed">
|
||||||
|
<colgroup>
|
||||||
|
<col />
|
||||||
|
<col className="w-36" />
|
||||||
|
<col className="w-44" />
|
||||||
|
<col className="w-44" />
|
||||||
|
<col className="w-32" />
|
||||||
|
</colgroup>
|
||||||
|
<thead className="text-xs text-gray-500 bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="text-left font-medium px-4 py-2.5">{t('admin.finance.expenses.description')}</th>
|
||||||
|
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.status')}</th>
|
||||||
|
<th scope="col" className="text-left font-medium px-3 py-2.5">{t('admin.finance.expenses.paidBy')}</th>
|
||||||
|
<th scope="col" className="text-right font-medium px-3 py-2.5">{t('admin.finance.expenses.amount')}</th>
|
||||||
|
<th scope="col" className="px-3 py-2.5"><span className="sr-only">{t('common.edit')}</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{rows.map((row) => row.kind === 'expense' ? (
|
||||||
|
<tr key={row.e.id} className="align-top hover:bg-gray-50/60">
|
||||||
|
<td className="px-4 py-3 min-w-0">
|
||||||
|
{canEditRow(row.e) ? (
|
||||||
|
<button onClick={() => openEdit(row.e)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
|
||||||
|
{row.e.description}
|
||||||
|
</button>
|
||||||
|
) : <p className="font-medium text-primary-dark">{row.e.description}</p>}
|
||||||
|
<Meta e={row.e} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3"><StatusToggle e={row.e} /></td>
|
||||||
|
<td className="px-3 py-3 text-sm text-gray-600 truncate">{row.e.status === 'paid' ? partnerName(row.e.paidByPartnerId) : ''}</td>
|
||||||
|
<td className="px-3 py-3 text-right">
|
||||||
|
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
|
||||||
|
{!privacyMode && formula(row.e, t, m) && <p className="text-xs text-gray-400 mt-0.5">{formula(row.e, t, m)}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<div className="flex justify-end items-center">
|
||||||
|
<LockButton e={row.e} />
|
||||||
|
{canEditRow(row.e) && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => openEdit(row.e)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
|
||||||
|
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={row.i.id} className="align-top hover:bg-gray-50/60">
|
||||||
|
<td className="px-4 py-3 min-w-0">
|
||||||
|
{canEditIncome ? (
|
||||||
|
<button onClick={() => openIncome(row.i)} className="font-medium text-primary-dark hover:underline text-left cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded">
|
||||||
|
{row.i.description}
|
||||||
|
</button>
|
||||||
|
) : <p className="font-medium text-primary-dark">{row.i.description}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3"><IncomeTag /></td>
|
||||||
|
<td className="px-3 py-3" />
|
||||||
|
<td className="px-3 py-3 text-right">{incomeAmount(row.i)}</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
{canEditIncome && (
|
||||||
|
<div className="flex justify-end items-center">
|
||||||
|
<button onClick={() => openIncome(row.i)} className={iconButtonClass} aria-label={t('common.edit')}><PencilIcon className="w-4 h-4" /></button>
|
||||||
|
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-2.5 font-semibold" colSpan={3}>
|
||||||
|
{t('admin.finance.ledger.costs')}
|
||||||
|
{category && <span className="font-normal text-gray-500"> · {t('admin.finance.expenses.filtered')}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Money value={costTotal} className="font-bold whitespace-nowrap" /></td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
{!category && incomeTotal > 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 pb-2.5 font-semibold" colSpan={3}>{t('admin.finance.otherIncome.title')}</td>
|
||||||
|
<td className="px-3 pb-2.5 text-right"><Money value={incomeTotal} signed className={clsx('font-bold whitespace-nowrap', !privacyMode && 'text-green-700')} /></td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile cards: tap the card to edit, status toggles in place */}
|
||||||
|
<ul className="md:hidden space-y-2">
|
||||||
|
{rows.map((row) => row.kind === 'expense' ? (
|
||||||
|
<li key={row.e.id} className="bg-white rounded-card shadow-card">
|
||||||
|
<div className="flex items-start gap-3 p-3">
|
||||||
|
<button
|
||||||
|
onClick={() => canEditRow(row.e) && openEdit(row.e)}
|
||||||
|
disabled={!canEditRow(row.e)}
|
||||||
|
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-primary-dark">{row.e.description}</p>
|
||||||
|
<Meta e={row.e} />
|
||||||
|
</button>
|
||||||
|
<div className="text-right">
|
||||||
|
<Money value={amountOf(row.e)} className="font-semibold whitespace-nowrap" />
|
||||||
|
{!privacyMode && formula(row.e, t, m) && <p className="text-[11px] text-gray-400">{formula(row.e, t, m)}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 pb-2">
|
||||||
|
<StatusToggle e={row.e} />
|
||||||
|
{row.e.status === 'paid' && <span className="text-xs text-gray-500 truncate">{partnerName(row.e.paidByPartnerId)}</span>}
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
|
<LockButton e={row.e} />
|
||||||
|
{canEditRow(row.e) && (
|
||||||
|
<button onClick={() => remove(row.e)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li key={row.i.id} className="bg-white rounded-card shadow-card">
|
||||||
|
<div className="flex items-start gap-3 p-3">
|
||||||
|
<button
|
||||||
|
onClick={() => canEditIncome && openIncome(row.i)}
|
||||||
|
disabled={!canEditIncome}
|
||||||
|
className="flex-1 min-w-0 text-left cursor-pointer disabled:cursor-default focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-primary-dark">{row.i.description}</p>
|
||||||
|
<div className="mt-1"><IncomeTag /></div>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{incomeAmount(row.i)}
|
||||||
|
{canEditIncome && (
|
||||||
|
<button onClick={() => removeIncome(row.i)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={t('common.delete')}><TrashIcon className="w-4 h-4" /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="flex justify-between px-3 py-2 text-sm font-semibold">
|
||||||
|
<span>{t('admin.finance.ledger.costs')}</span>
|
||||||
|
<Money value={costTotal} />
|
||||||
|
</li>
|
||||||
|
{!category && incomeTotal > 0 && (
|
||||||
|
<li className="flex justify-between px-3 pb-2 text-sm font-semibold">
|
||||||
|
<span>{t('admin.finance.otherIncome.title')}</span>
|
||||||
|
<Money value={incomeTotal} signed className={clsx(!privacyMode && 'text-green-700')} />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{modals}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { CheckCircleIcon, ExclamationTriangleIcon, InformationCircleIcon, LockClosedIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import type { EventFinance } from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Modal, Money } from './ui';
|
||||||
|
import { tCount, type Lifecycle } from './derive';
|
||||||
|
|
||||||
|
/** Last look before freezing the numbers: what is in, what is missing, and the result. */
|
||||||
|
export function FinalizeDialog({ open, onClose, onConfirm, busy, data, lifecycle }: {
|
||||||
|
open: boolean; onClose: () => void; onConfirm: () => void; busy: boolean; data: EventFinance; lifecycle: Lifecycle;
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const s = data.summary;
|
||||||
|
const partnersTotal = s.split.partners.reduce((sum, p) => sum + p.share, 0);
|
||||||
|
const loss = s.profit < 0;
|
||||||
|
|
||||||
|
const Row = ({ tone, children, value }: { tone: 'ok' | 'warn' | 'info'; children: React.ReactNode; value?: React.ReactNode }) => {
|
||||||
|
const Icon = tone === 'ok' ? CheckCircleIcon : tone === 'warn' ? ExclamationTriangleIcon : InformationCircleIcon;
|
||||||
|
return (
|
||||||
|
<li className={clsx('flex items-start gap-2 py-2', tone === 'warn' && 'text-amber-900')}>
|
||||||
|
<Icon className={clsx('w-5 h-5 flex-shrink-0', tone === 'ok' ? 'text-green-600' : tone === 'warn' ? 'text-amber-600' : 'text-gray-400')} aria-hidden />
|
||||||
|
<span className="flex-1 min-w-0">{children}</span>
|
||||||
|
{value && <span className="font-semibold whitespace-nowrap">{value}</span>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t('admin.finance.finalizeDialog.title')} subtitle={t('admin.finance.finalizeDialog.subtitle')}>
|
||||||
|
<ul className="divide-y divide-gray-100 text-sm">
|
||||||
|
{!lifecycle.ended && <Row tone="warn">{t('admin.finance.finalizeDialog.notEnded')}</Row>}
|
||||||
|
{lifecycle.expenseCount === 0
|
||||||
|
? <Row tone="warn">{t('admin.finance.finalizeDialog.noExpenses')}</Row>
|
||||||
|
: <Row tone="ok" value={<Money value={s.expenses.total} />}>{tCount(t, 'admin.finance.finalizeDialog.expenses', lifecycle.expenseCount)}</Row>}
|
||||||
|
{lifecycle.plannedCount > 0
|
||||||
|
? <Row tone="warn" value={<Money value={s.expenses.planned} />}>{tCount(t, 'admin.finance.finalizeDialog.planned', lifecycle.plannedCount)}</Row>
|
||||||
|
: lifecycle.expenseCount > 0 && <Row tone="ok">{t('admin.finance.finalizeDialog.allPaid')}</Row>}
|
||||||
|
{s.revenue.otherIncome > 0 && (
|
||||||
|
<Row tone="ok" value={<Money value={s.revenue.otherIncome} signed />}>{tCount(t, 'admin.finance.finalizeDialog.income', data.otherIncome.length)}</Row>
|
||||||
|
)}
|
||||||
|
{lifecycle.partnerCount > 0
|
||||||
|
? <Row tone="info" value={<Money value={partnersTotal} />}>{tCount(t, 'admin.finance.finalizeDialog.partners', lifecycle.partnerCount)}</Row>
|
||||||
|
: <Row tone="info">{t('admin.finance.finalizeDialog.noPartners')}</Row>}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="rounded-btn bg-gray-50 border border-gray-100 p-3 space-y-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-sm text-gray-600">{t(loss ? 'admin.finance.finalizeDialog.finalLoss' : 'admin.finance.finalizeDialog.finalProfit')}</span>
|
||||||
|
<Money value={s.profit} className={clsx('text-lg font-bold', loss && 'text-red-600')} />
|
||||||
|
</div>
|
||||||
|
{lifecycle.partnerCount > 0 && s.split.organization !== null && (
|
||||||
|
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||||
|
<span className="text-gray-600">{t('admin.finance.split.organization')}</span>
|
||||||
|
<Money value={s.split.organization} className="font-semibold" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">{t('admin.finance.finalizeDialog.hint')}</p>
|
||||||
|
|
||||||
|
<div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex flex-col-reverse sm:flex-row gap-3">
|
||||||
|
<Button variant="outline" onClick={onClose} className="min-h-[44px] sm:flex-none">{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={onConfirm} isLoading={busy} className="flex-1 min-h-[44px] whitespace-nowrap">
|
||||||
|
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.finalizeDialog.confirm')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Finance charts (Recharts). Loaded with next/dynamic from SummaryView so the
|
||||||
|
// chart library only ships when the Finance tab opens.
|
||||||
|
//
|
||||||
|
// Colors: one blue for single-series charts (category/method names sit on the
|
||||||
|
// axis, so identity never relies on color); on the waterfall blue = money in,
|
||||||
|
// red = money out and gray = totals, with signed value labels and a legend
|
||||||
|
// that lists only the kinds on screen; blue = paid / orange = planned with a
|
||||||
|
// legend. Validated with the dataviz palette checks.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine,
|
||||||
|
LineChart, Line, Legend, LabelList,
|
||||||
|
} from 'recharts';
|
||||||
|
import type { FinanceSummary, ExpenseCategory } from '@/lib/api';
|
||||||
|
import { formatNumber, formatPyg, formatPygShort } from '@/lib/money';
|
||||||
|
import { buildWaterfall, detailChartsAvailable, type WaterfallKind } from './derive';
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
blue: '#2a78d6',
|
||||||
|
orange: '#eb6834',
|
||||||
|
red: '#e34948',
|
||||||
|
total: '#52514e',
|
||||||
|
grid: '#eeeeec',
|
||||||
|
axis: '#8a8984',
|
||||||
|
text: '#3f3e3b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_COLOR: Record<WaterfallKind, string> = { in: C.blue, out: C.red, total: C.total };
|
||||||
|
const KIND_SYMBOL: Record<WaterfallKind, string> = { in: '+', out: '−', total: '=' };
|
||||||
|
|
||||||
|
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
const axisProps = { stroke: C.axis, fontSize: 12, tickLine: false, axisLine: false } as const;
|
||||||
|
|
||||||
|
/** Bar labels: exact under 10.000 PYG, abbreviated above, always with the currency. */
|
||||||
|
const labelPyg = (v: number, locale: string) => (Math.abs(v) < 10000 ? formatPyg(v, locale) : `${formatPygShort(v, locale)} PYG`);
|
||||||
|
|
||||||
|
export function ChartCard({ title, children, subtitle, legend, className }: {
|
||||||
|
title: string; subtitle?: string; legend?: React.ReactNode; children: React.ReactNode; className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-card shadow-card p-4 min-w-0 ${className || ''}`}>
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
|
<h3 className="text-sm font-semibold text-primary-dark">{title}</h3>
|
||||||
|
{legend}
|
||||||
|
</div>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>}
|
||||||
|
<div className="mt-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendChip({ color, label, symbol }: { color: string; label: string; symbol: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
|
||||||
|
<span className="w-3 h-3 rounded-sm inline-flex items-center justify-center text-[9px] text-white font-bold" style={{ background: color }} aria-hidden>{symbol}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PygTooltip({ active, payload, label, rows, locale }: any) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const items: { name: string; value: number; color?: string }[] = rows ? rows(payload[0].payload) : payload.map((p: any) => ({ name: p.name, value: p.value, color: p.color }));
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
|
||||||
|
{label !== undefined && <p className="font-semibold text-primary-dark mb-1">{label}</p>}
|
||||||
|
{items.map((it) => (
|
||||||
|
<p key={it.name} className="flex items-center gap-2 text-gray-600">
|
||||||
|
{it.color && <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: it.color }} />}
|
||||||
|
<span>{it.name}</span>
|
||||||
|
<span className="ml-auto pl-3 font-medium text-primary-dark tabular-nums">{formatPyg(it.value, locale)}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Waterfall ====================
|
||||||
|
|
||||||
|
/** Phones get a horizontal waterfall: six step names never fit side by side. */
|
||||||
|
function useNarrow(maxWidth = 640) {
|
||||||
|
const [narrow, setNarrow] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
|
||||||
|
const update = () => setNarrow(mq.matches);
|
||||||
|
update();
|
||||||
|
mq.addEventListener('change', update);
|
||||||
|
return () => mq.removeEventListener('change', update);
|
||||||
|
}, [maxWidth]);
|
||||||
|
return narrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The waterfall bars only; the card around it (with the breakdown) lives in SummaryView. */
|
||||||
|
export function WaterfallChart({ summary, locale, t, estimated }: { summary: FinanceSummary; locale: string; t: T; estimated: boolean }) {
|
||||||
|
const narrow = useNarrow();
|
||||||
|
const steps = buildWaterfall(summary);
|
||||||
|
let running = 0;
|
||||||
|
const rows = steps.map((w) => {
|
||||||
|
const isTotal = w.kind === 'total';
|
||||||
|
const start = isTotal ? 0 : running;
|
||||||
|
const end = isTotal ? w.amount : running + w.amount;
|
||||||
|
running = end;
|
||||||
|
const name = w.key === 'profit'
|
||||||
|
? t(w.amount < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')
|
||||||
|
: t(`admin.finance.waterfall.${w.key}`);
|
||||||
|
return {
|
||||||
|
key: w.key,
|
||||||
|
kind: w.kind,
|
||||||
|
name,
|
||||||
|
amount: w.amount,
|
||||||
|
range: [Math.min(start, end), Math.max(start, end)] as [number, number],
|
||||||
|
color: KIND_COLOR[w.kind],
|
||||||
|
signed: isTotal ? labelPyg(w.amount, locale) : `${w.amount >= 0 ? '+' : '−'}${labelPyg(Math.abs(w.amount), locale)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const partnerRows = summary.split.partners.map((p) => ({ name: p.name, value: -p.share, color: C.red }));
|
||||||
|
const tooltipRows = (r: any) => (r.key === 'partners' ? partnerRows : [{ name: r.name, value: r.amount, color: r.color }]);
|
||||||
|
const kinds = (['in', 'out', 'total'] as WaterfallKind[]).filter((k) => rows.some((r) => r.kind === k));
|
||||||
|
|
||||||
|
// Value label above each bar's top edge. Bars under zero hang from the zero
|
||||||
|
// line, so their label sits just above it and never runs into the axis labels.
|
||||||
|
const renderLabel = (props: any): React.ReactElement<SVGElement> => {
|
||||||
|
const { x, y, width, height, index } = props;
|
||||||
|
const r = rows[index];
|
||||||
|
if (!r) return <g />;
|
||||||
|
const top = Math.min(y, y + height);
|
||||||
|
return (
|
||||||
|
<text x={x + width / 2} y={top - 6} textAnchor="middle" fontSize={11} fill={C.text} fontWeight={600}>
|
||||||
|
{r.signed}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const short = (v: number) => formatPygShort(v, locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
|
<h3 className="text-sm font-semibold text-primary-dark">{t('admin.finance.waterfall.title')}</h3>
|
||||||
|
<span className="flex flex-wrap gap-3">
|
||||||
|
{kinds.map((k) => (
|
||||||
|
<LegendChip key={k} color={KIND_COLOR[k]} symbol={KIND_SYMBOL[k]} label={t(`admin.finance.waterfall.legend.${k}`)} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{estimated && <p className="text-xs text-gray-500 mt-0.5">{t('admin.finance.waterfall.estimatedHint')}</p>}
|
||||||
|
<div className="mt-3">
|
||||||
|
{narrow ? (
|
||||||
|
// Step name and signed amount as a two-line axis label; bars run left/right from zero.
|
||||||
|
<ResponsiveContainer width="100%" height={rows.length * 48 + 24}>
|
||||||
|
<BarChart data={rows} layout="vertical" margin={{ top: 0, right: 12, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid horizontal={false} stroke={C.grid} />
|
||||||
|
<XAxis type="number" {...axisProps} tickFormatter={short} fontSize={11} />
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
{...axisProps}
|
||||||
|
width={136}
|
||||||
|
interval={0}
|
||||||
|
tick={(props: any) => {
|
||||||
|
const r = rows[props.index];
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${props.x},${props.y})`}>
|
||||||
|
<text x={-6} y={-3} textAnchor="end" fontSize={11} fill={C.text}>{r?.name}</text>
|
||||||
|
<text x={-6} y={11} textAnchor="end" fontSize={11} fontWeight={600} fill={C.text}>{r?.signed}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReferenceLine x={0} stroke={C.axis} />
|
||||||
|
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
|
||||||
|
<Bar dataKey="range" radius={4} barSize={20} minPointSize={3} isAnimationActive={false}>
|
||||||
|
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<BarChart data={rows} margin={{ top: 24, right: 8, left: 0, bottom: 8 }}>
|
||||||
|
<CartesianGrid vertical={false} stroke={C.grid} />
|
||||||
|
<XAxis dataKey="name" {...axisProps} interval={0} tick={{ fontSize: 11, fill: C.text }} height={40} />
|
||||||
|
<YAxis {...axisProps} tickFormatter={short} width={48} />
|
||||||
|
<ReferenceLine y={0} stroke={C.axis} />
|
||||||
|
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} rows={tooltipRows} />} />
|
||||||
|
<Bar dataKey="range" radius={4} maxBarSize={56} minPointSize={3} isAnimationActive={false} label={renderLabel}>
|
||||||
|
{rows.map((r) => <Cell key={r.key} fill={r.color} />)}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Horizontal single-series bars ====================
|
||||||
|
|
||||||
|
function HorizontalBars({ data, locale }: { data: { name: string; value: number; detail?: { name: string; value: number }[] }[]; locale: string }) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(120, data.length * 40 + 28)}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 84, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid horizontal={false} stroke={C.grid} />
|
||||||
|
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
|
||||||
|
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'rgba(0,0,0,0.04)' }}
|
||||||
|
content={<PygTooltip locale={locale} rows={(r: any) => r.detail || [{ name: r.name, value: r.value, color: C.blue }]} />}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" fill={C.blue} radius={[0, 4, 4, 0]} barSize={18} minPointSize={2} isAnimationActive={false}>
|
||||||
|
<LabelList dataKey="value" position="right" formatter={(v: number) => labelPyg(v, locale)} style={{ fontSize: 11, fill: C.text, fontWeight: 600 }} />
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryName(id: string | null, categories: ExpenseCategory[], locale: string, t: T) {
|
||||||
|
const c = categories.find((x) => x.id === id);
|
||||||
|
if (!c) return t('admin.finance.charts.uncategorized');
|
||||||
|
return locale === 'es' ? c.nameEs : c.nameEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpensesByCategoryChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
|
||||||
|
const data = summary.expenses.byCategory
|
||||||
|
.filter((c) => c.total > 0)
|
||||||
|
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), value: c.total }));
|
||||||
|
return (
|
||||||
|
<ChartCard title={t('admin.finance.charts.byCategory')}>
|
||||||
|
<HorizontalBars data={data} locale={locale} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevenueByMethodChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
|
||||||
|
const data = summary.revenue.byMethod
|
||||||
|
.filter((m) => m.gross > 0)
|
||||||
|
.map((m) => ({
|
||||||
|
name: t(`admin.finance.methods.${m.method}`),
|
||||||
|
value: m.gross,
|
||||||
|
detail: [
|
||||||
|
{ name: t('admin.finance.waterfall.gross'), value: m.gross },
|
||||||
|
{ name: t('admin.finance.waterfall.refunds'), value: -m.refunds },
|
||||||
|
{ name: t('admin.finance.waterfall.fees'), value: -m.fees },
|
||||||
|
{ name: t('admin.finance.waterfall.net'), value: m.net },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<ChartCard title={t('admin.finance.charts.byMethod')}>
|
||||||
|
<HorizontalBars data={data} locale={locale} />
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Cumulative sales ====================
|
||||||
|
|
||||||
|
export function CumulativeSalesChart({ summary, locale, t }: { summary: FinanceSummary; locale: string; t: T }) {
|
||||||
|
const fmtDay = (d: string) => new Date(`${d}T12:00:00Z`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short' });
|
||||||
|
const data = summary.salesTimeline.map((p) => ({ ...p, label: fmtDay(p.date) }));
|
||||||
|
const breakEven = summary.expenses.total > 0 ? summary.breakEven.tickets : null;
|
||||||
|
const maxSold = Math.max(1, ...data.map((d) => d.tickets));
|
||||||
|
// Only draw the break-even line when it fits a readable scale; otherwise a
|
||||||
|
// far-away target would flatten the sales line to the floor.
|
||||||
|
const showLine = breakEven !== null && breakEven > 0 && breakEven <= maxSold * 3;
|
||||||
|
const top = Math.ceil(Math.max(maxSold, showLine ? breakEven! : 0) * 1.15);
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={t('admin.finance.charts.cumulative')}
|
||||||
|
subtitle={breakEven !== null && breakEven > 0 && !showLine ? t('admin.finance.charts.breakEvenOffChart', { count: formatNumber(breakEven!, locale) }) : undefined}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<LineChart data={data} margin={{ top: 16, right: 16, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid vertical={false} stroke={C.grid} />
|
||||||
|
<XAxis dataKey="label" {...axisProps} minTickGap={16} />
|
||||||
|
<YAxis {...axisProps} allowDecimals={false} domain={[0, top]} width={40} tickFormatter={(v: number) => formatNumber(v, locale)}
|
||||||
|
label={{ value: t('admin.finance.charts.tickets'), angle: -90, position: 'insideLeft', fontSize: 11, fill: C.axis, dy: 30 }} />
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }: any) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const p = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
|
||||||
|
<p className="font-semibold text-primary-dark mb-1">{p.label}</p>
|
||||||
|
<p className="text-gray-600">{t('admin.finance.kpi.ticketsSold', { count: formatNumber(p.tickets, locale) })}</p>
|
||||||
|
<p className="text-gray-600 tabular-nums">{formatPyg(p.revenue, locale)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showLine && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={breakEven!}
|
||||||
|
stroke={C.red}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
label={{ value: `${t('admin.finance.charts.breakEvenLine')}: ${formatNumber(breakEven!, locale)}`, position: 'insideTopRight', fontSize: 11, fill: C.text }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Line type="monotone" dataKey="tickets" stroke={C.blue} strokeWidth={2} dot={{ r: 4, fill: C.blue, stroke: '#fff', strokeWidth: 2 }} isAnimationActive={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Planned vs paid ====================
|
||||||
|
|
||||||
|
export function PlannedVsPaidChart({ summary, categories, locale, t }: { summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T }) {
|
||||||
|
const data = summary.expenses.byCategory
|
||||||
|
.filter((c) => c.total > 0)
|
||||||
|
.map((c) => ({ name: categoryName(c.categoryId, categories, locale, t), paid: c.paid, planned: c.planned }));
|
||||||
|
const paidLabel = t('admin.finance.charts.paid');
|
||||||
|
const plannedLabel = t('admin.finance.charts.planned');
|
||||||
|
const hasPaid = summary.expenses.paid > 0;
|
||||||
|
const hasPlanned = summary.expenses.planned > 0;
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={t('admin.finance.charts.plannedVsActual')}
|
||||||
|
subtitle={`${paidLabel} ${formatPyg(summary.expenses.paid, locale)} · ${plannedLabel} ${formatPyg(summary.expenses.planned, locale)}`}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(140, data.length * 40 + 56)}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid horizontal={false} stroke={C.grid} />
|
||||||
|
<XAxis type="number" {...axisProps} tickFormatter={(v: number) => formatPygShort(v, locale)} />
|
||||||
|
<YAxis type="category" dataKey="name" {...axisProps} width={104} tick={{ fontSize: 12, fill: C.text }} />
|
||||||
|
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<PygTooltip locale={locale} />} />
|
||||||
|
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 12, color: C.text }} />
|
||||||
|
{hasPaid && <Bar dataKey="paid" name={paidLabel} stackId="s" fill={C.blue} stroke="#fff" strokeWidth={2} barSize={18} isAnimationActive={false} />}
|
||||||
|
{hasPlanned && <Bar dataKey="planned" name={plannedLabel} stackId="s" fill={C.orange} stroke="#fff" strokeWidth={2} radius={[0, 4, 4, 0]} barSize={18} isAnimationActive={false} />}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinanceCharts({ summary, categories, locale, t, part, estimated = false }: {
|
||||||
|
summary: FinanceSummary; categories: ExpenseCategory[]; locale: string; t: T; part: 'waterfall' | 'details'; estimated?: boolean;
|
||||||
|
}) {
|
||||||
|
if (part === 'waterfall') return <WaterfallChart summary={summary} locale={locale} t={t} estimated={estimated} />;
|
||||||
|
const show = detailChartsAvailable(summary);
|
||||||
|
if (!show.costs && !show.methods && !show.timeline) return null;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{show.costs && <ExpensesByCategoryChart summary={summary} categories={categories} locale={locale} t={t} />}
|
||||||
|
{show.methods && <RevenueByMethodChart summary={summary} locale={locale} t={t} />}
|
||||||
|
{show.timeline && <CumulativeSalesChart summary={summary} locale={locale} t={t} />}
|
||||||
|
{show.costs && <PlannedVsPaidChart summary={summary} categories={categories} locale={locale} t={t} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { financeApi, type EventFinance } from '@/lib/api';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { SubNav } from './ui';
|
||||||
|
import { SummaryView } from './SummaryView';
|
||||||
|
import { ExpensesView } from './ExpensesView';
|
||||||
|
import { SplitView } from './SplitView';
|
||||||
|
import { LifecycleStepper } from './LifecycleStepper';
|
||||||
|
import { deriveLifecycle } from './derive';
|
||||||
|
|
||||||
|
export type FinanceSection = 'summary' | 'expenses' | 'split';
|
||||||
|
const SECTIONS: FinanceSection[] = ['summary', 'expenses', 'split'];
|
||||||
|
|
||||||
|
/** Keep the open section in the URL (?fin=expenses) so reloads and shared links land on it. */
|
||||||
|
function readSection(): FinanceSection {
|
||||||
|
if (typeof window === 'undefined') return 'summary';
|
||||||
|
const v = new URLSearchParams(window.location.search).get('fin');
|
||||||
|
return SECTIONS.includes(v as FinanceSection) ? (v as FinanceSection) : 'summary';
|
||||||
|
}
|
||||||
|
function writeSection(section: FinanceSection) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (section === 'summary') url.searchParams.delete('fin'); else url.searchParams.set('fin', section);
|
||||||
|
window.history.replaceState(window.history.state, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finance tab: P&L summary with charts, expenses and other income, and the
|
||||||
|
* partner split with payouts. The server already scopes what comes back (e.g.
|
||||||
|
* collaborators only get their own share), so this renders what it receives.
|
||||||
|
*/
|
||||||
|
export function FinanceTab({ eventId }: { eventId: string }) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [data, setData] = useState<EventFinance | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [section, setSectionState] = useState<FinanceSection>('summary');
|
||||||
|
|
||||||
|
useEffect(() => { setSectionState(readSection()); }, []);
|
||||||
|
const setSection = useCallback((s: FinanceSection) => { setSectionState(s); writeSection(s); }, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setData(await financeApi.get(eventId));
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || t('admin.finance.loadError'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [eventId, t]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
// Recomputed with every reload, so "ended X days ago" stays current.
|
||||||
|
const lifecycle = useMemo(() => (data ? deriveLifecycle(data) : null), [data]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" aria-busy="true">
|
||||||
|
<Skeleton className="h-24" />
|
||||||
|
<Skeleton className="h-11 w-full md:w-96" />
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data || !lifecycle) return <p className="text-sm text-gray-500 py-8 text-center">{t('admin.finance.loadError')}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<LifecycleStepper data={data} lifecycle={lifecycle} goTo={setSection} />
|
||||||
|
|
||||||
|
<SubNav<FinanceSection>
|
||||||
|
items={[
|
||||||
|
{ key: 'summary', label: t('admin.finance.subnav.summary') },
|
||||||
|
{ key: 'expenses', label: t('admin.finance.subnav.expenses'), count: data.expenses.length + data.otherIncome.length },
|
||||||
|
{ key: 'split', label: t('admin.finance.subnav.split'), count: data.partners.length },
|
||||||
|
]}
|
||||||
|
active={section}
|
||||||
|
onChange={setSection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{section === 'summary' && <SummaryView data={data} goTo={setSection} />}
|
||||||
|
{section === 'expenses' && <ExpensesView eventId={eventId} data={data} onChange={load} />}
|
||||||
|
{section === 'split' && <SplitView eventId={eventId} data={data} lifecycle={lifecycle} onChange={load} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { CheckIcon, ExclamationTriangleIcon, InformationCircleIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import type { EventFinance } from '@/lib/api';
|
||||||
|
import { LIFECYCLE_STEPS, tCount, type Lifecycle } from './derive';
|
||||||
|
import type { FinanceSection } from './FinanceTab';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the event's books stand: Selling -> Adding costs -> Ready to close ->
|
||||||
|
* Finalized, with what is still missing underneath. Closing the books itself
|
||||||
|
* happens in Split & Payouts; this only points the way.
|
||||||
|
*/
|
||||||
|
export function LifecycleStepper({ data, lifecycle, goTo }: { data: EventFinance; lifecycle: Lifecycle; goTo: (s: FinanceSection) => void }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const current = LIFECYCLE_STEPS.indexOf(lifecycle.step);
|
||||||
|
const open = data.status === 'open';
|
||||||
|
const dateFmt = (iso: string | null) =>
|
||||||
|
iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '';
|
||||||
|
|
||||||
|
// Once every cost is in, the nudge says so instead of asking for more.
|
||||||
|
const nudgeKey = lifecycle.step === 'ready' ? 'admin.finance.lifecycle.nudgeReady' : 'admin.finance.lifecycle.nudge';
|
||||||
|
const nudge = open && lifecycle.ended && lifecycle.daysSinceEnd !== null
|
||||||
|
? lifecycle.daysSinceEnd === 0
|
||||||
|
? t(`${nudgeKey}Today`)
|
||||||
|
: tCount(t, nudgeKey, lifecycle.daysSinceEnd)
|
||||||
|
: null;
|
||||||
|
const ready = lifecycle.step === 'ready';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-card shadow-card p-3 md:p-4" aria-label={t('admin.finance.lifecycle.label')}>
|
||||||
|
<ol className="grid grid-cols-4 gap-1">
|
||||||
|
{LIFECYCLE_STEPS.map((step, i) => {
|
||||||
|
const done = i < current || (step === 'finalized' && !open);
|
||||||
|
const active = i === current && open;
|
||||||
|
return (
|
||||||
|
<li key={step} className="flex flex-col items-center text-center min-w-0 relative" aria-current={i === current ? 'step' : undefined}>
|
||||||
|
{i > 0 && (
|
||||||
|
<span
|
||||||
|
className={clsx('absolute top-3 h-0.5', i <= current ? 'bg-primary-yellow' : 'bg-gray-200')}
|
||||||
|
// From the previous circle's edge to this one's (circles are 1.5rem, columns 0.25rem apart).
|
||||||
|
style={{ right: 'calc(50% + 0.875rem)', width: 'calc(100% - 1.5rem)' }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'relative z-10 w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold border-2',
|
||||||
|
done && 'bg-primary-yellow border-primary-yellow text-primary-dark',
|
||||||
|
active && 'bg-white border-primary-yellow text-primary-dark',
|
||||||
|
!done && !active && 'bg-white border-gray-200 text-gray-400',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{done ? <CheckIcon className="w-3.5 h-3.5" strokeWidth={3} /> : i + 1}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('mt-1 text-[11px] sm:text-xs leading-tight', i === current ? 'font-semibold text-primary-dark' : 'text-gray-500')}>
|
||||||
|
{t(`admin.finance.lifecycle.steps.${step}`)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{(nudge || lifecycle.todo.length > 0 || !open) && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 space-y-2 text-sm">
|
||||||
|
{nudge && (
|
||||||
|
<div className={clsx(
|
||||||
|
'flex flex-col sm:flex-row sm:items-center gap-2 rounded-btn border px-3 py-2',
|
||||||
|
ready ? 'bg-green-50 border-green-200 text-green-900' : 'bg-amber-50 border-amber-200 text-amber-900',
|
||||||
|
)}>
|
||||||
|
<p className="flex items-start gap-2 flex-1">
|
||||||
|
{ready ? <CheckIcon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden /> : <ClockIcon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />}
|
||||||
|
{nudge}
|
||||||
|
</p>
|
||||||
|
{data.viewer.canEditExpenses && !ready && (
|
||||||
|
<button onClick={() => goTo('expenses')} className="self-start sm:self-auto text-sm font-medium underline whitespace-nowrap cursor-pointer min-h-[32px]">
|
||||||
|
{t('admin.finance.lifecycle.reviewCosts')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!open && (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{t(data.status === 'paid_out' ? 'admin.finance.lifecycle.paidOut' : 'admin.finance.lifecycle.frozen', { date: dateFmt(data.finalizedAt) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lifecycle.todo.length > 0 && (
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{lifecycle.todo.map((item) => (
|
||||||
|
<li key={item.key} className={clsx('inline-flex items-center gap-1.5 text-xs sm:text-sm', item.tone === 'warn' ? 'text-amber-800' : 'text-gray-500')}>
|
||||||
|
{item.tone === 'warn'
|
||||||
|
? <ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />
|
||||||
|
: <InformationCircleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />}
|
||||||
|
{item.count !== undefined
|
||||||
|
? tCount(t, `admin.finance.lifecycle.todo.${item.key}`, item.count)
|
||||||
|
: t(`admin.finance.lifecycle.todo.${item.key}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
PlusIcon, PencilIcon, TrashIcon, DocumentArrowDownIcon, CheckCircleIcon, LockClosedIcon, LockOpenIcon, ClockIcon,
|
||||||
|
XMarkIcon, ChevronDownIcon, UserGroupIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
|
import { useMoney } from '@/lib/useMoney';
|
||||||
|
import {
|
||||||
|
financeApi, SHARE_TYPES, LOSS_RULES,
|
||||||
|
type EventFinance, type EventPartner, type FinanceAuditEntry, type LossRule, type PartnerInput, type ShareType,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { downloadBlob } from '../_utils/format';
|
||||||
|
import { Modal, Field, PygInput, DateInput, Money, ChoiceCards, EmptyState, Pill, inputClass, iconButtonClass, todayIso } from './ui';
|
||||||
|
import { UserSearch } from './UserSearch';
|
||||||
|
import { bpToPercent, percentToBp } from './format';
|
||||||
|
import { FinalizeDialog } from './FinalizeDialog';
|
||||||
|
import type { Lifecycle } from './derive';
|
||||||
|
|
||||||
|
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
type Fmt = ReturnType<typeof useMoney>;
|
||||||
|
|
||||||
|
function dealText(p: EventPartner, t: T, m: Fmt): string {
|
||||||
|
switch (p.shareType) {
|
||||||
|
case 'percent_profit': return t('admin.finance.deal.percentProfit', { percent: m.pct(p.percentBp) });
|
||||||
|
case 'percent_revenue': return t('admin.finance.deal.percentRevenue', { percent: m.pct(p.percentBp) });
|
||||||
|
case 'fixed': return t('admin.finance.deal.fixed', { amount: m.pyg(p.fixedAmount) });
|
||||||
|
case 'fixed_plus_percent_above_threshold':
|
||||||
|
return t('admin.finance.deal.fixedPlus', { amount: m.pyg(p.fixedAmount), percent: m.pct(p.percentBp), threshold: m.pyg(p.thresholdAmount) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalActions({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="sticky bottom-0 -mx-4 -mb-4 mt-2 px-4 py-3 bg-white border-t border-gray-100 flex gap-3">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Partner form ====================
|
||||||
|
|
||||||
|
interface PartnerForm {
|
||||||
|
userId: string | null;
|
||||||
|
userLabel: string;
|
||||||
|
externalName: string;
|
||||||
|
roleLabel: string;
|
||||||
|
shareType: ShareType;
|
||||||
|
percent: string;
|
||||||
|
fixedAmount: number;
|
||||||
|
thresholdAmount: number;
|
||||||
|
lossRule: LossRule;
|
||||||
|
lossCapAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyPartner = (): PartnerForm => ({
|
||||||
|
userId: null, userLabel: '', externalName: '', roleLabel: '', shareType: 'percent_profit', percent: '',
|
||||||
|
fixedAmount: 0, thresholdAmount: 0, lossRule: 'none', lossCapAmount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
function PartnerModal({ open, onClose, eventId, partner, onSaved }: {
|
||||||
|
open: boolean; onClose: () => void; eventId: string; partner: EventPartner | null; onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [form, setForm] = useState<PartnerForm>(emptyPartner());
|
||||||
|
const [touched, setTouched] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const set = <K extends keyof PartnerForm>(k: K, v: PartnerForm[K]) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
const search = useCallback((q: string) => financeApi.searchPartnerCandidates(eventId, q), [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTouched(false);
|
||||||
|
setForm(partner ? {
|
||||||
|
userId: partner.userId, userLabel: partner.userId ? partner.name : '', externalName: partner.externalName || '',
|
||||||
|
roleLabel: partner.roleLabel || '', shareType: partner.shareType, percent: partner.percentBp ? bpToPercent(partner.percentBp) : '',
|
||||||
|
fixedAmount: partner.fixedAmount, thresholdAmount: partner.thresholdAmount, lossRule: partner.lossRule, lossCapAmount: partner.lossCapAmount,
|
||||||
|
} : emptyPartner());
|
||||||
|
}, [open, partner]);
|
||||||
|
|
||||||
|
const st = form.shareType;
|
||||||
|
const hasPercent = st !== 'fixed';
|
||||||
|
const hasFixed = st === 'fixed' || st === 'fixed_plus_percent_above_threshold';
|
||||||
|
const whoError = touched && !form.userId && !form.externalName.trim() ? t('admin.finance.split.needsNameOrUser') : undefined;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setTouched(true);
|
||||||
|
if (!form.userId && !form.externalName.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
const payload: PartnerInput = {
|
||||||
|
userId: form.userId,
|
||||||
|
externalName: form.externalName.trim() || null,
|
||||||
|
roleLabel: form.roleLabel.trim() || null,
|
||||||
|
shareType: st,
|
||||||
|
percentBp: hasPercent ? percentToBp(form.percent) : 0,
|
||||||
|
fixedAmount: hasFixed ? form.fixedAmount : 0,
|
||||||
|
thresholdAmount: st === 'fixed_plus_percent_above_threshold' ? form.thresholdAmount : 0,
|
||||||
|
lossRule: st === 'percent_profit' ? form.lossRule : 'none',
|
||||||
|
lossCapAmount: st === 'percent_profit' && form.lossRule === 'capped' ? form.lossCapAmount : 0,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (partner) await financeApi.updatePartner(eventId, partner.id, payload);
|
||||||
|
else await financeApi.createPartner(eventId, payload);
|
||||||
|
toast.success(t('admin.finance.split.partnerSaved'));
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={t(partner ? 'admin.finance.split.editPartner' : 'admin.finance.split.addPartner')} wide>
|
||||||
|
<Field label={t('admin.finance.split.who')} required error={whoError} hint={t('admin.finance.split.whoHint')}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.userId ? (
|
||||||
|
<div className="flex items-center gap-2 pl-3 rounded-btn bg-gray-50 text-sm min-h-[44px]">
|
||||||
|
<span className="flex-1 truncate">{form.userLabel}</span>
|
||||||
|
<button type="button" onClick={() => setForm((f) => ({ ...f, userId: null, userLabel: '' }))} className={iconButtonClass} aria-label={t('admin.team.remove')}>
|
||||||
|
<XMarkIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<UserSearch
|
||||||
|
search={search}
|
||||||
|
onPick={(u) => setForm((f) => ({ ...f, userId: u.id, userLabel: `${u.name} (${u.email})` }))}
|
||||||
|
placeholder={t('admin.finance.split.searchUser')}
|
||||||
|
noResults={t('admin.team.noResults')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!form.userId && (
|
||||||
|
<input className={inputClass} value={form.externalName} maxLength={200} placeholder={t('admin.finance.split.externalName')} onChange={(e) => set('externalName', e.target.value)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.split.roleLabel')}>
|
||||||
|
<input className={inputClass} value={form.roleLabel} maxLength={100} placeholder={t('admin.finance.split.rolePlaceholder')} onChange={(e) => set('roleLabel', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.split.shareType')}>
|
||||||
|
<ChoiceCards<ShareType>
|
||||||
|
name="shareType"
|
||||||
|
value={st}
|
||||||
|
onChange={(v) => set('shareType', v)}
|
||||||
|
options={SHARE_TYPES.map((s) => ({ key: s, title: t(`admin.finance.shareTypes.${s}`), description: t(`admin.finance.shareHints.${s}`) }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{hasFixed && (
|
||||||
|
<Field label={t('admin.finance.split.fixedAmount')}>
|
||||||
|
<PygInput value={form.fixedAmount} onChange={(n) => set('fixedAmount', n)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{hasPercent && (
|
||||||
|
<Field label={t('admin.finance.split.percent')}>
|
||||||
|
<div className="relative">
|
||||||
|
<input className={clsx(inputClass, 'pr-8')} inputMode="decimal" value={form.percent} placeholder="30" onChange={(e) => set('percent', e.target.value)} />
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400">%</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{st === 'fixed_plus_percent_above_threshold' && (
|
||||||
|
<Field label={t('admin.finance.split.threshold')}>
|
||||||
|
<PygInput value={form.thresholdAmount} onChange={(n) => set('thresholdAmount', n)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{st === 'percent_profit' && (
|
||||||
|
<Field label={t('admin.finance.split.lossRule')}>
|
||||||
|
<ChoiceCards<LossRule>
|
||||||
|
name="lossRule"
|
||||||
|
columns={3}
|
||||||
|
value={form.lossRule}
|
||||||
|
onChange={(v) => set('lossRule', v)}
|
||||||
|
options={LOSS_RULES.map((r) => ({ key: r, title: t(`admin.finance.lossRuleTitles.${r}`), description: t(`admin.finance.lossRules.${r}`) }))}
|
||||||
|
/>
|
||||||
|
{form.lossRule === 'capped' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Field label={t('admin.finance.split.lossCap')}>
|
||||||
|
<PygInput value={form.lossCapAmount} onChange={(n) => set('lossCapAmount', n)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<ModalActions>
|
||||||
|
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('common.save')}</Button>
|
||||||
|
</ModalActions>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Payout ====================
|
||||||
|
|
||||||
|
function PayoutModal({ partner, amount, onClose, eventId, onSaved }: { partner: EventPartner | null; amount: number; onClose: () => void; eventId: string; onSaved: () => void }) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [method, setMethod] = useState('');
|
||||||
|
const [date, setDate] = useState('');
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (partner) { setMethod(''); setDate(todayIso()); setNote(''); }
|
||||||
|
}, [partner]);
|
||||||
|
const submit = async () => {
|
||||||
|
if (!partner) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await financeApi.markPartnerPaid(eventId, partner.id, { paid: true, payoutMethod: method || null, payoutDate: date || null, payoutNote: note || null });
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modal open={!!partner} onClose={onClose} title={t('admin.finance.split.markPaid')} subtitle={partner?.name}>
|
||||||
|
<div className="rounded-btn bg-gray-50 p-3 flex justify-between items-baseline">
|
||||||
|
<span className="text-sm text-gray-600">{t(amount < 0 ? 'admin.finance.split.owes' : 'admin.finance.split.payout')}</span>
|
||||||
|
<Money value={Math.abs(amount)} className="text-lg font-bold" />
|
||||||
|
</div>
|
||||||
|
<Field label={t('admin.finance.split.payoutMethod')}>
|
||||||
|
<input className={inputClass} value={method} maxLength={50} onChange={(e) => setMethod(e.target.value)} placeholder={t('admin.finance.split.payoutMethodPlaceholder')} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.expenses.date')}>
|
||||||
|
<DateInput value={date} onChange={setDate} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('admin.finance.split.payoutNote')}>
|
||||||
|
<textarea className={inputClass} rows={2} value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<ModalActions>
|
||||||
|
<Button variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={submit} isLoading={saving} className="flex-1 min-h-[44px]">{t('admin.finance.split.markPaid')}</Button>
|
||||||
|
</ModalActions>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Audit log ====================
|
||||||
|
|
||||||
|
function AuditLog({ eventId }: { eventId: string }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const [entries, setEntries] = useState<FinanceAuditEntry[] | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && entries === null) financeApi.auditLog(eventId).then((r) => setEntries(r.entries)).catch(() => setEntries([]));
|
||||||
|
}, [open, entries, eventId]);
|
||||||
|
const describe = (e: FinanceAuditEntry) => {
|
||||||
|
const name = e.after?.description || e.before?.description || e.after?.name || e.before?.name || '';
|
||||||
|
const action = t(`admin.finance.audit.${e.action}`);
|
||||||
|
const entity = t(`admin.finance.audit.entity.${e.entityType}`);
|
||||||
|
return `${entity} · ${action.startsWith('admin.') ? e.action.replace(/_/g, ' ') : action}${name ? ` · ${name}` : ''}`;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-card shadow-card">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full flex items-center gap-2 px-4 min-h-[48px] text-sm font-semibold cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow rounded-card"
|
||||||
|
>
|
||||||
|
<ClockIcon className="w-4 h-4 text-gray-500" aria-hidden />
|
||||||
|
{t('admin.finance.split.auditLog')}
|
||||||
|
<ChevronDownIcon className={clsx('w-4 h-4 ml-auto text-gray-400 transition-transform', open && 'rotate-180')} aria-hidden />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{entries === null ? <p className="text-sm text-gray-400">{t('common.loading')}</p> : entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">{t('admin.finance.split.auditEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100 text-sm">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li key={e.id} className="py-2 flex flex-col sm:flex-row sm:gap-3">
|
||||||
|
<span className="text-xs text-gray-400 tabular-nums sm:w-36 flex-shrink-0">
|
||||||
|
{e.createdAt ? new Date(e.createdAt).toLocaleString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'short', timeStyle: 'short' }) : ''}
|
||||||
|
</span>
|
||||||
|
<span><span className="font-medium">{e.actorName || '-'}</span> <span className="text-gray-600">{describe(e)}</span></span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== View ====================
|
||||||
|
|
||||||
|
export function SplitView({ eventId, data, lifecycle, onChange }: { eventId: string; data: EventFinance; lifecycle: Lifecycle; onChange: () => void }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const m = useMoney();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const [editing, setEditing] = useState<EventPartner | null>(null);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [paying, setPaying] = useState<EventPartner | null>(null);
|
||||||
|
const [finalizing, setFinalizing] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { viewer, summary } = data;
|
||||||
|
const open = data.status === 'open';
|
||||||
|
const manage = viewer.canManageSplit;
|
||||||
|
const lines = new Map(summary.split.partners.map((p) => [p.partnerId, p]));
|
||||||
|
const hasPartners = data.partners.length > 0;
|
||||||
|
const paidCount = data.partners.filter((p) => p.payoutStatus === 'paid').length;
|
||||||
|
|
||||||
|
const run = async (key: string, fn: () => Promise<unknown>, success?: string) => {
|
||||||
|
setBusy(key);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
if (success) toast.success(success);
|
||||||
|
onChange();
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalize = async () => {
|
||||||
|
if (await run('finalize', () => financeApi.finalize(eventId), t('admin.finance.split.finalized'))) setFinalizing(false);
|
||||||
|
};
|
||||||
|
const unfinalize = () => {
|
||||||
|
if (!confirm(t('admin.finance.split.unfinalizeConfirm'))) return;
|
||||||
|
run('unfinalize', () => financeApi.unfinalize(eventId), t('admin.finance.split.unfinalized'));
|
||||||
|
};
|
||||||
|
const remove = (p: EventPartner) => {
|
||||||
|
if (!confirm(t('admin.finance.split.confirmRemoveNamed', { name: p.name }))) return;
|
||||||
|
run(`rm-${p.id}`, () => financeApi.deletePartner(eventId, p.id), t('admin.finance.split.partnerRemoved'));
|
||||||
|
};
|
||||||
|
const statement = async (p: EventPartner) => {
|
||||||
|
setBusy(`pdf-${p.id}`);
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await financeApi.downloadStatement(eventId, p.id, locale);
|
||||||
|
downloadBlob(blob, filename);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
|
||||||
|
const openNew = () => { setEditing(null); setFormOpen(true); };
|
||||||
|
|
||||||
|
const loss = summary.profit < 0;
|
||||||
|
const distributable = summary.split.distributable;
|
||||||
|
// Percent-of-profit deals apply to what is left after fixed and revenue deals; say so when that differs from the profit.
|
||||||
|
const profitBase = distributable !== null && distributable !== summary.profit ? distributable : null;
|
||||||
|
const resultTone = (v: number | null) => (!open && !privacyMode && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
|
||||||
|
|
||||||
|
const BreakdownRow = ({ label, detail, value, sign, strong, className }: {
|
||||||
|
label: React.ReactNode; detail?: string; value: number | null; sign?: '+' | '−'; strong?: boolean; className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong ? 'font-semibold' : 'text-gray-600')}>
|
||||||
|
<span className="min-w-0">
|
||||||
|
{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}
|
||||||
|
{label}
|
||||||
|
{detail && <span className="block text-xs text-gray-400 pl-4">{detail}</span>}
|
||||||
|
</span>
|
||||||
|
<Money value={value} className={clsx('whitespace-nowrap', className)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
{/* Result, as a short calculation ending in what the organization keeps */}
|
||||||
|
<div className="bg-white rounded-card shadow-card p-4 lg:col-span-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-primary-dark">{t('admin.finance.split.calculation')}</h3>
|
||||||
|
{open && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<BreakdownRow label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={summary.profit} strong className={resultTone(summary.profit)} />
|
||||||
|
{summary.split.partners.map((line) => {
|
||||||
|
const p = data.partners.find((x) => x.id === line.partnerId);
|
||||||
|
const rule = p ? dealText(p, t, m) : '';
|
||||||
|
const detail = p?.shareType === 'percent_profit' && profitBase !== null && !privacyMode
|
||||||
|
? t('admin.finance.split.ofDistributable', { rule, base: m.pyg(profitBase) })
|
||||||
|
: rule;
|
||||||
|
return (
|
||||||
|
<BreakdownRow
|
||||||
|
key={line.partnerId}
|
||||||
|
sign={line.share < 0 ? '+' : '−'}
|
||||||
|
label={line.name}
|
||||||
|
detail={detail}
|
||||||
|
value={Math.abs(line.share)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{viewer.fullSplit && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-gray-200 mt-1" />
|
||||||
|
<BreakdownRow label={t('admin.finance.split.organization')} value={summary.split.organization} strong className={clsx('text-base', resultTone(summary.split.organization))} />
|
||||||
|
{!hasPartners && <p className="text-xs text-gray-400">{t('admin.finance.split.empty')}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!viewer.fullSplit && <p className="text-xs text-gray-500 mt-2">{t('admin.finance.split.ownShareOnly')}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close the books: the one place to finalize */}
|
||||||
|
{(manage || !open) && (
|
||||||
|
<div className={clsx('rounded-card shadow-card p-4 flex flex-col gap-3', open ? 'bg-white' : 'bg-blue-50')}>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-primary-dark flex items-center gap-2">
|
||||||
|
{open ? <LockOpenIcon className="w-5 h-5" aria-hidden /> : <LockClosedIcon className="w-5 h-5" aria-hidden />}
|
||||||
|
{t(open ? 'admin.finance.split.closeBooks' : 'admin.finance.split.booksClosed')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{open
|
||||||
|
? t('admin.finance.split.closeBooksHint')
|
||||||
|
: hasPartners
|
||||||
|
? t('admin.finance.split.payoutsProgress', { paid: paidCount, total: data.partners.length })
|
||||||
|
: t('admin.finance.split.frozenNoPartners')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto">
|
||||||
|
{open && manage && (
|
||||||
|
<Button onClick={() => setFinalizing(true)} className="w-full min-h-[44px]">
|
||||||
|
<LockClosedIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.closeBooks')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!open && viewer.canUnfinalize && (
|
||||||
|
<Button variant="outline" onClick={unfinalize} isLoading={busy === 'unfinalize'} className="w-full min-h-[44px]">
|
||||||
|
<LockOpenIcon className="w-4 h-4 mr-1.5" aria-hidden />{t('admin.finance.split.unfinalize')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partners */}
|
||||||
|
{!hasPartners ? (
|
||||||
|
viewer.fullSplit && (
|
||||||
|
<EmptyState icon={<UserGroupIcon className="w-5 h-5" />} title={t('admin.finance.split.noPartnersTitle')} body={t('admin.finance.split.noPartnersBody')}>
|
||||||
|
{manage && open && <Button onClick={openNew} className="min-h-[44px]"><PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}</Button>}
|
||||||
|
</EmptyState>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-card shadow-card">
|
||||||
|
<div className="flex items-center justify-between gap-2 px-4 py-3 border-b border-gray-100">
|
||||||
|
<h3 className="text-sm font-semibold">{t('admin.finance.split.partners')}</h3>
|
||||||
|
{manage && open && (
|
||||||
|
<Button size="sm" onClick={openNew} className="min-h-[44px] md:min-h-0">
|
||||||
|
<PlusIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.addPartner')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="divide-y divide-gray-100">
|
||||||
|
{data.partners.map((p) => {
|
||||||
|
const line = lines.get(p.id);
|
||||||
|
const payout = line?.payout ?? 0;
|
||||||
|
const owes = payout < 0;
|
||||||
|
const isPaid = p.payoutStatus === 'paid';
|
||||||
|
const reimbursement = line?.reimbursement ?? 0;
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="px-4 py-3 grid grid-cols-[1fr_auto] md:grid-cols-[minmax(0,1fr)_10rem_9rem_auto] gap-x-4 gap-y-2 items-center">
|
||||||
|
{/* Who and the deal */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-primary-dark truncate">{p.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{[p.roleLabel, dealText(p, t, m)].filter(Boolean).join(' · ')}</p>
|
||||||
|
{p.shareType === 'percent_profit' && loss && (
|
||||||
|
<p className="text-xs text-gray-400">{t(`admin.finance.lossRules.${p.lossRule}`)}{p.lossRule === 'capped' ? ` (${m.pyg(p.lossCapAmount)})` : ''}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="text-right">
|
||||||
|
<Money value={Math.abs(payout)} className={clsx('font-semibold whitespace-nowrap', owes && !privacyMode && 'text-red-600')} />
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{owes ? t('admin.finance.split.owes') : reimbursement !== 0 && !privacyMode
|
||||||
|
? t('admin.finance.split.includesReimbursement', { amount: m.pyg(reimbursement) })
|
||||||
|
: t('admin.finance.split.payout')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Status */}
|
||||||
|
<div className="col-span-2 md:col-span-1 flex items-center gap-2">
|
||||||
|
<span className={clsx('inline-flex items-center gap-1 rounded-full pl-1.5 pr-2.5 py-0.5 text-xs font-medium whitespace-nowrap', isPaid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800')}>
|
||||||
|
{isPaid ? <CheckCircleIcon className="w-4 h-4" aria-hidden /> : <ClockIcon className="w-4 h-4" aria-hidden />}
|
||||||
|
{isPaid ? t('admin.finance.split.paidOn', { date: fmtDate(p.payoutDate) }) : t('admin.finance.split.unpaid')}
|
||||||
|
</span>
|
||||||
|
{isPaid && (p.payoutMethod || p.payoutNote) && (
|
||||||
|
<span className="text-xs text-gray-500 truncate md:hidden">{[p.payoutMethod, p.payoutNote].filter(Boolean).join(' · ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="col-span-2 md:col-span-1 flex flex-wrap items-center gap-1 md:justify-end">
|
||||||
|
{manage && !open && !isPaid && (
|
||||||
|
<Button size="sm" onClick={() => setPaying(p)} className="min-h-[44px] md:min-h-[36px]">
|
||||||
|
<CheckCircleIcon className="w-4 h-4 mr-1" aria-hidden />{t('admin.finance.split.markPaid')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{manage && !open && isPaid && (
|
||||||
|
<Button size="sm" variant="ghost" isLoading={busy === `unpay-${p.id}`} className="min-h-[44px] md:min-h-[36px]"
|
||||||
|
onClick={() => run(`unpay-${p.id}`, () => financeApi.markPartnerPaid(eventId, p.id, { paid: false }))}>
|
||||||
|
{t('admin.finance.split.markUnpaid')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => statement(p)} disabled={busy === `pdf-${p.id}`} className={iconButtonClass} aria-label={`${t('admin.finance.split.statement')} ${p.name}`} title={t('admin.finance.split.statement')}>
|
||||||
|
<DocumentArrowDownIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{manage && open && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => { setEditing(p); setFormOpen(true); }} className={iconButtonClass} aria-label={`${t('common.edit')} ${p.name}`}><PencilIcon className="w-4 h-4" /></button>
|
||||||
|
<button onClick={() => remove(p)} className={clsx(iconButtonClass, 'text-red-600')} aria-label={`${t('common.delete')} ${p.name}`}><TrashIcon className="w-4 h-4" /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{manage && open && <p className="px-4 py-2.5 border-t border-gray-100 text-xs text-gray-500">{t('admin.finance.split.finalizeFirst')}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewer.fullSplit && <AuditLog key={`${data.status}-${data.computedAt}`} eventId={eventId} />}
|
||||||
|
|
||||||
|
<PartnerModal open={formOpen} onClose={() => setFormOpen(false)} eventId={eventId} partner={editing} onSaved={onChange} />
|
||||||
|
<PayoutModal partner={paying} amount={paying ? lines.get(paying.id)?.payout ?? 0 : 0} onClose={() => setPaying(null)} eventId={eventId} onSaved={onChange} />
|
||||||
|
<FinalizeDialog open={finalizing} onClose={() => setFinalizing(false)} onConfirm={finalize} busy={busy === 'finalize'} data={data} lifecycle={lifecycle} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
BanknotesIcon, ShoppingBagIcon, ArrowTrendingUpIcon, ArrowTrendingDownIcon, ScaleIcon, EyeSlashIcon,
|
||||||
|
ExclamationTriangleIcon, CheckCircleIcon, ChevronDownIcon, PlusIcon,
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
|
import { useMoney } from '@/lib/useMoney';
|
||||||
|
import type { EventFinance } from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { Money, EmptyState, Pill } from './ui';
|
||||||
|
import { breakEvenState, detailChartsAvailable } from './derive';
|
||||||
|
import type { FinanceSection } from './FinanceTab';
|
||||||
|
|
||||||
|
// Recharts only loads when someone opens the Finance tab.
|
||||||
|
const FinanceCharts = dynamic(() => import('./FinanceCharts'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <Skeleton className="h-64" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Kpi({ label, tag, value, detail, icon: Icon, tone, children }: {
|
||||||
|
label: string; tag?: React.ReactNode; value: React.ReactNode; detail?: React.ReactNode; icon: typeof BanknotesIcon;
|
||||||
|
tone: 'blue' | 'orange' | 'green' | 'red' | 'purple' | 'gray'; children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const tones = {
|
||||||
|
blue: 'bg-blue-50 text-blue-600', orange: 'bg-orange-50 text-orange-600', green: 'bg-green-50 text-green-600',
|
||||||
|
red: 'bg-red-50 text-red-600', purple: 'bg-purple-50 text-purple-600', gray: 'bg-gray-100 text-gray-600',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-card shadow-card p-3 md:p-4 min-w-0 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', tones[tone])}><Icon className="w-4 h-4" aria-hidden /></span>
|
||||||
|
<p className="text-xs md:text-sm text-gray-500 truncate">{label}</p>
|
||||||
|
{tag}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg md:text-2xl font-bold leading-tight break-words">{value}</div>
|
||||||
|
{detail && <div className="mt-1 text-xs text-gray-500 space-y-0.5">{detail}</div>}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The P&L as a compact statement under the waterfall, in the same order as its bars. */
|
||||||
|
function Breakdown({ data, finalized }: { data: EventFinance; finalized: boolean }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const { num } = useMoney();
|
||||||
|
const s = data.summary;
|
||||||
|
const catName = (id: string | null) => {
|
||||||
|
const c = data.categories.find((x) => x.id === id);
|
||||||
|
return c ? (locale === 'es' ? c.nameEs : c.nameEn) : t('admin.finance.charts.uncategorized');
|
||||||
|
};
|
||||||
|
const resultTone = (v: number | null) => (finalized && v !== null ? (v < 0 ? 'text-red-600' : 'text-green-700') : undefined);
|
||||||
|
const Row = ({ label, value, sign, strong, muted, className }: {
|
||||||
|
label: React.ReactNode; value: number | null; sign?: '+' | '−'; strong?: boolean; muted?: boolean; className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className={clsx('flex items-baseline justify-between gap-3 py-1', strong && 'font-semibold', muted && 'text-gray-500')}>
|
||||||
|
<span className="min-w-0">{sign && <span className="inline-block w-4 text-gray-400" aria-hidden>{sign}</span>}{label}</span>
|
||||||
|
<Money value={value} className={clsx('whitespace-nowrap', className)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const partners = s.split.partners;
|
||||||
|
const showSplit = partners.length > 0 && s.split.organization !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<h3 className="text-sm font-semibold text-primary-dark mb-1">{t('admin.finance.statement.title')}</h3>
|
||||||
|
<Row label={t('admin.finance.statement.ticketSales', { count: num(s.counts.ticketsSold) })} value={s.revenue.gross} />
|
||||||
|
{s.revenue.refunds > 0 && <Row sign="−" label={t('admin.finance.waterfall.refunds')} value={s.revenue.refunds} muted />}
|
||||||
|
{s.revenue.fees > 0 && <Row sign="−" label={t('admin.finance.waterfall.fees')} value={s.revenue.fees} muted />}
|
||||||
|
{s.expenses.total > 0 && (
|
||||||
|
<details className="group">
|
||||||
|
<summary className="list-none cursor-pointer flex items-baseline justify-between gap-3 py-1 text-gray-500 [&::-webkit-details-marker]:hidden">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span className="inline-block w-4 text-gray-400" aria-hidden>−</span>
|
||||||
|
{t('admin.finance.waterfall.expenses')}
|
||||||
|
<ChevronDownIcon className="w-3.5 h-3.5 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<Money value={s.expenses.total} className="whitespace-nowrap" />
|
||||||
|
</summary>
|
||||||
|
<div className="pl-5 pb-1">
|
||||||
|
{s.expenses.byCategory.map((c) => (
|
||||||
|
<div key={c.categoryId || 'none'} className="flex justify-between gap-3 py-0.5 text-xs text-gray-500">
|
||||||
|
<span className="truncate">{catName(c.categoryId)}</span>
|
||||||
|
<Money value={c.total} className="whitespace-nowrap" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{s.revenue.otherIncome > 0 && <Row sign="+" label={t('admin.finance.waterfall.otherIncome')} value={s.revenue.otherIncome} muted />}
|
||||||
|
<div className="border-t border-gray-200 mt-1" />
|
||||||
|
<Row label={t(s.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')} value={s.profit} strong className={resultTone(s.profit)} />
|
||||||
|
|
||||||
|
{showSplit && (
|
||||||
|
<>
|
||||||
|
{partners.map((p) => (
|
||||||
|
<Row
|
||||||
|
key={p.partnerId}
|
||||||
|
sign={p.share < 0 ? '+' : '−'}
|
||||||
|
label={<>{p.name}{p.share < 0 && <span className="block text-xs text-gray-400 pl-4">{t('admin.finance.statement.carriesLoss')}</span>}</>}
|
||||||
|
value={Math.abs(p.share)}
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="border-t border-gray-200 mt-1" />
|
||||||
|
<Row label={t('admin.finance.split.organization')} value={s.split.organization} strong className={resultTone(s.split.organization)} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SummaryView({ data, goTo }: { data: EventFinance; goTo: (s: FinanceSection) => void }) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const { num } = useMoney();
|
||||||
|
const s = data.summary;
|
||||||
|
const finalized = data.status !== 'open';
|
||||||
|
const loss = s.profit < 0;
|
||||||
|
const capacity = data.event.capacity;
|
||||||
|
const canAdd = data.status === 'open' && (data.viewer.canEditExpenses || data.viewer.canEditOwnExpenses);
|
||||||
|
|
||||||
|
const nothingYet = s.revenue.gross === 0 && s.expenses.total === 0 && s.revenue.otherIncome === 0;
|
||||||
|
if (nothingYet) {
|
||||||
|
return (
|
||||||
|
<EmptyState icon={<BanknotesIcon className="w-5 h-5" />} title={t('admin.finance.empty.title')} body={t('admin.finance.empty.body')}>
|
||||||
|
{canAdd && <Button onClick={() => goTo('expenses')}><PlusIcon className="w-4 h-4 mr-1" />{t('admin.finance.expenses.add')}</Button>}
|
||||||
|
</EmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sold = s.counts.ticketsSold;
|
||||||
|
const be = breakEvenState(s);
|
||||||
|
let breakEvenValue: React.ReactNode;
|
||||||
|
let breakEvenDetail: React.ReactNode;
|
||||||
|
let breakEvenBar: number | null = null;
|
||||||
|
if (be.kind === 'noCosts') {
|
||||||
|
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug text-gray-500">{t('admin.finance.breakEven.noCosts')}</span>;
|
||||||
|
breakEvenDetail = t('admin.finance.breakEven.noCostsHint');
|
||||||
|
} else if (be.kind === 'unreachable') {
|
||||||
|
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.kpi.breakEvenNever')}</span>;
|
||||||
|
} else if (privacyMode) {
|
||||||
|
breakEvenValue = '••••';
|
||||||
|
} else if (be.kind === 'reached') {
|
||||||
|
breakEvenValue = (
|
||||||
|
<span className="text-sm md:text-base font-semibold leading-snug inline-flex items-start gap-1.5 text-green-700">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 flex-shrink-0 mt-0.5" aria-hidden />
|
||||||
|
{be.at === 0
|
||||||
|
? t('admin.finance.breakEven.coveredWithoutTickets')
|
||||||
|
: t('admin.finance.breakEven.reached', { at: num(be.at), sold: num(be.sold) })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
breakEvenValue = <span className="text-sm md:text-base font-semibold leading-snug">{t('admin.finance.breakEven.needed', { count: num(be.remaining) })}</span>;
|
||||||
|
breakEvenBar = be.pct;
|
||||||
|
breakEvenDetail = be.needed > capacity
|
||||||
|
? <span className="inline-flex items-start gap-1 text-amber-700"><ExclamationTriangleIcon className="w-4 h-4 flex-shrink-0" aria-hidden />{t('admin.finance.kpi.beyondCapacity', { capacity: num(capacity) })}</span>
|
||||||
|
: t('admin.finance.kpi.breakEvenProgress', { sold: num(be.sold), total: num(be.needed) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = detailChartsAvailable(s);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 min-[420px]:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
<Kpi
|
||||||
|
label={t('admin.finance.kpi.ticketRevenue')}
|
||||||
|
value={<Money value={s.revenue.sales} />}
|
||||||
|
detail={privacyMode ? undefined : (
|
||||||
|
<>
|
||||||
|
<p>{t('admin.finance.kpi.revenueDetail', { count: num(sold) })}</p>
|
||||||
|
{s.revenue.gross > 0 && (
|
||||||
|
<p>{t('admin.finance.kpi.presale')} <Money value={s.revenue.presale} /> · {t('admin.finance.kpi.door')} <Money value={s.revenue.door} /></p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
icon={BanknotesIcon}
|
||||||
|
tone="blue"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label={t('admin.finance.kpi.expenses')}
|
||||||
|
value={<Money value={s.expenses.total} />}
|
||||||
|
detail={privacyMode || s.expenses.total === 0 ? undefined : (
|
||||||
|
<span>{t('admin.finance.charts.paid')} <Money value={s.expenses.paid} /> · {t('admin.finance.charts.planned')} <Money value={s.expenses.planned} /></span>
|
||||||
|
)}
|
||||||
|
icon={ShoppingBagIcon}
|
||||||
|
tone="orange"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
label={t(loss ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit')}
|
||||||
|
tag={!finalized && <Pill>{t('admin.finance.kpi.estimated')}</Pill>}
|
||||||
|
value={<Money value={s.profit} className={clsx(finalized && !privacyMode && (loss ? 'text-red-600' : 'text-green-700'))} />}
|
||||||
|
detail={t(finalized ? 'admin.finance.kpi.afterFees' : 'admin.finance.kpi.estimatedHint')}
|
||||||
|
icon={loss ? ArrowTrendingDownIcon : ArrowTrendingUpIcon}
|
||||||
|
tone={finalized ? (loss ? 'red' : 'green') : 'gray'}
|
||||||
|
/>
|
||||||
|
<Kpi label={t('admin.finance.kpi.breakEven')} value={breakEvenValue} detail={breakEvenDetail} icon={ScaleIcon} tone="purple">
|
||||||
|
{breakEvenBar !== null && (
|
||||||
|
<div className="mt-2 h-1.5 rounded-full bg-gray-100 overflow-hidden" role="progressbar" aria-valuenow={breakEvenBar} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<div className="h-full rounded-full bg-purple-500" style={{ width: `${Math.max(breakEvenBar, 2)}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Kpi>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{privacyMode ? (
|
||||||
|
<div className="bg-white rounded-card shadow-card p-8 text-center text-sm text-gray-500 flex flex-col items-center gap-2">
|
||||||
|
<EyeSlashIcon className="w-6 h-6 text-gray-400" aria-hidden />
|
||||||
|
{t('admin.finance.hiddenStats')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-white rounded-card shadow-card p-4 grid grid-cols-1 lg:grid-cols-5 gap-4 lg:gap-6">
|
||||||
|
<div className="lg:col-span-3 min-w-0">
|
||||||
|
<FinanceCharts part="waterfall" summary={s} categories={data.categories} locale={locale} t={t} estimated={!finalized} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2 min-w-0 border-t lg:border-t-0 lg:border-l border-gray-100 pt-4 lg:pt-0 lg:pl-6">
|
||||||
|
<Breakdown data={data} finalized={finalized} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FinanceCharts part="details" summary={s} categories={data.categories} locale={locale} t={t} />
|
||||||
|
{!show.costs && (
|
||||||
|
<p className="text-sm text-gray-400 text-center py-2">
|
||||||
|
{canAdd ? (
|
||||||
|
<button onClick={() => goTo('expenses')} className="hover:underline cursor-pointer">{t('admin.finance.charts.addExpensesHint')}</button>
|
||||||
|
) : t('admin.finance.charts.addExpensesHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { inputClass } from './ui';
|
||||||
|
|
||||||
|
export interface UserHit { id: string; name: string; email: string; role: string }
|
||||||
|
|
||||||
|
/** Debounced search box with a result list; `search` decides who is eligible. */
|
||||||
|
export function UserSearch({ search, onPick, placeholder, noResults }: {
|
||||||
|
search: (q: string) => Promise<{ users: UserHit[] }>;
|
||||||
|
onPick: (u: UserHit) => void;
|
||||||
|
placeholder: string;
|
||||||
|
noResults: string;
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [hits, setHits] = useState<UserHit[]>([]);
|
||||||
|
const [searched, setSearched] = useState(false);
|
||||||
|
const seq = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const term = q.trim();
|
||||||
|
if (term.length < 2) { setHits([]); setSearched(false); return; }
|
||||||
|
const mine = ++seq.current;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
search(term)
|
||||||
|
.then((res) => { if (mine === seq.current) { setHits(res.users); setSearched(true); } })
|
||||||
|
.catch(() => { if (mine === seq.current) { setHits([]); setSearched(true); } });
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [q, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="w-4 h-4 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input className={`${inputClass} pl-9`} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} />
|
||||||
|
</div>
|
||||||
|
{hits.length > 0 && (
|
||||||
|
<ul className="mt-1 border border-gray-100 rounded-btn divide-y divide-gray-100 max-h-56 overflow-y-auto">
|
||||||
|
{hits.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onPick(u); setQ(''); setHits([]); setSearched(false); }}
|
||||||
|
className="w-full text-left px-3 py-2 hover:bg-gray-50 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium truncate">{u.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{u.email} · {u.role}</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{searched && hits.length === 0 && <p className="text-xs text-gray-400 mt-1">{noResults}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Client-side preview of an expense amount, mirroring
|
||||||
|
// backend/src/lib/finance/calculate.ts expenseAmount(). Only used to show
|
||||||
|
// "this will be X PYG right now" while typing; the server stays authoritative.
|
||||||
|
|
||||||
|
import type { CalcType } from '@/lib/api';
|
||||||
|
|
||||||
|
export interface PreviewContext { ticketsSold: number; checkedIn: number; sales: number }
|
||||||
|
|
||||||
|
export function previewExpense(
|
||||||
|
e: { calcType: CalcType; quantity: number; unitAmount: number; percentBp: number; minimumAmount: number },
|
||||||
|
ctx: PreviewContext,
|
||||||
|
): { amount: number; count: number } {
|
||||||
|
switch (e.calcType) {
|
||||||
|
case 'per_ticket_sold': return { amount: e.unitAmount * ctx.ticketsSold, count: ctx.ticketsSold };
|
||||||
|
case 'per_checked_in': return { amount: e.unitAmount * ctx.checkedIn, count: ctx.checkedIn };
|
||||||
|
case 'percent_of_revenue': return { amount: Math.round((Math.max(0, ctx.sales) * e.percentBp) / 10000), count: 0 };
|
||||||
|
case 'minimum_spend': return { amount: Math.max(e.minimumAmount, e.unitAmount * ctx.checkedIn), count: ctx.checkedIn };
|
||||||
|
default: return { amount: e.unitAmount * e.quantity, count: e.quantity };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
calculateEventFinance, expenseAmount, CALC_TYPES,
|
||||||
|
type FinanceExpense, type FinanceInput, type FinancePartner, type FinancePayment,
|
||||||
|
} from '../../../../../../../backend/src/lib/finance/calculate';
|
||||||
|
import type { EventExpense, EventFinance, EventPartner, FinanceSummary } from '@/lib/api';
|
||||||
|
import { breakEvenState, buildWaterfall, deriveLifecycle, detailChartsAvailable, suggestCategory, tCount } from './derive';
|
||||||
|
import { previewExpense } from './calc';
|
||||||
|
|
||||||
|
// The frontend derivations run on the backend's real calculation, so these
|
||||||
|
// tests also check the UI reads the same numbers the server computes.
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const pay = (amount: number, over: Partial<FinancePayment> = {}): FinancePayment => ({
|
||||||
|
id: `p${++seq}`, amount, provider: 'tpago', source: 'presale', status: 'paid', paidAt: '2026-09-01T12:00:00Z', ...over,
|
||||||
|
});
|
||||||
|
const expense = (over: Partial<FinanceExpense> = {}): FinanceExpense => ({
|
||||||
|
id: `e${++seq}`, description: 'x', categoryId: null, calcType: 'fixed', quantity: 1, unitAmount: 0, percentBp: 0,
|
||||||
|
minimumAmount: 0, computedAmount: 0, isLocked: false, status: 'planned', paidByPartnerId: null, ...over,
|
||||||
|
});
|
||||||
|
const partner = (over: Partial<FinancePartner> = {}): FinancePartner => ({
|
||||||
|
id: `pt${++seq}`, name: 'Partner', shareType: 'percent_profit', percentBp: 5000, fixedAmount: 0, thresholdAmount: 0,
|
||||||
|
lossRule: 'none', lossCapAmount: 0, ...over,
|
||||||
|
});
|
||||||
|
const input = (over: Partial<FinanceInput> = {}): FinanceInput => ({
|
||||||
|
ticketPrice: 100000, ticketsSold: 0, checkedIn: 0, payments: [], expenses: [], otherIncome: [], fees: [], partners: [], ...over,
|
||||||
|
});
|
||||||
|
const summaryOf = (i: FinanceInput) => calculateEventFinance(i) as unknown as FinanceSummary;
|
||||||
|
|
||||||
|
// ---- Scenarios (same as the backend's pinned ones)
|
||||||
|
|
||||||
|
const noExpenses = summaryOf(input({
|
||||||
|
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||||
|
payments: [pay(50000), pay(50000), pay(50000), pay(50000)],
|
||||||
|
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
|
||||||
|
}));
|
||||||
|
const loss = summaryOf(input({
|
||||||
|
ticketPrice: 50000, ticketsSold: 4, checkedIn: 4,
|
||||||
|
payments: [pay(50000), pay(50000), pay(50000), pay(50000, { provider: 'cash', source: 'door' })],
|
||||||
|
fees: [{ method: 'tpago', percentBp: 290, fixedAmount: 0 }],
|
||||||
|
expenses: [
|
||||||
|
expense({ unitAmount: 300000, status: 'paid' }),
|
||||||
|
expense({ calcType: 'per_checked_in', unitAmount: 10000, status: 'planned' }),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const ana = partner({ name: 'Ana', shareType: 'percent_profit', percentBp: 3000 });
|
||||||
|
const withPartners = summaryOf(input({
|
||||||
|
ticketPrice: 100000, ticketsSold: 10, checkedIn: 9,
|
||||||
|
payments: Array.from({ length: 10 }, () => pay(100000, { provider: 'bank_transfer' })),
|
||||||
|
otherIncome: [{ id: 'i1', description: 'Sponsor', amount: 50000 }],
|
||||||
|
expenses: [
|
||||||
|
expense({ unitAmount: 200000, status: 'paid' }),
|
||||||
|
expense({ unitAmount: 150000, status: 'paid', paidByPartnerId: ana.id }),
|
||||||
|
],
|
||||||
|
partners: [ana, partner({ name: 'Venue Co', shareType: 'fixed', fixedAmount: 100000 })],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('buildWaterfall', () => {
|
||||||
|
const sumTo = (steps: ReturnType<typeof buildWaterfall>) =>
|
||||||
|
steps.slice(1, -1).reduce((s, w) => s + w.amount, steps[0].amount);
|
||||||
|
|
||||||
|
it('no expenses: gross, fees, profit; nothing else shown', () => {
|
||||||
|
const steps = buildWaterfall(noExpenses);
|
||||||
|
expect(steps.map((s) => [s.key, s.amount])).toEqual([['gross', 200000], ['fees', -5800], ['profit', 194200]]);
|
||||||
|
expect(sumTo(steps)).toBe(steps[steps.length - 1].amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a loss: ends at the (negative) profit', () => {
|
||||||
|
const steps = buildWaterfall(loss);
|
||||||
|
expect(steps.map((s) => s.key)).toEqual(['gross', 'fees', 'expenses', 'profit']);
|
||||||
|
expect(steps[steps.length - 1].amount).toBe(-144350);
|
||||||
|
expect(sumTo(steps)).toBe(-144350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partners: other income in, partner payouts out, ends at what the organization keeps', () => {
|
||||||
|
const steps = buildWaterfall(withPartners);
|
||||||
|
expect(steps.map((s) => [s.key, s.kind, s.amount])).toEqual([
|
||||||
|
['gross', 'total', 1000000],
|
||||||
|
['expenses', 'out', -350000],
|
||||||
|
['otherIncome', 'in', 50000],
|
||||||
|
['partners', 'out', -280000],
|
||||||
|
['organization', 'total', 420000],
|
||||||
|
]);
|
||||||
|
expect(sumTo(steps)).toBe(420000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('viewers without the full split end at the profit', () => {
|
||||||
|
const scoped = { ...withPartners, split: { ...withPartners.split, organization: null, distributable: null } };
|
||||||
|
const steps = buildWaterfall(scoped);
|
||||||
|
expect(steps[steps.length - 1]).toEqual({ key: 'profit', kind: 'total', amount: 700000 });
|
||||||
|
expect(steps.some((s) => s.key === 'partners')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('breakEvenState', () => {
|
||||||
|
it('no costs: neutral, no progress', () => {
|
||||||
|
expect(breakEvenState(noExpenses)).toEqual({ kind: 'noCosts' });
|
||||||
|
});
|
||||||
|
it('not covered: tickets still needed and progress sold / needed', () => {
|
||||||
|
expect(breakEvenState(loss)).toEqual({ kind: 'needed', remaining: 4, sold: 4, needed: 8, pct: 50 });
|
||||||
|
});
|
||||||
|
it('covered: reached at N of M sold', () => {
|
||||||
|
expect(breakEvenState(withPartners)).toEqual({ kind: 'reached', at: 3, sold: 10 });
|
||||||
|
});
|
||||||
|
it('unreachable at this price', () => {
|
||||||
|
const s = summaryOf(input({ ticketPrice: 0, expenses: [expense({ unitAmount: 1000 })] }));
|
||||||
|
expect(breakEvenState(s)).toEqual({ kind: 'unreachable' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detailChartsAvailable', () => {
|
||||||
|
it('hides cost charts without expenses and the timeline with fewer than 2 days', () => {
|
||||||
|
expect(detailChartsAvailable(noExpenses)).toEqual({ costs: false, methods: true, timeline: false });
|
||||||
|
});
|
||||||
|
it('shows the timeline once there are two sales days', () => {
|
||||||
|
const s = summaryOf(input({ payments: [pay(1, { paidAt: '2026-09-01T10:00:00Z' }), pay(1, { paidAt: '2026-09-02T10:00:00Z' })] }));
|
||||||
|
expect(detailChartsAvailable(s).timeline).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Lifecycle
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-09-28T12:00:00Z');
|
||||||
|
const exp = (status: 'planned' | 'paid') => ({ status } as EventExpense);
|
||||||
|
const prt = (payoutStatus: 'pending' | 'paid') => ({ payoutStatus } as EventPartner);
|
||||||
|
const fin = (over: Partial<Pick<EventFinance, 'status' | 'expenses' | 'partners'>> & { start?: string; end?: string | null } = {}) => ({
|
||||||
|
status: over.status ?? 'open',
|
||||||
|
expenses: over.expenses ?? [],
|
||||||
|
partners: over.partners ?? [],
|
||||||
|
event: { startDatetime: over.start ?? '2026-10-10T20:00:00Z', endDatetime: over.end ?? null },
|
||||||
|
} as Parameters<typeof deriveLifecycle>[0]);
|
||||||
|
|
||||||
|
describe('deriveLifecycle', () => {
|
||||||
|
it('upcoming with no costs: selling', () => {
|
||||||
|
const l = deriveLifecycle(fin(), NOW);
|
||||||
|
expect(l.step).toBe('selling');
|
||||||
|
expect(l.ended).toBe(false);
|
||||||
|
expect(l.todo.map((t) => t.key)).toEqual(['noCosts', 'noPartners', 'notEnded']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upcoming with costs: adding costs', () => {
|
||||||
|
expect(deriveLifecycle(fin({ expenses: [exp('paid')] }), NOW).step).toBe('costs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ended with planned costs: adding costs, warns about them, counts days since the end', () => {
|
||||||
|
const l = deriveLifecycle(fin({ start: '2026-09-20T20:00:00Z', end: '2026-09-20T23:00:00Z', expenses: [exp('paid'), exp('planned'), exp('planned')] }), NOW);
|
||||||
|
expect(l).toMatchObject({ step: 'costs', ended: true, daysSinceEnd: 7, plannedCount: 2 });
|
||||||
|
expect(l.todo).toContainEqual({ key: 'planned', count: 2, tone: 'warn' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ended with no costs: still adding costs, and that is a warning', () => {
|
||||||
|
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z' }), NOW);
|
||||||
|
expect(l.step).toBe('costs');
|
||||||
|
expect(l.todo).toContainEqual({ key: 'noCosts', tone: 'warn' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ended with every cost paid: ready to close', () => {
|
||||||
|
const l = deriveLifecycle(fin({ start: '2026-09-27T20:00:00Z', expenses: [exp('paid')], partners: [prt('pending')] }), NOW);
|
||||||
|
expect(l.step).toBe('ready');
|
||||||
|
expect(l.todo).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the end time, not the start, to decide the event is over', () => {
|
||||||
|
const l = deriveLifecycle(fin({ start: '2026-09-28T10:00:00Z', end: '2026-09-28T14:00:00Z' }), NOW);
|
||||||
|
expect(l.ended).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalized: lists partner payouts still to record', () => {
|
||||||
|
const l = deriveLifecycle(fin({ status: 'finalized', start: '2026-09-01T20:00:00Z', expenses: [exp('planned')], partners: [prt('paid'), prt('pending')] }), NOW);
|
||||||
|
expect(l.step).toBe('finalized');
|
||||||
|
expect(l.todo).toEqual([{ key: 'payoutsPending', count: 1, tone: 'info' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not nag collaborators about partners they cannot manage', () => {
|
||||||
|
const l = deriveLifecycle({ ...fin(), viewer: { fullSplit: false } } as any, NOW);
|
||||||
|
expect(l.todo.map((t) => t.key)).not.toContain('noPartners');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Category suggestion
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ id: 'venue', nameEn: 'Venue', nameEs: 'Lugar', archived: false },
|
||||||
|
{ id: 'host', nameEn: 'Instructor / host', nameEs: 'Instructor / anfitrión', archived: false },
|
||||||
|
{ id: 'food', nameEn: 'Food & drinks', nameEs: 'Comida y bebidas', archived: false },
|
||||||
|
{ id: 'staff', nameEn: 'Staff', nameEs: 'Personal', archived: false },
|
||||||
|
{ id: 'mkt', nameEn: 'Marketing', nameEs: 'Marketing', archived: false },
|
||||||
|
{ id: 'sup', nameEn: 'Supplies', nameEs: 'Materiales', archived: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('suggestCategory', () => {
|
||||||
|
it.each([
|
||||||
|
['Studio rental', 'venue'],
|
||||||
|
['Alquiler del salón', 'venue'],
|
||||||
|
['Teacher fee', 'host'],
|
||||||
|
['Pago a la profe', 'host'],
|
||||||
|
['Snacks and drinks', 'food'],
|
||||||
|
['Cerveza', 'food'],
|
||||||
|
['Security', 'staff'],
|
||||||
|
['Instagram ads', 'mkt'],
|
||||||
|
['Name badges', 'sup'],
|
||||||
|
])('%s -> %s', (description, id) => {
|
||||||
|
expect(suggestCategory(description, categories)).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing matches', () => {
|
||||||
|
expect(suggestCategory('Miscellaneous', categories)).toBeNull();
|
||||||
|
expect(suggestCategory('', categories)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips archived categories', () => {
|
||||||
|
expect(suggestCategory('venue', [{ ...categories[0], archived: true }])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches whole words only', () => {
|
||||||
|
// "ad" is a marketing keyword, but "headphones" should not match it.
|
||||||
|
expect(suggestCategory('headphones', categories)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tCount', () => {
|
||||||
|
const t = (key: string, p?: Record<string, string | number>) => `${key}:${p?.count}`;
|
||||||
|
it('picks _one for 1 and _other otherwise', () => {
|
||||||
|
expect(tCount(t, 'k', 1)).toBe('k_one:1');
|
||||||
|
expect(tCount(t, 'k', 0)).toBe('k_other:0');
|
||||||
|
expect(tCount(t, 'k', 3)).toBe('k_other:3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Modal preview mirrors the server
|
||||||
|
|
||||||
|
describe('previewExpense matches the backend expenseAmount', () => {
|
||||||
|
const ctx = { ticketsSold: 4, checkedIn: 3, sales: 200000 };
|
||||||
|
it.each(CALC_TYPES)('%s', (calcType) => {
|
||||||
|
const e = { calcType, quantity: 1, unitAmount: 50000, percentBp: 1250, minimumAmount: 300000 };
|
||||||
|
const server = expenseAmount({ ...e, computedAmount: 0, isLocked: false }, ctx);
|
||||||
|
expect(previewExpense(e, ctx).amount).toBe(server.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('minimum spend switches to per-guest once it passes the minimum', () => {
|
||||||
|
const e = { calcType: 'minimum_spend' as const, quantity: 1, unitAmount: 50000, percentBp: 0, minimumAmount: 300000 };
|
||||||
|
const big = { ticketsSold: 10, checkedIn: 8, sales: 0 };
|
||||||
|
expect(previewExpense(e, big).amount).toBe(expenseAmount({ ...e, computedAmount: 0, isLocked: false }, big).amount);
|
||||||
|
expect(previewExpense(e, big).amount).toBe(400000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Pure derivations for the Finance tab: where the event is in its money
|
||||||
|
// lifecycle, what is left to do, how break-even reads, and a category guess
|
||||||
|
// for a new expense. No React here so it can be unit tested.
|
||||||
|
|
||||||
|
import type { EventFinance, ExpenseCategory, FinanceSummary } from '@/lib/api';
|
||||||
|
|
||||||
|
export type LifecycleStep = 'selling' | 'costs' | 'ready' | 'finalized';
|
||||||
|
export const LIFECYCLE_STEPS: LifecycleStep[] = ['selling', 'costs', 'ready', 'finalized'];
|
||||||
|
|
||||||
|
export interface TodoItem {
|
||||||
|
/** i18n key under admin.finance.lifecycle.todo (pluralized with _one / _other when count is set). */
|
||||||
|
key: 'noCosts' | 'planned' | 'noPartners' | 'payoutsPending' | 'notEnded';
|
||||||
|
count?: number;
|
||||||
|
tone: 'warn' | 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lifecycle {
|
||||||
|
step: LifecycleStep;
|
||||||
|
ended: boolean;
|
||||||
|
/** Whole days since the event ended; null while it has not ended. */
|
||||||
|
daysSinceEnd: number | null;
|
||||||
|
expenseCount: number;
|
||||||
|
plannedCount: number;
|
||||||
|
partnerCount: number;
|
||||||
|
todo: TodoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type LifecycleInput = Pick<EventFinance, 'status' | 'expenses' | 'partners'> & {
|
||||||
|
event: { startDatetime: string; endDatetime?: string | null };
|
||||||
|
viewer?: { fullSplit: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
/** The moment an event counts as over: its end time, or its start when no end is set. */
|
||||||
|
export function eventEndTime(event: { startDatetime: string; endDatetime?: string | null }): number {
|
||||||
|
return new Date(event.endDatetime || event.startDatetime).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveLifecycle(data: LifecycleInput, now: number = Date.now()): Lifecycle {
|
||||||
|
const end = eventEndTime(data.event);
|
||||||
|
const ended = Number.isFinite(end) && now >= end;
|
||||||
|
const daysSinceEnd = ended ? Math.floor((now - end) / DAY) : null;
|
||||||
|
const expenseCount = data.expenses.length;
|
||||||
|
const plannedCount = data.expenses.filter((e) => e.status === 'planned').length;
|
||||||
|
const partnerCount = data.partners.length;
|
||||||
|
const fullSplit = data.viewer?.fullSplit ?? true;
|
||||||
|
|
||||||
|
const todo: TodoItem[] = [];
|
||||||
|
let step: LifecycleStep;
|
||||||
|
if (data.status !== 'open') {
|
||||||
|
step = 'finalized';
|
||||||
|
const unpaid = data.partners.filter((p) => p.payoutStatus !== 'paid').length;
|
||||||
|
if (unpaid > 0) todo.push({ key: 'payoutsPending', count: unpaid, tone: 'info' });
|
||||||
|
} else {
|
||||||
|
if (!ended) step = expenseCount === 0 ? 'selling' : 'costs';
|
||||||
|
else step = expenseCount > 0 && plannedCount === 0 ? 'ready' : 'costs';
|
||||||
|
|
||||||
|
if (expenseCount === 0) todo.push({ key: 'noCosts', tone: ended ? 'warn' : 'info' });
|
||||||
|
if (plannedCount > 0) todo.push({ key: 'planned', count: plannedCount, tone: 'warn' });
|
||||||
|
if (partnerCount === 0 && fullSplit) todo.push({ key: 'noPartners', tone: 'info' });
|
||||||
|
if (!ended) todo.push({ key: 'notEnded', tone: 'info' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { step, ended, daysSinceEnd, expenseCount, plannedCount, partnerCount, todo };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Break-even ====================
|
||||||
|
|
||||||
|
export type BreakEvenState =
|
||||||
|
| { kind: 'noCosts' }
|
||||||
|
| { kind: 'unreachable' }
|
||||||
|
| { kind: 'reached'; at: number; sold: number }
|
||||||
|
| { kind: 'needed'; remaining: number; sold: number; needed: number; pct: number };
|
||||||
|
|
||||||
|
export function breakEvenState(summary: Pick<FinanceSummary, 'breakEven' | 'expenses' | 'counts'>): BreakEvenState {
|
||||||
|
const be = summary.breakEven;
|
||||||
|
const sold = summary.counts.ticketsSold;
|
||||||
|
if (summary.expenses.total === 0) return { kind: 'noCosts' };
|
||||||
|
if (be.tickets === null) return { kind: 'unreachable' };
|
||||||
|
if (sold >= be.tickets) return { kind: 'reached', at: be.tickets, sold };
|
||||||
|
const needed = be.tickets;
|
||||||
|
return {
|
||||||
|
kind: 'needed',
|
||||||
|
remaining: be.remaining ?? needed - sold,
|
||||||
|
sold,
|
||||||
|
needed,
|
||||||
|
pct: needed > 0 ? Math.min(100, Math.round((sold / needed) * 100)) : 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Category suggestion ====================
|
||||||
|
|
||||||
|
/** Words in an expense description, and the words that identify the matching category by name. */
|
||||||
|
const CATEGORY_HINTS: { words: string[]; category: string[] }[] = [
|
||||||
|
{
|
||||||
|
words: ['venue', 'rent', 'rental', 'room', 'hall', 'space', 'studio', 'alquiler', 'local', 'sala', 'lugar', 'espacio', 'salon', 'estudio'],
|
||||||
|
category: ['venue', 'lugar'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
words: ['instructor', 'teacher', 'host', 'tutor', 'coach', 'facilitator', 'profesor', 'profesora', 'profe', 'anfitrion', 'anfitriona', 'moderador', 'moderadora'],
|
||||||
|
category: ['instructor', 'host', 'anfitrion'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
words: ['snack', 'snacks', 'drink', 'drinks', 'food', 'beer', 'coffee', 'water', 'pizza', 'catering', 'wine', 'soda',
|
||||||
|
'comida', 'bebida', 'bebidas', 'cerveza', 'cafe', 'agua', 'merienda', 'bocaditos', 'vino', 'gaseosa'],
|
||||||
|
category: ['food', 'drink', 'comida', 'bebida'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
words: ['staff', 'helper', 'volunteer', 'security', 'cleaning', 'waiter', 'personal', 'limpieza', 'seguridad', 'ayudante', 'mozo', 'voluntario'],
|
||||||
|
category: ['staff', 'personal'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
words: ['ads', 'ad', 'marketing', 'instagram', 'facebook', 'flyer', 'flyers', 'promo', 'poster', 'publicidad', 'anuncio', 'anuncios', 'volante', 'volantes'],
|
||||||
|
category: ['marketing'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
words: ['supplies', 'material', 'materials', 'print', 'printing', 'paper', 'markers', 'badges', 'stickers',
|
||||||
|
'materiales', 'impresion', 'impresiones', 'papel', 'marcadores', 'etiquetas'],
|
||||||
|
category: ['suppl', 'material'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||||
|
|
||||||
|
/** Best-guess category id for a description, or null when nothing matches. */
|
||||||
|
export function suggestCategory(description: string, categories: Pick<ExpenseCategory, 'id' | 'nameEn' | 'nameEs' | 'archived'>[]): string | null {
|
||||||
|
const tokens = new Set(normalize(description).split(/[^a-z0-9]+/).filter(Boolean));
|
||||||
|
if (tokens.size === 0) return null;
|
||||||
|
for (const hint of CATEGORY_HINTS) {
|
||||||
|
if (!hint.words.some((w) => tokens.has(w))) continue;
|
||||||
|
const match = categories.find((c) => !c.archived && hint.category.some((k) => normalize(`${c.nameEn} ${c.nameEs}`).includes(k)));
|
||||||
|
if (match) return match.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Copy helpers ====================
|
||||||
|
|
||||||
|
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
/** Picks `${key}_one` or `${key}_other` so counts read naturally in both languages. */
|
||||||
|
export function tCount(t: T, key: string, count: number, params: Record<string, string | number> = {}): string {
|
||||||
|
return t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...params });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Waterfall ====================
|
||||||
|
|
||||||
|
export type WaterfallKind = 'total' | 'in' | 'out';
|
||||||
|
export interface WaterfallStep {
|
||||||
|
key: 'gross' | 'refunds' | 'fees' | 'expenses' | 'otherIncome' | 'partners' | 'profit' | 'organization';
|
||||||
|
kind: WaterfallKind;
|
||||||
|
/** Signed: positive adds to the running total, negative takes from it. Totals are the level itself. */
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gross revenue -> fees -> expenses -> other income -> partner payouts ->
|
||||||
|
* what the organization keeps. Zero steps are left out; the steps always add
|
||||||
|
* up to the final bar. Viewers without the full split end at the profit.
|
||||||
|
*/
|
||||||
|
export function buildWaterfall(summary: Pick<FinanceSummary, 'revenue' | 'expenses' | 'profit' | 'split'>): WaterfallStep[] {
|
||||||
|
const r = summary.revenue;
|
||||||
|
const steps: WaterfallStep[] = [{ key: 'gross', kind: 'total', amount: r.gross }];
|
||||||
|
const add = (key: WaterfallStep['key'], amount: number) => {
|
||||||
|
if (amount !== 0) steps.push({ key, kind: amount > 0 ? 'in' : 'out', amount });
|
||||||
|
};
|
||||||
|
add('refunds', -r.refunds);
|
||||||
|
add('fees', -r.fees);
|
||||||
|
add('expenses', -summary.expenses.total);
|
||||||
|
add('otherIncome', r.otherIncome);
|
||||||
|
const withSplit = summary.split.organization !== null && summary.split.partners.length > 0;
|
||||||
|
if (withSplit) {
|
||||||
|
add('partners', -summary.split.partners.reduce((sum, p) => sum + p.share, 0));
|
||||||
|
steps.push({ key: 'organization', kind: 'total', amount: summary.split.organization! });
|
||||||
|
} else {
|
||||||
|
steps.push({ key: 'profit', kind: 'total', amount: summary.profit });
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which detail charts have something to show (cumulative sales needs at least two days). */
|
||||||
|
export function detailChartsAvailable(summary: Pick<FinanceSummary, 'expenses' | 'revenue' | 'salesTimeline'>) {
|
||||||
|
return {
|
||||||
|
costs: summary.expenses.byCategory.some((c) => c.total > 0),
|
||||||
|
methods: summary.revenue.byMethod.some((m) => m.gross > 0),
|
||||||
|
timeline: summary.salesTimeline.length >= 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Input parsing for finance forms. Display formatting lives in @/lib/money.
|
||||||
|
|
||||||
|
export { formatNumber, formatPyg, formatPygShort, formatPercent } from '@/lib/money';
|
||||||
|
|
||||||
|
/** Basis points to a percent string for inputs (290 -> "2.9"). */
|
||||||
|
export function bpToPercent(bp: number): string {
|
||||||
|
return String(Math.round(bp) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percent input ("2,9" or "2.9") to basis points. */
|
||||||
|
export function percentToBp(value: string): number {
|
||||||
|
const n = parseFloat(value.replace(',', '.'));
|
||||||
|
return Number.isFinite(n) ? Math.max(0, Math.min(10000, Math.round(n * 100))) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-PYG input ("1.500.000", "1,500,000" or "1500000") to an integer. */
|
||||||
|
export function parsePygInput(value: string): number {
|
||||||
|
const digits = value.replace(/[^\d]/g, '');
|
||||||
|
return digits ? Math.min(2_000_000_000, parseInt(digits, 10)) : 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Small shared pieces for the Finance / Team tabs, following the event page's
|
||||||
|
// existing modal (bottom sheet on mobile, centered card on desktop), compact
|
||||||
|
// input and badge styles.
|
||||||
|
|
||||||
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { XMarkIcon, CalendarDaysIcon } from '@heroicons/react/24/outline';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { PRIVACY_MASK } from '@/components/admin/SensitiveValue';
|
||||||
|
import type { FinanceStatus } from '@/lib/api';
|
||||||
|
import { formatNumber } from '@/lib/money';
|
||||||
|
import { useMoney } from '@/lib/useMoney';
|
||||||
|
import { parsePygInput } from './format';
|
||||||
|
|
||||||
|
export const inputClass =
|
||||||
|
'w-full px-3 py-2.5 min-h-[44px] text-base md:text-sm rounded-btn border border-secondary-light-gray bg-white focus:outline-none focus:ring-2 focus:ring-primary-yellow disabled:bg-gray-50 disabled:text-gray-400';
|
||||||
|
|
||||||
|
/** Icon-only buttons: 44px touch target on mobile, visible keyboard focus. */
|
||||||
|
export const iconButtonClass =
|
||||||
|
'inline-flex items-center justify-center min-h-[44px] min-w-[44px] md:min-h-[36px] md:min-w-[36px] rounded-btn cursor-pointer hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow disabled:opacity-40 disabled:cursor-default';
|
||||||
|
|
||||||
|
export function Modal({ open, title, subtitle, onClose, children, wide }: {
|
||||||
|
open: boolean; title: string; subtitle?: string; onClose: () => void; children: ReactNode; wide?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4" onClick={onClose}>
|
||||||
|
<Card
|
||||||
|
className={clsx('w-full rounded-t-2xl md:rounded-card max-h-[92vh] overflow-y-auto', wide ? 'md:max-w-2xl' : 'md:max-w-md')}
|
||||||
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray sticky top-0 bg-white z-10">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-base font-bold truncate">{title}</h2>
|
||||||
|
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" aria-label={t('admin.finance.close')}>
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">{children}</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({ label, hint, required, error, children }: { label: string; hint?: string; required?: boolean; error?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1 text-primary-dark">
|
||||||
|
{label}{required && <span className="text-red-600 ml-0.5" aria-hidden>*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{error ? <p className="text-xs text-red-600 mt-1">{error}</p> : hint && <p className="text-xs text-gray-500 mt-1">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radio group rendered as selectable cards: each option explains itself. */
|
||||||
|
export function ChoiceCards<K extends string>({ options, value, onChange, columns = 2, name }: {
|
||||||
|
options: { key: K; title: string; description?: string }[];
|
||||||
|
value: K;
|
||||||
|
onChange: (k: K) => void;
|
||||||
|
columns?: 2 | 3;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" className={clsx('grid grid-cols-1 gap-2', columns === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const active = o.key === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
name={name}
|
||||||
|
onClick={() => onChange(o.key)}
|
||||||
|
className={clsx(
|
||||||
|
'text-left rounded-btn border px-3 py-2.5 min-h-[44px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||||
|
active ? 'border-primary-yellow bg-yellow-50' : 'border-secondary-light-gray hover:border-gray-300 bg-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium text-primary-dark">
|
||||||
|
<span className={clsx('w-4 h-4 rounded-full border-2 flex-shrink-0', active ? 'border-primary-yellow bg-primary-yellow shadow-[inset_0_0_0_2px_white]' : 'border-gray-300')} />
|
||||||
|
{o.title}
|
||||||
|
</span>
|
||||||
|
{o.description && <span className="block text-xs text-gray-500 mt-0.5 pl-6">{o.description}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two or three mutually exclusive options as one control (e.g. Planned / Paid). */
|
||||||
|
export function Segmented<K extends string>({ options, value, onChange, label }: {
|
||||||
|
options: { key: K; label: string; icon?: ReactNode }[];
|
||||||
|
value: K;
|
||||||
|
onChange: (k: K) => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label={label} className="grid grid-flow-col auto-cols-fr gap-1 p-1 bg-gray-100 rounded-btn">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={o.key === value}
|
||||||
|
onClick={() => onChange(o.key)}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium min-h-[40px] cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||||
|
o.key === value ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.icon}{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explains why a list is empty and offers the next step. */
|
||||||
|
export function EmptyState({ icon, title, body, children }: { icon?: ReactNode; title: string; body?: string; children?: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-card shadow-card px-6 py-10 text-center flex flex-col items-center gap-2">
|
||||||
|
{icon && <div className="w-11 h-11 rounded-full bg-yellow-50 text-yellow-700 flex items-center justify-center">{icon}</div>}
|
||||||
|
<p className="font-semibold text-primary-dark">{title}</p>
|
||||||
|
{body && <p className="text-sm text-gray-500 max-w-md">{body}</p>}
|
||||||
|
{children && <div className="flex flex-wrap justify-center gap-2 mt-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole-PYG input that shows the UI language's thousands separator while typing. */
|
||||||
|
export function PygInput({ value, onChange, disabled, id }: { value: number; onChange: (n: number) => void; disabled?: boolean; id?: string }) {
|
||||||
|
const { locale } = useLanguage();
|
||||||
|
const [text, setText] = useState(value ? formatNumber(value, locale) : '');
|
||||||
|
useEffect(() => { setText(value ? formatNumber(value, locale) : ''); }, [value, locale]);
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
inputMode="numeric"
|
||||||
|
className={clsx(inputClass, 'pr-12 tabular-nums')}
|
||||||
|
value={text}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="0"
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parsePygInput(e.target.value);
|
||||||
|
setText(n ? formatNumber(n, locale) : '');
|
||||||
|
onChange(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400">PYG</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today as YYYY-MM-DD in the viewer's time zone. */
|
||||||
|
export function todayIso(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date field that reads in the active UI language. A native <input type="date">
|
||||||
|
* formats itself with the browser's locale (e.g. "dd-mm-jjjj" on a Dutch
|
||||||
|
* system), so the visible text is ours and the native input only supplies the
|
||||||
|
* picker, laid transparently on top.
|
||||||
|
*/
|
||||||
|
export function DateInput({ value, onChange, clearable, id, className }: {
|
||||||
|
value: string; onChange: (v: string) => void; clearable?: boolean; id?: string; className?: string;
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const label = value
|
||||||
|
? new Date(`${value}T12:00:00`).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<div className={clsx('relative', className)}>
|
||||||
|
<div className={clsx(inputClass, 'flex items-center gap-2 pr-2 focus-within:ring-2 focus-within:ring-primary-yellow')} aria-hidden>
|
||||||
|
<CalendarDaysIcon className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<span className={clsx('flex-1 truncate', !label && 'text-gray-400')}>{label || t('admin.finance.pickDate')}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
type="date"
|
||||||
|
lang={locale === 'es' ? 'es-PY' : 'en-US'}
|
||||||
|
value={value}
|
||||||
|
aria-label={label || t('admin.finance.pickDate')}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onClick={(e) => { try { e.currentTarget.showPicker?.(); } catch { /* picker opens natively */ } }}
|
||||||
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{clearable && value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 z-10 p-1.5 rounded-btn text-gray-400 hover:text-gray-700 hover:bg-gray-100 cursor-pointer"
|
||||||
|
aria-label={t('admin.finance.clearDate')}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A PYG amount that respects Hide Stats. */
|
||||||
|
export function Money({ value, className, signed }: { value: number | null | undefined; className?: string; signed?: boolean }) {
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const { pyg } = useMoney();
|
||||||
|
if (value === null || value === undefined) return <span className={className}>-</span>;
|
||||||
|
const text = signed && value > 0 ? `+${pyg(value)}` : pyg(value);
|
||||||
|
return <span className={clsx('tabular-nums', className)}>{privacyMode ? PRIVACY_MASK : text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<FinanceStatus, string> = {
|
||||||
|
open: 'bg-gray-100 text-gray-700',
|
||||||
|
finalized: 'bg-blue-100 text-blue-800',
|
||||||
|
paid_out: 'bg-green-100 text-green-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FinanceStatusBadge({ status, label }: { status: FinanceStatus; label: string }) {
|
||||||
|
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', STATUS_STYLES[status])}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pill({ children, tone = 'gray' }: { children: ReactNode; tone?: 'gray' | 'green' | 'yellow' | 'blue' | 'red' }) {
|
||||||
|
const tones = {
|
||||||
|
gray: 'bg-gray-100 text-gray-700',
|
||||||
|
green: 'bg-green-100 text-green-800',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-800',
|
||||||
|
blue: 'bg-blue-100 text-blue-800',
|
||||||
|
red: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
return <span className={clsx('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap', tones[tone])}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Segmented sub-navigation matching the event page's mobile tab pills. */
|
||||||
|
export function SubNav<K extends string>({ items, active, onChange }: { items: { key: K; label: string; count?: number }[]; active: K; onChange: (k: K) => void }) {
|
||||||
|
return (
|
||||||
|
<div role="tablist" className="grid grid-flow-col auto-cols-fr md:inline-grid md:auto-cols-auto gap-1 p-1 bg-gray-100 rounded-lg w-full md:w-auto">
|
||||||
|
{items.map((it) => (
|
||||||
|
<button
|
||||||
|
key={it.key}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active === it.key}
|
||||||
|
onClick={() => onChange(it.key)}
|
||||||
|
className={clsx(
|
||||||
|
// Phones: long labels wrap to two lines instead of running into the next tab.
|
||||||
|
'px-2 md:px-3 py-1.5 md:py-2 rounded-md text-sm font-medium leading-tight md:whitespace-nowrap min-h-[40px] cursor-pointer transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||||
|
active === it.key ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
{it.count !== undefined && it.count > 0 && <span className="ml-1.5 text-xs text-gray-400">{it.count}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,81 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
|
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary, type EventPermission } from '@/lib/api';
|
||||||
|
|
||||||
|
/** A 403 means "not for this viewer": render without that data instead of failing the page. */
|
||||||
|
const forbidden = (error: any) => error?.status === 403;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads the core data for the admin event detail page (event, tickets, active
|
* Loads the core data for the event detail page and exposes a reload function
|
||||||
* email templates) and exposes a reload function used after mutations.
|
* used after mutations. The event itself is fetched straight away so the
|
||||||
|
* header can render while the rest loads; tickets, active email templates and
|
||||||
|
* door takings wait for the viewer's permissions on the event and only request
|
||||||
|
* what they may see, so team members get a working page with fewer tabs.
|
||||||
|
*
|
||||||
|
* `eventLoading` and `detailsLoading` cover the first load only: reloads keep
|
||||||
|
* the current data on screen until the new data replaces it.
|
||||||
*/
|
*/
|
||||||
export function useEventDetailData(eventId: string) {
|
export function useEventDetailData(eventId: string, permissions: Set<EventPermission> | null) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [eventLoading, setEventLoading] = useState(true);
|
||||||
|
const [detailsLoading, setDetailsLoading] = useState(true);
|
||||||
const [event, setEvent] = useState<Event | null>(null);
|
const [event, setEvent] = useState<Event | null>(null);
|
||||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
|
const [doorSummary, setDoorSummary] = useState<DoorSummary | null>(null);
|
||||||
|
|
||||||
const loadEventData = async () => {
|
const loadEvent = async () => {
|
||||||
try {
|
try {
|
||||||
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
|
const { event } = await eventsApi.getById(eventId);
|
||||||
eventsApi.getById(eventId),
|
setEvent(event);
|
||||||
ticketsApi.getAll({ eventId }),
|
|
||||||
emailsApi.getTemplates(),
|
|
||||||
]);
|
|
||||||
setEvent(eventRes.event);
|
|
||||||
setTickets(ticketsRes.tickets);
|
|
||||||
setTemplates(templatesRes.templates.filter(t => t.isActive));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load event data');
|
toast.error('Failed to load event data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setEventLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDetails = async () => {
|
||||||
|
if (!permissions) return;
|
||||||
|
const can = (p: EventPermission) => permissions.has(p);
|
||||||
|
try {
|
||||||
|
const [ticketsRes, templatesRes, doorRes] = await Promise.all([
|
||||||
|
can('view_attendees_names')
|
||||||
|
? ticketsApi.getAll({ eventId }).catch((error) => {
|
||||||
|
if (forbidden(error)) return { tickets: [] as Ticket[] };
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
: { tickets: [] as Ticket[] },
|
||||||
|
can('email_attendees')
|
||||||
|
? emailsApi.getTemplates(eventId).catch((error) => {
|
||||||
|
if (forbidden(error)) return { templates: [] as EmailTemplate[] };
|
||||||
|
throw error;
|
||||||
|
})
|
||||||
|
: { templates: [] as EmailTemplate[] },
|
||||||
|
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
|
||||||
|
// night. It is supporting detail, so a failure here must not blank the page.
|
||||||
|
can('view_payments') ? doorApi.summary(eventId).catch(() => null) : null,
|
||||||
|
]);
|
||||||
|
setTickets(ticketsRes.tickets);
|
||||||
|
setTemplates(templatesRes.templates.filter(t => t.isActive));
|
||||||
|
setDoorSummary(doorRes);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load event data');
|
||||||
|
} finally {
|
||||||
|
setDetailsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadEventData = async () => {
|
||||||
|
await Promise.all([loadEvent(), loadDetails()]);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEventData();
|
loadEvent();
|
||||||
}, [eventId]);
|
}, [eventId]);
|
||||||
|
|
||||||
return { loading, event, tickets, templates, loadEventData };
|
useEffect(() => {
|
||||||
|
loadDetails();
|
||||||
|
}, [eventId, permissions]);
|
||||||
|
|
||||||
|
return { eventLoading, detailsLoading, event, tickets, templates, doorSummary, loadEventData };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,21 @@ interface AddTicketModalProps {
|
|||||||
|
|
||||||
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
|
const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [
|
||||||
{ value: 'paid', label: 'Paid' },
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
{ value: 'door', label: 'At Door' },
|
||||||
{ value: 'unpaid', label: 'Unpaid' },
|
{ value: 'unpaid', label: 'Unpaid' },
|
||||||
{ value: 'guest', label: 'Guest' },
|
{ value: 'guest', label: 'Guest' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SUBMIT_LABELS: Record<AddTicketType, string> = {
|
const SUBMIT_LABELS: Record<AddTicketType, string> = {
|
||||||
paid: 'Create & send ticket',
|
paid: 'Create & send ticket',
|
||||||
|
door: 'Record door payment',
|
||||||
unpaid: 'Create & send pay link',
|
unpaid: 'Create & send pay link',
|
||||||
guest: 'Invite guest',
|
guest: 'Invite guest',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
||||||
paid: EnvelopeIcon,
|
paid: EnvelopeIcon,
|
||||||
|
door: BanknotesIcon,
|
||||||
unpaid: LinkIcon,
|
unpaid: LinkIcon,
|
||||||
guest: StarIcon,
|
guest: StarIcon,
|
||||||
};
|
};
|
||||||
@@ -47,6 +50,15 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
|
|||||||
if (form.type === 'paid') {
|
if (form.type === 'paid') {
|
||||||
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
|
lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`);
|
||||||
lines.push('Confirmation email with QR ticket sent');
|
lines.push('Confirmation email with QR ticket sent');
|
||||||
|
} else if (form.type === 'door') {
|
||||||
|
lines.push(`Cash payment of ${eventPriceLabel} recorded as paid at the door — counts toward revenue`);
|
||||||
|
lines.push('QR code issued');
|
||||||
|
if (!form.firstName.trim()) {
|
||||||
|
lines.push('No name — the ticket is logged as a "Walk-in"');
|
||||||
|
}
|
||||||
|
lines.push(hasEmail
|
||||||
|
? 'Confirmation email with QR ticket sent'
|
||||||
|
: 'No email — nothing is sent, walk-in kept on the list only');
|
||||||
} else if (form.type === 'unpaid') {
|
} else if (form.type === 'unpaid') {
|
||||||
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
|
lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`);
|
||||||
lines.push('QR code issued, flagged "unpaid" for door staff');
|
lines.push('QR code issued, flagged "unpaid" for door staff');
|
||||||
@@ -66,12 +78,14 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string
|
|||||||
|
|
||||||
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
|
const PREVIEW_STYLES: Record<AddTicketType, { box: string; icon: string; text: string }> = {
|
||||||
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
|
paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' },
|
||||||
|
door: { box: 'bg-emerald-50 border-emerald-200', icon: 'text-emerald-500', text: 'text-emerald-800' },
|
||||||
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
|
unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' },
|
||||||
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
|
guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
const PREVIEW_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
|
||||||
paid: CheckCircleIcon,
|
paid: CheckCircleIcon,
|
||||||
|
door: BanknotesIcon,
|
||||||
unpaid: BanknotesIcon,
|
unpaid: BanknotesIcon,
|
||||||
guest: StarIcon,
|
guest: StarIcon,
|
||||||
};
|
};
|
||||||
@@ -88,6 +102,8 @@ export function AddTicketModal({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const emailRequired = form.type === 'paid';
|
const emailRequired = form.type === 'paid';
|
||||||
|
// Door walk-ins can be logged with nothing filled in
|
||||||
|
const nameRequired = form.type !== 'door';
|
||||||
const style = PREVIEW_STYLES[form.type];
|
const style = PREVIEW_STYLES[form.type];
|
||||||
const PreviewIcon = PREVIEW_ICONS[form.type];
|
const PreviewIcon = PREVIEW_ICONS[form.type];
|
||||||
const SubmitIcon = SUBMIT_ICONS[form.type];
|
const SubmitIcon = SUBMIT_ICONS[form.type];
|
||||||
@@ -120,7 +136,7 @@ export function AddTicketModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
|
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
|
'flex-1 px-2 py-2 text-xs sm:text-sm font-medium rounded-btn min-h-[36px] whitespace-nowrap transition-colors',
|
||||||
form.type === option.value
|
form.type === option.value
|
||||||
? 'bg-white shadow-sm text-primary-dark'
|
? 'bg-white shadow-sm text-primary-dark'
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
@@ -133,11 +149,11 @@ export function AddTicketModal({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
<label className="block text-xs font-medium mb-1">First Name {nameRequired && '*'}</label>
|
||||||
<input type="text" required value={form.firstName}
|
<input type="text" required={nameRequired} value={form.firstName}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
placeholder="First name" />
|
placeholder={nameRequired ? 'First name' : 'First name (optional)'} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||||
@@ -155,6 +171,7 @@ export function AddTicketModal({
|
|||||||
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
|
placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />
|
||||||
<p className="text-[10px] text-gray-500 mt-1">
|
<p className="text-[10px] text-gray-500 mt-1">
|
||||||
{form.type === 'paid' && 'Ticket will be sent to this email'}
|
{form.type === 'paid' && 'Ticket will be sent to this email'}
|
||||||
|
{form.type === 'door' && 'Optional — if provided, the ticket confirmation is sent here'}
|
||||||
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
|
{form.type === 'unpaid' && 'If provided, the payment link is sent here'}
|
||||||
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
|
{form.type === 'guest' && 'If provided, a confirmation email will be sent'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ export function EventModals(props: EventModalsProps) {
|
|||||||
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
|
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { openAddTicket('door'); setShowAddTicketSheet(false); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<BanknotesIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Paid at Door</p>
|
||||||
|
<p className="text-xs text-gray-500">Cash taken at the door, all fields optional</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
|
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
|
||||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Ticket } from '@/lib/api';
|
import { Ticket } from '@/lib/api';
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
||||||
|
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -79,6 +81,20 @@ export function AttendeesTab({
|
|||||||
handleMarkPaid,
|
handleMarkPaid,
|
||||||
handleCheckin,
|
handleCheckin,
|
||||||
}: AttendeesTabProps) {
|
}: AttendeesTabProps) {
|
||||||
|
// Paginated client-side: the parent already holds every ticket for the event
|
||||||
|
// so the status counts and the other tabs keep seeing the full set.
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const filterKey = `${searchQuery}|${statusFilter}`;
|
||||||
|
const prevFilterKey = useRef(filterKey);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFilterKey.current !== filterKey) {
|
||||||
|
prevFilterKey.current = filterKey;
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [filterKey]);
|
||||||
|
const pagedTickets = usePaginatedList(filteredTickets, page, pageSize, setPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Desktop toolbar */}
|
{/* Desktop toolbar */}
|
||||||
@@ -148,6 +164,9 @@ export function AttendeesTab({
|
|||||||
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
|
||||||
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
|
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => { openAddTicket('door'); setShowAddTicketDropdown(false); }}>
|
||||||
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Paid at Door
|
||||||
|
</DropdownItem>
|
||||||
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
|
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
|
||||||
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
|
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
@@ -234,7 +253,7 @@ export function AttendeesTab({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const primary = getPrimaryAction(ticket);
|
const primary = getPrimaryAction(ticket);
|
||||||
return (
|
return (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
||||||
@@ -320,7 +339,7 @@ export function AttendeesTab({
|
|||||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const primary = getPrimaryAction(ticket);
|
const primary = getPrimaryAction(ticket);
|
||||||
return (
|
return (
|
||||||
<Card key={ticket.id} className="p-3">
|
<Card key={ticket.id} className="p-3">
|
||||||
@@ -377,6 +396,16 @@ export function AttendeesTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
id="attendees"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={filteredTickets.length}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
className="mb-20 md:mb-0"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
{/* Mobile FAB */}
|
||||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Event } from '@/lib/api';
|
import { Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import SensitiveValue from '@/components/admin/SensitiveValue';
|
import SensitiveValue from '@/components/admin/SensitiveValue';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
@@ -10,9 +11,11 @@ interface OverviewTabProps {
|
|||||||
formatCurrency: (amount: number, currency: string) => string;
|
formatCurrency: (amount: number, currency: string) => string;
|
||||||
confirmedCount: number;
|
confirmedCount: number;
|
||||||
checkedInCount: number;
|
checkedInCount: number;
|
||||||
|
/** Tickets are still loading, so the seat counts are not known yet. */
|
||||||
|
countsLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
|
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount, countsLoading = false }: OverviewTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
@@ -49,8 +52,17 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
|
|||||||
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">Capacity</p>
|
<p className="font-medium text-sm">Capacity</p>
|
||||||
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
|
{countsLoading ? (
|
||||||
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
|
<div aria-hidden="true">
|
||||||
|
<Skeleton className="mt-1 h-4 w-28" />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
|
||||||
|
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PaymentOptionsConfig } from '@/lib/api';
|
import { PaymentOptionsConfig, DOOR_PAYMENT_METHODS, type DoorPaymentMethod, type DoorSummary } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -12,13 +12,86 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
|
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
|
||||||
|
import { formatCurrency } from '../_utils/format';
|
||||||
|
|
||||||
interface PaymentsTabProps {
|
interface PaymentsTabProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
payments: PaymentOverridesController;
|
payments: PaymentOverridesController;
|
||||||
|
/** Takings recorded on the door check-in screen; null while loading or unavailable. */
|
||||||
|
doorSummary: DoorSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }> = {
|
||||||
|
cash: { en: 'Cash', es: 'Efectivo' },
|
||||||
|
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
|
||||||
|
transfer: { en: 'Transfer', es: 'Transferencia' },
|
||||||
|
pos: { en: 'POS', es: 'POS' },
|
||||||
|
guest: { en: 'Guests', es: 'Invitados' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-of-night reconciliation for this event: what staff took on the door, split
|
||||||
|
* by tender, next to the pre-sale total. Guests are counted, not totalled — they
|
||||||
|
* are free and carry no revenue.
|
||||||
|
*/
|
||||||
|
function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary }) {
|
||||||
|
const es = locale === 'es';
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 md:p-5">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<BanknotesIcon className="w-4 h-4 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm">{es ? 'Ventas en Puerta' : 'Door Sales'}</h4>
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
{es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency, locale)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-3 border-t">
|
||||||
|
{DOOR_PAYMENT_METHODS.map((method) => {
|
||||||
|
const entry = summary.door.byMethod[method];
|
||||||
|
return (
|
||||||
|
<div key={method} className="bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-gray-500">
|
||||||
|
{es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en}
|
||||||
|
</p>
|
||||||
|
<p className="font-bold text-sm leading-tight">
|
||||||
|
{method === 'guest'
|
||||||
|
? `${entry.count}`
|
||||||
|
: formatCurrency(entry.total, summary.currency, locale)}
|
||||||
|
</p>
|
||||||
|
{method !== 'guest' && (
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
{entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-600 pt-3 mt-3 border-t">
|
||||||
|
<span>
|
||||||
|
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency, locale)}</strong>
|
||||||
|
{' '}({summary.presale.count})
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency, locale)}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) {
|
||||||
const {
|
const {
|
||||||
loadingPayments,
|
loadingPayments,
|
||||||
hasPaymentOverrides,
|
hasPaymentOverrides,
|
||||||
@@ -31,6 +104,10 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
|||||||
handleSavePaymentOptions,
|
handleSavePaymentOptions,
|
||||||
} = payments;
|
} = payments;
|
||||||
|
|
||||||
|
// Stored as 0/1 on Postgres; POS is on unless explicitly switched off.
|
||||||
|
const posEnabled = !!(getEffectivePaymentOption('posEnabled') ?? true);
|
||||||
|
const posDisabledGlobally = !!globalPaymentOptions && !(globalPaymentOptions.posEnabled ?? true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{loadingPayments ? (
|
{loadingPayments ? (
|
||||||
@@ -39,6 +116,11 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Door takings — reconciliation first, configuration below */}
|
||||||
|
{doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && (
|
||||||
|
<DoorTakings locale={locale} summary={doorSummary} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -364,6 +446,42 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* POS terminal at the door */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 md:p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 bg-violet-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<CreditCardIcon className="w-4 h-4 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm">POS</h4>
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
{locale === 'es' ? 'Solo en el Escáner, confirmación manual' : 'Scanner only, confirmed by staff'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{posDisabledGlobally && (
|
||||||
|
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
||||||
|
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => updatePaymentOverride('posEnabled', !posEnabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
posEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-4 md:p-5">
|
<div className="p-4 md:p-5">
|
||||||
@@ -376,6 +494,7 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
|||||||
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
|
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
|
||||||
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
||||||
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
||||||
|
{ label: 'POS', enabled: posEnabled },
|
||||||
].map((method) => (
|
].map((method) => (
|
||||||
<div key={method.label} className="flex items-center gap-1.5">
|
<div key={method.label} className="flex items-center gap-1.5">
|
||||||
{method.enabled ? (
|
{method.enabled ? (
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { TrashIcon, ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import {
|
||||||
|
financeApi, EVENT_PERMISSIONS, ROLE_PRESETS, PRESET_PERMISSIONS,
|
||||||
|
type EventMember, type EventPermission, type RolePreset,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import Button from '@/components/ui/Button';
|
||||||
|
import { TableSkeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { Modal, Field, inputClass } from '../_finance/ui';
|
||||||
|
import { UserSearch, type UserHit } from '../_finance/UserSearch';
|
||||||
|
|
||||||
|
/** Overrides after flipping one permission: drop the key when it matches the preset again. */
|
||||||
|
function toggleOverride(member: EventMember, key: EventPermission): Partial<Record<EventPermission, boolean>> {
|
||||||
|
const next = { ...member.permissions };
|
||||||
|
const want = !member.effective.includes(key);
|
||||||
|
const presetHas = PRESET_PERMISSIONS[member.rolePreset].includes(key);
|
||||||
|
if (want === presetHas) delete next[key]; else next[key] = want;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toggle({ on, overridden, disabled, onClick, label }: { on: boolean; overridden: boolean; disabled?: boolean; onClick: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={on}
|
||||||
|
aria-label={label}
|
||||||
|
title={overridden ? label : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
'relative inline-flex h-6 w-11 md:h-5 md:w-9 flex-shrink-0 rounded-full transition-colors cursor-pointer disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-yellow',
|
||||||
|
on ? 'bg-primary-yellow' : 'bg-gray-200',
|
||||||
|
overridden && 'ring-2 ring-offset-1 ring-blue-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx('inline-block h-5 w-5 md:h-4 md:w-4 mt-0.5 rounded-full bg-white shadow transition-transform', on ? 'translate-x-[22px] md:translate-x-[18px]' : 'translate-x-0.5')} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamTab({ eventId }: { eventId: string }) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [members, setMembers] = useState<EventMember[] | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [picked, setPicked] = useState<UserHit | null>(null);
|
||||||
|
const [preset, setPreset] = useState<RolePreset>('collaborator');
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setMembers((await financeApi.getMembers(eventId)).members);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
setMembers([]);
|
||||||
|
}
|
||||||
|
}, [eventId]);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const search = useCallback((q: string) => financeApi.searchMemberCandidates(eventId, q), [eventId]);
|
||||||
|
|
||||||
|
const update = async (m: EventMember, body: { rolePreset?: RolePreset; permissions?: Partial<Record<EventPermission, boolean>> }) => {
|
||||||
|
setBusy(m.id);
|
||||||
|
try {
|
||||||
|
const { member } = await financeApi.updateMember(eventId, m.id, body);
|
||||||
|
setMembers((list) => list?.map((x) => (x.id === m.id ? member : x)) ?? null);
|
||||||
|
toast.success(t('admin.team.saved'));
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (m: EventMember) => {
|
||||||
|
if (!confirm(t('admin.team.confirmRemove', { name: m.name || m.email || '' }))) return;
|
||||||
|
setBusy(m.id);
|
||||||
|
try {
|
||||||
|
await financeApi.removeMember(eventId, m.id);
|
||||||
|
setMembers((list) => list?.filter((x) => x.id !== m.id) ?? null);
|
||||||
|
toast.success(t('admin.team.removed'));
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!picked) return;
|
||||||
|
setBusy('add');
|
||||||
|
try {
|
||||||
|
await financeApi.addMember(eventId, { userId: picked.id, rolePreset: preset });
|
||||||
|
toast.success(t('admin.team.added'));
|
||||||
|
setAdding(false);
|
||||||
|
setPicked(null);
|
||||||
|
load();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (members === null) return <TableSkeleton rows={3} cols={4} />;
|
||||||
|
|
||||||
|
const PresetSelect = ({ m }: { m: EventMember }) => (
|
||||||
|
<select
|
||||||
|
className={clsx(inputClass, 'py-1.5 w-auto')}
|
||||||
|
value={m.rolePreset}
|
||||||
|
disabled={busy === m.id}
|
||||||
|
onChange={(e) => update(m, { rolePreset: e.target.value as RolePreset, permissions: m.permissions })}
|
||||||
|
>
|
||||||
|
{ROLE_PRESETS.map((p) => <option key={p} value={p}>{t(`admin.rolePresets.${p}`)}</option>)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
const hasOverrides = (m: EventMember) => Object.keys(m.permissions).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
|
||||||
|
<p className="text-xs text-gray-500 max-w-2xl">{t('admin.team.hint')}</p>
|
||||||
|
<Button size="sm" onClick={() => { setPicked(null); setPreset('collaborator'); setAdding(true); }} className="self-start md:self-auto">
|
||||||
|
{t('admin.team.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 py-8 text-center bg-white rounded-card shadow-card">{t('admin.team.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop: permission matrix */}
|
||||||
|
<div className="hidden lg:block bg-white rounded-card shadow-card overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
<th className="text-left font-medium text-xs text-gray-500 px-4 py-2.5 sticky left-0 bg-white">{t('admin.finance.split.linkedUser')}</th>
|
||||||
|
<th className="text-left font-medium text-xs text-gray-500 px-2 py-2.5">{t('admin.team.role')}</th>
|
||||||
|
{EVENT_PERMISSIONS.map((p) => (
|
||||||
|
<th key={p} className="px-1 py-2.5 align-bottom">
|
||||||
|
<span className="block text-[10px] font-medium text-gray-500 leading-tight w-16 mx-auto text-center">{t(`admin.permissions.${p}`)}</span>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{members.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td className="px-4 py-3 sticky left-0 bg-white">
|
||||||
|
<p className="font-medium truncate max-w-[180px]">{m.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate max-w-[180px]">{m.email}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-3"><PresetSelect m={m} /></td>
|
||||||
|
{EVENT_PERMISSIONS.map((p) => (
|
||||||
|
<td key={p} className="px-1 py-3 text-center">
|
||||||
|
<Toggle
|
||||||
|
on={m.effective.includes(p)}
|
||||||
|
overridden={p in m.permissions}
|
||||||
|
disabled={busy === m.id}
|
||||||
|
label={`${t(`admin.permissions.${p}`)}${p in m.permissions ? ` · ${t('admin.team.override')}` : ''}`}
|
||||||
|
onClick={() => update(m, { permissions: toggleOverride(m, p) })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-3 whitespace-nowrap text-right">
|
||||||
|
{hasOverrides(m) && (
|
||||||
|
<button onClick={() => update(m, { permissions: {} })} className="p-1.5 rounded-btn hover:bg-gray-100 text-gray-500" title={t('admin.team.reset')} aria-label={t('admin.team.reset')}>
|
||||||
|
<ArrowPathIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => remove(m)} disabled={busy === m.id} className="p-1.5 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="text-[11px] text-gray-500 px-4 py-2 flex items-center gap-2 border-t border-gray-100">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full ring-2 ring-blue-400" /> {t('admin.team.override')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile / tablet: one card per member */}
|
||||||
|
<div className="lg:hidden space-y-3">
|
||||||
|
{members.map((m) => (
|
||||||
|
<div key={m.id} className="bg-white rounded-card shadow-card p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{m.name}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{m.email}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => remove(m)} disabled={busy === m.id} className="p-2 rounded-btn hover:bg-gray-100 text-red-600" aria-label={t('admin.team.remove')}>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PresetSelect m={m} />
|
||||||
|
{hasOverrides(m) && (
|
||||||
|
<button onClick={() => update(m, { permissions: {} })} className="text-xs text-gray-500 underline">{t('admin.team.reset')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<details className="group rounded-btn border border-gray-100">
|
||||||
|
<summary className="list-none cursor-pointer flex items-center justify-between px-3 min-h-[44px] text-sm [&::-webkit-details-marker]:hidden">
|
||||||
|
<span className="font-medium">{t('admin.team.permissions')}</span>
|
||||||
|
<span className="flex items-center gap-1 text-gray-500">
|
||||||
|
{t('admin.team.permissionsSummary', { on: m.effective.length, total: EVENT_PERMISSIONS.length })}
|
||||||
|
<ChevronDownIcon className="w-4 h-4 transition-transform group-open:rotate-180" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<ul className="divide-y divide-gray-100 px-3">
|
||||||
|
{EVENT_PERMISSIONS.map((p) => (
|
||||||
|
<li key={p} className="flex items-center justify-between gap-3 text-sm min-h-[48px]">
|
||||||
|
<span className={clsx(p in m.permissions && 'text-blue-700')}>{t(`admin.permissions.${p}`)}</span>
|
||||||
|
<Toggle
|
||||||
|
on={m.effective.includes(p)}
|
||||||
|
overridden={p in m.permissions}
|
||||||
|
disabled={busy === m.id}
|
||||||
|
label={t(`admin.permissions.${p}`)}
|
||||||
|
onClick={() => update(m, { permissions: toggleOverride(m, p) })}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal open={adding} onClose={() => setAdding(false)} title={t('admin.team.add')}>
|
||||||
|
{picked ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-btn bg-gray-50 text-sm">
|
||||||
|
<span className="flex-1 truncate">{picked.name} ({picked.email})</span>
|
||||||
|
<button onClick={() => setPicked(null)} className="text-xs underline text-gray-500">{t('common.edit')}</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<UserSearch search={search} onPick={setPicked} placeholder={t('admin.team.search')} noResults={t('admin.team.noResults')} />
|
||||||
|
)}
|
||||||
|
<Field label={t('admin.team.role')}>
|
||||||
|
<select className={inputClass} value={preset} onChange={(e) => setPreset(e.target.value as RolePreset)}>
|
||||||
|
{ROLE_PRESETS.map((p) => <option key={p} value={p}>{t(`admin.rolePresets.${p}`)}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<p className="text-xs text-gray-500">{PRESET_PERMISSIONS[preset].map((p) => t(`admin.permissions.${p}`)).join(' · ')}</p>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setAdding(false)} className="flex-1 min-h-[44px]">{t('common.cancel')}</Button>
|
||||||
|
<Button onClick={add} disabled={!picked} isLoading={busy === 'add'} className="flex-1 min-h-[44px]">{t('admin.team.add')}</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Ticket } from '@/lib/api';
|
import { Ticket } from '@/lib/api';
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
||||||
|
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -48,6 +50,20 @@ export function TicketsTab({
|
|||||||
handleRemoveCheckin,
|
handleRemoveCheckin,
|
||||||
setShowTicketExportSheet,
|
setShowTicketExportSheet,
|
||||||
}: TicketsTabProps) {
|
}: TicketsTabProps) {
|
||||||
|
// Paginated client-side, same as the Attendees tab: the parent keeps the full
|
||||||
|
// ticket list for the header counts and the export actions.
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const filterKey = `${ticketSearchQuery}|${ticketStatusFilter}`;
|
||||||
|
const prevFilterKey = useRef(filterKey);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFilterKey.current !== filterKey) {
|
||||||
|
prevFilterKey.current = filterKey;
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [filterKey]);
|
||||||
|
const pagedTickets = usePaginatedList(filteredConfirmedTickets, page, pageSize, setPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Desktop toolbar */}
|
{/* Desktop toolbar */}
|
||||||
@@ -152,7 +168,7 @@ export function TicketsTab({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredConfirmedTickets.map((ticket) => (
|
pagedTickets.map((ticket) => (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||||
@@ -216,7 +232,7 @@ export function TicketsTab({
|
|||||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredConfirmedTickets.map((ticket) => (
|
pagedTickets.map((ticket) => (
|
||||||
<Card key={ticket.id} className="p-3">
|
<Card key={ticket.id} className="p-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -253,6 +269,15 @@ export function TicketsTab({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
id="tickets"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={filteredConfirmedTickets.length}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments' | 'finance' | 'team';
|
||||||
|
|
||||||
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold';
|
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled' | 'on_hold';
|
||||||
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
|
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
|
||||||
@@ -15,9 +15,10 @@ export interface PrimaryAction {
|
|||||||
|
|
||||||
// Ticket type in the unified Add Ticket modal:
|
// Ticket type in the unified Add Ticket modal:
|
||||||
// paid = confirmation + QR emailed, counts toward revenue
|
// paid = confirmation + QR emailed, counts toward revenue
|
||||||
|
// door = already paid in cash at the door, counts toward revenue, every field optional
|
||||||
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
|
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
|
||||||
// guest = free comp ticket, auto-confirmed, no revenue
|
// guest = free comp ticket, auto-confirmed, no revenue
|
||||||
export type AddTicketType = 'paid' | 'unpaid' | 'guest';
|
export type AddTicketType = 'paid' | 'door' | 'unpaid' | 'guest';
|
||||||
|
|
||||||
export interface AddTicketFormState {
|
export interface AddTicketFormState {
|
||||||
type: AddTicketType;
|
type: AddTicketType;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Pure formatting/IO helpers for the admin event detail page.
|
// Pure formatting/IO helpers for the admin event detail page.
|
||||||
|
|
||||||
export function formatCurrency(amount: number, currency: string): string {
|
import { formatPyg } from '@/lib/money';
|
||||||
if (currency === 'PYG') {
|
|
||||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
/** PYG goes through the shared money helper; `locale` is the UI language (emails keep the default). */
|
||||||
}
|
export function formatCurrency(amount: number, currency: string, locale: string = 'es'): string {
|
||||||
|
if (currency === 'PYG') return formatPyg(amount, locale);
|
||||||
return `$${amount.toFixed(2)} ${currency}`;
|
return `$${amount.toFixed(2)} ${currency}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { EventDetailSkeleton } from './_components/EventDetailSkeleton';
|
||||||
|
|
||||||
|
// Route-level shell shown the moment an admin opens an event, while the page
|
||||||
|
// chunk loads. Mirrors the page's own first-load state.
|
||||||
|
export default function AdminEventDetailLoading() {
|
||||||
|
return <EventDetailSkeleton backHref="/admin/events" />;
|
||||||
|
}
|
||||||
@@ -1,767 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import { EventDetailView } from './_components/EventDetailView';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
|
||||||
import { ticketsApi, emailsApi, adminApi, paymentsApi, siteSettingsApi, Ticket } from '@/lib/api';
|
|
||||||
import { formatDateLong, formatDateCompact, formatTime } from '@/lib/utils';
|
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import Button from '@/components/ui/Button';
|
|
||||||
import { Skeleton, TableSkeleton } from '@/components/ui/Skeleton';
|
|
||||||
import { Dropdown, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
|
||||||
import {
|
|
||||||
ArrowLeftIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
MapPinIcon,
|
|
||||||
CurrencyDollarIcon,
|
|
||||||
UsersIcon,
|
|
||||||
TicketIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
EnvelopeIcon,
|
|
||||||
PencilIcon,
|
|
||||||
EyeIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
EllipsisVerticalIcon,
|
|
||||||
ArrowUturnLeftIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { usePrivacy } from '@/context/PrivacyContext';
|
|
||||||
import type {
|
|
||||||
TabType,
|
|
||||||
AttendeeStatusFilter,
|
|
||||||
TicketStatusFilter,
|
|
||||||
RecipientFilter,
|
|
||||||
AddTicketType,
|
|
||||||
AddTicketFormState,
|
|
||||||
PrimaryAction,
|
|
||||||
} from './_types';
|
|
||||||
import { formatCurrency, downloadBlob } from './_utils/format';
|
|
||||||
import { useEventDetailData } from './_hooks/useEventDetailData';
|
|
||||||
import { usePaymentOverrides } from './_hooks/usePaymentOverrides';
|
|
||||||
import { OverviewTab } from './_tabs/OverviewTab';
|
|
||||||
import { AttendeesTab } from './_tabs/AttendeesTab';
|
|
||||||
import { TicketsTab } from './_tabs/TicketsTab';
|
|
||||||
import { EmailTab } from './_tabs/EmailTab';
|
|
||||||
import { PaymentsTab } from './_tabs/PaymentsTab';
|
|
||||||
import { EventModals } from './_modals/EventModals';
|
|
||||||
import { AddTicketModal } from './_modals/AddTicketModal';
|
|
||||||
import EventFormModal from '../_components/EventFormModal';
|
|
||||||
|
|
||||||
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
|
|
||||||
type: 'paid',
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
adminNote: '',
|
|
||||||
checkinNow: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminEventDetailPage() {
|
export default function AdminEventDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const eventId = params.id as string;
|
return <EventDetailView eventId={params.id as string} backHref="/admin/events" />;
|
||||||
const { locale } = useLanguage();
|
|
||||||
|
|
||||||
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId);
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
|
||||||
|
|
||||||
// Email state
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
|
||||||
const [recipientFilter, setRecipientFilter] = useState<RecipientFilter>('confirmed');
|
|
||||||
const [customMessage, setCustomMessage] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Attendees tab state
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
|
|
||||||
const { privacyMode } = usePrivacy();
|
|
||||||
const showStats = !privacyMode;
|
|
||||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
|
||||||
const [noteText, setNoteText] = useState('');
|
|
||||||
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
|
|
||||||
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
|
|
||||||
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const openAddTicket = (type: AddTicketType) => {
|
|
||||||
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
|
|
||||||
setShowAddTicketModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
|
||||||
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
|
|
||||||
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
|
|
||||||
const [showTicketExportDropdown, setShowTicketExportDropdown] = useState(false); // desktop
|
|
||||||
const [showTicketExportSheet, setShowTicketExportSheet] = useState(false); // mobile
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
// Add Ticket — separate desktop dropdown vs mobile bottom sheet
|
|
||||||
const [showAddTicketDropdown, setShowAddTicketDropdown] = useState(false); // desktop
|
|
||||||
const [showAddTicketSheet, setShowAddTicketSheet] = useState(false); // mobile FAB
|
|
||||||
|
|
||||||
// Tickets tab state
|
|
||||||
const [ticketSearchQuery, setTicketSearchQuery] = useState('');
|
|
||||||
const [ticketStatusFilter, setTicketStatusFilter] = useState<TicketStatusFilter>('all');
|
|
||||||
|
|
||||||
// Payment options state + handlers
|
|
||||||
const payments = usePaymentOverrides(eventId, locale);
|
|
||||||
|
|
||||||
// Edit event modal (opens in place instead of redirecting to the list page)
|
|
||||||
const [showEditForm, setShowEditForm] = useState(false);
|
|
||||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
siteSettingsApi
|
|
||||||
.get()
|
|
||||||
.then(({ settings }) => setFeaturedEventId(settings.featuredEventId || null))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Mobile-specific state
|
|
||||||
const [mobileHeaderMenuOpen, setMobileHeaderMenuOpen] = useState(false);
|
|
||||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
|
||||||
const [mobileStatsExpanded, setMobileStatsExpanded] = useState(false);
|
|
||||||
|
|
||||||
// Tab bar ref for sticky
|
|
||||||
const tabBarRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'payments') {
|
|
||||||
payments.loadPaymentOptions();
|
|
||||||
}
|
|
||||||
}, [activeTab]);
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
|
||||||
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
|
|
||||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
|
||||||
|
|
||||||
const getTicketsByStatus = (status: string) => {
|
|
||||||
return tickets.filter(t => t.status === status);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFilteredRecipientCount = () => {
|
|
||||||
if (recipientFilter === 'all') return tickets.length;
|
|
||||||
return getTicketsByStatus(recipientFilter).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkPaid = async (ticketId: string) => {
|
|
||||||
try {
|
|
||||||
await ticketsApi.markPaid(ticketId);
|
|
||||||
toast.success('Payment marked as received');
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to mark payment');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckin = async (ticketId: string) => {
|
|
||||||
try {
|
|
||||||
await ticketsApi.checkin(ticketId);
|
|
||||||
toast.success('Attendee checked in');
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to check in');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReactivate = async (ticket: Ticket) => {
|
|
||||||
if (!ticket.payment?.id) return;
|
|
||||||
try {
|
|
||||||
await paymentsApi.reactivate(ticket.payment.id);
|
|
||||||
toast.success('Booking reactivated');
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to reactivate booking');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveCheckin = async (ticketId: string) => {
|
|
||||||
if (!confirm('Are you sure you want to remove the check-in for this attendee?')) return;
|
|
||||||
try {
|
|
||||||
await ticketsApi.removeCheckin(ticketId);
|
|
||||||
toast.success('Check-in removed');
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to remove check-in');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenNoteModal = (ticket: Ticket) => {
|
|
||||||
setSelectedTicket(ticket);
|
|
||||||
setNoteText(ticket.adminNote || '');
|
|
||||||
setShowNoteModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveNote = async () => {
|
|
||||||
if (!selectedTicket) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await ticketsApi.updateNote(selectedTicket.id, noteText);
|
|
||||||
toast.success('Note saved');
|
|
||||||
setShowNoteModal(false);
|
|
||||||
setSelectedTicket(null);
|
|
||||||
setNoteText('');
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to save note');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddTicket = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!event) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const res = await ticketsApi.adminAdd({
|
|
||||||
eventId: event.id,
|
|
||||||
type: addTicketForm.type,
|
|
||||||
firstName: addTicketForm.firstName,
|
|
||||||
lastName: addTicketForm.lastName || undefined,
|
|
||||||
email: addTicketForm.email || undefined,
|
|
||||||
phone: addTicketForm.phone || undefined,
|
|
||||||
checkinNow: addTicketForm.checkinNow,
|
|
||||||
adminNote: addTicketForm.adminNote || undefined,
|
|
||||||
});
|
|
||||||
toast.success(res.message || 'Ticket created');
|
|
||||||
setShowAddTicketModal(false);
|
|
||||||
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
|
|
||||||
loadEventData();
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to add ticket');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
|
|
||||||
if (!event) return;
|
|
||||||
setExporting(true);
|
|
||||||
setShowExportDropdown(false);
|
|
||||||
try {
|
|
||||||
const { blob, filename } = await adminApi.exportAttendees(event.id, { status, format: 'csv', q: searchQuery || undefined });
|
|
||||||
downloadBlob(blob, filename);
|
|
||||||
toast.success('Export downloaded');
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to export attendees');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportTickets = async (status: 'confirmed' | 'checked_in' | 'all') => {
|
|
||||||
if (!event) return;
|
|
||||||
setExporting(true);
|
|
||||||
setShowTicketExportDropdown(false);
|
|
||||||
try {
|
|
||||||
const { blob, filename } = await adminApi.exportTicketsCSV(event.id, { status, q: ticketSearchQuery || undefined });
|
|
||||||
downloadBlob(blob, filename);
|
|
||||||
toast.success('Export downloaded');
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to export tickets');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filtered tickets for attendees tab
|
|
||||||
const filteredTickets = tickets.filter((ticket) => {
|
|
||||||
if (statusFilter !== 'all' && ticket.status !== statusFilter) return false;
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
|
||||||
return (
|
|
||||||
fullName.includes(query) ||
|
|
||||||
(ticket.attendeeEmail?.toLowerCase().includes(query) || false) ||
|
|
||||||
(ticket.attendeePhone?.toLowerCase().includes(query) || false) ||
|
|
||||||
ticket.id.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filtered tickets for the Tickets tab (only confirmed/checked_in)
|
|
||||||
const confirmedTickets = tickets.filter(t => ['confirmed', 'checked_in'].includes(t.status));
|
|
||||||
const filteredConfirmedTickets = confirmedTickets.filter((ticket) => {
|
|
||||||
if (ticketStatusFilter !== 'all' && ticket.status !== ticketStatusFilter) return false;
|
|
||||||
if (ticketSearchQuery) {
|
|
||||||
const query = ticketSearchQuery.toLowerCase();
|
|
||||||
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim().toLowerCase();
|
|
||||||
return (
|
|
||||||
fullName.includes(query) ||
|
|
||||||
ticket.id.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handlePreviewEmail = async () => {
|
|
||||||
if (!selectedTemplate) {
|
|
||||||
toast.error('Please select a template');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await emailsApi.preview({
|
|
||||||
templateSlug: selectedTemplate,
|
|
||||||
variables: {
|
|
||||||
attendeeName: 'John Doe',
|
|
||||||
attendeeEmail: 'john@example.com',
|
|
||||||
ticketId: 'TKT-PREVIEW',
|
|
||||||
eventTitle: event?.title || '',
|
|
||||||
eventDate: event ? formatDate(event.startDatetime) : '',
|
|
||||||
eventTime: event ? fmtTime(event.startDatetime) : '',
|
|
||||||
eventLocation: event?.location || '',
|
|
||||||
eventLocationUrl: event?.locationUrl || '',
|
|
||||||
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
|
||||||
customMessage: customMessage || 'Your custom message will appear here.',
|
|
||||||
},
|
|
||||||
locale,
|
|
||||||
});
|
|
||||||
setPreviewHtml(res.bodyHtml);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to preview email');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendEmail = async () => {
|
|
||||||
if (!selectedTemplate) {
|
|
||||||
toast.error('Please select a template');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipientCount = getFilteredRecipientCount();
|
|
||||||
if (recipientCount === 0) {
|
|
||||||
toast.error('No recipients match the selected filter');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirm(`Send email to ${recipientCount} ${recipientFilter === 'all' ? 'attendee(s)' : `${recipientFilter} attendee(s)`}?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
const res = await emailsApi.sendToEvent(eventId, {
|
|
||||||
templateSlug: selectedTemplate,
|
|
||||||
recipientFilter,
|
|
||||||
customVariables: customMessage ? { customMessage } : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(`${res.queuedCount} email(s) are being sent in the background.`);
|
|
||||||
} else {
|
|
||||||
toast.error(res.error || 'Failed to queue emails');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast.error(error.message || 'Failed to send emails');
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<Skeleton className="h-8 w-64" />
|
|
||||||
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
|
||||||
</div>
|
|
||||||
<TableSkeleton rows={5} cols={4} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<p className="text-gray-500">Event not found</p>
|
|
||||||
<Link href="/admin/events">
|
|
||||||
<Button variant="outline" className="mt-4">
|
|
||||||
Back to Events
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmedCount = getTicketsByStatus('confirmed').length;
|
|
||||||
const pendingCount = getTicketsByStatus('pending').length;
|
|
||||||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
|
||||||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
|
||||||
const onHoldCount = getTicketsByStatus('on_hold').length;
|
|
||||||
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
|
|
||||||
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
|
|
||||||
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
|
|
||||||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
|
|
||||||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
|
|
||||||
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
|
|
||||||
|
|
||||||
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
|
||||||
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
|
|
||||||
{ key: 'attendees', label: 'Attendees', icon: UserGroupIcon, count: tickets.length },
|
|
||||||
{ key: 'tickets', label: 'Tickets', icon: TicketIcon, count: confirmedTickets.length },
|
|
||||||
{ key: 'email', label: 'Email', icon: EnvelopeIcon },
|
|
||||||
{ key: 'payments', label: locale === 'es' ? 'Pagos' : 'Payments', icon: CreditCardIcon },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ========== Primary action for a ticket ==========
|
|
||||||
const getPrimaryAction = (ticket: Ticket): PrimaryAction | null => {
|
|
||||||
if (ticket.status === 'pending' || ticket.status === 'on_hold') {
|
|
||||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
|
||||||
}
|
|
||||||
if (ticket.status === 'confirmed') {
|
|
||||||
// Unpaid tickets resolve their balance first; check-in stays available via scanner
|
|
||||||
if (ticket.paymentStatus === 'unpaid') {
|
|
||||||
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
|
|
||||||
}
|
|
||||||
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
|
|
||||||
}
|
|
||||||
if (ticket.status === 'checked_in') {
|
|
||||||
return { label: 'Undo', onClick: () => handleRemoveCheckin(ticket.id), variant: 'outline', icon: ArrowUturnLeftIcon };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-full overflow-x-hidden">
|
|
||||||
{/* ============= HEADER ============= */}
|
|
||||||
<div className="flex items-start gap-3 mb-3">
|
|
||||||
<Link href="/admin/events" className="mt-1 flex-shrink-0">
|
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
|
||||||
<ArrowLeftIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</Link>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
|
|
||||||
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}</p>
|
|
||||||
</div>
|
|
||||||
{/* Desktop header actions */}
|
|
||||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
|
||||||
<Link href={`/events/${event.slug}`} target="_blank">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
View Public
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowEditForm(true)}>
|
|
||||||
<PencilIcon className="w-4 h-4 mr-1.5" />
|
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* Mobile header overflow menu */}
|
|
||||||
<div className="md:hidden flex-shrink-0">
|
|
||||||
<Dropdown
|
|
||||||
open={mobileHeaderMenuOpen}
|
|
||||||
onOpenChange={setMobileHeaderMenuOpen}
|
|
||||||
trigger={
|
|
||||||
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
|
||||||
<EllipsisVerticalIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownItem onClick={() => { window.open(`/events/${event.slug}`, '_blank'); setMobileHeaderMenuOpen(false); }}>
|
|
||||||
<EyeIcon className="w-4 h-4 mr-2" /> View Public
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
|
|
||||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
|
||||||
</DropdownItem>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============= COMPACT META CHIPS (desktop) ============= */}
|
|
||||||
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
|
||||||
<CalendarIcon className="w-3.5 h-3.5" />
|
|
||||||
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
|
||||||
<MapPinIcon className="w-3.5 h-3.5" />
|
|
||||||
{event.location}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
|
||||||
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
|
||||||
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
|
||||||
</span>
|
|
||||||
{showStats && (
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
|
||||||
<UsersIcon className="w-3.5 h-3.5" />
|
|
||||||
{confirmedCount + checkedInCount}/{event.capacity}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============= STATS ROW ============= */}
|
|
||||||
{/* Desktop: always-visible compact 4-card row */}
|
|
||||||
<div className="hidden md:block mb-4">
|
|
||||||
{showStats && (
|
|
||||||
<div className="grid grid-cols-4 gap-3">
|
|
||||||
{[
|
|
||||||
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
|
|
||||||
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
|
|
||||||
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
|
|
||||||
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' },
|
|
||||||
].map((stat) => (
|
|
||||||
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
|
|
||||||
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
|
||||||
<stat.icon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
|
|
||||||
<p className="text-xs text-gray-500">{stat.label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile: collapsible stats */}
|
|
||||||
<div className="md:hidden mb-3">
|
|
||||||
{showStats && (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileStatsExpanded(!mobileStatsExpanded)}
|
|
||||||
className="w-full flex items-center justify-between px-3 py-2 bg-white rounded-card shadow-card mb-2 min-h-[44px]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 overflow-x-auto">
|
|
||||||
<span className="text-xs font-medium text-gray-500 whitespace-nowrap">Stats</span>
|
|
||||||
<span className="text-sm font-semibold whitespace-nowrap">{confirmedCount + checkedInCount}/{event.capacity}</span>
|
|
||||||
<span className="text-xs text-gray-400">|</span>
|
|
||||||
<span className="text-sm whitespace-nowrap">{checkedInCount} in</span>
|
|
||||||
<span className="text-xs text-gray-400">|</span>
|
|
||||||
<span className="text-sm whitespace-nowrap">{formatCurrency(revenue, event.currency)}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDownIcon className={clsx('w-4 h-4 text-gray-400 transition-transform flex-shrink-0 ml-2', mobileStatsExpanded && 'rotate-180')} />
|
|
||||||
</button>
|
|
||||||
{mobileStatsExpanded && (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{[
|
|
||||||
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
|
|
||||||
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
|
|
||||||
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
|
|
||||||
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' },
|
|
||||||
].map((stat) => (
|
|
||||||
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
|
|
||||||
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
|
|
||||||
<stat.icon className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
|
|
||||||
<p className="text-[10px] text-gray-500">{stat.label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============= TAB BAR ============= */}
|
|
||||||
{/* Desktop: tab bar inside a card top-section */}
|
|
||||||
<div className="hidden md:block bg-white rounded-t-2xl shadow-card border-b border-gray-200">
|
|
||||||
<div ref={tabBarRef} className="sticky top-0 z-30 bg-white rounded-t-2xl px-4">
|
|
||||||
<nav className="flex gap-1">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={clsx(
|
|
||||||
'py-2.5 px-3 font-medium text-sm transition-colors flex items-center gap-1.5 whitespace-nowrap relative',
|
|
||||||
activeTab === tab.key
|
|
||||||
? 'text-primary-dark'
|
|
||||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<tab.icon className="w-4 h-4" />
|
|
||||||
{tab.label}
|
|
||||||
{tab.count !== undefined && <span className="text-xs text-gray-400">({tab.count})</span>}
|
|
||||||
{activeTab === tab.key && (
|
|
||||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary-yellow rounded-full" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile: segmented tab bar */}
|
|
||||||
<div className="md:hidden sticky top-0 z-30 -mx-4 px-3 py-1.5 bg-gray-100">
|
|
||||||
<div className="overflow-x-auto scrollbar-hide">
|
|
||||||
<div className="flex gap-0.5 min-w-max">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={clsx(
|
|
||||||
'px-3 py-2 rounded-lg text-xs font-medium transition-all whitespace-nowrap min-h-[36px] flex items-center gap-1',
|
|
||||||
activeTab === tab.key
|
|
||||||
? 'bg-white shadow-sm text-primary-dark'
|
|
||||||
: 'text-gray-500 hover:text-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<tab.icon className="w-3.5 h-3.5" />
|
|
||||||
{tab.label}
|
|
||||||
{tab.count !== undefined && <span className="text-[10px] opacity-70">({tab.count})</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============= TAB CONTENT ============= */}
|
|
||||||
<div className="md:bg-white md:rounded-b-2xl md:shadow-card md:p-4 pt-3 md:pt-4">
|
|
||||||
{activeTab === 'overview' && (
|
|
||||||
<OverviewTab
|
|
||||||
event={event}
|
|
||||||
formatDate={formatDate}
|
|
||||||
fmtTime={fmtTime}
|
|
||||||
formatCurrency={formatCurrency}
|
|
||||||
confirmedCount={confirmedCount}
|
|
||||||
checkedInCount={checkedInCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'attendees' && (
|
|
||||||
<AttendeesTab
|
|
||||||
locale={locale}
|
|
||||||
tickets={tickets}
|
|
||||||
filteredTickets={filteredTickets}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
setSearchQuery={setSearchQuery}
|
|
||||||
statusFilter={statusFilter}
|
|
||||||
setStatusFilter={setStatusFilter}
|
|
||||||
pendingCount={pendingCount}
|
|
||||||
confirmedCount={confirmedCount}
|
|
||||||
checkedInCount={checkedInCount}
|
|
||||||
cancelledCount={cancelledCount}
|
|
||||||
onHoldCount={onHoldCount}
|
|
||||||
exporting={exporting}
|
|
||||||
showExportDropdown={showExportDropdown}
|
|
||||||
setShowExportDropdown={setShowExportDropdown}
|
|
||||||
showAddTicketDropdown={showAddTicketDropdown}
|
|
||||||
setShowAddTicketDropdown={setShowAddTicketDropdown}
|
|
||||||
handleExportAttendees={handleExportAttendees}
|
|
||||||
openAddTicket={openAddTicket}
|
|
||||||
setMobileFilterOpen={setMobileFilterOpen}
|
|
||||||
setShowExportSheet={setShowExportSheet}
|
|
||||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
|
||||||
getPrimaryAction={getPrimaryAction}
|
|
||||||
handleOpenNoteModal={handleOpenNoteModal}
|
|
||||||
handleReactivate={handleReactivate}
|
|
||||||
handleMarkPaid={handleMarkPaid}
|
|
||||||
handleCheckin={handleCheckin}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'tickets' && (
|
|
||||||
<TicketsTab
|
|
||||||
locale={locale}
|
|
||||||
confirmedTickets={confirmedTickets}
|
|
||||||
filteredConfirmedTickets={filteredConfirmedTickets}
|
|
||||||
ticketSearchQuery={ticketSearchQuery}
|
|
||||||
setTicketSearchQuery={setTicketSearchQuery}
|
|
||||||
ticketStatusFilter={ticketStatusFilter}
|
|
||||||
setTicketStatusFilter={setTicketStatusFilter}
|
|
||||||
confirmedCount={confirmedCount}
|
|
||||||
checkedInCount={checkedInCount}
|
|
||||||
exporting={exporting}
|
|
||||||
showTicketExportDropdown={showTicketExportDropdown}
|
|
||||||
setShowTicketExportDropdown={setShowTicketExportDropdown}
|
|
||||||
handleExportTickets={handleExportTickets}
|
|
||||||
handleCheckin={handleCheckin}
|
|
||||||
handleRemoveCheckin={handleRemoveCheckin}
|
|
||||||
setShowTicketExportSheet={setShowTicketExportSheet}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'email' && (
|
|
||||||
<EmailTab
|
|
||||||
templates={templates}
|
|
||||||
selectedTemplate={selectedTemplate}
|
|
||||||
setSelectedTemplate={setSelectedTemplate}
|
|
||||||
recipientFilter={recipientFilter}
|
|
||||||
setRecipientFilter={setRecipientFilter}
|
|
||||||
customMessage={customMessage}
|
|
||||||
setCustomMessage={setCustomMessage}
|
|
||||||
sending={sending}
|
|
||||||
handlePreviewEmail={handlePreviewEmail}
|
|
||||||
handleSendEmail={handleSendEmail}
|
|
||||||
getFilteredRecipientCount={getFilteredRecipientCount}
|
|
||||||
ticketsCount={tickets.length}
|
|
||||||
confirmedCount={confirmedCount}
|
|
||||||
pendingCount={pendingCount}
|
|
||||||
checkedInCount={checkedInCount}
|
|
||||||
cancelledCount={cancelledCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'payments' && (
|
|
||||||
<PaymentsTab locale={locale} payments={payments} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ============= MODALS ============= */}
|
|
||||||
<EventModals
|
|
||||||
ticketsCount={tickets.length}
|
|
||||||
pendingCount={pendingCount}
|
|
||||||
confirmedCount={confirmedCount}
|
|
||||||
checkedInCount={checkedInCount}
|
|
||||||
cancelledCount={cancelledCount}
|
|
||||||
onHoldCount={onHoldCount}
|
|
||||||
statusFilter={statusFilter}
|
|
||||||
setStatusFilter={setStatusFilter}
|
|
||||||
mobileFilterOpen={mobileFilterOpen}
|
|
||||||
setMobileFilterOpen={setMobileFilterOpen}
|
|
||||||
showAddTicketSheet={showAddTicketSheet}
|
|
||||||
setShowAddTicketSheet={setShowAddTicketSheet}
|
|
||||||
openAddTicket={openAddTicket}
|
|
||||||
showExportSheet={showExportSheet}
|
|
||||||
setShowExportSheet={setShowExportSheet}
|
|
||||||
handleExportAttendees={handleExportAttendees}
|
|
||||||
showTicketExportSheet={showTicketExportSheet}
|
|
||||||
setShowTicketExportSheet={setShowTicketExportSheet}
|
|
||||||
handleExportTickets={handleExportTickets}
|
|
||||||
submitting={submitting}
|
|
||||||
showNoteModal={showNoteModal}
|
|
||||||
setShowNoteModal={setShowNoteModal}
|
|
||||||
selectedTicket={selectedTicket}
|
|
||||||
setSelectedTicket={setSelectedTicket}
|
|
||||||
noteText={noteText}
|
|
||||||
setNoteText={setNoteText}
|
|
||||||
handleSaveNote={handleSaveNote}
|
|
||||||
previewHtml={previewHtml}
|
|
||||||
setPreviewHtml={setPreviewHtml}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AddTicketModal
|
|
||||||
open={showAddTicketModal}
|
|
||||||
onClose={() => setShowAddTicketModal(false)}
|
|
||||||
form={addTicketForm}
|
|
||||||
setForm={setAddTicketForm}
|
|
||||||
onSubmit={handleAddTicket}
|
|
||||||
submitting={submitting}
|
|
||||||
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EventFormModal
|
|
||||||
open={showEditForm}
|
|
||||||
event={event}
|
|
||||||
featuredEventId={featuredEventId}
|
|
||||||
onFeaturedChange={setFeaturedEventId}
|
|
||||||
onClose={() => setShowEditForm(false)}
|
|
||||||
onSaved={() => { setShowEditForm(false); loadEventData(); }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdminMobileStyles />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import MediaPicker from '@/components/MediaPicker';
|
import MediaPicker from '@/components/MediaPicker';
|
||||||
|
import DurationInput from '@/components/admin/DurationInput';
|
||||||
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { StarIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
import { parseDate, EVENT_TIMEZONE, formatDurationWords, formatPrice } from '@/lib/utils';
|
||||||
|
|
||||||
interface EventFormData {
|
interface EventFormData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -23,23 +24,47 @@ interface EventFormData {
|
|||||||
endDatetime: string;
|
endDatetime: string;
|
||||||
location: string;
|
location: string;
|
||||||
locationUrl: string;
|
locationUrl: string;
|
||||||
|
series: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
// Kept as the raw input so "empty" (not set -> falls back to price) stays
|
||||||
|
// distinguishable from 0 (a free walk-in).
|
||||||
|
walkInPrice: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||||
bannerUrl: string;
|
bannerUrl: string;
|
||||||
externalBookingEnabled: boolean;
|
externalBookingEnabled: boolean;
|
||||||
externalBookingUrl: string;
|
externalBookingUrl: string;
|
||||||
|
presaleClosureEnabled: boolean;
|
||||||
|
presaleCloseMinutesBefore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Site-wide pre-sale defaults, used to pre-fill events that haven't overridden them
|
||||||
|
interface PresaleDefaults {
|
||||||
|
enabled: boolean;
|
||||||
|
minutesBefore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_PRESALE_DEFAULTS: PresaleDefaults = { enabled: true, minutesBefore: 120 };
|
||||||
|
|
||||||
const EMPTY_FORM: EventFormData = {
|
const EMPTY_FORM: EventFormData = {
|
||||||
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
title: '', titleEs: '', slug: '', description: '', descriptionEs: '',
|
||||||
shortDescription: '', shortDescriptionEs: '',
|
shortDescription: '', shortDescriptionEs: '',
|
||||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
startDatetime: '', endDatetime: '', location: '', locationUrl: '', series: '',
|
||||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft',
|
price: 0, walkInPrice: '', currency: 'PYG', capacity: 50, status: 'draft',
|
||||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||||
|
presaleClosureEnabled: FALLBACK_PRESALE_DEFAULTS.enabled,
|
||||||
|
presaleCloseMinutesBefore: FALLBACK_PRESALE_DEFAULTS.minutesBefore,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Empty is valid (not set); otherwise the same rule as Price: a whole number >= 0. */
|
||||||
|
function isValidWalkInPrice(value: string): boolean {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === '') return true;
|
||||||
|
const n = Number(trimmed);
|
||||||
|
return Number.isInteger(n) && n >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function isoToLocalDatetime(isoString: string): string {
|
function isoToLocalDatetime(isoString: string): string {
|
||||||
const date = parseDate(isoString);
|
const date = parseDate(isoString);
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
@@ -89,6 +114,30 @@ export default function EventFormModal({
|
|||||||
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
const [slugAliases, setSlugAliases] = useState<{ slug: string; createdAt: string }[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [settingFeatured, setSettingFeatured] = useState(false);
|
const [settingFeatured, setSettingFeatured] = useState(false);
|
||||||
|
const [presaleDefaults, setPresaleDefaults] = useState<PresaleDefaults>(FALLBACK_PRESALE_DEFAULTS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let cancelled = false;
|
||||||
|
// Site defaults fill in whatever the event hasn't overridden (null = inherit)
|
||||||
|
siteSettingsApi.get()
|
||||||
|
.then(({ settings }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const defaults: PresaleDefaults = {
|
||||||
|
enabled: settings.presaleClosureEnabled ?? FALLBACK_PRESALE_DEFAULTS.enabled,
|
||||||
|
minutesBefore: settings.presaleCloseMinutesBefore ?? FALLBACK_PRESALE_DEFAULTS.minutesBefore,
|
||||||
|
};
|
||||||
|
setPresaleDefaults(defaults);
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
presaleClosureEnabled: event?.presaleClosureEnabled ?? defaults.enabled,
|
||||||
|
presaleCloseMinutesBefore: event?.presaleCloseMinutesBefore ?? defaults.minutesBefore,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep fallback defaults */ });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, event]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -99,11 +148,14 @@ export default function EventFormModal({
|
|||||||
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||||
location: event.location, locationUrl: event.locationUrl || '',
|
location: event.location, locationUrl: event.locationUrl || '', series: event.series || '',
|
||||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||||
|
walkInPrice: event.walkInPrice == null ? '' : String(event.walkInPrice),
|
||||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||||
externalBookingUrl: event.externalBookingUrl || '',
|
externalBookingUrl: event.externalBookingUrl || '',
|
||||||
|
presaleClosureEnabled: event.presaleClosureEnabled ?? presaleDefaults.enabled,
|
||||||
|
presaleCloseMinutesBefore: event.presaleCloseMinutesBefore ?? presaleDefaults.minutesBefore,
|
||||||
});
|
});
|
||||||
loadSlugAliases(event.id);
|
loadSlugAliases(event.id);
|
||||||
} else {
|
} else {
|
||||||
@@ -161,6 +213,19 @@ export default function EventFormModal({
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
formData.presaleClosureEnabled &&
|
||||||
|
(!Number.isFinite(formData.presaleCloseMinutesBefore) || formData.presaleCloseMinutesBefore < 0)
|
||||||
|
) {
|
||||||
|
toast.error('Pre-sale closure time must be zero or more');
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidWalkInPrice(formData.walkInPrice)) {
|
||||||
|
toast.error(t('admin.events.form.walkInPriceInvalid'));
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const eventData: Partial<Event> = {
|
const eventData: Partial<Event> = {
|
||||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||||
@@ -168,10 +233,16 @@ export default function EventFormModal({
|
|||||||
startDatetime: formData.startDatetime,
|
startDatetime: formData.startDatetime,
|
||||||
endDatetime: formData.endDatetime || undefined,
|
endDatetime: formData.endDatetime || undefined,
|
||||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||||
|
series: formData.series.trim() || null,
|
||||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||||
|
// Empty means "not set" and must be saved as null, never 0
|
||||||
|
walkInPrice: formData.walkInPrice.trim() === '' ? null : Number(formData.walkInPrice),
|
||||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||||
externalBookingEnabled: formData.externalBookingEnabled,
|
externalBookingEnabled: formData.externalBookingEnabled,
|
||||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||||
|
// Saving from the modal always writes explicit values (overrides the site default)
|
||||||
|
presaleClosureEnabled: formData.presaleClosureEnabled,
|
||||||
|
presaleCloseMinutesBefore: formData.presaleCloseMinutesBefore,
|
||||||
};
|
};
|
||||||
if (event) {
|
if (event) {
|
||||||
// Only send slug when editing so creates still auto-generate from title
|
// Only send slug when editing so creates still auto-generate from title
|
||||||
@@ -291,18 +362,36 @@ export default function EventFormModal({
|
|||||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
|
||||||
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||||
|
<div>
|
||||||
|
<Input label={t('admin.eventForm.series')} value={formData.series} maxLength={100} placeholder="Morning Club"
|
||||||
|
onChange={(e) => setFormData({ ...formData, series: e.target.value })} />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{t('admin.eventForm.seriesHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div>
|
||||||
<Input label="Price" type="number" min="0" value={formData.price}
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
<Input label="Price" type="number" min="0" value={formData.price}
|
||||||
<div>
|
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
<Input label={t('admin.events.form.walkInPrice')} type="number" min="0" inputMode="numeric"
|
||||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
value={formData.walkInPrice}
|
||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
onChange={(e) => setFormData({ ...formData, walkInPrice: e.target.value })}
|
||||||
<option value="PYG">PYG</option>
|
placeholder={t('admin.events.form.walkInPricePlaceholder', {
|
||||||
<option value="USD">USD</option>
|
price: formatPrice(formData.price || 0, formData.currency),
|
||||||
</select>
|
})}
|
||||||
|
error={isValidWalkInPrice(formData.walkInPrice) ? undefined : t('admin.events.form.walkInPriceInvalid')} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||||
|
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||||
|
<option value="PYG">PYG</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{t('admin.events.form.walkInPriceHelp')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +435,35 @@ export default function EventFormModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Pre-sale Closure</label>
|
||||||
|
<p className="text-xs text-gray-500">Stop online registration before the event starts</p>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, presaleClosureEnabled: !formData.presaleClosureEnabled })}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||||
|
formData.presaleClosureEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
|
||||||
|
}`}>
|
||||||
|
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||||
|
formData.presaleClosureEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formData.presaleClosureEnabled && (
|
||||||
|
<DurationInput label="Close pre-sale this long before the start"
|
||||||
|
valueMinutes={formData.presaleCloseMinutesBefore}
|
||||||
|
onChange={(minutes) => setFormData({ ...formData, presaleCloseMinutesBefore: minutes })} />
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Site default: {presaleDefaults.enabled
|
||||||
|
? `closes ${formatDurationWords(presaleDefaults.minutesBefore)} before the start`
|
||||||
|
: 'off'}
|
||||||
|
{' '}(Admin › Settings › General).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MediaPicker value={formData.bannerUrl}
|
<MediaPicker value={formData.bannerUrl}
|
||||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||||
relatedId={event?.id} relatedType="event" />
|
relatedId={event?.id} relatedType="event" />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton';
|
||||||
|
|
||||||
|
// Loading placeholders for the admin events list. The page keeps its title,
|
||||||
|
// Create button and table header on screen and swaps only the rows for these,
|
||||||
|
// so nothing jumps when the data lands. loading.tsx composes the same pieces
|
||||||
|
// for the instant route-transition shell.
|
||||||
|
|
||||||
|
const TH = 'px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider';
|
||||||
|
|
||||||
|
export function EventsTableHead() {
|
||||||
|
return (
|
||||||
|
<thead className="bg-secondary-gray">
|
||||||
|
<tr>
|
||||||
|
<th className={`text-left ${TH}`}>Event</th>
|
||||||
|
<th className={`text-left ${TH}`}>Date</th>
|
||||||
|
<th className={`text-left ${TH}`}>Capacity</th>
|
||||||
|
<th className={`text-left ${TH}`}>Status</th>
|
||||||
|
<th className={`text-right ${TH}`}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Desktop table body rows: thumbnail + title/location, date, capacity, badge, actions. */
|
||||||
|
export function EventRowsSkeleton({ rows = 8 }: { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<tbody className="divide-y divide-secondary-light-gray" aria-hidden="true">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="w-10 h-10 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className={i % 2 === 0 ? 'h-4 w-48' : 'h-4 w-36'} />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3"><Skeleton className="h-4 w-24" /></td>
|
||||||
|
<td className="px-4 py-3"><Skeleton className="h-4 w-14" /></td>
|
||||||
|
<td className="px-4 py-3"><Skeleton className="h-5 w-20 rounded-full" /></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Skeleton className="w-8 h-8 rounded-btn" />
|
||||||
|
<Skeleton className="w-8 h-8 rounded-btn" />
|
||||||
|
<Skeleton className="w-8 h-8 rounded-btn" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mobile card list: thumbnail, three text lines, status badge, footer row. */
|
||||||
|
export function EventCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<SkeletonGroup className="space-y-2">
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Card key={i} className="p-3" aria-hidden="true">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Skeleton className="w-14 h-14 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-1/3" />
|
||||||
|
<Skeleton className="mt-1.5 h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="w-9 h-9 rounded-btn" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SkeletonGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
|
||||||
|
|
||||||
|
// Route-level shell shown the moment an admin navigates here, while the page
|
||||||
|
// chunk loads. The title and table header are real; only the rows are placeholders.
|
||||||
|
export default function AdminEventsLoading() {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||||
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<Card className="overflow-hidden hidden md:block">
|
||||||
|
<table className="w-full" aria-busy="true">
|
||||||
|
<EventsTableHead />
|
||||||
|
<EventRowsSkeleton />
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
<div className="md:hidden">
|
||||||
|
<EventCardsSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
@@ -15,39 +14,78 @@ import toast from 'react-hot-toast';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import EventFormModal from './_components/EventFormModal';
|
import EventFormModal from './_components/EventFormModal';
|
||||||
|
import { EventsTableHead, EventRowsSkeleton, EventCardsSkeleton } from './_components/EventsListSkeleton';
|
||||||
|
import Pagination from '@/components/admin/Pagination';
|
||||||
|
|
||||||
export default function AdminEventsPage() {
|
export default function AdminEventsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
// True while the rows on screen are not the rows for the current page (first
|
||||||
|
// load, page or page-size change). Reloads after a mutation keep the old rows
|
||||||
|
// up instead, so the table does not flash after every action.
|
||||||
|
const [fetching, setFetching] = useState(true);
|
||||||
|
const requestId = useRef(0);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||||
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
const [featuredEventId, setFeaturedEventId] = useState<string | null>(null);
|
||||||
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
const [settingFeatured, setSettingFeatured] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEvents();
|
|
||||||
loadFeaturedEvent();
|
loadFeaturedEvent();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFetching(true);
|
||||||
|
loadEvents();
|
||||||
|
}, [page, pageSize]);
|
||||||
|
|
||||||
|
// The ?edit=<id> deep link may point at an event that is not on the current
|
||||||
|
// page, so fall back to fetching it directly instead of only scanning the page.
|
||||||
|
const handledEditId = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editId = searchParams.get('edit');
|
const editId = searchParams.get('edit');
|
||||||
if (editId && events.length > 0) {
|
if (!editId || handledEditId.current === editId) return;
|
||||||
const event = events.find(e => e.id === editId);
|
const event = events.find(e => e.id === editId);
|
||||||
if (event) handleEdit(event);
|
if (event) {
|
||||||
|
handledEditId.current = editId;
|
||||||
|
handleEdit(event);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [searchParams, events]);
|
if (loading) return;
|
||||||
|
handledEditId.current = editId;
|
||||||
|
eventsApi.getById(editId)
|
||||||
|
.then(({ event }) => handleEdit(event))
|
||||||
|
.catch(() => toast.error('Event not found'));
|
||||||
|
}, [searchParams, events, loading]);
|
||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
|
// Drop responses that arrive after a newer request (e.g. a slow page 1
|
||||||
|
// landing after the user has already moved to page 2).
|
||||||
|
const id = ++requestId.current;
|
||||||
try {
|
try {
|
||||||
const { events } = await eventsApi.getAll();
|
const { events, total } = await eventsApi.getAll({ page, pageSize });
|
||||||
|
if (id !== requestId.current) return;
|
||||||
setEvents(events);
|
setEvents(events);
|
||||||
|
setTotal(total ?? events.length);
|
||||||
|
// If the current page emptied out (e.g. after deleting its last event),
|
||||||
|
// fall back to the new last page.
|
||||||
|
if (events.length === 0 && (total ?? 0) > 0 && page > 1) {
|
||||||
|
setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize)));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (id !== requestId.current) return;
|
||||||
toast.error('Failed to load events');
|
toast.error('Failed to load events');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (id === requestId.current) {
|
||||||
|
setLoading(false);
|
||||||
|
setFetching(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,10 +186,6 @@ export default function AdminEventsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <AdminPageSkeleton cols={5} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -174,16 +208,11 @@ export default function AdminEventsPage() {
|
|||||||
{/* Desktop: Table */}
|
{/* Desktop: Table */}
|
||||||
<Card className="overflow-hidden hidden md:block">
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full" aria-busy={fetching}>
|
||||||
<thead className="bg-secondary-gray">
|
<EventsTableHead />
|
||||||
<tr>
|
{fetching ? (
|
||||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
<EventRowsSkeleton rows={Math.min(pageSize, 8)} />
|
||||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
) : (
|
||||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th>
|
|
||||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -295,13 +324,16 @@ export default function AdminEventsPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
)}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Mobile: Card List */}
|
{/* Mobile: Card List */}
|
||||||
<div className="md:hidden space-y-2">
|
<div className="md:hidden space-y-2">
|
||||||
{events.length === 0 ? (
|
{fetching ? (
|
||||||
|
<EventCardsSkeleton />
|
||||||
|
) : events.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500 text-sm">
|
<div className="text-center py-10 text-gray-500 text-sm">
|
||||||
No events found. Create your first event!
|
No events found. Create your first event!
|
||||||
</div>
|
</div>
|
||||||
@@ -401,6 +433,16 @@ export default function AdminEventsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
id="events"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
className="mb-20 md:mb-0"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
{/* Mobile FAB */}
|
||||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
<button onClick={() => { setEditingEvent(null); setShowForm(true); }}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Profit per event. Diverging by sign: blue = profit, red = loss, one axis,
|
||||||
|
// event names on the category axis so color is never the only cue. A bar far
|
||||||
|
// larger than the rest is clipped at the axis limit, marked with a break and
|
||||||
|
// labelled with its real value, so the other events stay readable.
|
||||||
|
|
||||||
|
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell, ReferenceLine, LabelList } from 'recharts';
|
||||||
|
import { formatPyg, formatPygShort } from '@/lib/money';
|
||||||
|
import { clampRows, clampedAxis } from './clamp';
|
||||||
|
|
||||||
|
export default function ProfitByEventChart({ rows, locale }: { rows: { id: string; name: string; profit: number }[]; locale: string }) {
|
||||||
|
const { rows: clamped, limit } = clampRows(rows);
|
||||||
|
const axis = limit !== null ? clampedAxis(clamped.map((c) => c.shown)) : null;
|
||||||
|
// Clipped bars run to the (rounded) end of the axis so the cut is obvious.
|
||||||
|
const data = clamped.map((c) => ({
|
||||||
|
...c.row,
|
||||||
|
clipped: c.clipped,
|
||||||
|
shown: c.clipped && axis ? (c.row.profit > 0 ? axis.domain[1] : axis.domain[0]) : c.shown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Break mark plus the real value inside the clipped end of the bar.
|
||||||
|
const renderClipLabel = (props: any) => {
|
||||||
|
const { x, y, width, height, index } = props;
|
||||||
|
const d = data[index];
|
||||||
|
if (!d?.clipped) return null;
|
||||||
|
const left = Math.min(x, x + width);
|
||||||
|
const right = Math.max(x, x + width);
|
||||||
|
const positive = d.profit > 0;
|
||||||
|
const edge = positive ? right : left;
|
||||||
|
const breakX = positive ? edge - 10 : edge + 10;
|
||||||
|
const text = `${formatPygShort(d.profit, locale)} PYG`;
|
||||||
|
return (
|
||||||
|
<g pointerEvents="none">
|
||||||
|
<path d={`M${breakX - 3},${y + height + 2} L${breakX + 1},${y - 2} M${breakX + 2},${y + height + 2} L${breakX + 6},${y - 2}`} stroke="#fff" strokeWidth={2} />
|
||||||
|
<text
|
||||||
|
x={positive ? breakX - 6 : breakX + 10}
|
||||||
|
y={y + height / 2}
|
||||||
|
dy={4}
|
||||||
|
textAnchor={positive ? 'end' : 'start'}
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={600}
|
||||||
|
fill="#fff"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ResponsiveContainer width="100%" height={Math.max(160, rows.length * 30 + 32)}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid horizontal={false} stroke="#eeeeec" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
domain={axis ? axis.domain : ['auto', 'auto']}
|
||||||
|
ticks={axis?.ticks}
|
||||||
|
allowDataOverflow={axis !== null}
|
||||||
|
stroke="#8a8984"
|
||||||
|
fontSize={11}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v: number) => formatPygShort(v, locale)}
|
||||||
|
/>
|
||||||
|
<YAxis type="category" dataKey="name" stroke="#8a8984" fontSize={11} tickLine={false} axisLine={false} width={150} tick={{ fill: '#52514e', fontSize: 11 }} />
|
||||||
|
<ReferenceLine x={0} stroke="#8a8984" />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'rgba(0,0,0,0.04)' }}
|
||||||
|
content={({ active, payload }: any) => active && payload?.length ? (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg shadow-sm px-3 py-2 text-xs">
|
||||||
|
<p className="font-semibold text-primary-dark">{payload[0].payload.name}</p>
|
||||||
|
<p className="tabular-nums text-gray-600">{formatPyg(payload[0].payload.profit, locale)}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="shown" barSize={14} radius={4} isAnimationActive={false}>
|
||||||
|
{data.map((r) => <Cell key={r.id} fill={r.profit >= 0 ? '#2a78d6' : '#e34948'} />)}
|
||||||
|
{limit !== null && <LabelList dataKey="shown" content={renderClipLabel} />}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { clampLimit, clampRows, clampedAxis, niceCeil } from './clamp';
|
||||||
|
|
||||||
|
describe('clampLimit', () => {
|
||||||
|
it('does nothing when values are comparable', () => {
|
||||||
|
expect(clampLimit([100000, 250000, -80000, 190000])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips a single large outlier just above the next bar', () => {
|
||||||
|
expect(clampLimit([12000000, 300000, 250000, -100000])).toBe(375000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a large loss as an outlier too', () => {
|
||||||
|
expect(clampLimit([-9000000, 200000, 150000])).toBe(250000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clips up to three outliers above the first big gap', () => {
|
||||||
|
expect(clampLimit([50e6, 40e6, 30e6, 200000, 150000])).toBe(250000);
|
||||||
|
expect(clampLimit([8e6, 3e6, 1.1e6, 400000, 150000])).toBe(3750000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a steadily spread-out series alone', () => {
|
||||||
|
expect(clampLimit([8e6, 4e6, 2e6, 1e6, 500000])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not clip when the gap is below the fourth bar', () => {
|
||||||
|
expect(clampLimit([4e6, 3e6, 2e6, 1.5e6, 100000])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs at least three non-zero bars', () => {
|
||||||
|
expect(clampLimit([12000000, 1000])).toBeNull();
|
||||||
|
expect(clampLimit([12000000, 0, 0, 1000])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampRows', () => {
|
||||||
|
it('keeps the real value and marks clipped rows', () => {
|
||||||
|
const { rows, limit } = clampRows([
|
||||||
|
{ id: 'a', profit: 12000000 }, { id: 'b', profit: 300000 }, { id: 'c', profit: -250000 },
|
||||||
|
]);
|
||||||
|
expect(limit).toBe(375000);
|
||||||
|
expect(rows[0]).toMatchObject({ shown: 375000, clipped: true, row: { profit: 12000000 } });
|
||||||
|
expect(rows[1]).toMatchObject({ shown: 300000, clipped: false });
|
||||||
|
expect(rows[2]).toMatchObject({ shown: -250000, clipped: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clampedAxis', () => {
|
||||||
|
it('rounds the ends up to round numbers', () => {
|
||||||
|
expect(niceCeil(375000)).toBe(500000);
|
||||||
|
expect(niceCeil(250000)).toBe(250000);
|
||||||
|
expect(niceCeil(105)).toBe(200);
|
||||||
|
expect(niceCeil(0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts ticks at the ends, halves and zero', () => {
|
||||||
|
expect(clampedAxis([-375000, 120000, 375000])).toEqual({ domain: [-500000, 500000], ticks: [-500000, -250000, 0, 250000, 500000] });
|
||||||
|
expect(clampedAxis([10, 375])).toEqual({ domain: [0, 500], ticks: [0, 250, 500] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Axis clamping for the profit-per-event chart: one huge event would otherwise
|
||||||
|
// flatten every other bar. Pure so it can be unit tested.
|
||||||
|
|
||||||
|
/** How much bigger than the next bar a bar must be to count as an outlier. */
|
||||||
|
const OUTLIER_RATIO = 2.5;
|
||||||
|
/** Never clip more than this many bars; past that the data is just spread out. */
|
||||||
|
const MAX_CLIPPED = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The axis limit (absolute PYG) to clip at, or null when nothing stands out.
|
||||||
|
* Walks the largest magnitudes from the top and clips above the first big gap.
|
||||||
|
*/
|
||||||
|
export function clampLimit(values: number[]): number | null {
|
||||||
|
const mags = values.map((v) => Math.abs(v)).filter((v) => v > 0).sort((a, b) => b - a);
|
||||||
|
if (mags.length < 3) return null;
|
||||||
|
for (let k = 1; k <= Math.min(MAX_CLIPPED, mags.length - 2); k++) {
|
||||||
|
if (mags[k - 1] > OUTLIER_RATIO * mags[k]) return Math.round(mags[k] * 1.25);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClampedRow<R> {
|
||||||
|
row: R;
|
||||||
|
/** Value drawn on the chart (clipped to the limit). */
|
||||||
|
shown: number;
|
||||||
|
/** True when the bar is cut off; its real value is labelled on it. */
|
||||||
|
clipped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampRows<R extends { profit: number }>(rows: R[]): { rows: ClampedRow<R>[]; limit: number | null } {
|
||||||
|
const limit = clampLimit(rows.map((r) => r.profit));
|
||||||
|
return {
|
||||||
|
limit,
|
||||||
|
rows: rows.map((row) => {
|
||||||
|
const clipped = limit !== null && Math.abs(row.profit) > limit;
|
||||||
|
return { row, shown: clipped ? Math.sign(row.profit) * limit! : row.profit, clipped };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smallest "round" number (1, 2, 2.5 or 5 times a power of ten) at or above n, for axis ends. */
|
||||||
|
export function niceCeil(n: number): number {
|
||||||
|
if (n <= 0) return 0;
|
||||||
|
const pow = 10 ** Math.floor(Math.log10(n));
|
||||||
|
const step = [1, 2, 2.5, 5, 10].find((s) => s * pow >= n)!;
|
||||||
|
return step * pow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Axis domain and ticks for a clipped chart: round ends, ticks at halves. */
|
||||||
|
export function clampedAxis(shown: number[]): { domain: [number, number]; ticks: number[] } {
|
||||||
|
const max = Math.max(0, ...shown);
|
||||||
|
const min = Math.min(0, ...shown);
|
||||||
|
const hi = niceCeil(max);
|
||||||
|
const lo = min < 0 ? -niceCeil(-min) : 0;
|
||||||
|
const ticks = [lo, lo / 2, 0, hi / 2, hi].filter((v, i, a) => a.indexOf(v) === i);
|
||||||
|
return { domain: [lo, hi], ticks };
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { ChartBarIcon, EyeSlashIcon, FunnelIcon, ChevronDownIcon, ClockIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
|
import { financeApi, type FinanceOverview, type FinanceTotals } from '@/lib/api';
|
||||||
|
import Card from '@/components/ui/Card';
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton';
|
||||||
|
import { Money, FinanceStatusBadge, DateInput, inputClass } from '../events/[id]/_finance/ui';
|
||||||
|
import { tCount } from '../events/[id]/_finance/derive';
|
||||||
|
import { clampLimit } from './_components/clamp';
|
||||||
|
|
||||||
|
const ProfitByEventChart = dynamic(() => import('./_components/ProfitByEventChart'), { ssr: false, loading: () => <Skeleton className="h-40" /> });
|
||||||
|
|
||||||
|
type Filters = { from: string; to: string; series: string; venue: string; partner: string };
|
||||||
|
|
||||||
|
function TotalsTable({ rows, label, t }: { rows: (FinanceTotals & { key: string; name: string })[]; label: string; t: (k: string) => string }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-gray-500 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-medium py-2 pr-3">{label}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.events')}</th>
|
||||||
|
{/* Phones: gross and expenses fold away so the table fits without scrolling */}
|
||||||
|
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.kpi.gross')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.kpi.expenses')}</th>
|
||||||
|
<th className="text-right font-medium py-2 pl-3">{t('admin.finance.kpi.profit')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.key}>
|
||||||
|
<td className="py-2 pr-3 truncate max-w-[220px]">{r.name}</td>
|
||||||
|
<td className="py-2 px-3 text-right tabular-nums">{r.events}</td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={r.gross} /></td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={r.expenses} /></td>
|
||||||
|
<td className="py-2 pl-3 text-right whitespace-nowrap"><Money value={r.profit} className={clsx('font-semibold', r.profit < 0 && 'text-red-600')} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminFinancePage() {
|
||||||
|
const { t, locale } = useLanguage();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const [filters, setFilters] = useState<Filters>({ from: '', to: '', series: '', venue: '', partner: '' });
|
||||||
|
const [data, setData] = useState<FinanceOverview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||||
|
const [readyOnly, setReadyOnly] = useState(false);
|
||||||
|
const activeFilters = Object.values(filters).filter(Boolean).length;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
financeApi.overview(filters)
|
||||||
|
.then(setData)
|
||||||
|
.catch((error) => toast.error(error.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
const set = (k: keyof Filters, v: string) => setFilters((f) => ({ ...f, [k]: v }));
|
||||||
|
const title = (e: { title: string; titleEs: string | null }) => (locale === 'es' && e.titleEs) || e.title;
|
||||||
|
// Past events whose books are still open: the server lists them, including
|
||||||
|
// ones with no money recorded yet (which the main list leaves out).
|
||||||
|
const readyIds = new Set(data?.readyToClose.map((e) => e.id) ?? []);
|
||||||
|
const isReady = (e: { id: string }) => readyIds.has(e.id);
|
||||||
|
const readyCount = readyIds.size;
|
||||||
|
const showReady = readyOnly && readyCount > 0;
|
||||||
|
const tableEvents = data ? (showReady ? data.readyToClose : data.events) : [];
|
||||||
|
const chartRows = data ? data.events.slice(0, 30).map((e) => ({ id: e.id, name: title(e), profit: e.profit })) : [];
|
||||||
|
const clipped = clampLimit(chartRows.map((r) => r.profit)) !== null;
|
||||||
|
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', { dateStyle: 'medium' }) : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-primary-dark flex items-center gap-3">
|
||||||
|
<ChartBarIcon className="w-7 h-7" />
|
||||||
|
{t('admin.financeOverview.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">{t('admin.financeOverview.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters: one row above everything they affect */}
|
||||||
|
<Card className="p-4">
|
||||||
|
{/* Phones: filters fold away behind one button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||||
|
aria-expanded={filtersOpen}
|
||||||
|
className="md:hidden w-full flex items-center gap-2 min-h-[44px] text-sm font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<FunnelIcon className="w-4 h-4" aria-hidden />
|
||||||
|
{t('admin.financeOverview.filters')}
|
||||||
|
{activeFilters > 0 && <span className="rounded-full bg-primary-yellow px-2 text-xs">{activeFilters}</span>}
|
||||||
|
<ChevronDownIcon className={clsx('w-4 h-4 ml-auto transition-transform', filtersOpen && 'rotate-180')} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<div className={clsx('grid-cols-2 md:grid-cols-5 gap-3 md:grid', filtersOpen ? 'grid mt-3 md:mt-0' : 'hidden')}>
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t('admin.financeOverview.from')}
|
||||||
|
<DateInput className="mt-1" clearable value={filters.from} onChange={(v) => set('from', v)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t('admin.financeOverview.to')}
|
||||||
|
<DateInput className="mt-1" clearable value={filters.to} onChange={(v) => set('to', v)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t('admin.financeOverview.series')}
|
||||||
|
<select className={clsx(inputClass, 'mt-1')} value={filters.series} onChange={(e) => set('series', e.target.value)}>
|
||||||
|
<option value="">{t('admin.financeOverview.all')}</option>
|
||||||
|
{data?.filters.series.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
|
<option value="__none__">{t('admin.financeOverview.noSeries')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
{t('admin.financeOverview.venue')}
|
||||||
|
<select className={clsx(inputClass, 'mt-1')} value={filters.venue} onChange={(e) => set('venue', e.target.value)}>
|
||||||
|
<option value="">{t('admin.financeOverview.all')}</option>
|
||||||
|
{data?.filters.venues.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-xs font-medium col-span-2 md:col-span-1">
|
||||||
|
{t('admin.financeOverview.partner')}
|
||||||
|
<select className={clsx(inputClass, 'mt-1')} value={filters.partner} onChange={(e) => set('partner', e.target.value)}>
|
||||||
|
<option value="">{t('admin.financeOverview.all')}</option>
|
||||||
|
{data?.filters.partners.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{loading && !data ? <Skeleton className="h-64" /> : !data || (data.events.length === 0 && data.readyToClose.length === 0) ? (
|
||||||
|
<Card className="p-8 text-center text-sm text-gray-500">{t('admin.financeOverview.empty')}</Card>
|
||||||
|
) : (
|
||||||
|
<div className={clsx('space-y-6', loading && 'opacity-60')}>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: t('admin.financeOverview.events'), value: <span>{data.totals.events}</span> },
|
||||||
|
{ label: t('admin.finance.kpi.gross'), value: <Money value={data.totals.gross} /> },
|
||||||
|
{ label: t('admin.finance.kpi.expenses'), value: <Money value={data.totals.expenses} /> },
|
||||||
|
{ label: t(data.totals.profit < 0 ? 'admin.finance.kpi.loss' : 'admin.finance.kpi.profit'), value: <Money value={data.totals.profit} className={clsx(data.totals.profit < 0 && !privacyMode && 'text-red-600')} /> },
|
||||||
|
].map((k) => (
|
||||||
|
<div key={k.label} className="bg-white rounded-card shadow-card px-4 py-3">
|
||||||
|
<p className="text-lg font-bold">{k.value}</p>
|
||||||
|
<p className="text-xs text-gray-500">{k.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{readyCount > 0 && (
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2 rounded-card border border-amber-200 bg-amber-50 text-amber-900 px-4 py-3">
|
||||||
|
<p className="flex items-center gap-2 text-sm flex-1">
|
||||||
|
<ClockIcon className="w-5 h-5 flex-shrink-0" aria-hidden />
|
||||||
|
<span><span className="font-semibold">{tCount(t, 'admin.financeOverview.readyCount', readyCount)}</span> {t('admin.financeOverview.readyHint')}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setReadyOnly(!readyOnly)}
|
||||||
|
aria-pressed={readyOnly}
|
||||||
|
className={clsx(
|
||||||
|
'self-start sm:self-auto inline-flex items-center gap-1.5 rounded-full px-3 min-h-[36px] text-sm font-medium cursor-pointer border',
|
||||||
|
readyOnly ? 'bg-amber-900 text-white border-amber-900' : 'bg-white border-amber-300 hover:bg-amber-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{readyOnly && <XMarkIcon className="w-4 h-4" aria-hidden />}
|
||||||
|
{t(readyOnly ? 'admin.financeOverview.showAll' : 'admin.financeOverview.showReady')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="text-sm font-semibold mb-3">{t(showReady ? 'admin.financeOverview.readyTitle' : 'admin.financeOverview.perEvent')}</h2>
|
||||||
|
{showReady || chartRows.length === 0 ? null : privacyMode ? (
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-2 py-6 justify-center"><EyeSlashIcon className="w-5 h-5" />{t('admin.finance.hiddenStats')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ProfitByEventChart rows={chartRows} locale={locale} />
|
||||||
|
{clipped && <p className="text-xs text-gray-500 mt-1">{t('admin.financeOverview.clippedHint')}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Phones: one card per event, no sideways scrolling */}
|
||||||
|
<ul className="md:hidden mt-4 divide-y divide-gray-100">
|
||||||
|
{tableEvents.map((e) => (
|
||||||
|
<li key={e.id}>
|
||||||
|
<Link href={`/admin/events/${e.id}?tab=finance`} className="block py-3 active:bg-gray-50">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate">{title(e)}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{fmtDate(e.startDatetime)} · {e.location}</p>
|
||||||
|
</div>
|
||||||
|
<Money value={e.profit} className={clsx('font-semibold whitespace-nowrap', e.profit < 0 && 'text-red-600')} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
{t('admin.finance.kpi.gross')} <Money value={e.gross} /> · {t('admin.finance.kpi.expenses')} <Money value={e.expenses} />
|
||||||
|
</span>
|
||||||
|
{isReady(e)
|
||||||
|
? <span className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium bg-amber-100 text-amber-900 whitespace-nowrap">{t('admin.financeOverview.readyBadge')}</span>
|
||||||
|
: <FinanceStatusBadge status={e.financeStatus} label={t(`admin.finance.status.${e.financeStatus}`)} />}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="hidden md:block mt-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-gray-500 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-medium py-2 pr-3">{t('admin.financeOverview.event')}</th>
|
||||||
|
<th className="text-left font-medium py-2 px-3">{t('admin.financeOverview.series')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.gross')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.expenses')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3">{t('admin.finance.kpi.profit')}</th>
|
||||||
|
<th className="text-left font-medium py-2 pl-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{tableEvents.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<Link href={`/admin/events/${e.id}?tab=finance`} className="font-medium hover:underline">{title(e)}</Link>
|
||||||
|
<p className="text-xs text-gray-500">{fmtDate(e.startDatetime)} · {e.location}</p>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-gray-600">{e.series || '-'}</td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.gross} /></td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.expenses} /></td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={e.profit} className={clsx('font-semibold', e.profit < 0 && 'text-red-600')} /></td>
|
||||||
|
<td className="py-2 pl-3">
|
||||||
|
{isReady(e)
|
||||||
|
? <span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-900 whitespace-nowrap">{t('admin.financeOverview.readyBadge')}</span>
|
||||||
|
: <FinanceStatusBadge status={e.financeStatus} label={t(`admin.finance.status.${e.financeStatus}`)} />}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perSeries')}</h2>
|
||||||
|
<TotalsTable t={t} label={t('admin.financeOverview.series')}
|
||||||
|
rows={data.bySeries.map((s) => ({ ...s, key: s.series || '__none__', name: s.series || t('admin.financeOverview.noSeries') }))} />
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perVenue')}</h2>
|
||||||
|
<TotalsTable t={t} label={t('admin.financeOverview.venue')} rows={data.byVenue.map((v) => ({ ...v, key: v.venue, name: v.venue || '-' }))} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<h2 className="text-sm font-semibold mb-2">{t('admin.financeOverview.perPartner')}</h2>
|
||||||
|
{data.byPartner.length === 0 ? <p className="text-sm text-gray-400">{t('admin.finance.split.empty')}</p> : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-xs text-gray-500 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left font-medium py-2 pr-3">{t('admin.financeOverview.partner')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.events')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.split.share')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3 hidden md:table-cell">{t('admin.finance.split.reimbursement')}</th>
|
||||||
|
<th className="text-right font-medium py-2 px-3">{t('admin.financeOverview.paid')}</th>
|
||||||
|
<th className="text-right font-medium py-2 pl-3">{t('admin.financeOverview.pending')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{data.byPartner.map((p) => (
|
||||||
|
<tr key={p.key}>
|
||||||
|
<td className="py-2 pr-3">{p.name}</td>
|
||||||
|
<td className="py-2 px-3 text-right tabular-nums">{p.events}</td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={p.share} /></td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap hidden md:table-cell"><Money value={p.reimbursement} /></td>
|
||||||
|
<td className="py-2 px-3 text-right whitespace-nowrap"><Money value={p.paid} /></td>
|
||||||
|
<td className="py-2 pl-3 text-right whitespace-nowrap"><Money value={p.pending} className="font-semibold" /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
QuestionMarkCircleIcon,
|
QuestionMarkCircleIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeSlashIcon,
|
EyeSlashIcon,
|
||||||
|
ChartBarIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -67,6 +68,7 @@ function AdminLayoutInner({
|
|||||||
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
||||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
||||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
{ name: t('admin.nav.finance'), href: '/admin/finance', icon: ChartBarIcon, allowedRoles: ['admin'] },
|
||||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
||||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function PaymentOptionsPage() {
|
|||||||
bankNotesEs: null,
|
bankNotesEs: null,
|
||||||
lightningEnabled: true,
|
lightningEnabled: true,
|
||||||
cashEnabled: true,
|
cashEnabled: true,
|
||||||
|
posEnabled: true,
|
||||||
cashInstructions: null,
|
cashInstructions: null,
|
||||||
cashInstructionsEs: null,
|
cashInstructionsEs: null,
|
||||||
allowDuplicateBookings: false,
|
allowDuplicateBookings: false,
|
||||||
@@ -83,6 +84,9 @@ export default function PaymentOptionsPage() {
|
|||||||
setOptions((prev) => ({ ...prev, [key]: value }));
|
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stored as 0/1 on Postgres; on unless explicitly switched off.
|
||||||
|
const posEnabled = !!(options.posEnabled ?? true);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -424,6 +428,39 @@ export default function PaymentOptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* POS terminal at the door */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-violet-100 rounded-full flex items-center justify-center">
|
||||||
|
<CreditCardIcon className="w-5 h-5 text-violet-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">POS</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{locale === 'es'
|
||||||
|
? 'Terminal de tarjetas en la puerta. Solo en el Escáner; el personal confirma el pago manualmente.'
|
||||||
|
: 'Card terminal at the door. Scanner only; staff confirm the payment by hand.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => updateOption('posEnabled', !posEnabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||||
|
posEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
posEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Booking Settings */}
|
{/* Booking Settings */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -531,6 +568,16 @@ export default function PaymentOptionsPage() {
|
|||||||
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{posEnabled ? (
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||||
|
)}
|
||||||
|
<span className={posEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||||
|
POS
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { usePrivacy } from '@/context/PrivacyContext';
|
||||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||||
import { isManualProvider } from '@/lib/api/payments';
|
import { isManualProvider } from '@/lib/api/payments';
|
||||||
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
@@ -36,6 +37,7 @@ type Tab = 'pending_approval' | 'all';
|
|||||||
|
|
||||||
export default function AdminPaymentsPage() {
|
export default function AdminPaymentsPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
|
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
|
||||||
// Manual-gateway payments still in bare 'pending': the customer may have paid
|
// Manual-gateway payments still in bare 'pending': the customer may have paid
|
||||||
@@ -311,6 +313,7 @@ export default function AdminPaymentsPage() {
|
|||||||
bank_transfer: BuildingLibraryIcon,
|
bank_transfer: BuildingLibraryIcon,
|
||||||
tpago: CreditCardIcon,
|
tpago: CreditCardIcon,
|
||||||
bancard: CreditCardIcon,
|
bancard: CreditCardIcon,
|
||||||
|
pos: CreditCardIcon,
|
||||||
};
|
};
|
||||||
const Icon = icons[provider] || CreditCardIcon;
|
const Icon = icons[provider] || CreditCardIcon;
|
||||||
return <Icon className="w-4 h-4" />;
|
return <Icon className="w-4 h-4" />;
|
||||||
@@ -323,6 +326,7 @@ export default function AdminPaymentsPage() {
|
|||||||
lightning: 'Lightning',
|
lightning: 'Lightning',
|
||||||
tpago: 'TPago',
|
tpago: 'TPago',
|
||||||
bancard: 'Bancard',
|
bancard: 'Bancard',
|
||||||
|
pos: 'POS',
|
||||||
};
|
};
|
||||||
return labels[provider] || provider;
|
return labels[provider] || provider;
|
||||||
};
|
};
|
||||||
@@ -734,7 +738,7 @@ export default function AdminPaymentsPage() {
|
|||||||
{/* By Provider */}
|
{/* By Provider */}
|
||||||
<div className="bg-secondary-gray rounded-btn p-4">
|
<div className="bg-secondary-gray rounded-btn p-4">
|
||||||
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
|
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
||||||
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
|
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
|
||||||
@@ -755,6 +759,10 @@ export default function AdminPaymentsPage() {
|
|||||||
<p className="text-gray-500">Bancard</p>
|
<p className="text-gray-500">Bancard</p>
|
||||||
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">POS</p>
|
||||||
|
<p className="text-lg font-bold">{exportData.summary.byProvider.pos?.toLocaleString() || 0} PYG</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -778,6 +786,7 @@ export default function AdminPaymentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
|
{!privacyMode && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -846,6 +855,7 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
|
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
|
||||||
@@ -1050,6 +1060,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||||
<option value="tpago">TPago</option>
|
<option value="tpago">TPago</option>
|
||||||
|
<option value="pos">POS</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-[200px]">
|
<div className="flex-1 min-w-[200px]">
|
||||||
@@ -1288,6 +1299,7 @@ export default function AdminPaymentsPage() {
|
|||||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||||
<option value="tpago">TPago</option>
|
<option value="tpago">TPago</option>
|
||||||
|
<option value="pos">POS</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 pt-2">
|
<div className="flex gap-3 pt-2">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user