Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f92a8c3e9 | ||
|
|
6fc6c35214 | ||
|
|
be1d6f2e31 | ||
|
|
dc02f6b9ff | ||
|
|
103c328a6a | ||
|
|
7446388bfa | ||
|
|
8a4c144f6d | ||
|
|
3a68b24a0f | ||
|
|
9be78c3c34 | ||
|
|
d268236686 | ||
|
|
95a8bd0796 | ||
|
|
101632e758 | ||
|
|
7cf5f79d01 | ||
|
|
00464c086d | ||
|
|
4e07034bbd | ||
|
|
2d6c840614 | ||
|
|
235da5bf61 | ||
|
|
f6581eb441 | ||
|
|
0a8788afff | ||
|
|
b560d0d340 | ||
|
|
b2439a781e | ||
|
|
545f3d913e | ||
|
|
619b5b9a93 | ||
|
|
958a5a5950 | ||
|
|
f2be3a8b45 | ||
|
|
ce22f44f7c |
@@ -7,6 +7,63 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.27.1] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- `npm run set-demo` also stages ten completed events from the past year, with door sales recorded by staff, staff cash counts, check-ins, refunds, donations and post-event feedback. Their cashups go through every path: a quick close; full closes with card fees, a cash deposit fee, a written-off shortfall, cash kept on hand and an unaccounted amount; a close, reopen and re-close; pending EFT money that was later received and some still outstanding; and one event left open with the cash counted, ready for a live cashup. Funnel stats older than 90 days are stored as daily totals, the way the app rolls them up. See `backend/DEMO_DATA.md`.
|
||||
|
||||
## [1.27.0] - 2026-10-21
|
||||
|
||||
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
|
||||
|
||||
### Added
|
||||
|
||||
- Cashups now record where each payment method's money ended up. For cash, card, EFT and other, the expected takings are split into what's **in the bank**, **cash on hand**, and **pending** (on the card machine or Yoco report, or with a proof of payment), with a reference for the pending amount. Card fees, bank charges, cash shortfalls and other losses are **written off** with a reason. Each method shows what's still **unaccounted**. Closing with an unaccounted amount is allowed after a warning, and the amount comes off profit, as variance did before.
|
||||
- When staff have counted the cash, cash on hand is worked out for you: the count, less what reached the bank and any deposit fee written off as a bank charge.
|
||||
- Pending money on a closed event can be marked **received** or **written off** later from the cashup screen, without reopening the event. Each one is logged with who did it and when.
|
||||
- Reopening an event starts the next cashup from the last one, with any pending money settled since folded in.
|
||||
- Write-offs show as **Bank & card charges** on the cashup screen and in the Cashup, Finance and Profit reports. The Cashup report has new In bank, On hand, Pending, Written off and Unaccounted columns.
|
||||
|
||||
### Changed
|
||||
|
||||
- Net profit now subtracts write-offs as their own line instead of showing card fees and bank charges as missing income. The net figure is the same.
|
||||
- Every dashboard page uses the same back link: the small "← Back to dashboard" link above the page title, as on the Waitlist page. The grey Back buttons are gone. Site settings, profile, donate and manual registration, which had no way back, now have one too.
|
||||
|
||||
## [1.26.1] - 2026-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- `npm run set-demo` in the backend replaces the database contents with demo data for showing the platform to other churches. It keeps the site settings and the admin account, and stages six upcoming events with registrations, payments, tickets, a waitlist, camp cabins, coupons, costs and funnel stats. See `backend/DEMO_DATA.md`.
|
||||
|
||||
## [1.26.0] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Prisma from 5.22 to 7.10. Prisma 7 connects through the `@prisma/adapter-pg` driver adapter (node-postgres) instead of the Rust query engine. The database URL moved from `schema.prisma` to the new `backend/prisma.config.js`, which loads `backend/.env` itself. All Prisma clients are now created in `src/config/db.js`, and the server shares one client instead of opening two connection pools. No schema or migration changes.
|
||||
- Prisma 7 checks TLS certificates on database connections. A `DATABASE_URL` that uses SSL with a self-signed or otherwise invalid certificate now fails to connect where Prisma 5 accepted it.
|
||||
- `npm run dev` in the backend now uses Node's built-in `node --watch` instead of `nodemon`.
|
||||
- The frontend now passes ESLint with no errors or warnings, down from 1,137, and `next build` runs ESLint again. It was skipped before (`eslint.ignoreDuringBuilds`), which is how the issues built up.
|
||||
- API data is now typed. `src/types` describes the records the backend returns, modelled on the Prisma schema, and `apiFetch` returns `unknown` unless the caller names a type, so untyped responses no longer pass silently.
|
||||
- Effects list everything they depend on. Data loaders are wrapped in `useCallback`, so a page reloads when its inputs change instead of showing stale data.
|
||||
- The pricing helpers that were copied into several pages now live in `src/lib/pricing.ts`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Internal links on the home page, the 404 page and "Forgot your password?" now navigate client-side instead of reloading the whole page.
|
||||
- Changing the page size on Admin → Users fetched the list twice; it now fetches once.
|
||||
- At the door, the refund history now shows a refund's reason. The page was reading a field that doesn't exist, so the reason never appeared.
|
||||
- In the Master Orders report, a ticket option named like one of the fixed columns (e.g. "name") could overwrite that column's value.
|
||||
- Removed code that could never run: an unreachable "Pay with Yoco" button on the registration form, an unused attendee-forms block on the registration success page, unused export helpers in Reports, and fonts that were downloaded but never applied.
|
||||
|
||||
### Security
|
||||
|
||||
- Backend `npm audit` now reports 0 vulnerabilities:
|
||||
- Patched `follow-redirects` (pulled in by `axios`) to 1.16.1, which fixes leaking credential headers on cross-origin redirects.
|
||||
- Removed `nodemon`, the only backend package that pulled in `braces`.
|
||||
- Jest's coverage loader now uses `js-yaml` 4, which drops the `sprintf-js` advisory.
|
||||
- Frontend: overrode `postcss-selector-parser` to 7.1.7, used by the Tailwind 3 build. The generated CSS is byte-identical. The remaining frontend findings (7 high) are all the same `braces` advisory (GHSA-vfj7-8cjw-p6xm). It has no patched release, comes in only through build and lint tools (Tailwind 3 and `eslint-config-next`), and only ever handles glob patterns from our own config, never user input.
|
||||
|
||||
## [1.25.1] - 2026-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# Demo data
|
||||
|
||||
`npm run set-demo` replaces everything in the database with a realistic set of demo data,
|
||||
for showing the platform to other churches. It stages six upcoming events with
|
||||
registrations, payments, tickets and people, and ten completed events from the past year
|
||||
with door sales, check-ins, feedback and cashups. That way every dashboard, report,
|
||||
check-in and cashup screen has something to show.
|
||||
|
||||
> **This deletes real data.** Run it only against a database you're happy to wipe, such
|
||||
> as a dedicated demo deployment. It is not something to run on a live church site.
|
||||
|
||||
---
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run set-demo # shows the database it will wipe and asks you to type "demo"
|
||||
npm run set-demo -- --yes # no prompt (for scripts)
|
||||
```
|
||||
|
||||
The script uses `DATABASE_URL` from `backend/.env`. To point it at another database
|
||||
for one run, set it in the environment first. A value already in the environment wins
|
||||
over `.env`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="postgresql://user:pass@host:5432/hope_demo" npm run set-demo
|
||||
```
|
||||
|
||||
It takes about five seconds and is safe to re-run as often as you like. Each run wipes the
|
||||
previous demo and creates it again.
|
||||
|
||||
### Before you run it
|
||||
|
||||
- The admin account must already exist. By default that's `joshua@crosscode.co.za`.
|
||||
To keep a different account, set `DEMO_ADMIN_EMAIL`:
|
||||
```bash
|
||||
DEMO_ADMIN_EMAIL="admin@example.org" npm run set-demo
|
||||
```
|
||||
If the account isn't found, the script stops before changing anything.
|
||||
- Migrations must be up to date (`npm run prisma:deploy`).
|
||||
|
||||
---
|
||||
|
||||
## What it keeps and what it clears
|
||||
|
||||
| Kept | Cleared |
|
||||
|---|---|
|
||||
| Site settings (`AppSetting`): org name, branding, SMTP, Yoco, WhatsApp, legal pages | Every event, with its options, variants, early-bird tiers, sections, forms, costs and cash-ups |
|
||||
| The admin account, including its login history, trusted devices and 2FA | Every other user |
|
||||
| Migration history | Registrations, payments, tickets, scans, coupons, waitlists, consents, cabins, feedback, analytics, audit log |
|
||||
| Uploaded files on disk (`public/uploads`) | Queued scheduled emails/WhatsApps (`data/scheduled-emails.json`) |
|
||||
|
||||
The wipe and the new data are written in one database transaction. If that fails,
|
||||
nothing changes. The past events' cashups run straight afterwards, through the same
|
||||
code as the cashup screen. If one of those fails, re-run the script.
|
||||
|
||||
---
|
||||
|
||||
## What it creates
|
||||
|
||||
### Upcoming events
|
||||
|
||||
All dates are worked out from the day you run it, so these events are always upcoming.
|
||||
Registrations are spread over the past month, as if sign-ups have been coming in.
|
||||
The data is generated from a fixed seed, so every run looks the same.
|
||||
|
||||
| When (from today) | Event | Shows off |
|
||||
|---|---|---|
|
||||
| ~1½ weeks, Sat 07:30 | **Men's Breakfast: Built to Last** | **Sold out** (60 places), 5 people on the **waitlist**, card / cash / EFT payments recorded by staff, 2 cancelled registrations |
|
||||
| ~2½ weeks, Sat 10:00 | **Family Fun Day** | Several ticket types (adult, child, family pass), a non-ticket **add-on** (lunch pack), a fixed-amount **coupon** (`FUNDAY20`), entry and food-stall check-in sections |
|
||||
| ~5 weeks, Fri–Sat | **Rooted Women's Conference** | Two **early-bird tiers** (one already expired, one still open) plus a Saturday-only ticket, T-shirt **variants** (S–XXL), a **required sign-up form** (dietary needs, workshop choice, prayer requests), a 20% **group coupon** (`ROOTED20`) used by 4 registrations |
|
||||
| ~7 weeks, Mon–Thu | **Ignite Summer Camp** | **Named attendees** (campers aged 12–17), signed **parental consent**, gender collected, **deposits and part-payments**, campers placed in boys' and girls' **cabins** with staff leaders, **sponsor-a-spot** donations towards a 10-spot goal |
|
||||
| ~8 weeks, Sun 19:00 | **Carols by Candlelight** | A **free event** with group tickets (1–6 people), plus general **donations** |
|
||||
| ~14 weeks, Fri–Sun | **Marriage Enrichment Weekend** | Room-type **variants with limited stock**, early-bird prices per room type, a per-person spa add-on, R1 000 EFT deposits |
|
||||
|
||||
Each event also gets:
|
||||
|
||||
- A cover photo (from Unsplash, which `next.config` already allows) and a contact person.
|
||||
- **Costs**, both once-off and per item, so the profit view and cash-up have figures.
|
||||
- About 30 days of **funnel analytics** (views → started → submitted → paid) by source
|
||||
(WhatsApp, Facebook, Instagram, email, direct) and device.
|
||||
|
||||
Paid registrations have tickets with QR codes, the same way the app issues them once
|
||||
payment is complete. Part-paid and pending ones don't have tickets yet.
|
||||
|
||||
### Past events
|
||||
|
||||
Ten completed events spread over the past year, for showing cashups and the historical
|
||||
reports. Each one has:
|
||||
|
||||
- Online sales in the five weeks before the event, with a few unpaid no-shows.
|
||||
- **Door sales** on the day, recorded by the staff team (cash and card machine).
|
||||
- **Check-ins**: about 90% of tickets scanned, with the odd group arriving short.
|
||||
- **Staff cash counts** with note and coin denominations.
|
||||
|
||||
Some events also have refunds, donations and post-event feedback.
|
||||
|
||||
The cashups cover every path through the cashup screen:
|
||||
|
||||
| When | Event | Cashup to demo |
|
||||
|---|---|---|
|
||||
| ~11 months ago | **Church Family Picnic** | **Quick closed** (no settlement) |
|
||||
| 24 Dec | **Christmas Eve Candlelight Service** | Free event with card and cash **donations**. Card settled net of **Yoco fees**, collection banked |
|
||||
| Easter Sunday | **Easter Sunrise Service & Breakfast** | Cash banked less a **cash deposit fee** (bank charge), card fees, a **refund** |
|
||||
| Mother's Day | **Mother's Day Brunch** | **Closed with R50 cash short → reopened → recounted → closed again.** The history shows all three steps |
|
||||
| ~3½ months ago | **Youth Winter Camp** | Deposits and balances. **EFT pending** (POPs) at close, later **marked received** without reopening |
|
||||
| ~2½ months ago | **Men's Golf Day** | Card-heavy, card fees written off, a refund |
|
||||
| ~2 months ago | **Kids Holiday Club** | Cash-heavy door sales. **R20 cash shortfall written off**, **R500 kept on hand** as float |
|
||||
| ~6 weeks ago | **Women's High Tea** | Closed with **R30 unaccounted**, shown in red |
|
||||
| ~4 weeks ago | **Missions Fundraiser Dinner** | **R2 600 EFT still pending** (POP received, not cleared). Shows "Mark received" / "Write off" |
|
||||
| ~10 days ago | **Worship Night** | **Still open, with the cash already counted.** Use it to do a cashup live in the demo |
|
||||
|
||||
Easter, the Youth Camp, the Holiday Club and the High Tea have **feedback forms** with
|
||||
ratings and comments. Funnel stats older than 90 days are stored as daily totals, the
|
||||
way the app rolls them up overnight, so the funnel report covers the whole year.
|
||||
|
||||
### Totals
|
||||
|
||||
A typical run gives roughly:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Users | 130 (plus the admin) |
|
||||
| Registrations | ~700 (paid, part-paid, pending, cancelled and refunded) |
|
||||
| Payments | ~640, including donations, sponsorships and refunds |
|
||||
| Tickets | ~780, with ~490 check-ins |
|
||||
| Named campers | ~45 |
|
||||
| Feedback responses | ~80 |
|
||||
| Funnel rows | ~4 400 |
|
||||
|
||||
---
|
||||
|
||||
## Logging in
|
||||
|
||||
Every demo account uses the password **`Demo@1234`**.
|
||||
|
||||
| Role | Email | Good for showing |
|
||||
|---|---|---|
|
||||
| Supervisor | `lerato.khumalo@example.com` | Event setup, reports, coupons, viewing cashups |
|
||||
| Staff | `sipho.dlamini@example.com` | At-the-door payments and check-in scanning |
|
||||
| Staff | `anri.botha@example.com` | |
|
||||
| Staff | `daniel.pillay@example.com` | |
|
||||
|
||||
Closing, reopening and settling pending money in a cashup are admin-only, so do the
|
||||
live Worship Night cashup as your own admin account.
|
||||
|
||||
The other 126 people are ordinary members (role `user`), with emails like
|
||||
`thandi.mokoena@example.com`. Log in as one of them to show the member view: their
|
||||
registrations, tickets and payments. Any registrant's email from an event's attendee
|
||||
list works.
|
||||
|
||||
Your own admin account keeps its existing password.
|
||||
|
||||
---
|
||||
|
||||
## Safety
|
||||
|
||||
- **Nothing is sent.** The script writes straight to the database and never calls the
|
||||
email, WhatsApp or Yoco code.
|
||||
- **Nothing can reach a real person later.** Demo people use `@example.com` addresses
|
||||
(a reserved domain that can't receive mail) and have **no phone numbers**, so
|
||||
reminders or broadcasts sent during a demo go nowhere. All parental consents are
|
||||
already signed, so no consent reminders go out.
|
||||
- Card payments have made-up Yoco ids (`demo_…`). They don't exist in Yoco, so leave
|
||||
the demo site's Yoco settings in test mode or empty.
|
||||
|
||||
---
|
||||
|
||||
## Customising
|
||||
|
||||
Everything lives in [`scripts/set-demo.js`](scripts/set-demo.js). There is one
|
||||
`build…()` function per event (`buildMensBreakfast`, `buildYouthCamp`, …), and
|
||||
`main()` decides which ones run. To change an event's name, prices, ticket types or
|
||||
number of registrations, edit its function. To add an event, write a new `build…()`
|
||||
and add it to the list in `main()`.
|
||||
|
||||
The past events are in `buildPastEvents()`, one spec each: dates, ticket types, costs,
|
||||
how many people bought online and at the door, and a `cashup` that says how it was
|
||||
closed. Supported closes:
|
||||
- `'quick'`
|
||||
- `'full'`, with `lines` saying where the money ended up
|
||||
- `'reclose'`
|
||||
- `'none'`, which leaves the event open
|
||||
|
||||
A full close can add `later` to settle pending money after closing. The helpers
|
||||
`cardLine`, `eftLine` and `cashLine` build the usual settlements: card net of Yoco fees,
|
||||
EFT with an optional pending POP, and cash banked with an optional float, deposit fee or
|
||||
shortfall.
|
||||
@@ -143,6 +143,10 @@ npm run prisma:status
|
||||
npm run prisma:generate
|
||||
```
|
||||
|
||||
### Demo data
|
||||
|
||||
`npm run set-demo` wipes the database (keeping site settings and the admin account) and stages six upcoming demo events with registrations, payments and tickets. See [DEMO_DATA.md](DEMO_DATA.md).
|
||||
|
||||
---
|
||||
|
||||
## Running the Server
|
||||
|
||||
Generated
+1666
-372
File diff suppressed because it is too large
Load Diff
+14
-10
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
@@ -13,10 +13,12 @@
|
||||
"prisma:status": "prisma migrate status",
|
||||
"prisma:reset": "prisma migrate reset --force",
|
||||
"migrate:env-to-db": "node scripts/migrate-env-to-db.js",
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run",
|
||||
"set-demo": "node scripts/set-demo.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"@sentry/node": "^10.71.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -37,16 +39,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
"prisma": "^7.10.1"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
"uuid": "^11.1.1",
|
||||
"@istanbuljs/load-nyc-config": {
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"follow-redirects": "^1.16.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@5.22.0": true,
|
||||
"@prisma/client@5.22.0": true,
|
||||
"@prisma/engines@5.22.0": true,
|
||||
"prisma@7.10.1": true,
|
||||
"@prisma/engines@7.10.1": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Prisma CLI config (migrate, generate, studio). Prisma 7 no longer reads the
|
||||
// datasource URL from schema.prisma or auto-loads .env, so both happen here.
|
||||
require('dotenv/config');
|
||||
const { defineConfig } = require('prisma/config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
// process.env rather than env() so `prisma generate` still works on a
|
||||
// machine with no DATABASE_URL (generate never connects).
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CashupWriteOffCategory" AS ENUM ('card_fees', 'bank_charges', 'cash_shortfall', 'other');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventCashupLine" ADD COLUMN "bankAmount" DOUBLE PRECISION,
|
||||
ADD COLUMN "onHandAmount" DOUBLE PRECISION,
|
||||
ADD COLUMN "pendingAmount" DOUBLE PRECISION,
|
||||
ADD COLUMN "pendingReference" TEXT,
|
||||
ADD COLUMN "writeOffTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupWriteOff" (
|
||||
"id" TEXT NOT NULL,
|
||||
"lineId" TEXT NOT NULL,
|
||||
"category" "CashupWriteOffCategory" NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EventCashupWriteOff_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EventCashupPendingResolution" (
|
||||
"id" TEXT NOT NULL,
|
||||
"lineId" TEXT NOT NULL,
|
||||
"outcome" TEXT NOT NULL,
|
||||
"amount" DOUBLE PRECISION NOT NULL,
|
||||
"category" "CashupWriteOffCategory",
|
||||
"notes" TEXT,
|
||||
"performedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EventCashupPendingResolution_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupWriteOff_lineId_idx" ON "EventCashupWriteOff"("lineId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupPendingResolution_lineId_idx" ON "EventCashupPendingResolution"("lineId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventCashupPendingResolution_performedById_idx" ON "EventCashupPendingResolution"("performedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupWriteOff" ADD CONSTRAINT "EventCashupWriteOff_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "EventCashupLine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventCashupPendingResolution" ADD CONSTRAINT "EventCashupPendingResolution_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -7,7 +7,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
@@ -89,6 +88,7 @@ model User {
|
||||
eventsClosed Event[] @relation("EventClosedBy")
|
||||
eventsReopened Event[] @relation("EventReopenedBy")
|
||||
cashupsPerformed EventCashup[]
|
||||
pendingResolved EventCashupPendingResolution[] @relation("PendingResolutionBy")
|
||||
notifyForEvents Event[] @relation("EventNotifyRecipients")
|
||||
|
||||
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
|
||||
@@ -709,20 +709,68 @@ model EventCashup {
|
||||
@@index([eventId, createdAt])
|
||||
}
|
||||
|
||||
// Where one method's expected takings ended up. actualAmount = bank + on hand + pending
|
||||
// (money held or on its way); write-offs explain money that's gone (fees, charges,
|
||||
// shortfalls). expected - actual - writeOffTotal is what's still unaccounted for.
|
||||
model EventCashupLine {
|
||||
id String @id @default(uuid())
|
||||
cashupId String
|
||||
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
||||
method String
|
||||
expectedAmount Float @default(0)
|
||||
actualAmount Float?
|
||||
variance Float?
|
||||
notes String?
|
||||
denominations EventCashupDenomination[]
|
||||
id String @id @default(uuid())
|
||||
cashupId String
|
||||
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
|
||||
method String
|
||||
expectedAmount Float @default(0)
|
||||
actualAmount Float?
|
||||
variance Float?
|
||||
notes String?
|
||||
bankAmount Float?
|
||||
onHandAmount Float?
|
||||
pendingAmount Float?
|
||||
// Card/Yoco report or POP reference for the pending amount.
|
||||
pendingReference String?
|
||||
writeOffTotal Float @default(0)
|
||||
denominations EventCashupDenomination[]
|
||||
writeOffs EventCashupWriteOff[]
|
||||
pendingResolutions EventCashupPendingResolution[]
|
||||
|
||||
@@index([cashupId])
|
||||
}
|
||||
|
||||
enum CashupWriteOffCategory {
|
||||
card_fees
|
||||
bank_charges
|
||||
cash_shortfall
|
||||
other
|
||||
}
|
||||
|
||||
model EventCashupWriteOff {
|
||||
id String @id @default(uuid())
|
||||
lineId String
|
||||
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
|
||||
category CashupWriteOffCategory
|
||||
amount Float
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([lineId])
|
||||
}
|
||||
|
||||
// Pending money settled after the event was closed: it reached the bank ('received') or
|
||||
// never will ('written_off'). Append-only, like EventCashup — the line itself is never edited.
|
||||
model EventCashupPendingResolution {
|
||||
id String @id @default(uuid())
|
||||
lineId String
|
||||
line EventCashupLine @relation(fields: [lineId], references: [id], onDelete: Cascade)
|
||||
outcome String // 'received' | 'written_off'
|
||||
amount Float
|
||||
category CashupWriteOffCategory?
|
||||
notes String?
|
||||
performedById String?
|
||||
performedBy User? @relation("PendingResolutionBy", fields: [performedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([lineId])
|
||||
@@index([performedById])
|
||||
}
|
||||
|
||||
model EventCashupDenomination {
|
||||
id String @id @default(uuid())
|
||||
lineId String
|
||||
|
||||
@@ -24,7 +24,7 @@ const dotenv = require('dotenv');
|
||||
// Load .env from the backend root
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
@@ -90,7 +90,7 @@ const MAPPINGS = [
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(' Settings migration: .env → DB');
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Run: node scripts/seed-users.js
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
const firstNames = [
|
||||
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,17 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
|
||||
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
|
||||
function createPrismaClient() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
module.exports = prisma;
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
module.exports.createPrismaClient = createPrismaClient;
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
|
||||
const { ALL_METHODS, WRITE_OFF_CATEGORIES, PENDING_OUTCOMES, settleLine, applyPendingResolutions, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
|
||||
|
||||
// What the cashup screen sends per method: where the money ended up and what was written off.
|
||||
function cleanDraftLine(l) {
|
||||
return {
|
||||
method: l.method,
|
||||
bankAmount: l.bankAmount ?? null,
|
||||
onHandAmount: l.onHandAmount ?? null,
|
||||
pendingAmount: l.pendingAmount ?? null,
|
||||
pendingReference: l.pendingReference || null,
|
||||
writeOffs: (Array.isArray(l.writeOffs) ? l.writeOffs : []).map(w => ({ category: w.category, amount: w.amount ?? null, reason: w.reason || null })),
|
||||
notes: l.notes || null
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
|
||||
// @route GET /api/cashups/event/:eventId
|
||||
@@ -66,7 +79,7 @@ const saveEventCashupDraft = async (req, res) => {
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupDraft: { lines: Array.isArray(lines) ? lines : [], notes: notes || null, savedAt: new Date().toISOString() } }
|
||||
data: { cashupDraft: { lines: (Array.isArray(lines) ? lines : []).filter(l => l && ALL_METHODS.includes(l.method)).map(cleanDraftLine), notes: notes || null, savedAt: new Date().toISOString() } }
|
||||
});
|
||||
|
||||
res.json({ message: 'Draft saved' });
|
||||
@@ -88,40 +101,32 @@ const closeEvent = async (req, res) => {
|
||||
const financials = await computeEventFinancials(eventId);
|
||||
const isFullCashup = Array.isArray(lines) && lines.length > 0;
|
||||
|
||||
// Each method says where its expected money ended up (see settleLine). Cash is never a
|
||||
// single event-wide figure: when staff counted it, the count is the cash there is, sourced
|
||||
// here from the per-person counts rather than from whatever the frontend sent.
|
||||
let cashupLines = [];
|
||||
if (isFullCashup) {
|
||||
const nonCashLines = lines
|
||||
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
|
||||
.map(l => {
|
||||
const expected = financials.expectedCashByMethod[l.method] || 0;
|
||||
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
|
||||
return {
|
||||
id: uuidv4(),
|
||||
method: l.method,
|
||||
expectedAmount: expected,
|
||||
actualAmount: actual,
|
||||
variance: actual !== null ? actual - expected : null,
|
||||
notes: l.notes || null
|
||||
};
|
||||
});
|
||||
|
||||
// Cash is never entered as a single event-wide figure — it's always the live sum of every
|
||||
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
|
||||
// always sourced here rather than from whatever (if anything) the frontend sent for it.
|
||||
const cashLineInput = lines.find(l => l && l.method === 'cash');
|
||||
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
|
||||
const cashExpected = financials.expectedCashByMethod.cash || 0;
|
||||
const cashLine = {
|
||||
id: uuidv4(),
|
||||
method: 'cash',
|
||||
expectedAmount: cashExpected,
|
||||
actualAmount: cashActual,
|
||||
variance: cashActual !== null ? cashActual - cashExpected : null,
|
||||
notes: cashLineInput?.notes || null,
|
||||
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
|
||||
};
|
||||
|
||||
cashupLines = [cashLine, ...nonCashLines];
|
||||
const { actual: cashCounted, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
|
||||
cashupLines = ALL_METHODS.map(method => {
|
||||
const input = lines.find(l => l && l.method === method) || {};
|
||||
const expected = financials.expectedCashByMethod[method] || 0;
|
||||
const settled = settleLine({ method, expected, counted: method === 'cash' ? cashCounted : null, input });
|
||||
return {
|
||||
id: uuidv4(),
|
||||
method,
|
||||
expectedAmount: expected,
|
||||
actualAmount: settled.actual,
|
||||
variance: settled.variance,
|
||||
notes: input.notes || null,
|
||||
bankAmount: settled.bankAmount,
|
||||
onHandAmount: settled.onHandAmount,
|
||||
pendingAmount: settled.pendingAmount,
|
||||
pendingReference: settled.pendingReference,
|
||||
writeOffTotal: settled.writeOffTotal,
|
||||
writeOffs: settled.writeOffs.length > 0 ? { create: settled.writeOffs } : undefined,
|
||||
denominations: method === 'cash' && cashDenominations.length > 0 ? { create: cashDenominations } : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const totalActualRevenue = isFullCashup
|
||||
@@ -142,7 +147,7 @@ const closeEvent = async (req, res) => {
|
||||
performedById: req.user.id,
|
||||
lines: { create: cashupLines }
|
||||
},
|
||||
include: { lines: { include: { denominations: true } }, performedBy: { select: { id: true, name: true, email: true } } }
|
||||
include: { lines: { include: { denominations: true, writeOffs: true } }, performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
await prisma.event.update({
|
||||
@@ -185,9 +190,33 @@ const reopenEvent = async (req, res) => {
|
||||
include: { performedBy: { select: { id: true, name: true, email: true } } }
|
||||
});
|
||||
|
||||
// Start the next cashup from the last one, with pending money that has since been
|
||||
// received or written off folded in, so nothing has to be typed in again.
|
||||
const lastClose = await prisma.eventCashup.findFirst({
|
||||
where: { eventId, action: { in: ['closed', 'quick_closed'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { lines: { include: { writeOffs: true, pendingResolutions: true } } }
|
||||
});
|
||||
const cashupDraft = lastClose && lastClose.lines.length > 0 ? {
|
||||
lines: lastClose.lines.map(l => {
|
||||
const settled = applyPendingResolutions(l);
|
||||
return cleanDraftLine({
|
||||
method: l.method,
|
||||
bankAmount: settled.bank,
|
||||
onHandAmount: settled.onHand,
|
||||
pendingAmount: settled.pendingOutstanding || null,
|
||||
pendingReference: settled.pendingOutstanding ? settled.pendingReference : null,
|
||||
writeOffs: settled.writeOffs,
|
||||
notes: l.notes
|
||||
});
|
||||
}),
|
||||
notes: null,
|
||||
savedAt: new Date().toISOString()
|
||||
} : undefined;
|
||||
|
||||
await prisma.event.update({
|
||||
where: { id: eventId },
|
||||
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id }
|
||||
data: { cashupStatus: 'open', reopenedAt: new Date(), reopenedById: req.user.id, ...(cashupDraft ? { cashupDraft } : {}) }
|
||||
});
|
||||
|
||||
res.status(201).json(auditRow);
|
||||
@@ -196,6 +225,66 @@ const reopenEvent = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Settle money that was pending when the event was closed (on the card report, or a
|
||||
// POP we hadn't seen in the bank yet): it has now reached the bank, or it never will and
|
||||
// is written off. Allowed while the event is closed — no reopen needed.
|
||||
// @route POST /api/cashups/lines/:lineId/pending
|
||||
// @access Private/Admin
|
||||
const resolvePending = async (req, res) => {
|
||||
try {
|
||||
const { lineId } = req.params;
|
||||
const { outcome, amount, category, notes } = req.body;
|
||||
|
||||
if (!PENDING_OUTCOMES.includes(outcome)) {
|
||||
res.status(400);
|
||||
throw new Error('Say whether the money was received or written off');
|
||||
}
|
||||
const value = Math.round(Number(amount) * 100) / 100;
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
res.status(400);
|
||||
throw new Error('Enter an amount more than R0');
|
||||
}
|
||||
|
||||
const resolution = await prisma.$transaction(async (tx) => {
|
||||
// Lock the line so two people settling at once can't settle more than is pending.
|
||||
await tx.$executeRaw`SELECT 1 FROM "EventCashupLine" WHERE id = ${lineId} FOR UPDATE`;
|
||||
const line = await tx.eventCashupLine.findUnique({
|
||||
where: { id: lineId },
|
||||
include: { cashup: { select: { id: true, eventId: true } }, pendingResolutions: true }
|
||||
});
|
||||
if (!line) {
|
||||
res.status(404);
|
||||
throw new Error('Cashup line not found');
|
||||
}
|
||||
const latest = await tx.eventCashup.findFirst({ where: { eventId: line.cashup.eventId }, orderBy: { createdAt: 'desc' }, select: { id: true } });
|
||||
if (!latest || latest.id !== line.cashupId) {
|
||||
res.status(400);
|
||||
throw new Error('Only the current cashup of a closed event can be updated');
|
||||
}
|
||||
const { pendingOutstanding } = applyPendingResolutions(line);
|
||||
if (value > pendingOutstanding + 0.005) {
|
||||
res.status(400);
|
||||
throw new Error(`Only R${pendingOutstanding.toFixed(2)} is still pending`);
|
||||
}
|
||||
return tx.eventCashupPendingResolution.create({
|
||||
data: {
|
||||
lineId,
|
||||
outcome,
|
||||
amount: value,
|
||||
category: outcome === 'written_off' ? (WRITE_OFF_CATEGORIES.includes(category) ? category : 'other') : null,
|
||||
notes: notes ? String(notes).trim().slice(0, 200) : null,
|
||||
performedById: req.user.id
|
||||
},
|
||||
include: { performedBy: { select: { id: true, name: true } } }
|
||||
});
|
||||
});
|
||||
|
||||
res.status(201).json(resolution);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Flat audit log of every close/quick-close/reopen, optionally filtered
|
||||
// @route GET /api/cashups/audit
|
||||
// @access Private/Supervisor
|
||||
@@ -234,5 +323,6 @@ module.exports = {
|
||||
saveEventCashupDraft,
|
||||
closeEvent,
|
||||
reopenEvent,
|
||||
resolvePending,
|
||||
getCashupAudit
|
||||
};
|
||||
|
||||
@@ -24,13 +24,10 @@ const express = require('express');
|
||||
const { version: API_VERSION } = require('../package.json');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = require('./config/db');
|
||||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||||
const getRawBody = require('raw-body');
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Initialize Express app
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, resolvePending, getCashupAudit } = require('../controllers/cashupController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit', protect, supervisor, getCashupAudit);
|
||||
@@ -10,5 +10,6 @@ router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePerso
|
||||
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
|
||||
router.post('/event/:eventId/close', protect, admin, closeEvent);
|
||||
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
|
||||
router.post('/lines/:lineId/pending', protect, admin, resolvePending);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -6,10 +6,114 @@ const ALL_METHODS = ['cash', 'card', 'eft', 'other'];
|
||||
// Standard South African Rand note/coin denominations used for the cash count grid.
|
||||
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
|
||||
|
||||
const WRITE_OFF_CATEGORIES = ['card_fees', 'bank_charges', 'cash_shortfall', 'other'];
|
||||
const PENDING_OUTCOMES = ['received', 'written_off'];
|
||||
|
||||
function emptyByMethod(fill = 0) {
|
||||
return { cash: fill, card: fill, eft: fill, other: fill };
|
||||
}
|
||||
|
||||
const cents = (n) => Math.round(n * 100) / 100;
|
||||
const sum = (rows) => rows.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
// A non-negative amount rounded to cents, or null when left blank.
|
||||
function amountOrNull(value, label) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) throw new Error(`${label} must be zero or more`);
|
||||
return cents(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where one method's expected takings ended up, as entered on the cashup screen. Pure.
|
||||
*
|
||||
* actual = in bank + on hand + pending (money held or on its way); write-offs explain money
|
||||
* that's gone (card fees, bank charges, a cash shortfall). Whatever is left —
|
||||
* expected − actual − write-offs — is unaccounted for. Cash has no pending; when staff
|
||||
* counted it (per-person counts), the count is all the cash there is, so whatever wasn't
|
||||
* deposited (or taken by the bank as a deposit fee) is on hand.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {string} p.method - 'cash' | 'card' | 'eft' | 'other'
|
||||
* @param {number} p.expected - expected amount for the method (payments − costs paid from it)
|
||||
* @param {number|null} [p.counted] - cash counted by staff, or null when nobody counted
|
||||
* @param {object} [p.input] - { bankAmount, onHandAmount, pendingAmount, pendingReference, writeOffs: [{ category, amount, reason }] }
|
||||
*/
|
||||
function settleLine({ method, expected, counted = null, input = {} }) {
|
||||
const bankAmount = amountOrNull(input.bankAmount, 'In bank');
|
||||
let onHandAmount = method === 'cash' ? amountOrNull(input.onHandAmount, 'Cash on hand') : null;
|
||||
const pendingAmount = method === 'cash' ? null : amountOrNull(input.pendingAmount, 'Pending');
|
||||
|
||||
const writeOffs = (Array.isArray(input.writeOffs) ? input.writeOffs : [])
|
||||
.filter(w => w && w.amount !== undefined && w.amount !== null && w.amount !== '')
|
||||
.map(w => {
|
||||
if (!WRITE_OFF_CATEGORIES.includes(w.category)) throw new Error('Choose what each write-off is for');
|
||||
const amount = amountOrNull(w.amount, 'Write-off');
|
||||
if (!(amount > 0)) throw new Error('A write-off must be more than R0');
|
||||
return { category: w.category, amount, reason: String(w.reason || '').trim().slice(0, 200) || null };
|
||||
});
|
||||
const writeOffTotal = cents(sum(writeOffs));
|
||||
|
||||
if (method === 'cash' && counted !== null && counted !== undefined) {
|
||||
// A deposit fee comes out of cash that was counted and banked, so the count is what
|
||||
// reached the bank + the fee + what's still on hand.
|
||||
const depositFees = cents(sum(writeOffs.filter(w => w.category === 'bank_charges')));
|
||||
if ((bankAmount || 0) + depositFees > counted + 0.005) throw new Error('More cash was deposited than was counted');
|
||||
onHandAmount = cents(counted - (bankAmount || 0) - depositFees);
|
||||
}
|
||||
|
||||
const parts = [bankAmount, onHandAmount, pendingAmount];
|
||||
const entered = parts.some(v => v !== null) || writeOffs.length > 0;
|
||||
const actual = entered ? cents(parts.reduce((s, v) => s + (v || 0), 0)) : null;
|
||||
|
||||
return {
|
||||
bankAmount,
|
||||
onHandAmount,
|
||||
pendingAmount,
|
||||
pendingReference: pendingAmount ? (String(input.pendingReference || '').trim().slice(0, 200) || null) : null,
|
||||
writeOffs,
|
||||
writeOffTotal,
|
||||
actual,
|
||||
variance: actual === null ? null : cents(actual - expected),
|
||||
unaccounted: entered ? cents(expected - actual - writeOffTotal) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A closed cashup line with any later pending resolutions folded in. Pure.
|
||||
* 'received' moves pending money into the bank (actual unchanged); 'written_off' turns it
|
||||
* into a write-off (actual goes down). Lines from before settlement existed have only
|
||||
* actualAmount, so their unaccounted is the old variance.
|
||||
*/
|
||||
function applyPendingResolutions(line) {
|
||||
const resolutions = line.pendingResolutions || [];
|
||||
const received = cents(sum(resolutions.filter(r => r.outcome === 'received')));
|
||||
const lateWriteOffs = resolutions.filter(r => r.outcome === 'written_off');
|
||||
const writtenOff = cents(sum(lateWriteOffs));
|
||||
|
||||
const pending = line.pendingAmount || 0;
|
||||
const actual = line.actualAmount === null || line.actualAmount === undefined ? null : cents(line.actualAmount - writtenOff);
|
||||
const writeOffTotal = cents((line.writeOffTotal || 0) + writtenOff);
|
||||
const expected = line.expectedAmount || 0;
|
||||
|
||||
return {
|
||||
bank: line.bankAmount == null && received === 0 ? null : cents((line.bankAmount || 0) + received),
|
||||
onHand: line.onHandAmount ?? null,
|
||||
pending: line.pendingAmount ?? null,
|
||||
pendingReference: line.pendingReference || null,
|
||||
pendingReceived: received,
|
||||
pendingOutstanding: cents(Math.max(pending - received - writtenOff, 0)),
|
||||
writeOffs: [
|
||||
...(line.writeOffs || []).map(w => ({ id: w.id, category: w.category, amount: w.amount, reason: w.reason, createdAt: w.createdAt, afterClose: false })),
|
||||
...lateWriteOffs.map(r => ({ id: r.id, category: r.category || 'other', amount: r.amount, reason: r.notes, createdAt: r.createdAt, afterClose: true })),
|
||||
],
|
||||
writeOffTotal,
|
||||
actual,
|
||||
variance: actual === null ? null : cents(actual - expected),
|
||||
unaccounted: actual === null && writeOffTotal === 0 ? null : cents(expected - (actual || 0) - writeOffTotal),
|
||||
};
|
||||
}
|
||||
|
||||
// Normalizes a free-text Payment.method into one of the fixed cashup buckets.
|
||||
// Card-network wallets (Apple Pay, Google Pay, etc.) settle exactly like a card payment —
|
||||
// no separate float to reconcile — so they belong in the 'card' bucket, not 'other'.
|
||||
@@ -88,7 +192,13 @@ async function computeEventFinancials(eventId) {
|
||||
prisma.eventCashup.findMany({
|
||||
where: { eventId },
|
||||
include: {
|
||||
lines: { include: { denominations: true } },
|
||||
lines: {
|
||||
include: {
|
||||
denominations: true,
|
||||
writeOffs: { orderBy: { createdAt: 'asc' } },
|
||||
pendingResolutions: { include: { performedBy: { select: { id: true, name: true } } }, orderBy: { createdAt: 'asc' } }
|
||||
}
|
||||
},
|
||||
performedBy: { select: { id: true, name: true, email: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
@@ -169,29 +279,55 @@ async function computeEventFinancials(eventId) {
|
||||
const out = {};
|
||||
for (const m of ALL_METHODS) {
|
||||
const line = latestReconciled.lines.find(l => l.method === m);
|
||||
const settled = line ? applyPendingResolutions(line) : null;
|
||||
out[m] = {
|
||||
lineId: line ? line.id : null,
|
||||
expected: line ? line.expectedAmount : expectedCashByMethod[m],
|
||||
actual: line && line.actualAmount != null ? line.actualAmount : null,
|
||||
variance: line && line.variance != null ? line.variance : null,
|
||||
actual: settled ? settled.actual : null,
|
||||
variance: settled ? settled.variance : null,
|
||||
notes: line ? line.notes : null,
|
||||
denominations: line ? line.denominations : []
|
||||
denominations: line ? line.denominations : [],
|
||||
bank: settled ? settled.bank : null,
|
||||
onHand: settled ? settled.onHand : null,
|
||||
pending: settled ? settled.pending : null,
|
||||
pendingReference: settled ? settled.pendingReference : null,
|
||||
pendingReceived: settled ? settled.pendingReceived : 0,
|
||||
pendingOutstanding: settled ? settled.pendingOutstanding : 0,
|
||||
pendingResolutions: line ? line.pendingResolutions : [],
|
||||
writeOffs: settled ? settled.writeOffs : [],
|
||||
writeOffTotal: settled ? settled.writeOffTotal : 0,
|
||||
unaccounted: settled ? settled.unaccounted : null
|
||||
};
|
||||
}
|
||||
return out;
|
||||
})()
|
||||
} : null;
|
||||
|
||||
// Money written off at (or after) the cashup: card fees, bank charges, shortfalls.
|
||||
const writeOffsByMethod = emptyByMethod();
|
||||
const writeOffsByCategory = Object.fromEntries(WRITE_OFF_CATEGORIES.map(c => [c, 0]));
|
||||
let pendingOutstandingTotal = 0;
|
||||
for (const m of ALL_METHODS) {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
if (!r) continue;
|
||||
writeOffsByMethod[m] = r.writeOffTotal;
|
||||
pendingOutstandingTotal += r.pendingOutstanding;
|
||||
for (const w of r.writeOffs) writeOffsByCategory[w.category] = cents((writeOffsByCategory[w.category] || 0) + w.amount);
|
||||
}
|
||||
const totalWriteOffs = cents(ALL_METHODS.reduce((s, m) => s + writeOffsByMethod[m], 0));
|
||||
|
||||
// Reconciled value is truth; system (expected) value is the fallback when nothing was counted.
|
||||
// Tagged costs are added back so a reconciled cash count still yields a correct *gross* income figure —
|
||||
// costs get subtracted from profit exactly once, via totalCosts, never twice.
|
||||
// Tagged costs and write-offs are added back so income stays a *gross* figure — costs and
|
||||
// write-offs each come off profit exactly once, below, never twice. (Unaccounted money
|
||||
// isn't added back: it lowers income, like any variance.)
|
||||
const effectiveGrossIncomeByMethod = emptyByMethod();
|
||||
for (const m of ALL_METHODS) {
|
||||
const actual = reconciled?.byMethod?.[m]?.actual;
|
||||
const base = actual != null ? actual : expectedCashByMethod[m];
|
||||
const base = actual != null ? actual + writeOffsByMethod[m] : expectedCashByMethod[m];
|
||||
effectiveGrossIncomeByMethod[m] = base + costsByMethod[m];
|
||||
}
|
||||
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
||||
const netProfit = effectiveTotalRevenue - totalCosts;
|
||||
const netProfit = effectiveTotalRevenue - totalCosts - totalWriteOffs;
|
||||
|
||||
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
|
||||
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
|
||||
@@ -221,6 +357,10 @@ async function computeEventFinancials(eventId) {
|
||||
reconciled,
|
||||
effectiveGrossIncomeByMethod,
|
||||
effectiveTotalRevenue,
|
||||
writeOffsByMethod,
|
||||
writeOffsByCategory,
|
||||
totalWriteOffs,
|
||||
pendingOutstandingTotal: cents(pendingOutstandingTotal),
|
||||
netProfit,
|
||||
unallocatedDonations,
|
||||
unallocatedDonationsTotal,
|
||||
@@ -362,6 +502,10 @@ module.exports = {
|
||||
METHOD_BUCKETS,
|
||||
ALL_METHODS,
|
||||
ZAR_DENOMINATIONS,
|
||||
WRITE_OFF_CATEGORIES,
|
||||
PENDING_OUTCOMES,
|
||||
settleLine,
|
||||
applyPendingResolutions,
|
||||
bucketForMethod,
|
||||
isDonationLeg,
|
||||
assertEventOpen,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
const { settleLine, applyPendingResolutions } = require('../src/utils/cashupUtils');
|
||||
|
||||
describe('settleLine', () => {
|
||||
test('card settled net of fees, with the fees written off, leaves nothing unaccounted', () => {
|
||||
const s = settleLine({
|
||||
method: 'card',
|
||||
expected: 12450,
|
||||
input: { bankAmount: '12080.40', writeOffs: [{ category: 'card_fees', amount: '369.60', reason: 'Yoco 2.95%' }] },
|
||||
});
|
||||
expect(s.actual).toBe(12080.4);
|
||||
expect(s.writeOffTotal).toBe(369.6);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
expect(s.variance).toBe(-369.6);
|
||||
expect(s.writeOffs).toEqual([{ category: 'card_fees', amount: 369.6, reason: 'Yoco 2.95%' }]);
|
||||
});
|
||||
|
||||
test('EFT with a POP not yet in the bank counts the pending money as accounted for', () => {
|
||||
const s = settleLine({ method: 'eft', expected: 8200, input: { bankAmount: 7200, pendingAmount: 1000, pendingReference: 'POP #417' } });
|
||||
expect(s.actual).toBe(8200);
|
||||
expect(s.pendingAmount).toBe(1000);
|
||||
expect(s.pendingReference).toBe('POP #417');
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('a pending reference is dropped when nothing is pending', () => {
|
||||
const s = settleLine({ method: 'eft', expected: 100, input: { bankAmount: 100, pendingReference: 'POP' } });
|
||||
expect(s.pendingReference).toBeNull();
|
||||
});
|
||||
|
||||
test('counted cash that was not deposited is on hand, and a shortfall shows as unaccounted', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 3000, counted: 2950, input: { bankAmount: 2000, onHandAmount: 999 } });
|
||||
expect(s.onHandAmount).toBe(950); // derived from the count, not the typed 999
|
||||
expect(s.actual).toBe(2950);
|
||||
expect(s.unaccounted).toBe(50);
|
||||
});
|
||||
|
||||
test('a cash shortfall written off leaves nothing unaccounted', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 3000, counted: 2950, input: { bankAmount: 2950, writeOffs: [{ category: 'cash_shortfall', amount: 50 }] } });
|
||||
expect(s.onHandAmount).toBe(0);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('a deposit fee comes out of the counted cash, not on top of it', () => {
|
||||
// Counted R1000; the bank credited R941.50 after a R58.50 deposit fee; nothing left on hand.
|
||||
const s = settleLine({ method: 'cash', expected: 1000, counted: 1000, input: { bankAmount: 941.5, writeOffs: [{ category: 'bank_charges', amount: 58.5 }] } });
|
||||
expect(s.onHandAmount).toBe(0);
|
||||
expect(s.actual).toBe(941.5);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('a cash shortfall write-off does not change what is on hand', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 1000, counted: 980, input: { bankAmount: 480, writeOffs: [{ category: 'cash_shortfall', amount: 20 }] } });
|
||||
expect(s.onHandAmount).toBe(500);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('banking more than was counted, counting the deposit fee, is rejected', () => {
|
||||
expect(() => settleLine({ method: 'cash', expected: 1000, counted: 1000, input: { bankAmount: 1000, writeOffs: [{ category: 'bank_charges', amount: 58.5 }] } })).toThrow('More cash was deposited');
|
||||
});
|
||||
|
||||
test('cash never has a pending amount', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 500, input: { onHandAmount: 500, pendingAmount: 200 } });
|
||||
expect(s.pendingAmount).toBeNull();
|
||||
expect(s.actual).toBe(500);
|
||||
});
|
||||
|
||||
test('without a count, cash on hand can be typed in', () => {
|
||||
const s = settleLine({ method: 'cash', expected: 500, counted: null, input: { bankAmount: 300, onHandAmount: 200 } });
|
||||
expect(s.actual).toBe(500);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('depositing more cash than was counted is rejected', () => {
|
||||
expect(() => settleLine({ method: 'cash', expected: 500, counted: 400, input: { bankAmount: 450 } })).toThrow('More cash was deposited');
|
||||
});
|
||||
|
||||
test('negative or non-numeric amounts are rejected', () => {
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { bankAmount: -5 } })).toThrow('In bank');
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { pendingAmount: 'abc' } })).toThrow('Pending');
|
||||
});
|
||||
|
||||
test('a write-off needs a known category and an amount over zero', () => {
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { writeOffs: [{ category: 'lunch', amount: 5 }] } })).toThrow('what each write-off is for');
|
||||
expect(() => settleLine({ method: 'card', expected: 100, input: { writeOffs: [{ category: 'other', amount: 0 }] } })).toThrow('more than R0');
|
||||
});
|
||||
|
||||
test('blank write-off rows are ignored', () => {
|
||||
const s = settleLine({ method: 'card', expected: 100, input: { bankAmount: 100, writeOffs: [{ category: 'other', amount: '' }] } });
|
||||
expect(s.writeOffs).toEqual([]);
|
||||
});
|
||||
|
||||
test('nothing entered leaves the method unreconciled', () => {
|
||||
const s = settleLine({ method: 'eft', expected: 100, input: {} });
|
||||
expect(s.actual).toBeNull();
|
||||
expect(s.variance).toBeNull();
|
||||
expect(s.unaccounted).toBeNull();
|
||||
});
|
||||
|
||||
test('amounts are rounded to cents', () => {
|
||||
const s = settleLine({ method: 'card', expected: 0.3, input: { bankAmount: 0.1, pendingAmount: 0.2 } });
|
||||
expect(s.actual).toBe(0.3);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPendingResolutions', () => {
|
||||
const line = {
|
||||
expectedAmount: 8200,
|
||||
actualAmount: 8200,
|
||||
bankAmount: 7200,
|
||||
onHandAmount: null,
|
||||
pendingAmount: 1000,
|
||||
pendingReference: 'POP #417',
|
||||
writeOffTotal: 0,
|
||||
writeOffs: [],
|
||||
};
|
||||
|
||||
test('with nothing settled yet, all the pending money is outstanding', () => {
|
||||
const s = applyPendingResolutions({ ...line, pendingResolutions: [] });
|
||||
expect(s.pendingOutstanding).toBe(1000);
|
||||
expect(s.bank).toBe(7200);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
});
|
||||
|
||||
test('money received later moves into the bank without changing actual', () => {
|
||||
const s = applyPendingResolutions({ ...line, pendingResolutions: [{ id: 'r1', outcome: 'received', amount: 600 }] });
|
||||
expect(s.bank).toBe(7800);
|
||||
expect(s.actual).toBe(8200);
|
||||
expect(s.pendingOutstanding).toBe(400);
|
||||
expect(s.writeOffTotal).toBe(0);
|
||||
});
|
||||
|
||||
test('money written off later lowers actual and becomes a write-off', () => {
|
||||
const s = applyPendingResolutions({
|
||||
...line,
|
||||
pendingResolutions: [
|
||||
{ id: 'r1', outcome: 'received', amount: 600 },
|
||||
{ id: 'r2', outcome: 'written_off', amount: 400, category: 'other', notes: 'Never paid' },
|
||||
],
|
||||
});
|
||||
expect(s.actual).toBe(7800);
|
||||
expect(s.writeOffTotal).toBe(400);
|
||||
expect(s.pendingOutstanding).toBe(0);
|
||||
expect(s.unaccounted).toBe(0);
|
||||
expect(s.writeOffs).toEqual([expect.objectContaining({ id: 'r2', category: 'other', amount: 400, reason: 'Never paid', afterClose: true })]);
|
||||
});
|
||||
|
||||
test('a line from before settlement existed reports its old variance as unaccounted', () => {
|
||||
const s = applyPendingResolutions({ expectedAmount: 1000, actualAmount: 980, variance: -20 });
|
||||
expect(s.bank).toBeNull();
|
||||
expect(s.pendingOutstanding).toBe(0);
|
||||
expect(s.unaccounted).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,6 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
domains: ['images.unsplash.com'],
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
@@ -8478,9 +8478,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"version": "7.1.7",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz",
|
||||
"integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.25.1",
|
||||
"version": "1.27.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -62,7 +62,8 @@
|
||||
"overrides": {
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
},
|
||||
"postcss-selector-parser": "^7.1.7"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@sentry/cli@2.58.6": true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -16,14 +17,14 @@ export default function ForgotPasswordPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const res: any = await apiFetch("/api/users/forgot", { method: "POST", body: { email } });
|
||||
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
|
||||
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
try {
|
||||
const parsed = JSON.parse(err?.message || "");
|
||||
const parsed = JSON.parse(errorMessage(err) || "");
|
||||
setStatus(parsed?.message || "Something went wrong");
|
||||
} catch {
|
||||
setStatus(err?.message || "Something went wrong");
|
||||
setStatus(errorMessage(err) || "Something went wrong");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -41,7 +42,7 @@ export default function ForgotPasswordPage() {
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Forgot your password?</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
|
||||
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -14,11 +16,11 @@ const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
|
||||
const { redirectUrl } = await params;
|
||||
|
||||
let event: any = null;
|
||||
let event: (Event & { message?: string }) | null = null;
|
||||
|
||||
if (VALID_ALIAS.test(redirectUrl)) {
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
}
|
||||
@@ -32,9 +34,9 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
<p className="text-gray-500 mb-6">
|
||||
The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<a href="/" className="text-brand-600 hover:underline transition-all duration-200">
|
||||
<Link href="/" className="text-brand-600 hover:underline transition-all duration-200">
|
||||
Back to Home →
|
||||
</a>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -47,9 +49,9 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
<main className="flex flex-col items-center justify-center min-h-screen text-center">
|
||||
<h1 className="text-3xl font-bold mb-2">This event has ended</h1>
|
||||
<p className="text-gray-600">Thanks for joining us! Stay tuned for the next one.</p>
|
||||
<a href="/" className="mt-4 text-brand-600 hover:underline">
|
||||
<Link href="/" className="mt-4 text-brand-600 hover:underline">
|
||||
Back to Home →
|
||||
</a>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { saveToken } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
function ActivateAccountContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -24,14 +25,14 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
// Auto-login: save the session token (under the key AuthContext reads) and load the
|
||||
// dashboard fresh so the new session is picked up.
|
||||
if (data?.token) saveToken(data.token);
|
||||
setStatus("Your account is now active. Redirecting…");
|
||||
setTimeout(() => { window.location.href = "/dashboard"; }, 1500);
|
||||
} catch (err: any) {
|
||||
setStatus(err?.message || "Activation failed. The link may have expired.");
|
||||
} catch (err) {
|
||||
setStatus(errorMessage(err) || "Activation failed. The link may have expired.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
const ACTIONS = [
|
||||
"refund_created",
|
||||
@@ -174,14 +176,15 @@ export default function AdminAuditLogPage() {
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load audit log");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load audit log");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, setError]);
|
||||
|
||||
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
||||
// load changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
|
||||
useEffect(() => { if (token) load(1); }, [token, load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
@@ -190,7 +193,7 @@ export default function AdminAuditLogPage() {
|
||||
<History className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard/admin" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Admin Audit Log</h1>
|
||||
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod, CashupWriteOffCategory, ReconciledByMethod, Event } from "@/types";
|
||||
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||
@@ -19,12 +21,9 @@ function denomLabel(v: number): string {
|
||||
return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`;
|
||||
}
|
||||
|
||||
type LineInput = { actualAmount: string; notes: string };
|
||||
|
||||
export default function EventCashupPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const eventId = params.id;
|
||||
const router = useRouter();
|
||||
const { token } = useAuth();
|
||||
|
||||
const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs");
|
||||
@@ -36,25 +35,25 @@ export default function EventCashupPage() {
|
||||
|
||||
const isClosed = data?.event?.cashupStatus === "closed";
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [financials, event] = await Promise.all([
|
||||
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
|
||||
apiFetch<any>(`/api/events/${eventId}`, { authToken: token })
|
||||
apiFetch<Event>(`/api/events/${eventId}`, { authToken: token })
|
||||
]);
|
||||
setData(financials);
|
||||
setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name })));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load cashup data");
|
||||
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load cashup data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [token, eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
@@ -64,7 +63,7 @@ export default function EventCashupPage() {
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}>← Back to cashup</button>
|
||||
<BackLink href="/dashboard/admin/cashup" label="Back to cashup" />
|
||||
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">{data?.event?.title || "Event"} — Cashup</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,8 +150,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
}
|
||||
setEditingId(null);
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to save cost");
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save cost");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -163,8 +162,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
try {
|
||||
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || "Failed to delete cost");
|
||||
} catch (e) {
|
||||
alert(errorMessage(e) || "Failed to delete cost");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,7 +178,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
@@ -257,11 +256,11 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Paid from (optional)</label>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as any)}>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as "" | CashupMethod)}>
|
||||
<option value="">Not from event takings</option>
|
||||
{METHODS.map(m => <option key={m} value={m}>{METHOD_LABELS[m]}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Notes (optional)</label>
|
||||
@@ -306,16 +305,17 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
const load = useCallback(() => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
|
||||
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
|
||||
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
}, [eventId, token, onCashSummaryChange]);
|
||||
|
||||
useEffect(load, [eventId, token]);
|
||||
// onCashSummaryChange is a state setter in the parent, so this only reruns when the event/token change.
|
||||
useEffect(load, [load]);
|
||||
|
||||
const rows = data?.rows || [];
|
||||
const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") =>
|
||||
@@ -461,8 +461,8 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
|
||||
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to save count");
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save count");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -535,40 +535,79 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
|
||||
<div className="text-sm font-medium">Revenue by method</div>
|
||||
<table className="w-full text-sm min-w-[560px]">
|
||||
<div className="text-sm font-medium">Where the money ended up</div>
|
||||
<table className="w-full text-sm min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Method</th>
|
||||
<th className="py-1 text-right">Income</th>
|
||||
<th className="py-1 text-right">Expected cash</th>
|
||||
<th className="py-1 text-right">Actual</th>
|
||||
<th className="py-1 text-right">Variance</th>
|
||||
<th className="py-1 text-right">Expected</th>
|
||||
<th className="py-1 text-right">In bank</th>
|
||||
<th className="py-1 text-right">On hand</th>
|
||||
<th className="py-1 text-right">Pending</th>
|
||||
<th className="py-1 text-right">Written off</th>
|
||||
<th className="py-1 text-right">Unaccounted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METHODS.map(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
const cell = (v: number | null | undefined) => (v != null && Math.abs(v) >= 0.005 ? money(v) : "—");
|
||||
return (
|
||||
<tr key={m} className="border-b last:border-0">
|
||||
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
||||
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{r?.actual != null ? money(r.actual) : "—"}</td>
|
||||
<td className="py-1.5 text-right">{r?.variance != null ? money(r.variance) : "—"}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.bank)}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.onHand)}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.pendingOutstanding)}</td>
|
||||
<td className="py-1.5 text-right">{cell(r?.writeOffTotal)}</td>
|
||||
<td className={"py-1.5 text-right " + (r?.unaccounted != null && Math.abs(r.unaccounted) >= 0.005 ? "text-rose-700 font-medium" : "")}>
|
||||
{r?.unaccounted != null ? (Math.abs(r.unaccounted) >= 0.005 ? money(r.unaccounted) : "R0.00") : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!reconciled && <div className="text-xs text-gray-400">Not settled yet. Columns fill in once the event is closed with a cashup.</div>}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const writeOffs = METHODS.flatMap(m => (reconciled?.byMethod?.[m]?.writeOffs || []).map(w => ({ ...w, method: m })));
|
||||
return writeOffs.length > 0 ? (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
|
||||
<div className="text-sm font-medium">Write-offs</div>
|
||||
<table className="w-full text-sm min-w-[480px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Method</th>
|
||||
<th className="py-1">Type</th>
|
||||
<th className="py-1">Reason</th>
|
||||
<th className="py-1 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{writeOffs.map((w, i) => (
|
||||
<tr key={w.id || i} className="border-b last:border-0">
|
||||
<td className="py-1.5">{METHOD_LABELS[w.method]}</td>
|
||||
<td className="py-1.5">{WRITE_OFF_LABELS[w.category]}{w.afterClose ? " (after closing)" : ""}</td>
|
||||
<td className="py-1.5 text-gray-600">{w.reason || "—"}</td>
|
||||
<td className="py-1.5 text-right">{money(w.amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{reconciled?.byMethod?.cash?.denominations?.length ? (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
<div className="text-sm font-medium">Cash denomination count</div>
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{reconciled.byMethod.cash.denominations.map((d: any) => (
|
||||
{reconciled.byMethod.cash.denominations.map((d) => (
|
||||
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -590,7 +629,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.costs.map((c: any) => (
|
||||
{data.costs.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5">{c.label}</td>
|
||||
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
|
||||
@@ -602,19 +641,273 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<ProfitSummary data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Settlement ────────────────────────────────────────────────────────────
|
||||
// For each method, where did the expected money end up: in the bank, still on hand (cash),
|
||||
// or pending (on the card report / a POP we haven't seen in the bank yet) — and what was
|
||||
// written off (card fees, bank charges, a shortfall). Mirrors settleLine in
|
||||
// backend/src/utils/cashupUtils.js, which has the final say when the event is closed.
|
||||
|
||||
type WriteOffInput = { category: CashupWriteOffCategory; amount: string; reason: string };
|
||||
type SettlementInput = { bankAmount: string; onHandAmount: string; pendingAmount: string; pendingReference: string; writeOffs: WriteOffInput[]; notes: string };
|
||||
|
||||
const WRITE_OFF_LABELS: Record<CashupWriteOffCategory, string> = {
|
||||
card_fees: "Card / online fees",
|
||||
bank_charges: "Bank charges",
|
||||
cash_shortfall: "Cash shortfall",
|
||||
other: "Other",
|
||||
};
|
||||
const WRITE_OFF_CATEGORIES = Object.keys(WRITE_OFF_LABELS) as CashupWriteOffCategory[];
|
||||
const DEFAULT_WRITE_OFF: Record<CashupMethod, CashupWriteOffCategory> = { cash: "cash_shortfall", card: "card_fees", eft: "bank_charges", other: "other" };
|
||||
const BANK_HELP: Record<CashupMethod, string> = {
|
||||
cash: "What reached the bank, after any deposit fee",
|
||||
card: "Settled into the bank account",
|
||||
eft: "Showing on the bank statement",
|
||||
other: "Received in the bank",
|
||||
};
|
||||
const PENDING_HELP: Record<CashupMethod, string> = {
|
||||
cash: "",
|
||||
card: "On the card machine / Yoco report, not in the bank yet",
|
||||
eft: "Proof of payment received, not in the bank yet",
|
||||
other: "Expected but not in the bank yet",
|
||||
};
|
||||
const PENDING_REFERENCE_PLACEHOLDER: Record<CashupMethod, string> = { cash: "", card: "Report or batch reference", eft: "POP reference", other: "Reference" };
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
const emptySettlement = (): SettlementInput => ({ bankAmount: "", onHandAmount: "", pendingAmount: "", pendingReference: "", writeOffs: [], notes: "" });
|
||||
const amountText = (v: string | number | null | undefined) => (v === null || v === undefined ? "" : String(v));
|
||||
|
||||
function settle(method: CashupMethod, expected: number, counted: number | null, input: SettlementInput) {
|
||||
const num = (v: string) => (v.trim() === "" || isNaN(Number(v)) ? null : Number(v));
|
||||
const bank = num(input.bankAmount);
|
||||
const writeOffTotal = round2(input.writeOffs.reduce((s, w) => s + (num(w.amount) || 0), 0));
|
||||
// A deposit fee comes out of the counted cash that was banked.
|
||||
const depositFees = round2(input.writeOffs.filter(w => w.category === "bank_charges").reduce((s, w) => s + (num(w.amount) || 0), 0));
|
||||
const onHand = method === "cash" ? (counted !== null ? round2(counted - (bank || 0) - depositFees) : num(input.onHandAmount)) : null;
|
||||
const pending = method === "cash" ? null : num(input.pendingAmount);
|
||||
const entered = [bank, onHand, pending].some(v => v !== null) || input.writeOffs.some(w => num(w.amount) !== null);
|
||||
const actual = entered ? round2((bank || 0) + (onHand || 0) + (pending || 0)) : null;
|
||||
return { bank, onHand, pending, writeOffTotal, actual, unaccounted: actual === null ? null : round2(expected - actual - writeOffTotal) };
|
||||
}
|
||||
|
||||
function UnaccountedBadge({ unaccounted }: { unaccounted: number | null }) {
|
||||
if (unaccounted === null) return <span className="text-xs text-gray-400">Not settled yet</span>;
|
||||
if (Math.abs(unaccounted) < 0.005) return <span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-emerald-50 text-emerald-700">All accounted for</span>;
|
||||
return (
|
||||
<span className="inline-block px-2 py-0.5 rounded text-xs font-medium bg-rose-50 text-rose-700">
|
||||
{unaccounted > 0 ? `${money(unaccounted)} unaccounted` : `${money(-unaccounted)} more than expected`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SettlementCard({ method, data, counted, input, onChange }: {
|
||||
method: CashupMethod; data: EventFinancials; counted: number | null; input: SettlementInput; onChange: (next: SettlementInput) => void;
|
||||
}) {
|
||||
const expected = data.expectedCashByMethod[method];
|
||||
const s = settle(method, expected, counted, input);
|
||||
const set = (field: keyof SettlementInput, value: string) => onChange({ ...input, [field]: value });
|
||||
const setWriteOff = (i: number, patch: Partial<WriteOffInput>) =>
|
||||
onChange({ ...input, writeOffs: input.writeOffs.map((w, j) => (j === i ? { ...w, ...patch } : w)) });
|
||||
const addWriteOff = () => onChange({ ...input, writeOffs: [...input.writeOffs, { category: DEFAULT_WRITE_OFF[method], amount: "", reason: "" }] });
|
||||
const removeWriteOff = (i: number) => onChange({ ...input, writeOffs: input.writeOffs.filter((_, j) => j !== i) });
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-3 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
<div className="text-sm font-medium">{METHOD_LABELS[method]}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Expected {money(expected)}
|
||||
{data.costsByMethod[method] > 0 && <> ({money(data.paymentsByMethod[method])} taken, less {money(data.costsByMethod[method])} paid out)</>}
|
||||
</div>
|
||||
</div>
|
||||
<UnaccountedBadge unaccounted={s.unaccounted} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Total costs</div>
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
<label className="block text-xs text-gray-600 mb-1">In bank</label>
|
||||
<input type="number" step="0.01" min={0} className="w-full border rounded px-2 py-1.5 text-sm" value={input.bankAmount} onChange={e => set("bankAmount", e.target.value)} />
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">{BANK_HELP[method]}</p>
|
||||
</div>
|
||||
{method === "cash" ? (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Cash on hand</label>
|
||||
{counted !== null ? (
|
||||
<div className="text-sm py-1.5">{money(s.onHand)}</div>
|
||||
) : (
|
||||
<input type="number" step="0.01" min={0} className="w-full border rounded px-2 py-1.5 text-sm" value={input.onHandAmount} onChange={e => set("onHandAmount", e.target.value)} />
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{counted !== null ? `Counted ${money(counted)}, less what was deposited and any deposit fee` : "Not deposited yet. Enter staff cash counts below to work this out."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Pending</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="number" step="0.01" min={0} className="w-28 border rounded px-2 py-1.5 text-sm" value={input.pendingAmount} onChange={e => set("pendingAmount", e.target.value)} />
|
||||
<input className="flex-1 min-w-0 border rounded px-2 py-1.5 text-sm" placeholder={PENDING_REFERENCE_PLACEHOLDER[method]} value={input.pendingReference} onChange={e => set("pendingReference", e.target.value)} />
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">{PENDING_HELP[method]}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-600">Write-offs</div>
|
||||
{input.writeOffs.map((w, i) => (
|
||||
<div key={i} className="flex flex-wrap gap-2 items-center">
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={w.category} onChange={e => setWriteOff(i, { category: e.target.value as CashupWriteOffCategory })}>
|
||||
{WRITE_OFF_CATEGORIES.map(c => <option key={c} value={c}>{WRITE_OFF_LABELS[c]}</option>)}
|
||||
</select>
|
||||
<input type="number" step="0.01" min={0} className="w-28 border rounded px-2 py-1.5 text-sm" placeholder="Amount" value={w.amount} onChange={e => setWriteOff(i, { amount: e.target.value })} />
|
||||
<input className="flex-1 min-w-[140px] border rounded px-2 py-1.5 text-sm" placeholder="Reason (optional)" value={w.reason} onChange={e => setWriteOff(i, { reason: e.target.value })} />
|
||||
<button className="text-xs text-red-600 hover:underline" onClick={() => removeWriteOff(i)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={addWriteOff}>+ Add write-off</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Notes (optional)</label>
|
||||
<input className="w-full border rounded px-2 py-1.5 text-sm" value={input.notes} onChange={e => set("notes", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A closed method's settlement, read-only, with a way to settle pending money that has
|
||||
// since arrived (or never will) without reopening the event.
|
||||
function SettledCard({ method, r, token, onChanged }: { method: CashupMethod; r: ReconciledByMethod; token: string; onChanged: () => void }) {
|
||||
return (
|
||||
<div className="border rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
<div className="text-sm font-medium">{METHOD_LABELS[method]}</div>
|
||||
<div className="text-xs text-gray-500">Expected {money(r.expected)}</div>
|
||||
</div>
|
||||
<UnaccountedBadge unaccounted={r.unaccounted} />
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
|
||||
<div><dt className="text-xs text-gray-500">In bank</dt><dd>{r.bank !== null ? money(r.bank) : "—"}</dd></div>
|
||||
{method === "cash"
|
||||
? <div><dt className="text-xs text-gray-500">On hand</dt><dd>{r.onHand !== null ? money(r.onHand) : "—"}</dd></div>
|
||||
: <div><dt className="text-xs text-gray-500">Pending</dt><dd>{r.pendingOutstanding > 0 ? money(r.pendingOutstanding) : "—"}</dd></div>}
|
||||
<div><dt className="text-xs text-gray-500">Written off</dt><dd>{r.writeOffTotal > 0 ? money(r.writeOffTotal) : "—"}</dd></div>
|
||||
<div><dt className="text-xs text-gray-500">Actual</dt><dd>{r.actual !== null ? money(r.actual) : "—"}</dd></div>
|
||||
</dl>
|
||||
{r.writeOffs.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 space-y-0.5">
|
||||
{r.writeOffs.map((w, i) => (
|
||||
<li key={w.id || i}>
|
||||
{WRITE_OFF_LABELS[w.category]} {money(w.amount)}{w.reason ? ` — ${w.reason}` : ""}{w.afterClose ? " (after closing)" : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{(r.pending || 0) > 0 && (
|
||||
<PendingSettler method={method} r={r} token={token} onChanged={onChanged} />
|
||||
)}
|
||||
{r.notes && <div className="text-xs text-gray-500">Notes: {r.notes}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingSettler({ method, r, token, onChanged }: { method: CashupMethod; r: ReconciledByMethod; token: string; onChanged: () => void }) {
|
||||
const [mode, setMode] = useState<"received" | "written_off" | null>(null);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [category, setCategory] = useState<CashupWriteOffCategory>(DEFAULT_WRITE_OFF[method]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const start = (m: "received" | "written_off") => { setMode(m); setAmount(String(r.pendingOutstanding)); setNotes(""); setErr(null); };
|
||||
|
||||
const save = async () => {
|
||||
if (!r.lineId || !mode) return;
|
||||
setSaving(true); setErr(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/lines/${r.lineId}/pending`, { method: "POST", authToken: token, body: { outcome: mode, amount: parseFloat(amount), category, notes: notes || null } });
|
||||
setMode(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded p-2 space-y-2 text-xs">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<span>
|
||||
Pending {money(r.pending)}{r.pendingReference ? ` (${r.pendingReference})` : ""}
|
||||
{r.pendingOutstanding > 0 ? <> — <strong>{money(r.pendingOutstanding)} still outstanding</strong></> : " — all settled"}
|
||||
</span>
|
||||
{r.pendingOutstanding > 0 && mode === null && (
|
||||
<span className="flex gap-3">
|
||||
<button className="text-brand-600 hover:underline" onClick={() => start("received")}>Mark received</button>
|
||||
<button className="text-red-600 hover:underline" onClick={() => start("written_off")}>Write off</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.pendingResolutions.length > 0 && (
|
||||
<ul className="text-gray-600 space-y-0.5">
|
||||
{r.pendingResolutions.map(p => (
|
||||
<li key={p.id}>
|
||||
{p.outcome === "received" ? "Received in bank" : `Written off (${WRITE_OFF_LABELS[p.category || "other"]})`} {money(p.amount)}
|
||||
{" — "}{p.performedBy?.name || "Unknown"}, {new Date(p.createdAt).toLocaleDateString()}{p.notes ? ` — ${p.notes}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{mode !== null && (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input type="number" step="0.01" min={0} className="w-28 border rounded px-2 py-1 text-sm bg-white" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
{mode === "written_off" && (
|
||||
<select className="border rounded px-2 py-1 text-sm bg-white" value={category} onChange={e => setCategory(e.target.value as CashupWriteOffCategory)}>
|
||||
{WRITE_OFF_CATEGORIES.map(c => <option key={c} value={c}>{WRITE_OFF_LABELS[c]}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<input className="flex-1 min-w-[140px] border rounded px-2 py-1 text-sm bg-white" placeholder={mode === "received" ? "Bank reference (optional)" : "Reason (optional)"} value={notes} onChange={e => setNotes(e.target.value)} />
|
||||
<button className="px-2 py-1 rounded border bg-white" onClick={() => setMode(null)} disabled={saving}>Cancel</button>
|
||||
<button className="px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>
|
||||
{saving ? "Saving…" : mode === "received" ? "Mark received" : "Write off"}
|
||||
</button>
|
||||
{err && <span className="text-red-600 w-full">{err}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfitSummary({ data }: { data: EventFinancials }) {
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-2 sm:grid-cols-5 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Total costs</div>
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Bank & card charges</div>
|
||||
<div className="font-semibold">{money(data.totalWriteOffs)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Pending (outstanding)</div>
|
||||
<div className="font-semibold">{money(data.pendingOutstandingTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -625,70 +918,98 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void;
|
||||
}) {
|
||||
const isClosed = data.event.cashupStatus === "closed";
|
||||
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
|
||||
const draftLines = data.event.cashupDraft?.lines;
|
||||
|
||||
const initialLines: Record<CashupMethod, LineInput> = useMemo(() => {
|
||||
const base: Record<CashupMethod, LineInput> = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } };
|
||||
const initialLines: Record<CashupMethod, SettlementInput> = useMemo(() => {
|
||||
const base: Record<CashupMethod, SettlementInput> = { cash: emptySettlement(), card: emptySettlement(), eft: emptySettlement(), other: emptySettlement() };
|
||||
for (const l of draftLines || []) {
|
||||
if (l.method in base) base[l.method as CashupMethod] = { actualAmount: l.actualAmount != null ? String(l.actualAmount) : "", notes: l.notes || "" };
|
||||
if (!(l.method in base)) continue;
|
||||
base[l.method as CashupMethod] = {
|
||||
bankAmount: amountText(l.bankAmount),
|
||||
onHandAmount: amountText(l.onHandAmount),
|
||||
pendingAmount: amountText(l.pendingAmount),
|
||||
pendingReference: l.pendingReference || "",
|
||||
writeOffs: (l.writeOffs || []).map(w => ({ category: w.category, amount: amountText(w.amount), reason: w.reason || "" })),
|
||||
notes: l.notes || "",
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}, [draftLines]);
|
||||
|
||||
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
|
||||
const [lines, setLines] = useState<Record<CashupMethod, SettlementInput>>(initialLines);
|
||||
const [closeNotes, setCloseNotes] = useState("");
|
||||
const [reopenNotes, setReopenNotes] = useState("");
|
||||
// Cash no longer has its own manual entry — it's the live sum of every staff member's
|
||||
// per-person count, reported up from CashByUserSection below.
|
||||
// Counted cash is the live sum of every staff member's per-person count, reported up from
|
||||
// CashByUserSection below; when there is one, cash on hand is whatever wasn't deposited.
|
||||
const [cashSummary, setCashSummary] = useState<{ actualTotal: number | null; denominations: PersonDenom[] }>({ actualTotal: null, denominations: [] });
|
||||
|
||||
useEffect(() => { setLines(initialLines); }, [initialLines]);
|
||||
|
||||
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
|
||||
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
|
||||
};
|
||||
const setLine = (method: CashupMethod, next: SettlementInput) => setLines(prev => ({ ...prev, [method]: next }));
|
||||
|
||||
// Cash is included with notes only — its actual/denominations are always sourced server-side
|
||||
// from per-person counts (see cashupController.closeEvent), never from this payload.
|
||||
const buildLinesPayload = () => METHODS.map(m => m === "cash"
|
||||
? { method: "cash", notes: lines.cash.notes || null }
|
||||
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
|
||||
// Methods with nothing expected and nothing entered are hidden to keep the screen short.
|
||||
const hasInput = (m: CashupMethod) => {
|
||||
const l = lines[m];
|
||||
return l.bankAmount !== "" || l.onHandAmount !== "" || l.pendingAmount !== "" || l.writeOffs.length > 0;
|
||||
};
|
||||
const shownMethods = METHODS.filter(m => Math.abs(data.expectedCashByMethod[m]) > 0.005 || hasInput(m) || (m === "cash" && cashSummary.actualTotal !== null));
|
||||
|
||||
const buildLinesPayload = () => METHODS.map(m => {
|
||||
const l = lines[m];
|
||||
const amount = (v: string) => (v.trim() === "" ? null : parseFloat(v));
|
||||
return {
|
||||
method: m,
|
||||
bankAmount: amount(l.bankAmount),
|
||||
onHandAmount: m === "cash" ? amount(l.onHandAmount) : null,
|
||||
pendingAmount: m === "cash" ? null : amount(l.pendingAmount),
|
||||
pendingReference: l.pendingReference || null,
|
||||
writeOffs: l.writeOffs.map(w => ({ category: w.category, amount: amount(w.amount), reason: w.reason || null })),
|
||||
notes: l.notes || null,
|
||||
};
|
||||
});
|
||||
|
||||
const saveDraft = async () => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save draft");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save draft");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeWithCashup = async () => {
|
||||
if (!confirm("Close this event with the entered reconciliation? This will count unallocated donations as profit and fully lock the event until it's reopened.")) return;
|
||||
const open = shownMethods
|
||||
.map(m => ({ m, s: settle(m, data.expectedCashByMethod[m], m === "cash" ? cashSummary.actualTotal : null, lines[m]) }))
|
||||
.filter(({ s }) => s.unaccounted === null || Math.abs(s.unaccounted) >= 0.005)
|
||||
.map(({ m, s }) => `${METHOD_LABELS[m]}: ${s.unaccounted === null ? "not settled" : money(s.unaccounted)}`);
|
||||
const warning = open.length > 0
|
||||
? `\n\nNot everything is accounted for:\n${open.join("\n")}\n\nThis will be recorded as a variance and comes off profit.`
|
||||
: "";
|
||||
if (!confirm(`Close this event with this cashup? Unallocated donations will be counted as profit and the event will be locked until it's reopened.${warning}`)) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quickClose = async () => {
|
||||
if (!confirm("Quick close this event without a per-method cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
if (!confirm("Quick close this event without a cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -700,68 +1021,49 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
try {
|
||||
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reopen event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reopen event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reconciled = data.reconciled;
|
||||
const settledMethods = METHODS.filter(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
return r && (Math.abs(r.expected) > 0.005 || r.actual !== null || r.writeOffTotal > 0);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white border rounded-lg p-4 space-y-3 overflow-auto">
|
||||
<div className="text-sm font-medium">Cash-up reconciliation</div>
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="py-1">Method</th>
|
||||
<th className="py-1 text-right">Income</th>
|
||||
<th className="py-1 text-right">Costs from method</th>
|
||||
<th className="py-1 text-right">Expected cash</th>
|
||||
<th className="py-1 text-right">Actual</th>
|
||||
<th className="py-1 text-right">Variance</th>
|
||||
<th className="py-1">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{METHODS.map(m => {
|
||||
const r = reconciled?.byMethod?.[m];
|
||||
return (
|
||||
<tr key={m} className="border-b last:border-0 align-top">
|
||||
<td className="py-1.5">{METHOD_LABELS[m]}</td>
|
||||
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isClosed ? (
|
||||
money(r?.actual ?? null)
|
||||
) : m === "cash" ? (
|
||||
cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : <span className="text-gray-400">not counted</span>
|
||||
) : (
|
||||
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isClosed
|
||||
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
|
||||
: (m === "cash"
|
||||
? (cashSummary.actualTotal != null ? money(cashSummary.actualTotal - data.expectedCashByMethod[m]) : "—")
|
||||
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
{isClosed ? (r?.notes || "—") : (
|
||||
<input className="w-full border rounded px-2 py-1 text-sm" value={lines[m].notes} onChange={e => setLine(m, "notes", e.target.value)} />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{!isClosed && (
|
||||
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.</div>
|
||||
<div className="bg-white border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Where did the money end up?</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{isClosed
|
||||
? "As recorded when the event was closed. Pending money can be marked received or written off without reopening."
|
||||
: "For each payment method, say how much is in the bank, still on hand or pending, and write off anything lost to fees, charges or shortfalls."}
|
||||
</div>
|
||||
</div>
|
||||
{isClosed ? (
|
||||
reconciled?.action === "closed" && settledMethods.length > 0 ? (
|
||||
settledMethods.map(m => <SettledCard key={m} method={m} r={reconciled.byMethod[m]} token={token} onChanged={onChanged} />)
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">This event was quick closed, so no settlement was recorded.</div>
|
||||
)
|
||||
) : shownMethods.length === 0 ? (
|
||||
<div className="text-xs text-gray-400">No payments to settle for this event.</div>
|
||||
) : (
|
||||
shownMethods.map(m => (
|
||||
<SettlementCard
|
||||
key={m}
|
||||
method={m}
|
||||
data={data}
|
||||
counted={m === "cash" ? cashSummary.actualTotal : null}
|
||||
input={lines[m]}
|
||||
onChange={next => setLine(m, next)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -774,7 +1076,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
return denoms.length > 0 ? (
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{denoms.map((d: any) => (
|
||||
{denoms.map((d: PersonDenom & { id?: string }) => (
|
||||
<tr key={d.id || d.value}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -787,20 +1089,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Donations counted as profit</div>
|
||||
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Total costs</div>
|
||||
<div className="font-semibold">{money(data.totalCosts)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">Net profit</div>
|
||||
<div className="font-semibold">{money(data.netProfit)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfitSummary data={data} />
|
||||
|
||||
{!isClosed && (
|
||||
<div className="bg-white border rounded-lg p-4 space-y-2">
|
||||
|
||||
@@ -6,12 +6,15 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Wallet } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
export default function CashupLandingPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -25,15 +28,15 @@ export default function CashupLandingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
}, [token, setError]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const now = new Date();
|
||||
@@ -53,15 +56,11 @@ export default function CashupLandingPage() {
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Post-event Cashup</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">Post-event Cashup</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>Back</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats } from "@/types";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -67,35 +68,35 @@ export default function AdminDashboardPage() {
|
||||
// /api/stats/admin covers today/week/month payment totals (unchanged from before);
|
||||
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are
|
||||
// computed server-side — no full payments/events list ever ships to the client.
|
||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||
const [overview, setOverview] = useStableState<Overview | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [dash, overviewData] = await Promise.all([
|
||||
apiFetch<any>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||
]);
|
||||
setPaymentStats(dash.paymentStats);
|
||||
setOverview(overviewData);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore errors for dashboard summaries
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setPaymentStats, setOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 15s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -105,7 +106,7 @@ export default function AdminDashboardPage() {
|
||||
}, 15000, !!token);
|
||||
|
||||
const paymentsTabValue = paymentStats
|
||||
? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth
|
||||
? (paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth) ?? 0
|
||||
: 0;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, FormResponse, Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -17,6 +20,16 @@ function fuzzyMatch(query: string, target: string): boolean {
|
||||
return tokens.every(tok => t.includes(tok));
|
||||
}
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
|
||||
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
|
||||
const totalDueFor = (r: Registration) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
return sum + unit * (opt.quantity || 0);
|
||||
}, 0);
|
||||
const totalPaidFor = (r: Registration) => (r.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
export default function AdminRegistrationsPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -28,8 +41,8 @@ export default function AdminRegistrationsPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
@@ -43,48 +56,48 @@ export default function AdminRegistrationsPage() {
|
||||
|
||||
// Expanded rows + form responses cache
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [formResponses, setFormResponses] = useState<Record<string, any[]>>({});
|
||||
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
|
||||
const [loadingForms, setLoadingForms] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadRegistrations = async () => {
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const list = Array.isArray(regs) ? regs.sort((a, b) => {
|
||||
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
|
||||
if (eventCompare !== 0) return eventCompare;
|
||||
return (a.user?.name || a.userId).localeCompare(b.user?.name || b.userId);
|
||||
}) : [];
|
||||
setRegistrations(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load registrations");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load registrations");
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (includePastEvents) params.set("includePast", "true");
|
||||
if (includeInactiveEvents) params.set("includeInactive", "true");
|
||||
const qs = params.toString() ? `?${params.toString()}` : "";
|
||||
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
} catch {}
|
||||
};
|
||||
}, [token, includePastEvents, includeInactiveEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRegistrations();
|
||||
}, [token]);
|
||||
}, [loadRegistrations]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [token, includePastEvents, includeInactiveEvents]);
|
||||
}, [loadEvents]);
|
||||
|
||||
const toggleExpand = async (reg: any) => {
|
||||
const toggleExpand = async (reg: Registration) => {
|
||||
const id = reg.id;
|
||||
const next = new Set(expanded);
|
||||
if (next.has(id)) {
|
||||
@@ -98,8 +111,8 @@ export default function AdminRegistrationsPage() {
|
||||
if (!formResponses[id] && !loadingForms.has(id)) {
|
||||
setLoadingForms(prev => new Set(prev).add(id));
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const items = Array.isArray(res?.items) ? res.items : (Array.isArray(res) ? res : []);
|
||||
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
setFormResponses(prev => ({ ...prev, [id]: items }));
|
||||
} catch {
|
||||
setFormResponses(prev => ({ ...prev, [id]: [] }));
|
||||
@@ -109,7 +122,7 @@ export default function AdminRegistrationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRegistration = async (reg: any) => {
|
||||
const cancelRegistration = async (reg: Registration) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
|
||||
@@ -117,27 +130,27 @@ export default function AdminRegistrationsPage() {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
|
||||
setInfo("Registration cancelled");
|
||||
await loadRegistrations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to cancel registration");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to cancel registration");
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (reg: any, status: string) => {
|
||||
const updateStatus = async (reg: Registration, status: string) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
|
||||
setInfo("Status updated");
|
||||
await loadRegistrations();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update status");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update status");
|
||||
}
|
||||
};
|
||||
|
||||
const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]);
|
||||
const eventIds = useMemo(() => new Set(events.map((ev) => ev.id)), [events]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return registrations.filter((r: any) => {
|
||||
return registrations.filter((r) => {
|
||||
if (!eventIds.has(r.eventId)) return false;
|
||||
if (eventFilter && r.eventId !== eventFilter) return false;
|
||||
if (statusFilter && r.status !== statusFilter) return false;
|
||||
@@ -145,10 +158,10 @@ export default function AdminRegistrationsPage() {
|
||||
const haystack = [
|
||||
r.id, r.user?.name, r.user?.email, r.user?.phoneNumber,
|
||||
r.userId, r.event?.title, r.eventId, r.status,
|
||||
].map((x: any) => String(x || "")).join(" ");
|
||||
].map((x) => String(x || "")).join(" ");
|
||||
return fuzzyMatch(query.trim(), haystack);
|
||||
});
|
||||
}, [registrations, query, eventFilter, statusFilter]);
|
||||
}, [registrations, query, eventFilter, statusFilter, eventIds]);
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
if (s === "paid") return "text-green-700 bg-green-50";
|
||||
@@ -158,15 +171,6 @@ export default function AdminRegistrationsPage() {
|
||||
return "text-gray-700 bg-gray-50";
|
||||
};
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
|
||||
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
|
||||
const totalDueFor = (r: any) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
return sum + unit * (opt.quantity || 0);
|
||||
}, 0);
|
||||
const totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
|
||||
|
||||
// Aggregate stats across the currently filtered registrations — counts by status, plus
|
||||
// revenue/outstanding totals (cancelled registrations are excluded from the money totals
|
||||
@@ -175,7 +179,7 @@ export default function AdminRegistrationsPage() {
|
||||
const counts: Record<string, number> = {};
|
||||
let totalRevenue = 0;
|
||||
let totalOutstanding = 0;
|
||||
filtered.forEach((r: any) => {
|
||||
filtered.forEach((r) => {
|
||||
counts[r.status] = (counts[r.status] || 0) + 1;
|
||||
if (r.status === "cancelled") return;
|
||||
const due = totalDueFor(r);
|
||||
@@ -194,11 +198,11 @@ export default function AdminRegistrationsPage() {
|
||||
<ClipboardList className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Manage Registrations</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Manage Registrations</h1>
|
||||
<p className="text-sm text-gray-500">{registrations.length} registration{registrations.length !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
@@ -275,7 +279,7 @@ export default function AdminRegistrationsPage() {
|
||||
|
||||
<div className="border rounded-xl bg-white shadow-sm">
|
||||
<ul className="divide-y text-sm">
|
||||
{filtered.map((r: any) => {
|
||||
{filtered.map((r) => {
|
||||
const totalDue = totalDueFor(r);
|
||||
const totalPaid = totalPaidFor(r);
|
||||
const outstanding = Math.max(totalDue - totalPaid, 0);
|
||||
@@ -336,7 +340,7 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.registrationOptions.map((opt: any) => {
|
||||
{(r.registrationOptions || []).map((opt) => {
|
||||
// A line can span multiple price tranches (e.g. tickets bought
|
||||
// before and after an early-bird tier expired) — show one row per
|
||||
// tranche so its own price/tier status is accurate, not blended.
|
||||
@@ -356,7 +360,7 @@ export default function AdminRegistrationsPage() {
|
||||
{opt.eventOption?.name || opt.eventOptionId}
|
||||
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
|
||||
</div>
|
||||
{tranches.map((t: any, idx: number) => {
|
||||
{tranches.map((t, idx) => {
|
||||
const unit = Number(t.priceSnapshot || 0);
|
||||
return (
|
||||
<div key={t.id || idx} className="text-gray-500">
|
||||
@@ -382,7 +386,7 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="text-xs text-gray-400">No payments recorded.</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.payments.map((p: any) => (
|
||||
{(r.payments || []).map((p) => (
|
||||
<div key={p.id} className="bg-white border rounded p-2 text-xs">
|
||||
<div className="font-medium">
|
||||
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
|
||||
@@ -409,11 +413,11 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="text-xs text-gray-400">No form responses submitted.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{responses.map((resp: any, idx: number) => (
|
||||
{responses.map((resp, idx) => (
|
||||
<div key={resp.id || idx} className="bg-white border rounded p-2">
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Response #{idx + 1}</div>
|
||||
<div className="grid sm:grid-cols-2 gap-1.5">
|
||||
{(resp.answers || []).map((a: any) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<div key={a.id} className="bg-gray-50 border rounded p-1.5 text-xs">
|
||||
<div className="text-[10px] text-gray-500">{a.field?.label || a.fieldId}</div>
|
||||
<div className="font-medium">{a.value}</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
|
||||
@@ -13,6 +13,8 @@ import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
|
||||
|
||||
@@ -84,7 +86,6 @@ export default function SiteSettingsPage() {
|
||||
|
||||
function SiteSettingsPageInner() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { reload: reloadSettings } = useSiteSettings();
|
||||
|
||||
@@ -178,9 +179,9 @@ function SiteSettingsPageInner() {
|
||||
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
||||
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
|
||||
})
|
||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||
.catch((e: unknown) => setResult({ ok: false, message: errorMessage(e) || "Failed to load settings" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
}, [token]);
|
||||
}, [token, setResult]);
|
||||
|
||||
// Clear result when switching tabs
|
||||
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
|
||||
@@ -194,8 +195,8 @@ function SiteSettingsPageInner() {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -253,8 +254,8 @@ function SiteSettingsPageInner() {
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -278,8 +279,8 @@ function SiteSettingsPageInner() {
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Reset to defaults." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to reset" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to reset" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -312,8 +313,8 @@ function SiteSettingsPageInner() {
|
||||
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -347,8 +348,9 @@ function SiteSettingsPageInner() {
|
||||
},
|
||||
});
|
||||
setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." });
|
||||
} catch (e: any) {
|
||||
setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw });
|
||||
} catch (e) {
|
||||
const raw = e instanceof ApiError && typeof e.data?.raw === "string" ? e.data.raw : undefined;
|
||||
setSmtpTestResult({ ok: false, message: errorMessage(e) || "SMTP test failed", raw });
|
||||
} finally {
|
||||
setSmtpTesting(false);
|
||||
}
|
||||
@@ -364,15 +366,10 @@ function SiteSettingsPageInner() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Site Settings</h1>
|
||||
<BackLink href="/dashboard/admin" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Site Settings</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200"
|
||||
onClick={() => router.push("/dashboard/admin")}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
@@ -875,7 +872,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
|
||||
setStatus(res.status ?? null);
|
||||
setStatusMsg(res.message ?? null);
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
await fetchConfig();
|
||||
setStatus("FAILED");
|
||||
setStatusMsg(null);
|
||||
@@ -919,12 +916,12 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setBusy(action);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
|
||||
const res = await apiFetch<{ message?: string }>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
|
||||
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
|
||||
await fetchStatus();
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
let msg = e?.message || `${action} failed.`;
|
||||
} catch (e) {
|
||||
let msg = errorMessage(e) || `${action} failed.`;
|
||||
try { msg = JSON.parse(msg)?.message || msg; } catch {}
|
||||
await fetchConfig();
|
||||
if (!msg.includes("SESSION_NOT_FOUND")) {
|
||||
@@ -947,8 +944,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: inputToken.trim(), instanceId: "" } });
|
||||
setInputToken("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save token." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
@@ -965,8 +962,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save Instance ID." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
@@ -976,11 +973,11 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
|
||||
const res = await apiFetch<{ message?: string }>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
|
||||
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to create instance." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
@@ -1224,8 +1221,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setInputToken("");
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to update." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { beginImpersonation } from "@/lib/impersonation";
|
||||
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
|
||||
import { Users as UsersIcon } from "lucide-react";
|
||||
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -27,15 +30,6 @@ type Role = typeof roleOptions[number];
|
||||
|
||||
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
|
||||
|
||||
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
|
||||
function fuzzyMatch(query: string, target: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
const t = target.toLowerCase();
|
||||
if (t.includes(q)) return true;
|
||||
// token-based: all tokens must appear somewhere
|
||||
const tokens = q.split(/\s+/).filter(Boolean);
|
||||
return tokens.every(tok => t.includes(tok));
|
||||
}
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
@@ -98,26 +92,26 @@ export default function AdminUsersPage() {
|
||||
setFetching(true);
|
||||
try {
|
||||
const url = buildQuery(p, ps);
|
||||
const res = await apiFetch<any>(url, { authToken: token });
|
||||
const res = await apiFetch<Paginated<UserItem>>(url, { authToken: token });
|
||||
setUsers(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setTotalPages(res?.pages ?? 1);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load users");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load users");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, pageSize, setError]);
|
||||
|
||||
// Initial load + reload on filter change
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
|
||||
// Initial load, and back to page 1 whenever a filter or the page size changes
|
||||
// (loadUsers is recreated when any of them do).
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, loadUsers]);
|
||||
|
||||
const goToPage = (p: number) => loadUsers(p);
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPageSize(newSize);
|
||||
loadUsers(1, newSize);
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
@@ -129,7 +123,7 @@ export default function AdminUsersPage() {
|
||||
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
|
||||
try {
|
||||
setCreating(true);
|
||||
const created = await apiFetch<any>("/api/users", {
|
||||
const created = await apiFetch<{ id?: string }>("/api/users", {
|
||||
method: "POST",
|
||||
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
|
||||
});
|
||||
@@ -141,8 +135,8 @@ export default function AdminUsersPage() {
|
||||
resetCreateForm();
|
||||
setCreateOpen(false);
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create user");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -155,7 +149,7 @@ export default function AdminUsersPage() {
|
||||
if (!editingId) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload: any = {
|
||||
const payload: Partial<UserItem> & { password?: string } = {
|
||||
name: editData.name,
|
||||
email: editData.email,
|
||||
role: editData.role,
|
||||
@@ -172,8 +166,8 @@ export default function AdminUsersPage() {
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save user");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -183,8 +177,8 @@ export default function AdminUsersPage() {
|
||||
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to revoke sessions");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to revoke sessions");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,10 +191,10 @@ export default function AdminUsersPage() {
|
||||
setImpBusy(true);
|
||||
setImpError(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
beginImpersonation(res);
|
||||
} catch (e: any) {
|
||||
setImpError(e?.message || "Couldn't start impersonation");
|
||||
} catch (e) {
|
||||
setImpError(errorMessage(e) || "Couldn't start impersonation");
|
||||
setImpBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -215,8 +209,8 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reset two-factor authentication");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reset two-factor authentication");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,8 +219,8 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to deactivate user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to deactivate user");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,8 +229,8 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to delete user data");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to delete user data");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -266,12 +260,12 @@ export default function AdminUsersPage() {
|
||||
<UsersIcon className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">User Management</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">User Management</h1>
|
||||
<p className="text-sm text-gray-500">{total} user{total !== 1 ? "s" : ""} total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={() => setCreateOpen(v => !v)}>
|
||||
{createOpen ? "Close" : "Create user"}
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, Ticket as TicketRecord } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
@@ -17,9 +20,9 @@ function EventTicketsContent() {
|
||||
const paramEventId = params.get("eventId") || "";
|
||||
const paramUserId = params.get("userId") || "";
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(paramEventId);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -36,7 +39,7 @@ function EventTicketsContent() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter((ev) => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -47,25 +50,27 @@ function EventTicketsContent() {
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
);
|
||||
setEvents(active);
|
||||
if (!paramEventId && active.length > 0) {
|
||||
setEventId(active[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(active[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
}, [token, setError]);
|
||||
|
||||
// Sync params → state
|
||||
// No event in the URL: default to the first upcoming one once the list arrives.
|
||||
useEffect(() => {
|
||||
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
|
||||
if (paramUserId && paramUserId !== selectedUserId)
|
||||
setSelectedUserId(paramUserId);
|
||||
if (paramEventId || events.length === 0) return;
|
||||
setEventId(events[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(events[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}, [events, paramEventId, router, selectedUserId]);
|
||||
|
||||
// Sync params → state (setting the value it already has is a no-op)
|
||||
useEffect(() => {
|
||||
if (paramEventId) setEventId(paramEventId);
|
||||
if (paramUserId) setSelectedUserId(paramUserId);
|
||||
}, [paramEventId, paramUserId]);
|
||||
|
||||
// Load tickets when event changes
|
||||
@@ -75,18 +80,18 @@ function EventTicketsContent() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await apiFetch<any[]>(
|
||||
const list = await apiFetch<TicketRecord[]>(
|
||||
`/api/tickets/event/${encodeURIComponent(eventId)}`,
|
||||
{ authToken: token }
|
||||
);
|
||||
setTickets(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load tickets");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load tickets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, eventId]);
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
// Build variants list for filter (only variants present in current event's tickets)
|
||||
const variantsList = useMemo(() => {
|
||||
@@ -138,7 +143,7 @@ function EventTicketsContent() {
|
||||
}, [tickets, eventId, selectedUserId, selectedVariantId]);
|
||||
|
||||
// Printing helpers
|
||||
const buildTicketHtmlCard = (t: any) => {
|
||||
const buildTicketHtmlCard = (t: TicketRecord) => {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
@@ -216,7 +221,7 @@ function EventTicketsContent() {
|
||||
w.focus();
|
||||
};
|
||||
|
||||
const printTickets = (ticketList: any[]) => {
|
||||
const printTickets = (ticketList: TicketRecord[]) => {
|
||||
if (!ticketList || ticketList.length === 0) return;
|
||||
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
|
||||
const PAGE_SIZE = 8;
|
||||
@@ -235,14 +240,11 @@ function EventTicketsContent() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Ticket className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Event Tickets</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Event Tickets</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
View and print tickets for an event.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { Users } from "lucide-react";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Member = {
|
||||
id: string; name: string; gender: string | null; dateOfBirth: string | null; phone: string | null;
|
||||
@@ -25,7 +25,6 @@ const ageOn = (dob: string | null, on: string) => {
|
||||
/** Groups the signed-in staff member leads (rooms, small groups, buses) and who's in them. */
|
||||
export default function MyGroupsPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<{ medicalShown: boolean; groups: MyGroup[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -42,7 +41,7 @@ export default function MyGroupsPage() {
|
||||
<Users className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">My groups</h1>
|
||||
<p className="text-sm text-gray-500">Rooms, small groups and buses you lead</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -9,6 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
@@ -35,31 +36,31 @@ export default function StaffDashboardPage() {
|
||||
// (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when
|
||||
// a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows
|
||||
// on the very first load — together these stop the stats panel from flickering.
|
||||
const [stats, setStats] = useStableState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
||||
const [stats, setStats] = useStableState<ScanStats | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const data = await apiFetch<any>("/api/stats/staff", { authToken: token });
|
||||
const data = await apiFetch<DashboardStats>("/api/stats/staff", { authToken: token });
|
||||
setStats(data.scanStats);
|
||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setStats, setRecentScans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 10s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -104,7 +105,7 @@ export default function StaffDashboardPage() {
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
@@ -122,9 +123,9 @@ export default function StaffDashboardPage() {
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
||||
{stats?.byStaff?.length > 0 ? (
|
||||
{stats && stats.byStaff?.length > 0 ? (
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QrCode } from "lucide-react";
|
||||
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
|
||||
import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
|
||||
import type { SnapshotTicket } from "@/lib/offlineScan";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, RecentScan, ScanStats, Section } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
|
||||
// scans build the same shape from the downloaded ticket list.
|
||||
type ScanTicket = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
eventOptionId?: string;
|
||||
quantity?: number;
|
||||
consentStatus?: string;
|
||||
event?: { id?: string; title?: string } | null;
|
||||
registrationOption?: {
|
||||
eventOption?: { id?: string; name?: string } | null;
|
||||
variant?: { name?: string } | null;
|
||||
registration?: { user?: { name?: string } | null } | null;
|
||||
} | null;
|
||||
attendee?: { name: string } | null;
|
||||
user?: { name?: string; email?: string } | null;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
};
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
const scannerRef = useRef<QRScannerHandle>(null);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: ScanTicket } | null>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const { token, user } = useAuth();
|
||||
const offline = useOfflineScanning(token);
|
||||
const [recentScans, setRecentScans] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[]>([]);
|
||||
const [stats, setStats] = useState<ScanStats | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState<boolean>(false);
|
||||
const [successModal, setSuccessModal] = useState<{
|
||||
optionName: string;
|
||||
@@ -36,7 +55,7 @@ export default function TicketScanningPage() {
|
||||
// Confirm step state
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
qrCode: string;
|
||||
ticket: any;
|
||||
ticket: ScanTicket;
|
||||
remaining: number;
|
||||
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
|
||||
} | null>(null);
|
||||
@@ -47,11 +66,11 @@ export default function TicketScanningPage() {
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
|
||||
const [loadingSections, setLoadingSections] = useState(false);
|
||||
|
||||
@@ -61,10 +80,10 @@ export default function TicketScanningPage() {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
|
||||
setAllEvents(sorted);
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
@@ -90,7 +109,7 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
try {
|
||||
setLoadingSections(true);
|
||||
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
setSections(Array.isArray(list) ? list : []);
|
||||
setSelectedSectionId("all");
|
||||
} catch {
|
||||
@@ -120,7 +139,6 @@ export default function TicketScanningPage() {
|
||||
}, [selectedEventId, selectedSectionId]);
|
||||
|
||||
async function handleResult(text: string) {
|
||||
setLastResult(text);
|
||||
setError(null);
|
||||
setScanInfo(null);
|
||||
try {
|
||||
@@ -157,11 +175,11 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Always preview first to validate the ticket before showing confirm step
|
||||
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
|
||||
let preview: any;
|
||||
let preview: ScanTicket;
|
||||
try {
|
||||
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
} catch (pe: any) {
|
||||
setErrorModal(pe?.message || 'Failed to validate ticket');
|
||||
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
} catch (pe) {
|
||||
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,7 +200,7 @@ export default function TicketScanningPage() {
|
||||
return;
|
||||
}
|
||||
const section = sections.find(s => s.id === selectedSectionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o) => o.eventOptionId);
|
||||
if (!allowedOptionIds.includes(ticketOptionId)) {
|
||||
setErrorModal("This ticket is not valid for this section");
|
||||
return;
|
||||
@@ -192,7 +210,7 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Compute remaining qty
|
||||
const ticketQty = preview?.quantity || 1;
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = ticketQty - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
@@ -204,8 +222,8 @@ export default function TicketScanningPage() {
|
||||
setConfirmQty(remaining);
|
||||
setConfirmQtyRaw(String(remaining));
|
||||
setConfirmModal({ qrCode: text, ticket: preview, remaining });
|
||||
} catch (e: any) {
|
||||
setErrorModal(e?.message || "Failed to scan ticket");
|
||||
} catch (e) {
|
||||
setErrorModal(errorMessage(e) || "Failed to scan ticket");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,9 +257,9 @@ export default function TicketScanningPage() {
|
||||
if (offlineTicket) {
|
||||
try {
|
||||
await offline.scanLocally(offlineTicket, qty);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSuccessModal(null);
|
||||
setErrorModal(e?.message || "Couldn't save the scan on this device");
|
||||
setErrorModal(errorMessage(e) || "Couldn't save the scan on this device");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -251,7 +269,7 @@ export default function TicketScanningPage() {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
|
||||
}`;
|
||||
await apiFetch<any>(url, {
|
||||
await apiFetch(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -262,10 +280,10 @@ export default function TicketScanningPage() {
|
||||
},
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSuccessModal(null);
|
||||
setScanInfo(null);
|
||||
const raw = e?.message || "Failed to scan ticket";
|
||||
const raw = errorMessage(e) || "Failed to scan ticket";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const msg: string | undefined = parsed?.message;
|
||||
@@ -280,28 +298,27 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecent = async () => {
|
||||
const loadRecent = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
const list = await apiFetch<RecentScan[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
setRecentScans(list);
|
||||
} catch {}
|
||||
};
|
||||
const loadStats = async () => {
|
||||
}, [token]);
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingStats(true);
|
||||
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
const s = await apiFetch<ScanStats>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
setStats(s);
|
||||
} catch {} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
|
||||
}, [token]);
|
||||
const refreshStatsAndRecent = useCallback(() => { loadRecent(); loadStats(); }, [loadRecent, loadStats]);
|
||||
useEffect(() => {
|
||||
refreshStatsAndRecent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
}, [refreshStatsAndRecent]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4">
|
||||
@@ -312,9 +329,11 @@ export default function TicketScanningPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<QrCode className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Ticket Scanning</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Use the button to start/stop scanning. The back camera will be used when available.
|
||||
@@ -403,11 +422,11 @@ export default function TicketScanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.byStaff?.length > 0 && (
|
||||
{stats && stats.byStaff?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
@@ -419,7 +438,7 @@ export default function TicketScanningPage() {
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
|
||||
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2 bg-white">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || 'Event'}</div>
|
||||
@@ -533,8 +552,8 @@ export default function TicketScanningPage() {
|
||||
<div className="mt-2">
|
||||
<div className="font-medium">Usages:</div>
|
||||
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
|
||||
{alreadyUsedModal.ticket.usages.map((u: any) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
{alreadyUsedModal.ticket.usages.map((u) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{(u.quantityRedeemed ?? 1) > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,24 @@ import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
|
||||
// GET /api/allocations/sets/:id/lists — medical/emergency only when ?medical=true and consent was given.
|
||||
type ListMember = {
|
||||
groupId: string; id: string; name: string; gender: string | null; dateOfBirth: string | null;
|
||||
phone: string | null; parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
|
||||
medical?: Record<string, unknown> | null; emergency?: Record<string, unknown> | null;
|
||||
};
|
||||
type SetLists = {
|
||||
set: { id: string; name: string };
|
||||
event: { id: string; title: string; startDate: string };
|
||||
groups: { id: string; name: string; capacity: number | null; gender: string | null; leader: string | null; members: ListMember[] }[];
|
||||
};
|
||||
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
|
||||
|
||||
const UNASSIGNED = "unassigned";
|
||||
@@ -67,7 +81,7 @@ export default function AllocationsPage() {
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [setId, setSetId] = useState("");
|
||||
@@ -80,7 +94,7 @@ export default function AllocationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
@@ -96,8 +110,8 @@ export default function AllocationsPage() {
|
||||
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
setBoard(b);
|
||||
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load allocations");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load allocations");
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
@@ -126,8 +140,8 @@ export default function AllocationsPage() {
|
||||
}));
|
||||
try {
|
||||
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't move them");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't move them");
|
||||
load();
|
||||
}
|
||||
};
|
||||
@@ -138,8 +152,8 @@ export default function AllocationsPage() {
|
||||
const msg = await fn();
|
||||
if (msg) setInfo(msg);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -160,19 +174,19 @@ export default function AllocationsPage() {
|
||||
const printLists = async (master: boolean, medical: boolean) => {
|
||||
if (!current || !board) return;
|
||||
try {
|
||||
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const data = await apiFetch<SetLists>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
const row = (m: ListMember) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
|
||||
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
|
||||
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
|
||||
let body = "";
|
||||
if (master) {
|
||||
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
const all = data.groups.flatMap((g) => g.members.map((m) => ({ ...m, group: g.name }))).sort((a, b) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
} else {
|
||||
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
body = data.groups.map((g) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
|
||||
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
|
||||
}
|
||||
@@ -182,8 +196,8 @@ export default function AllocationsPage() {
|
||||
section{page-break-after:always} section:last-child{page-break-after:auto}
|
||||
</style></head><body>${body}<script>window.onload=function(){window.print()}</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't load the lists");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't load the lists");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,7 +208,7 @@ export default function AllocationsPage() {
|
||||
<BedDouble className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Rooms & groups</h1>
|
||||
<p className="text-sm text-gray-500">Put attendees into rooms, small groups, teams or buses</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -10,6 +10,16 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
// GET /api/tickets/event/:id — a bare list (older builds wrapped it in `tickets` or `data`).
|
||||
type EventTicketsResponse = TicketRecord[] | { tickets?: TicketRecord[]; data?: TicketRecord[] };
|
||||
const ticketsFrom = (res: EventTicketsResponse | null | undefined): TicketRecord[] =>
|
||||
Array.isArray(res) ? res : (res?.tickets || res?.data || []);
|
||||
|
||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||
|
||||
@@ -31,7 +41,7 @@ const MODE_ICONS: Record<Mode, LucideIcon> = {
|
||||
|
||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
||||
function fuzzyFilterRegs(allRegs: Registration[], search: string): Registration[] {
|
||||
if (!search) return allRegs;
|
||||
if (search.length === 1) {
|
||||
const q = search.toLowerCase();
|
||||
@@ -50,37 +60,24 @@ function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function effectiveOptionUnit(opt: any): number {
|
||||
const base = opt.price || 0;
|
||||
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId);
|
||||
if (!tiers.length) return base;
|
||||
const now = new Date();
|
||||
const hit = tiers
|
||||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t: any) => now < t.deadline)
|
||||
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
|
||||
return hit.length ? hit[0].price : base;
|
||||
// Total due on a registration: the backend's tranche-aware total when present, else
|
||||
// the sum of its lines at their snapshot (or current list) prices.
|
||||
function registrationTotal(r: Registration): number {
|
||||
return r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? 0);
|
||||
return sum + price * (opt.quantity || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function effectiveVariantUnit(opt: any, variant: any): number {
|
||||
const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0;
|
||||
const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : [];
|
||||
const vtiers = allTiers.filter((t: any) => t.variantId === variant.id);
|
||||
const tiers = vtiers.length ? vtiers : allTiers.filter((t: any) => !t.variantId);
|
||||
if (!tiers.length) return base;
|
||||
const now = new Date();
|
||||
const hit = tiers
|
||||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t: any) => now < t.deadline)
|
||||
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
|
||||
return hit.length ? hit[0].price : base;
|
||||
}
|
||||
const totalPaidOn = (r: Registration) => (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
function initQuantities(eventOptions: any[]): Record<string, number> {
|
||||
function initQuantities(eventOptions: EventOption[]): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
(eventOptions || []).forEach((o: any) => {
|
||||
(eventOptions || []).forEach((o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
(o.variants as any[]).forEach((v: any) => { map[`${o.id}::${v.id}`] = 0; });
|
||||
(o.variants || []).forEach((v) => { map[`${o.id}::${v.id}`] = 0; });
|
||||
} else {
|
||||
map[o.id] = 0;
|
||||
}
|
||||
@@ -88,7 +85,7 @@ function initQuantities(eventOptions: any[]): Record<string, number> {
|
||||
return map;
|
||||
}
|
||||
|
||||
function ticketLabel(t: any): string {
|
||||
function ticketLabel(t: TicketRecord): string {
|
||||
const opt = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variant = t.registrationOption?.variant?.name;
|
||||
return variant ? `${opt} — ${variant}` : opt;
|
||||
@@ -97,12 +94,12 @@ function ticketLabel(t: any): string {
|
||||
export default function AtTheDoorPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
|
||||
const [eventOptions, setEventOptions] = useState<any[]>([]);
|
||||
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
|
||||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||||
const [pendingUser, setPendingUser] = useState<any | null>(null);
|
||||
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
@@ -110,7 +107,7 @@ export default function AtTheDoorPage() {
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
|
||||
const [pendingEditReg, setPendingEditReg] = useState<Registration | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
// Already-issued ticket quantity per option/variant, keyed like `quantities` — a
|
||||
@@ -119,7 +116,7 @@ export default function AtTheDoorPage() {
|
||||
const [minQuantities, setMinQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
const [showDonationModal, setShowDonationModal] = useState(false);
|
||||
const [donationUser, setDonationUser] = useState<any | null>(null);
|
||||
const [donationUser, setDonationUser] = useState<UserSummary | null>(null);
|
||||
|
||||
const [showNewAttendeeModal, setShowNewAttendeeModal] = useState(false);
|
||||
const [newAttendeeSeed, setNewAttendeeSeed] = useState("");
|
||||
@@ -128,7 +125,7 @@ export default function AtTheDoorPage() {
|
||||
if (!token) return;
|
||||
(async () => {
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
|
||||
const active = (evs || []).filter(ev => {
|
||||
@@ -158,7 +155,7 @@ export default function AtTheDoorPage() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ev = await apiFetch(`/api/events/${eventId}`, {
|
||||
const ev = await apiFetch<Event>(`/api/events/${eventId}`, {
|
||||
authToken: token
|
||||
});
|
||||
|
||||
@@ -180,19 +177,19 @@ export default function AtTheDoorPage() {
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading]);
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [mode, setMode] = useState<Mode>("registration");
|
||||
|
||||
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
|
||||
const [activeRegistration, setActiveRegistration] = useState<Registration | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
|
||||
const handleRegistrationCreated = (registration: any) => {
|
||||
const handleRegistrationCreated = (registration: Registration) => {
|
||||
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
|
||||
};
|
||||
|
||||
const handleRegistrationSelected = (registration: any) => {
|
||||
const handleRegistrationSelected = (registration: Registration) => {
|
||||
setActiveRegistration(registration);
|
||||
};
|
||||
|
||||
@@ -227,8 +224,8 @@ export default function AtTheDoorPage() {
|
||||
for (const [key, minQty] of Object.entries(minQuantities)) {
|
||||
if (minQty > 0 && (quantities[key] || 0) < minQty) {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
const opt = eventOptions.find((o: any) => o.id === eventOptionId);
|
||||
const variant = variantId ? (opt?.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const opt = eventOptions.find((o) => o.id === eventOptionId);
|
||||
const variant = variantId ? (opt?.variants || []).find((v) => v.id === variantId) : null;
|
||||
const label = variant ? `${opt?.name || "item"} (${variant.name})` : (opt?.name || "item");
|
||||
setError(`Cannot reduce "${label}" below the ${minQty} already issued`);
|
||||
return;
|
||||
@@ -244,7 +241,7 @@ export default function AtTheDoorPage() {
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
// Edit existing registration — replace options via PUT
|
||||
let res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
let res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { options: opts }
|
||||
@@ -253,14 +250,14 @@ export default function AtTheDoorPage() {
|
||||
if (doorCoupon) {
|
||||
// Applied after the items are saved, so it's worked out on the final items.
|
||||
try {
|
||||
res = await apiFetch(`/api/registrations/${pendingEditReg.id}/coupon`, {
|
||||
res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/coupon`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { code: doorCoupon.code },
|
||||
});
|
||||
message = `Registration updated — code ${doorCoupon.code} applied`;
|
||||
} catch (ce: any) {
|
||||
message = `Registration updated, but the code wasn't applied: ${ce?.message || "unknown error"}`;
|
||||
} catch (ce) {
|
||||
message = `Registration updated, but the code wasn't applied: ${errorMessage(ce) || "unknown error"}`;
|
||||
}
|
||||
}
|
||||
setShowOptionsModal(false);
|
||||
@@ -269,7 +266,7 @@ export default function AtTheDoorPage() {
|
||||
handleRegistrationSelected(res);
|
||||
} else if (pendingUser) {
|
||||
// New attendee
|
||||
const res = await apiFetch("/api/registrations/manual", {
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -291,8 +288,8 @@ export default function AtTheDoorPage() {
|
||||
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
|
||||
handleRegistrationCreated(res);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed");
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
@@ -311,7 +308,7 @@ export default function AtTheDoorPage() {
|
||||
eventOptions.forEach(o => {
|
||||
if (!o.isMainTicket) return;
|
||||
if ((o.variants || []).length > 0) {
|
||||
qtyMap[`${o.id}::${(o.variants as any[])[0].id}`] = 1;
|
||||
qtyMap[`${o.id}::${(o.variants || [])[0].id}`] = 1;
|
||||
} else {
|
||||
qtyMap[o.id] = 1;
|
||||
}
|
||||
@@ -332,27 +329,27 @@ export default function AtTheDoorPage() {
|
||||
// Re-fetches the registration fresh rather than trusting the (possibly up to 5-minutes-stale,
|
||||
// see DoorRegistrationPanel's polling interval) cached search-result snapshot, so the
|
||||
// already-issued-ticket floor below is always computed from current data.
|
||||
const handleEditRegistration = async (reg: any) => {
|
||||
const handleEditRegistration = async (reg: Registration) => {
|
||||
let freshReg = reg;
|
||||
try {
|
||||
freshReg = await apiFetch<any>(`/api/registrations/${reg.id}`, { authToken: token });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load latest registration data");
|
||||
freshReg = await apiFetch<Registration>(`/api/registrations/${reg.id}`, { authToken: token });
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load latest registration data");
|
||||
return;
|
||||
}
|
||||
|
||||
const qtyMap = initQuantities(eventOptions);
|
||||
const regOptions = freshReg.registrationOptions || freshReg.options || [];
|
||||
regOptions.forEach((ro: any) => {
|
||||
const regOptions = freshReg.registrationOptions || [];
|
||||
regOptions.forEach((ro) => {
|
||||
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
|
||||
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
|
||||
});
|
||||
|
||||
// Already-issued ticket quantity per option/variant — floor for the edit below
|
||||
const minQtyMap: Record<string, number> = {};
|
||||
regOptions.forEach((ro: any) => {
|
||||
regOptions.forEach((ro) => {
|
||||
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
|
||||
const issuedQty = (ro.tickets || []).reduce((s: number, t: any) => s + (t.quantity || 0), 0);
|
||||
const issuedQty = (ro.tickets || []).reduce((s, t) => s + (t.quantity || 0), 0);
|
||||
if (issuedQty > 0) minQtyMap[key] = (minQtyMap[key] || 0) + issuedQty;
|
||||
});
|
||||
|
||||
@@ -364,7 +361,7 @@ export default function AtTheDoorPage() {
|
||||
setShowOptionsModal(true);
|
||||
};
|
||||
|
||||
const handlePaymentCaptured = async (registration?: any, partial?: boolean) => {
|
||||
const handlePaymentCaptured = async (registration?: Registration, partial?: boolean) => {
|
||||
|
||||
if (partial && registration) {
|
||||
setActiveRegistration(registration);
|
||||
@@ -384,7 +381,7 @@ export default function AtTheDoorPage() {
|
||||
setMode("checkin");
|
||||
};
|
||||
|
||||
const handleDonation = (user?: any) => {
|
||||
const handleDonation = (user?: UserSummary) => {
|
||||
setDonationUser(user || null); // null = anonymous
|
||||
setShowDonationModal(true);
|
||||
};
|
||||
@@ -465,12 +462,10 @@ export default function AtTheDoorPage() {
|
||||
<DoorRegistrationPanel
|
||||
token={token}
|
||||
eventId={eventId}
|
||||
onCreated={handleRegistrationCreated}
|
||||
onSelected={handleRegistrationSelected}
|
||||
onEditRegistration={handleEditRegistration}
|
||||
onNewAttendee={handleShowNewAttendee}
|
||||
onDonation={handleDonation}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -492,7 +487,7 @@ export default function AtTheDoorPage() {
|
||||
)}
|
||||
|
||||
{mode === "refund" && (
|
||||
<DoorRefundPanel token={token} eventId={eventId} setError={setError} setInfo={setInfo} />
|
||||
<DoorRefundPanel token={token} eventId={eventId} setInfo={setInfo} />
|
||||
)}
|
||||
<OptionsModal
|
||||
open={showOptionsModal}
|
||||
@@ -504,7 +499,7 @@ export default function AtTheDoorPage() {
|
||||
onConfirm={confirmRegistration}
|
||||
confirming={confirming}
|
||||
isEdit={!!pendingEditReg}
|
||||
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
|
||||
totalPaid={pendingEditReg ? totalPaidOn(pendingEditReg) : 0}
|
||||
eventId={eventId}
|
||||
token={token}
|
||||
coupon={doorCoupon}
|
||||
@@ -537,9 +532,16 @@ export default function AtTheDoorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRegistration, onNewAttendee, onDonation, setError }: any) {
|
||||
function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration, onNewAttendee, onDonation }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
onSelected: (r: Registration) => void;
|
||||
onEditRegistration: (r: Registration) => void;
|
||||
onNewAttendee: (seed: string) => void;
|
||||
onDonation: (user?: UserSummary) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [allRegs, setAllRegs] = useState<any[]>([]);
|
||||
const [allRegs, setAllRegs] = useState<Registration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load registrations for this event — once on mount/eventId change, then every 5 min
|
||||
@@ -550,7 +552,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
if (!cancelled) setAllRegs(regs || []);
|
||||
} catch {
|
||||
if (!cancelled) setAllRegs([]);
|
||||
@@ -576,7 +578,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
setAllRegs(regs || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}}
|
||||
@@ -644,7 +646,12 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
);
|
||||
}
|
||||
|
||||
function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
function DoorPaymentPanel({ token, registration, onSuccess, setError }: {
|
||||
token: string | null;
|
||||
registration: Registration | null;
|
||||
onSuccess: (registration?: Registration, partial?: boolean) => void;
|
||||
setError: (e: string | null) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("card");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -659,23 +666,10 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
const options = registration.options || registration.registrationOptions || [];
|
||||
const payments = registration.payments || [];
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? opt.price ?? 0);
|
||||
const qty = opt.quantity || 0;
|
||||
return sum + price * qty;
|
||||
}, 0);
|
||||
|
||||
const paidValue = payments.reduce(
|
||||
(sum: number, p: any) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const totalValue = registrationTotal(registration);
|
||||
const paidValue = totalPaidOn(registration);
|
||||
|
||||
const balance = Math.max(0, totalValue - paidValue);
|
||||
|
||||
@@ -698,26 +692,11 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await apiFetch(`/api/registrations/${registration.id}`, {
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, {
|
||||
authToken: token
|
||||
});
|
||||
|
||||
const updatedOptions = updated.options || updated.registrationOptions || [];
|
||||
const updatedPayments = updated.payments || [];
|
||||
|
||||
const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? opt.price ?? 0);
|
||||
return sum + price * (opt.quantity || 0);
|
||||
}, 0);
|
||||
|
||||
const paidValue = updatedPayments.reduce(
|
||||
(sum: number, p: any) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const updatedBalance = Math.max(0, totalValue - paidValue);
|
||||
const updatedBalance = Math.max(0, registrationTotal(updated) - totalPaidOn(updated));
|
||||
|
||||
if (updatedBalance === 0) {
|
||||
onSuccess(updated); // 🎯 ONLY NOW generate tickets
|
||||
@@ -725,8 +704,8 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
onSuccess(updated, true); // 🎯 partial payment flow
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Payment failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Payment failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -849,11 +828,10 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
token={token}
|
||||
registration={registration}
|
||||
maxRefund={paidValue}
|
||||
onRefunded={(updated: any) => {
|
||||
onRefunded={(updated) => {
|
||||
setShowRefund(false);
|
||||
onSuccess(updated, true);
|
||||
}}
|
||||
setError={setError}
|
||||
/>
|
||||
|
||||
<SendTicketsModal
|
||||
@@ -861,20 +839,23 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
onClose={() => setShowSendTickets(false)}
|
||||
token={token}
|
||||
registration={registration}
|
||||
setError={setError}
|
||||
setInfo={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoorTicketsPanel({ token, eventId }: any) {
|
||||
function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [allTickets, setAllTickets] = useState<any[]>([]);
|
||||
const [allTickets, setAllTickets] = useState<TicketRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendTarget, setSendTarget] = useState<any | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
const [sendTarget, setSendTarget] = useState<TicketRecord | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
// Memoised so the send modal's reset effect doesn't rerun on every render of this panel.
|
||||
const sendRegistration = useMemo(() => sendTarget ? {
|
||||
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId || "",
|
||||
user: sendTarget.user,
|
||||
} : null, [sendTarget]);
|
||||
|
||||
// Load all tickets for the event once
|
||||
useEffect(() => {
|
||||
@@ -882,8 +863,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(ticketsFrom(res));
|
||||
} catch {
|
||||
setAllTickets([]);
|
||||
} finally {
|
||||
@@ -916,8 +897,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(ticketsFrom(res));
|
||||
} catch {
|
||||
setAllTickets([]);
|
||||
} finally {
|
||||
@@ -925,7 +906,7 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
}
|
||||
};
|
||||
|
||||
const print = (ticket: any) => {
|
||||
const print = (ticket: TicketRecord) => {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const eventTitle = ticket.event?.title || ticket.eventId || "Event";
|
||||
@@ -1061,49 +1042,50 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
<TransferTicketModal
|
||||
ticket={transferTarget}
|
||||
event={transferTarget.event}
|
||||
token={token}
|
||||
token={token ?? ""}
|
||||
onClose={() => setTransferTarget(null)}
|
||||
onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }}
|
||||
/>
|
||||
)}
|
||||
{sendTarget && (
|
||||
{sendRegistration && (
|
||||
<SendTicketsModal
|
||||
open={true}
|
||||
onClose={() => setSendTarget(null)}
|
||||
token={token}
|
||||
registration={{
|
||||
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId,
|
||||
user: sendTarget.user,
|
||||
}}
|
||||
setError={() => {}}
|
||||
setInfo={() => {}}
|
||||
registration={sendRegistration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
|
||||
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
|
||||
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
registration: Registration | null;
|
||||
setError: (e: string | null) => void;
|
||||
setInfo: (s: string | null) => void;
|
||||
}) {
|
||||
const [ticketsForEvent, setTicketsForEvent] = useState<TicketRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
|
||||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
|
||||
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
const tickets = ticketsFrom(res);
|
||||
setTicketsForEvent(tickets.filter((t) => t.registrationOption?.eventOption?.isMainTicket));
|
||||
} catch {
|
||||
setTicketsForEvent([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId]);
|
||||
|
||||
useEffect(() => { load(); }, [token, eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (!registration) {
|
||||
return (
|
||||
@@ -1116,29 +1098,30 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
|
||||
const myTickets = ticketsForEvent
|
||||
.filter(t => t.registrationOption?.registration?.id === registration.id)
|
||||
.map(t => {
|
||||
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (t.quantity || 1) - totalRedeemed;
|
||||
return { ...t, totalRedeemed, remaining };
|
||||
});
|
||||
|
||||
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
|
||||
const setQtyFor = (t: any, n: number) =>
|
||||
type CheckInTicket = (typeof myTickets)[number];
|
||||
const qtyFor = (t: CheckInTicket) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
|
||||
const setQtyFor = (t: CheckInTicket, n: number) =>
|
||||
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
|
||||
|
||||
const commit = async (ticket: any) => {
|
||||
const commit = async (ticket: CheckInTicket) => {
|
||||
const qty = qtyFor(ticket);
|
||||
try {
|
||||
setSubmittingId(ticket.id);
|
||||
const res = await apiFetch<any>(
|
||||
const res = await apiFetch<{ qtyRedeemed?: number; remaining?: number }>(
|
||||
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
|
||||
{ method: "POST", authToken: token, body: { qty } }
|
||||
);
|
||||
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
|
||||
res?.remaining > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||
(res?.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||
}. Confirmation sent.`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Check-in failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Check-in failed");
|
||||
} finally {
|
||||
setSubmittingId(null);
|
||||
}
|
||||
@@ -1225,33 +1208,54 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
|
||||
);
|
||||
}
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: any) {
|
||||
type OptionsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
options: EventOption[];
|
||||
quantities: Record<string, number>;
|
||||
setQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>;
|
||||
minQuantities?: Record<string, number>;
|
||||
onConfirm: () => void;
|
||||
confirming: boolean;
|
||||
isEdit: boolean;
|
||||
totalPaid?: number;
|
||||
eventId: string;
|
||||
token: string | null;
|
||||
coupon: AppliedCoupon | null;
|
||||
setCoupon: (c: AppliedCoupon | null) => void;
|
||||
couponForUserId: string | null;
|
||||
existingCode: string | null;
|
||||
// Named-attendee editor settings, or null when the event doesn't take names.
|
||||
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
|
||||
};
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: OptionsModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const basket = Object.entries(quantities as Record<string, number>)
|
||||
const basket = Object.entries(quantities)
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
});
|
||||
|
||||
const safeOptions: any[] = options || [];
|
||||
const safeOptions: EventOption[] = options || [];
|
||||
|
||||
const total = safeOptions.reduce((sum: number, o: any) => {
|
||||
const total = safeOptions.reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
return sum + (o.variants as any[]).reduce((vs: number, v: any) =>
|
||||
vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
|
||||
return sum + (o.variants || []).reduce((vs, v) =>
|
||||
vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
}, 0);
|
||||
|
||||
const belowPaid = isEdit && total < totalPaid;
|
||||
const belowIssued = isEdit && Object.entries(minQuantities).some(
|
||||
([key, minQty]: [string, any]) => minQty > 0 && (quantities[key] || 0) < minQty
|
||||
([key, minQty]) => minQty > 0 && (quantities[key] || 0) < minQty
|
||||
);
|
||||
|
||||
const stepper = (key: string, delta: number) =>
|
||||
setQuantities((q: Record<string, number>) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
|
||||
setQuantities((q) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
@@ -1271,7 +1275,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
|
||||
{safeOptions.map((opt: any) => {
|
||||
{safeOptions.map((opt) => {
|
||||
const hasVariants = (opt.variants || []).length > 0;
|
||||
if (hasVariants) {
|
||||
return (
|
||||
@@ -1280,8 +1284,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
{opt.name}
|
||||
{opt.isMainTicket && <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span>}
|
||||
</div>
|
||||
{(opt.variants as any[]).map((v: any) => {
|
||||
const unit = effectiveVariantUnit(opt, v);
|
||||
{(opt.variants || []).map((v) => {
|
||||
const unit = variantUnitNow(opt, v);
|
||||
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
const minQty = minQuantities[key] || 0;
|
||||
@@ -1306,7 +1310,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const unit = effectiveOptionUnit(opt);
|
||||
const unit = optionUnitNow(opt);
|
||||
const minQty = minQuantities[opt.id] || 0;
|
||||
return (
|
||||
<div key={opt.id} className="border rounded-xl p-3">
|
||||
@@ -1386,7 +1390,15 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
);
|
||||
}
|
||||
|
||||
function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: any) {
|
||||
function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
user: UserSummary | null;
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
onSuccess?: () => void;
|
||||
setError: (e: string | null) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("cash");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -1421,8 +1433,8 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
|
||||
setAmount("");
|
||||
onClose();
|
||||
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Donation failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Donation failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1480,7 +1492,12 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
|
||||
);
|
||||
}
|
||||
|
||||
function SendTicketsModal({ open, onClose, token, registration, setError, setInfo }: any) {
|
||||
function SendTicketsModal({ open, onClose, token, registration }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
token: string | null;
|
||||
registration: Pick<Registration, "id" | "user"> | null;
|
||||
}) {
|
||||
const [channel, setChannel] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -1495,7 +1512,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
setLocalError("");
|
||||
setLocalInfo("");
|
||||
}
|
||||
}, [open, registration]);
|
||||
}, [open, registration, setLocalError]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -1515,7 +1532,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: registration.id,
|
||||
registrationId: registration?.id,
|
||||
channel,
|
||||
overrideEmail: email.trim() || undefined,
|
||||
overridePhone: phone.trim() || undefined,
|
||||
@@ -1523,8 +1540,8 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
});
|
||||
setLocalInfo("Tickets sent successfully.");
|
||||
setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
|
||||
} catch (e: any) {
|
||||
setLocalError(e?.message || "Failed to send tickets");
|
||||
} catch (e) {
|
||||
setLocalError(errorMessage(e) || "Failed to send tickets");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -1609,7 +1626,14 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
);
|
||||
}
|
||||
|
||||
function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded, setError }: any) {
|
||||
function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
token: string | null;
|
||||
registration: Registration | null;
|
||||
maxRefund: number;
|
||||
onRefunded: (updated: Registration) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("cash");
|
||||
const [reason, setReason] = useState("");
|
||||
@@ -1618,9 +1642,9 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setAmount(""); setReason(""); setLocalError(""); }
|
||||
}, [open]);
|
||||
}, [open, setLocalError]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!open || !registration) return null;
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1643,10 +1667,10 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
}
|
||||
});
|
||||
// Re-fetch registration to get updated status & payments
|
||||
const updated = await apiFetch<any>(`/api/registrations/${registration.id}`, { authToken: token });
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, { authToken: token });
|
||||
onRefunded(updated);
|
||||
} catch (e: any) {
|
||||
setLocalError(e?.message || "Refund failed.");
|
||||
} catch (e) {
|
||||
setLocalError(errorMessage(e) || "Refund failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1729,48 +1753,48 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
);
|
||||
}
|
||||
|
||||
function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
function DoorRefundPanel({ token, eventId, setInfo }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
setInfo: (s: string | null) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [allRegs, setAllRegs] = useState<any[]>([]);
|
||||
const [allRegs, setAllRegs] = useState<Registration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedReg, setSelectedReg] = useState<any | null>(null);
|
||||
const [selectedReg, setSelectedReg] = useState<Registration | null>(null);
|
||||
const [showRefund, setShowRefund] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
setAllRegs(regs || []);
|
||||
} catch {
|
||||
setAllRegs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [token, eventId]);
|
||||
}, [load]);
|
||||
|
||||
// Only show registrations that have at least one payment
|
||||
const paidRegs = allRegs.filter(r =>
|
||||
(r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) > 0
|
||||
);
|
||||
const paidRegs = allRegs.filter(r => totalPaidOn(r) > 0);
|
||||
const results = fuzzyFilterRegs(paidRegs, search);
|
||||
|
||||
const handleRefunded = async (updated: any) => {
|
||||
const handleRefunded = async (updated: Registration) => {
|
||||
setShowRefund(false);
|
||||
setSelectedReg(updated);
|
||||
setInfo("Refund recorded");
|
||||
// Refresh list
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
|
||||
if (regs) setAllRegs(regs);
|
||||
};
|
||||
|
||||
const maxRefund = selectedReg
|
||||
? (selectedReg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)
|
||||
: 0;
|
||||
const maxRefund = selectedReg ? totalPaidOn(selectedReg) : 0;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
@@ -1796,7 +1820,7 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
|
||||
<div className="space-y-2 max-h-[32rem] overflow-auto">
|
||||
{results.map(r => {
|
||||
const paidValue = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const paidValue = totalPaidOn(r);
|
||||
return (
|
||||
<div key={r.id} className="border rounded p-3 flex justify-between items-center">
|
||||
<div>
|
||||
@@ -1845,12 +1869,7 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
<div className="border rounded p-3 text-center">
|
||||
<div className="text-xs text-gray-500">TOTAL</div>
|
||||
<div className="font-semibold">
|
||||
R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
|
||||
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
|
||||
? Number(o.priceSnapshot)
|
||||
: (o.eventOption?.price || o.price || 0);
|
||||
return s + price * (o.quantity || 0);
|
||||
}, 0)).toFixed(2)}
|
||||
R {registrationTotal(selectedReg).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 text-center bg-red-50">
|
||||
@@ -1863,11 +1882,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
<div className="mt-3">
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">Payment history</div>
|
||||
<div className="space-y-1">
|
||||
{selectedReg.payments.map((p: any, i: number) => (
|
||||
{(selectedReg.payments || []).map((p, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span className={p.amount < 0 ? "text-red-600" : ""}>
|
||||
{p.amount < 0 ? "Refund" : "Payment"} — {p.method || "—"}
|
||||
{p.reason ? ` (${p.reason})` : ""}
|
||||
{/* A refund's reason is stored in its status field. */}
|
||||
{p.amount < 0 && p.status ? ` (${p.status})` : ""}
|
||||
</span>
|
||||
<span className={p.amount < 0 ? "text-red-600 font-medium" : ""}>
|
||||
R {Number(p.amount).toFixed(2)}
|
||||
@@ -1901,7 +1921,6 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
registration={selectedReg}
|
||||
maxRefund={maxRefund}
|
||||
onRefunded={handleRefunded}
|
||||
setError={setError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,26 +8,10 @@ import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, ConsentRow, MedicalRow } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type ConsentRow = {
|
||||
attendeeId: string;
|
||||
name: string;
|
||||
dateOfBirth: string | null;
|
||||
minor: boolean;
|
||||
parentName: string | null;
|
||||
parentEmail: string | null;
|
||||
parentPhone: string | null;
|
||||
registrationStatus: string;
|
||||
bookedBy: { name: string; email: string };
|
||||
status: "not_required" | "missing" | "pending" | "given" | "declined";
|
||||
request: { id: string; status: string; textVersion: number; signedName: string | null; respondedAt: string | null; lastSentAt: string; remindersSent: number; photoConsent: boolean | null; outdated: boolean } | null;
|
||||
};
|
||||
type MedicalRow = {
|
||||
name: string; dateOfBirth: string | null; parentName: string | null; parentPhone: string | null; parentEmail: string | null;
|
||||
medical: { allergies: string; medication: string; conditions: string; medicalAid: string } | null;
|
||||
emergency: { name: string; phone: string; relationship: string } | null;
|
||||
photoConsent: boolean | null; purged: boolean;
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
not_required: { label: "Not needed (18+)", cls: "bg-gray-100 text-gray-600" },
|
||||
@@ -51,9 +35,9 @@ export default function ConsentTrackerPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: any; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -61,7 +45,7 @@ export default function ConsentTrackerPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
@@ -76,8 +60,8 @@ export default function ConsentTrackerPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load consent status");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load consent status");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -100,8 +84,8 @@ export default function ConsentTrackerPage() {
|
||||
const res = await fn();
|
||||
setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -114,7 +98,7 @@ export default function ConsentTrackerPage() {
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: any; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
@@ -139,8 +123,8 @@ export default function ConsentTrackerPage() {
|
||||
<tbody>${rowsHtml || '<tr><td colspan="5">No consent given yet.</td></tr>'}</tbody></table>
|
||||
<script>window.onload=()=>window.print()</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the medical list");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the medical list");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,7 +135,7 @@ export default function ConsentTrackerPage() {
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Parental consent</h1>
|
||||
<p className="text-sm text-gray-500">Youth events: who still needs a parent's consent before check-in</p>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { TicketPercent, Plus, X, Search } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type CouponType = "percent" | "fixed" | "comp";
|
||||
type Role = "user" | "staff" | "supervisor" | "admin";
|
||||
@@ -164,8 +166,8 @@ export default function CouponsPage() {
|
||||
]);
|
||||
setCoupons(c || []);
|
||||
setEvents(ev || []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load coupons");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load coupons");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
@@ -183,8 +185,8 @@ export default function CouponsPage() {
|
||||
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
|
||||
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update coupon");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update coupon");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,8 +194,8 @@ export default function CouponsPage() {
|
||||
try {
|
||||
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
|
||||
setViewing({ coupon: c, redemptions: full.redemptions || [] });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load uses");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load uses");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -205,7 +207,7 @@ export default function CouponsPage() {
|
||||
<TicketPercent className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Coupon codes</h1>
|
||||
<p className="text-sm text-gray-500">Discounts for staff, leaders, volunteers and promotions</p>
|
||||
</div>
|
||||
@@ -406,8 +408,8 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
|
||||
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
|
||||
onSaved(`${body.code.toUpperCase()} created.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Mail, Users, Zap, Clock } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
type StatusFilter = 'any'|'paid'|'unpaid'|'partial_paid'|'cancelled';
|
||||
type MessageFilter = { status?: Exclude<StatusFilter, 'any'>; attendeeIds?: string[]; allocationGroupId?: string };
|
||||
// A message body goes as `html` when it looks like markup, otherwise as plain `text`.
|
||||
type EmailContent = { html?: string; text?: string };
|
||||
type EmailSendResult = MessageSendResult<{ email: string; name?: string }>;
|
||||
|
||||
export default function EmailAttendeesPage() {
|
||||
return (
|
||||
@@ -135,16 +144,14 @@ function AttendeesCheckboxDropdown({
|
||||
}
|
||||
|
||||
function PrefWarning({ attendees, selectedIds, channel }: { attendees: { id: string; pref: string }[]; selectedIds: string[]; channel: "email" | "whatsapp" }) {
|
||||
const prefMatch = (pref: string) =>
|
||||
channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both";
|
||||
|
||||
const mismatched = useMemo(
|
||||
() => selectedIds.filter(id => {
|
||||
const mismatched = useMemo(() => {
|
||||
const prefMatch = (pref: string) =>
|
||||
channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both";
|
||||
return selectedIds.filter(id => {
|
||||
const a = attendees.find(x => x.id === id);
|
||||
return a && !prefMatch(a.pref);
|
||||
}),
|
||||
[attendees, selectedIds, channel]
|
||||
);
|
||||
});
|
||||
}, [attendees, selectedIds, channel]);
|
||||
|
||||
if (mismatched.length === 0) return null;
|
||||
return (
|
||||
@@ -180,9 +187,7 @@ function EmailAttendeesPageInner() {
|
||||
// Tabs: attendees (current), automations (coming soon), broadcasts
|
||||
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
@@ -194,20 +199,21 @@ function EmailAttendeesPageInner() {
|
||||
});
|
||||
}, [allEvents, evIncludePast]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
const sorted = (evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
setAllEvents(sorted);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [user, token]);
|
||||
// Reload when the signed-in user changes too (e.g. after an impersonation switch).
|
||||
useEffect(() => { loadEvents(); }, [user, loadEvents]);
|
||||
|
||||
// Load users for broadcasts
|
||||
useEffect(() => {
|
||||
@@ -217,11 +223,11 @@ function EmailAttendeesPageInner() {
|
||||
setLoadingUsers(true);
|
||||
const allUsers = await fetchAllUsers(token);
|
||||
const mapped = allUsers
|
||||
.filter((u: any) => u.isActive !== false && !u.email?.endsWith('@guest.local'))
|
||||
.map((u: any) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' }))
|
||||
.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
|
||||
.filter((u) => u.isActive !== false && !u.email?.endsWith('@guest.local'))
|
||||
.map((u) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' }))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
|
||||
setUsers(mapped);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore for now
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
@@ -236,7 +242,7 @@ function EmailAttendeesPageInner() {
|
||||
if (preselectEventId) setEventId(preselectEventId);
|
||||
}, [preselectEventId]);
|
||||
|
||||
const [templateKey, setTemplateKey] = useState<'custom'|'payment_reminder'|'event_reminder'|'tickets'>('custom');
|
||||
const [templateKey, setTemplateKey] = useState<TemplateKey>('custom');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
// Broadcasts state
|
||||
@@ -271,20 +277,20 @@ function EmailAttendeesPageInner() {
|
||||
const [editWhen, setEditWhen] = useState<string>('');
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
const loadScheduled = async () => {
|
||||
const loadScheduled = useCallback(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingScheduled(true);
|
||||
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
|
||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore here; surfaces via UI when tab open
|
||||
} finally {
|
||||
setLoadingScheduled(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, token]);
|
||||
useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, loadScheduled]);
|
||||
|
||||
const openEdit = (job: ScheduledJob) => {
|
||||
setEditing(job);
|
||||
@@ -298,7 +304,7 @@ function EmailAttendeesPageInner() {
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
const body: any = {};
|
||||
const body: { scheduledAt?: string; subject?: string } & EmailContent = {};
|
||||
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
|
||||
if (editSubject.trim().length || editBody.trim().length) {
|
||||
body.subject = editSubject;
|
||||
@@ -314,8 +320,8 @@ function EmailAttendeesPageInner() {
|
||||
setInfo('Scheduled email updated.');
|
||||
setEditing(null);
|
||||
loadScheduled();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to update scheduled email');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to update scheduled email');
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
@@ -327,8 +333,8 @@ function EmailAttendeesPageInner() {
|
||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token });
|
||||
setInfo('Scheduled email removed.');
|
||||
loadScheduled();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to remove scheduled email');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to remove scheduled email');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -336,7 +342,7 @@ function EmailAttendeesPageInner() {
|
||||
const [body, setBody] = useState("");
|
||||
const [subjectDirty, setSubjectDirty] = useState(false);
|
||||
const [bodyDirty, setBodyDirty] = useState(false);
|
||||
const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any");
|
||||
const [status, setStatus] = useState<StatusFilter>("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
// Attendees list and selection
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
@@ -360,11 +366,8 @@ function EmailAttendeesPageInner() {
|
||||
setBodyDirty(false);
|
||||
setSubject('');
|
||||
setBody('');
|
||||
} else {
|
||||
// custom: do not overwrite user content unless not dirty and we have event change
|
||||
if (!subjectDirty && subject) setSubject(subject); // keep as-is
|
||||
if (!bodyDirty && body) setBody(body);
|
||||
}
|
||||
// custom: leave whatever the supervisor typed alone.
|
||||
// When event changes, refresh defaults if not dirty
|
||||
}, [templateKey, currentEvent, subjectDirty, bodyDirty]);
|
||||
|
||||
@@ -389,10 +392,10 @@ function EmailAttendeesPageInner() {
|
||||
setAttendees([]);
|
||||
setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r: any) => {
|
||||
const u = r?.user;
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = r?.user;
|
||||
if (u?.id && u?.email && !u.email.endsWith('@guest.local') && u.isActive !== false) {
|
||||
uniq.set(u.id, { id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' });
|
||||
}
|
||||
@@ -402,7 +405,7 @@ function EmailAttendeesPageInner() {
|
||||
// Default: select those with email/both preference; fall back to all
|
||||
const matching = list.filter(a => a.pref === 'email' || a.pref === 'both').map(a => a.id);
|
||||
setSelectedAttendeeIds(matching.length > 0 ? matching : list.map(a => a.id));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingAttendees(false);
|
||||
@@ -420,7 +423,7 @@ function EmailAttendeesPageInner() {
|
||||
if (!subject.trim()) { setError("Subject is required"); return; }
|
||||
if (!body.trim()) { setError("Message is required"); return; }
|
||||
}
|
||||
const payload: any = {
|
||||
const payload: { subject: string; filter: MessageFilter; dryRun: boolean; template: TemplateKey } & EmailContent = {
|
||||
subject: subject || "(no subject)",
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
dryRun: true,
|
||||
@@ -429,12 +432,12 @@ function EmailAttendeesPageInner() {
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
|
||||
}
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
setPreviewCount(res?.matched ?? 0);
|
||||
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
|
||||
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to preview recipients');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to preview recipients');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -460,7 +463,7 @@ function EmailAttendeesPageInner() {
|
||||
if (!body.trim()) { setError("Message is required"); return; }
|
||||
}
|
||||
setSending(true);
|
||||
const payload: any = {
|
||||
const payload: { subject: string; filter: MessageFilter; template: TemplateKey } & EmailContent = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
@@ -468,13 +471,13 @@ function EmailAttendeesPageInner() {
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
||||
}
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const queued = res?.queued ?? res?.matched ?? 0;
|
||||
setInfo(`Queued ${queued} recipient(s) for sending.`);
|
||||
// Reset form to default state
|
||||
resetAttendeesForm();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to send emails');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to send emails');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -487,10 +490,10 @@ function EmailAttendeesPageInner() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Mail className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Email Attendees</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Email Attendees</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -556,7 +559,7 @@ function EmailAttendeesPageInner() {
|
||||
Info
|
||||
</button>
|
||||
</div>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={e => setTemplateKey(e.target.value as any)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={e => setTemplateKey(e.target.value as TemplateKey)}>
|
||||
<option value="custom">Custom message</option>
|
||||
<option value="payment_reminder">Payment reminder (with outstanding)</option>
|
||||
<option value="event_reminder">Event reminder (date & time)</option>
|
||||
@@ -584,7 +587,7 @@ function EmailAttendeesPageInner() {
|
||||
<li><code>{'{{event.title}}'}</code> — the event title.</li>
|
||||
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
|
||||
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
</ul>
|
||||
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
|
||||
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
|
||||
@@ -601,7 +604,7 @@ function EmailAttendeesPageInner() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Payment status filter</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={e => setStatus(e.target.value as any)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={e => setStatus(e.target.value as StatusFilter)}>
|
||||
<option value="any">Any</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="unpaid">Unpaid (pending or partial)</option>
|
||||
@@ -660,16 +663,16 @@ function EmailAttendeesPageInner() {
|
||||
if (!body.trim()) { setError('Message is required'); return; }
|
||||
}
|
||||
const whenIso = new Date(scheduledAtLocal).toISOString();
|
||||
const payload = {
|
||||
const payload: { subject: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } & EmailContent = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
scheduledAt: whenIso,
|
||||
} as any;
|
||||
};
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
||||
}
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
if (res?.job?.id) {
|
||||
setInfo('Email scheduled. It will be sent around the specified time.');
|
||||
} else {
|
||||
@@ -677,8 +680,8 @@ function EmailAttendeesPageInner() {
|
||||
}
|
||||
// Reset form to default state
|
||||
resetAttendeesForm();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to schedule email');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to schedule email');
|
||||
}
|
||||
}}>Schedule send</button>
|
||||
{previewCount != null && (
|
||||
@@ -779,13 +782,13 @@ Jane Doe <jane@example.com>
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const body: any = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined };
|
||||
const res = await apiFetch(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
|
||||
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined };
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
|
||||
setBroadcastPreviewCount(res?.matched ?? 0);
|
||||
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
|
||||
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to preview recipients');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to preview recipients');
|
||||
}
|
||||
}}>Preview recipients</button>
|
||||
|
||||
@@ -795,14 +798,14 @@ Jane Doe <jane@example.com>
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const payload: any = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
|
||||
const res = await apiFetch(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
|
||||
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
|
||||
// Reset broadcast form
|
||||
resetBroadcastForm();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to send broadcast');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to send broadcast');
|
||||
}
|
||||
}}>Send now</button>
|
||||
|
||||
@@ -813,14 +816,14 @@ Jane Doe <jane@example.com>
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
|
||||
const payload: any = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
|
||||
const res = await apiFetch(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
|
||||
// Reset broadcast form
|
||||
resetBroadcastForm();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to schedule broadcast');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to schedule broadcast');
|
||||
}
|
||||
}}>Schedule send</button>
|
||||
|
||||
@@ -963,9 +966,9 @@ function toLocalInputValue(d: Date) {
|
||||
return `${y}-${m}-${day}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
|
||||
function AutomationsPanel({ events, token, onInfo, onError }:{ events: Event[]; token?: string | null; onInfo: (s: string | null) => void; onError: (s: string | null) => void }) {
|
||||
const [autoEventId, setAutoEventId] = React.useState<string>('');
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e:any) => e.id === autoEventId), [events, autoEventId]);
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e) => e.id === autoEventId), [events, autoEventId]);
|
||||
|
||||
// Defaults builders
|
||||
const preWeekDefault = React.useMemo(() => ({
|
||||
@@ -1055,37 +1058,38 @@ With gratitude`);
|
||||
|
||||
const onSchedule = async () => {
|
||||
try {
|
||||
onError(null as any); onInfo(null as any);
|
||||
onError(null); onInfo(null);
|
||||
if (!token) { onError('Not authenticated'); return; }
|
||||
if (!autoEventId) { onError('Please select an event'); return; }
|
||||
const jobs: any[] = [];
|
||||
type AutomationJob = { subject: string; scheduledAt: string; audience?: 'sponsors'; promoEventId?: string } & EmailContent;
|
||||
const jobs: AutomationJob[] = [];
|
||||
if (enablePre && preSubject.trim() && preBody.trim() && preWhen) {
|
||||
const payload: any = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() };
|
||||
const payload: AutomationJob = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() };
|
||||
if (preBody.trim().startsWith('<')) payload.html = preBody; else payload.text = preBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enableFinal && finalSubject.trim() && finalBody.trim() && finalWhen) {
|
||||
const payload: any = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() };
|
||||
const payload: AutomationJob = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() };
|
||||
if (finalBody.trim().startsWith('<')) payload.html = finalBody; else payload.text = finalBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enableThanks && thanksSubject.trim() && thanksBody.trim() && thanksWhen) {
|
||||
const payload: any = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() };
|
||||
const payload: AutomationJob = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() };
|
||||
if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (currentEvent?.allowSponsorships && enableSponsors && sponsorSubject.trim() && sponsorBody.trim() && thanksWhen) {
|
||||
const payload: any = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
|
||||
const payload: AutomationJob = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
|
||||
if (sponsorBody.trim().startsWith('<')) payload.html = sponsorBody; else payload.text = sponsorBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) {
|
||||
const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
|
||||
const payload: AutomationJob = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
|
||||
if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; }
|
||||
const res = await apiFetch(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
|
||||
const res = await apiFetch<{ message?: string }>(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
|
||||
onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`);
|
||||
// Reset to defaults (keep event selection)
|
||||
setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false);
|
||||
@@ -1093,8 +1097,8 @@ With gratitude`);
|
||||
setFinalSubject(finalDefault.subject); setFinalBody(finalDefault.body); setFinalHours('24');
|
||||
setThanksSubject(thanksDefault.subject); setThanksBody(thanksDefault.body);
|
||||
setPromoSubject(promoDefault.subject); setPromoBody(promoDefault.body); setPromoEventId('');
|
||||
} catch (e:any) {
|
||||
onError(e?.message || 'Failed to schedule automations');
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || 'Failed to schedule automations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1104,7 +1108,7 @@ With gratitude`);
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={e => setAutoEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{(events||[]).map((ev:any) => (
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1137,7 +1141,7 @@ With gratitude`);
|
||||
<input type="checkbox" checked={enableFinal} onChange={e => setEnableFinal(e.target.checked)} /> Enable
|
||||
</label>
|
||||
<label className="text-xs text-gray-600">Hours before: </label>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={e => setFinalHours(e.target.value as any)}>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={e => setFinalHours(e.target.value as '24'|'48')}>
|
||||
<option value="24">24</option>
|
||||
<option value="48">48</option>
|
||||
</select>
|
||||
@@ -1201,7 +1205,7 @@ With gratitude`);
|
||||
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={e => setPromoEventId(e.target.value)}>
|
||||
<option value="">Select event to promote…</option>
|
||||
{(events||[]).map((ev:any) => (
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventOption } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||
@@ -21,7 +24,10 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
|
||||
return `${y}-${m}-${day}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
|
||||
type TierInput = { id?: string; deadline: string; price: number; order?: number };
|
||||
type OptionPatch = Partial<Pick<EventOption, "name" | "price" | "isMainTicket">> & { earlyBirdTiers?: TierInput[] };
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: EventOption; onSave: (tiers: TierInput[]) => void }) {
|
||||
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -30,8 +36,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
const tiers = Array.isArray(option?.earlyBirdTiers) ? option.earlyBirdTiers : [];
|
||||
const normalized = tiers
|
||||
.slice()
|
||||
.sort((a: any, b: any) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
|
||||
.map((t: any, i: number) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
|
||||
.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
|
||||
.map((t, i) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
|
||||
setRows(normalized);
|
||||
}, [option?.id, option?.earlyBirdTiers]);
|
||||
|
||||
@@ -45,7 +51,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
};
|
||||
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
|
||||
const copy = rows.slice();
|
||||
copy[idx] = { ...copy[idx], ...patch } as any;
|
||||
copy[idx] = { ...copy[idx], ...patch };
|
||||
setRows(copy);
|
||||
};
|
||||
|
||||
@@ -110,27 +116,23 @@ function EventOptionsContent() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
const [loadingEv, setLoadingEv] = useState(false);
|
||||
const [options, setOptions] = useState<EventOption[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingEv(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingEv(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [token]);
|
||||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||||
|
||||
// Preselect event from query (?eventId=)
|
||||
useEffect(() => {
|
||||
@@ -165,12 +167,12 @@ function EventOptionsContent() {
|
||||
setNewOpt({ name: "", price: "", isMainTicket: false });
|
||||
setInfo("Option created");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create option");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create option");
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = async (opt: any, patch: any) => {
|
||||
const updateOption = async (opt: EventOption, patch: OptionPatch) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
@@ -181,12 +183,12 @@ function EventOptionsContent() {
|
||||
});
|
||||
setInfo("Option updated");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update option");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update option");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOption = async (opt: any) => {
|
||||
const deleteOption = async (opt: EventOption) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
@@ -196,8 +198,8 @@ function EventOptionsContent() {
|
||||
});
|
||||
setInfo("Option deleted");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to delete option");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to delete option");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -208,9 +210,11 @@ function EventOptionsContent() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Ticket className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Event options</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard/supervisor/events" label="Back to events" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Event options</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,7 +48,26 @@ function DTInput({ label, value, onChange, hint, required }: { label: string; va
|
||||
// ─── types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice";
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: any }[] };
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: unknown }[] };
|
||||
type FieldDef = EventFormDef["fields"][number];
|
||||
|
||||
// What PUT /api/events/options/:id takes as earlyBirdTiers (variantId null = option-level).
|
||||
type TierPayload = { id?: string; deadline: string; price: number; stockLimit: number; order: number; variantId: string | null };
|
||||
|
||||
// GET /api/feedback/event/:id — the event's post-event feedback form, if it has one.
|
||||
type FeedbackFormResponse = {
|
||||
fields?: EventFormField[];
|
||||
sendAt?: string | null;
|
||||
audience?: string;
|
||||
anonymous?: boolean;
|
||||
intro?: string | null;
|
||||
sentAt: string | null;
|
||||
invited: number;
|
||||
responses: number;
|
||||
};
|
||||
|
||||
const toFieldDefs = (fields: EventFormField[] | undefined): FieldDef[] =>
|
||||
(fields || []).map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined }));
|
||||
|
||||
type Tier = { id?: string; deadline: string; price: string; stockLimit: string; order?: number };
|
||||
|
||||
@@ -88,8 +111,9 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const res: any = await apiFetch("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
|
||||
if (res?.url || res?.path) onUploaded(res.url || res.path);
|
||||
const res = await apiFetch<{ url?: string; path?: string }>("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
|
||||
const uploaded = res?.url || res?.path;
|
||||
if (uploaded) onUploaded(uploaded);
|
||||
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
|
||||
}} />
|
||||
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
|
||||
@@ -109,7 +133,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
|
||||
}) {
|
||||
const fields = value.fields || [];
|
||||
const add = (type: FormFieldType) => onChange({ ...value, fields: [...fields, { type, label: "", isRequired: false, helpText: "" }] });
|
||||
const upd = (idx: number, patch: any) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
|
||||
const upd = (idx: number, patch: Partial<FieldDef>) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
|
||||
const rem = (idx: number) => { const c = fields.slice(); c.splice(idx, 1); onChange({ ...value, fields: c }); };
|
||||
const mov = (idx: number, dir: -1 | 1) => {
|
||||
const c = fields.slice(); const t = idx + dir;
|
||||
@@ -143,7 +167,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
|
||||
<button type="button" className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => rem(idx)}>Remove</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-1.5">
|
||||
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value })}>
|
||||
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value as FormFieldType })}>
|
||||
<option value="text">Text</option><option value="numeric">Number</option><option value="date">Date</option>
|
||||
<option value="yes_no">Yes/No</option><option value="rating">Rating (1–5 stars)</option><option value="multiple_choice">Multiple choice</option>
|
||||
<option value="statement">Statement</option><option value="paragraph">Heading+para</option>
|
||||
@@ -452,24 +476,24 @@ function SectionsDraftEditor({ sections, options, onChange }: {
|
||||
/** Edit-mode: live sections manager (DB-backed) */
|
||||
function SectionsManager({ eventId }: { eventId: string }) {
|
||||
const { token } = useAuth();
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [eventOptions, setEventOptions] = useState<any[]>([]);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newOptionIds, setNewOptionIds] = useState<string[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [secList, evFull] = await Promise.all([
|
||||
apiFetch<any[]>("/api/sections", { authToken: token || undefined }),
|
||||
apiFetch<any>(`/api/events/${eventId}`)
|
||||
apiFetch<Section[]>("/api/sections", { authToken: token || undefined }),
|
||||
apiFetch<Event>(`/api/events/${eventId}`)
|
||||
]);
|
||||
setSections((secList || []).filter(s => s.eventId === eventId));
|
||||
setEventOptions(evFull?.eventOptions || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { if (eventId) load(); }, [eventId]);
|
||||
}, [eventId, token]);
|
||||
useEffect(() => { if (eventId) load(); }, [eventId, load]);
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim() || !token) return;
|
||||
@@ -514,14 +538,19 @@ function SectionsManager({ eventId }: { eventId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({ sec, options, onDelete, onUpdate }: any) {
|
||||
function SectionRow({ sec, options, onDelete, onUpdate }: {
|
||||
sec: Section;
|
||||
options: EventOption[];
|
||||
onDelete: () => void;
|
||||
onUpdate: (id: string, name: string, optionIds: string[]) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(sec.name || "");
|
||||
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o: any) => o.eventOptionId));
|
||||
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o) => o.eventOptionId));
|
||||
return (
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<input className="border rounded px-2 py-1 text-sm w-full mb-2" value={name} onChange={e => setName(e.target.value)} />
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{options.map((opt: any) => (
|
||||
{options.map((opt) => (
|
||||
<label key={opt.id} className="text-xs flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={optIds.includes(opt.id)}
|
||||
onChange={e => setOptIds(e.target.checked ? [...optIds, opt.id] : optIds.filter(id => id !== opt.id))} />
|
||||
@@ -541,15 +570,15 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) {
|
||||
|
||||
function AttachmentsManager({ eventId }: { eventId: string }) {
|
||||
const { token } = useAuth();
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [items, setItems] = useState<EventAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const res = await apiFetch<any[]>(`/api/events/${eventId}/attachments`);
|
||||
const load = useCallback(async () => {
|
||||
const res = await apiFetch<EventAttachment[]>(`/api/events/${eventId}/attachments`);
|
||||
setItems(Array.isArray(res) ? res : []);
|
||||
};
|
||||
useEffect(() => { load(); }, [eventId]);
|
||||
}, [eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const upload: React.ChangeEventHandler<HTMLInputElement> = async e => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -645,7 +674,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
|
||||
setSearching(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
|
||||
const res = await apiFetch<Paginated<NotifyUser>>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
|
||||
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
|
||||
} catch {
|
||||
if (!cancelled) setResults([]);
|
||||
@@ -728,10 +757,10 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
|
||||
const res = await apiFetch<NotifyUser[]>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
|
||||
if (!cancelled) setSelected(Array.isArray(res) ? res : []);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || "Failed to load notification recipients");
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(errorMessage(e) || "Failed to load notification recipients");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -750,9 +779,9 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { userIds: next.map(u => u.id) },
|
||||
});
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSelected(prev);
|
||||
setError(e?.message || "Failed to save notification recipients");
|
||||
setError(errorMessage(e) || "Failed to save notification recipients");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -813,14 +842,13 @@ const blankOptions = (): OptionDraft[] => [
|
||||
|
||||
interface EventModalProps {
|
||||
mode: "create" | "edit";
|
||||
event?: any;
|
||||
event?: Event | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const { token, user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const { token } = useAuth();
|
||||
const { settings } = useSiteSettings();
|
||||
const [step, setStep] = useState<StepIdx>(0);
|
||||
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
||||
@@ -851,8 +879,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// ── options (with per-variant tiers) ──
|
||||
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
||||
if (!ev?.eventOptions?.length) return blankOptions();
|
||||
return ev.eventOptions.map((o: any) => {
|
||||
const allTiers: any[] = o.earlyBirdTiers || [];
|
||||
return ev.eventOptions.map((o) => {
|
||||
const allTiers = o.earlyBirdTiers || [];
|
||||
return {
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
@@ -862,17 +890,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
|
||||
// Option-level tiers only (no variantId)
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t: any) => !t.variantId)
|
||||
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
variants: (o.variants || []).map((v: any) => ({
|
||||
.filter((t) => !t.variantId)
|
||||
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
variants: (o.variants || []).map((v) => ({
|
||||
id: v.id, name: v.name,
|
||||
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
|
||||
stockLimit: String(v.stockLimit ?? "0"),
|
||||
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
|
||||
// Variant-level tiers — filter by variantId
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t: any) => t.variantId === v.id)
|
||||
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
.filter((t) => t.variantId === v.id)
|
||||
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -892,9 +920,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const formLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
|
||||
apiFetch<any>(`/api/events/${ev.id}`).then(full => {
|
||||
apiFetch<Event>(`/api/events/${ev.id}`).then(full => {
|
||||
const f = full?.form;
|
||||
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
|
||||
if (f) setFormDef({ isRequired: !!f.isRequired, fields: toFieldDefs(f.fields) });
|
||||
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
|
||||
}, [mode, ev?.id]);
|
||||
|
||||
@@ -905,10 +933,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || !token) return;
|
||||
apiFetch<any>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
|
||||
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
|
||||
if (!f) return;
|
||||
setFbEnabled(true);
|
||||
setFbDef({ isRequired: false, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
|
||||
setFbDef({ isRequired: false, fields: toFieldDefs(f.fields) });
|
||||
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
|
||||
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
|
||||
}).catch(() => {});
|
||||
@@ -991,7 +1019,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (mode === "edit") {
|
||||
for (const opt of options) {
|
||||
// Build flat tier array: option-level + all variant tiers
|
||||
const allTiers: any[] = [
|
||||
const allTiers: TierPayload[] = [
|
||||
...opt.earlyBirdTiers
|
||||
.filter(t => t.deadline && t.price !== "")
|
||||
.map((t, i) => ({ id: t.id, deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: i, variantId: null })),
|
||||
@@ -1006,16 +1034,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
if (opt.id) {
|
||||
// Update option + variants first to get/confirm variant IDs
|
||||
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
|
||||
});
|
||||
|
||||
// Build variant tiers with confirmed IDs from response
|
||||
const serverVariants: any[] = updated?.variants || [];
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
const serverVariant = serverVariants[vi] || serverVariants.find((sv: any) => sv.name === v.name);
|
||||
const serverVariant = serverVariants[vi] || serverVariants.find((sv) => sv.name === v.name);
|
||||
if (!serverVariant) continue;
|
||||
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
|
||||
const t = v.earlyBirdTiers[ti];
|
||||
@@ -1038,19 +1066,19 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
});
|
||||
} else {
|
||||
// New option added during edit
|
||||
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
});
|
||||
if (created?.id) {
|
||||
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { variants: variantsPayload }
|
||||
});
|
||||
const serverVariants: any[] = updated?.variants || [];
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
|
||||
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
|
||||
if (!sv) continue;
|
||||
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
|
||||
const t = v.earlyBirdTiers[ti];
|
||||
@@ -1068,9 +1096,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
} else {
|
||||
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
|
||||
let createdEvent: any = null;
|
||||
try { createdEvent = await apiFetch(`/api/events/${eventId}`); } catch {}
|
||||
const existingMain = (createdEvent?.eventOptions || []).find((o: any) => !!o.isMainTicket);
|
||||
let createdEvent: Event | null = null;
|
||||
try { createdEvent = await apiFetch<Event>(`/api/events/${eventId}`); } catch {}
|
||||
const existingMain = (createdEvent?.eventOptions || []).find((o) => !!o.isMainTicket);
|
||||
const uiMainIdx = options.findIndex(o => o.isMainTicket);
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
@@ -1091,7 +1119,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
});
|
||||
optId = existingMain.id;
|
||||
} else {
|
||||
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
});
|
||||
@@ -1101,18 +1129,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (!optId) continue;
|
||||
|
||||
// Save variants, then build tiers with resolved IDs
|
||||
const allTiers: any[] = opt.earlyBirdTiers
|
||||
const allTiers: TierPayload[] = opt.earlyBirdTiers
|
||||
.filter(t => t.deadline && t.price !== "")
|
||||
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
|
||||
|
||||
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
|
||||
const updated: any = await apiFetch(`/api/events/options/${optId}`, {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${optId}`, {
|
||||
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
|
||||
});
|
||||
const serverVariants: any[] = updated?.variants || [];
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
|
||||
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
|
||||
if (!sv) continue;
|
||||
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
|
||||
const t = v.earlyBirdTiers[ti];
|
||||
@@ -1131,7 +1159,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const saveStagedSections = async (eventId: string, createdOptions: any[]) => {
|
||||
const saveStagedSections = async (eventId: string, createdOptions: EventOption[]) => {
|
||||
for (const sec of sectionDrafts) {
|
||||
if (!sec.name.trim()) continue;
|
||||
const optionIds = sec.optionIndices
|
||||
@@ -1155,7 +1183,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
setSaving(true); setError(null);
|
||||
try {
|
||||
const body: any = {
|
||||
const body: Record<string, unknown> = {
|
||||
title: draft.title.trim(), description: draft.description || undefined,
|
||||
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
|
||||
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
|
||||
@@ -1186,6 +1214,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
if (mode === "edit") {
|
||||
body.isActive = draft.isActive;
|
||||
if (!ev) throw new Error("No event loaded to save");
|
||||
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
|
||||
await saveOptions(ev.id);
|
||||
// Form is saved inside saveOptions for edit mode; avoid double call
|
||||
@@ -1194,12 +1223,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (formDef.isRequired || formDef.fields.length > 0) {
|
||||
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
|
||||
}
|
||||
const created: any = await apiFetch("/api/events", { method: "POST", authToken: token || undefined, body });
|
||||
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
|
||||
if (created?.id) {
|
||||
await saveOptions(created.id);
|
||||
await saveFeedback(created.id);
|
||||
// Fetch event to get final option IDs for sections
|
||||
const fullEvent: any = await apiFetch(`/api/events/${created.id}`).catch(() => null);
|
||||
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
|
||||
const createdOptions = fullEvent?.eventOptions || [];
|
||||
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
|
||||
// Upload staged attachments
|
||||
@@ -1219,8 +1248,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
|
||||
onSuccess(); onClose();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save event");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1306,7 +1335,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
|
||||
{isClosed && (
|
||||
<div className="p-3 border rounded bg-rose-50 text-rose-800 text-sm">
|
||||
This event is closed — it can't be edited until it's reopened from the cashup page (admin only).
|
||||
This event is closed — it can't be edited until it's reopened from the cashup page (admin only).
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
@@ -1342,7 +1371,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
onChange={e => upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })}
|
||||
/>
|
||||
<label htmlFor="contact-only-toggle" className="text-xs text-gray-700">
|
||||
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
|
||||
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
|
||||
</label>
|
||||
</div>
|
||||
{draft.requiresRegistration ? (
|
||||
@@ -1369,7 +1398,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Picture</label>
|
||||
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
|
||||
<ApiImage src={draft.picture} className="h-20 w-20 object-cover rounded border mb-2" />
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="flex-1 border rounded px-3 py-2 text-sm" placeholder="/uploads/... or https://" value={draft.picture} onChange={e => upd({ picture: e.target.value })} />
|
||||
<UploadImageButton onUploaded={url => upd({ picture: url })} label="Upload" />
|
||||
@@ -1405,7 +1434,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{/* ── Step 2: Sections ── */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-3">Group ticket options into named sections shown on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").</p>
|
||||
<p className="text-xs text-gray-500 mb-3">Group ticket options into named sections shown on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").</p>
|
||||
{mode === "edit" && ev?.id
|
||||
? <SectionsManager eventId={ev.id} />
|
||||
: <SectionsDraftEditor sections={sectionDrafts} options={options} onChange={setSectionDrafts} />
|
||||
@@ -1670,7 +1699,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
// ─── event card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
|
||||
function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
|
||||
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
|
||||
const isInactive = ev.isActive === false;
|
||||
const isClosed = ev.cashupStatus === "closed";
|
||||
@@ -1710,27 +1739,28 @@ export default function ManageEventsPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingEvents(true);
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [user, token]);
|
||||
// Reload when the signed-in user changes too (e.g. after an impersonation switch).
|
||||
useEffect(() => { loadEvents(); }, [user, loadEvents]);
|
||||
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<any | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [includePast, setIncludePast] = useState(false);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showClosed, setShowClosed] = useState(false);
|
||||
@@ -1747,9 +1777,9 @@ export default function ManageEventsPage() {
|
||||
}), [events, includePast, showInactive, showClosed, search, now]);
|
||||
|
||||
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
|
||||
const openEdit = async (ev: any) => {
|
||||
const openEdit = async (ev: Event) => {
|
||||
try {
|
||||
const full = await apiFetch<any>(`/api/events/${ev.id}`, { authToken: token || undefined });
|
||||
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
|
||||
setEditingEvent(full);
|
||||
} catch { setEditingEvent(ev); }
|
||||
setModalMode("edit");
|
||||
@@ -1763,13 +1793,13 @@ export default function ManageEventsPage() {
|
||||
<Calendar className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Events</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Events</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
|
||||
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: unknown }[] };
|
||||
|
||||
// The form as GET /api/events/:id returns it, mapped into the editor's shape.
|
||||
const toFormDef = (f: EventForm | null | undefined): EventFormDef =>
|
||||
f && Array.isArray(f.fields)
|
||||
? { isRequired: !!f.isRequired, fields: f.fields.map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) }
|
||||
: { isRequired: false, fields: [] };
|
||||
|
||||
// /api/forms/responses returns { items } (older builds returned a bare array).
|
||||
const responseItems = (res: { items?: FormResponse[] } | FormResponse[] | null | undefined): FormResponse[] =>
|
||||
Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
|
||||
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
|
||||
const fields = value.fields || [];
|
||||
@@ -138,36 +151,36 @@ export default function FormsBrowserPage() {
|
||||
|
||||
const [mode, setMode] = useState<'view' | 'manage' | 'fill'>('view');
|
||||
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
const [evIncludeInactive, setEvIncludeInactive] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [userId, setUserId] = useState<string>("");
|
||||
const [userSearch, setUserSearch] = useState<string>("");
|
||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<User[]>([]);
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
||||
const [registrationId, setRegistrationId] = useState<string>("");
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [items, setItems] = useState<FormResponse[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const sorted = (evs || []).sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
setAllEvents(Array.isArray(sorted) ? sorted : []);
|
||||
} catch {}
|
||||
};
|
||||
useEffect(() => { loadEvents(); }, [token]);
|
||||
}, [token]);
|
||||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetchAllUsers(token)
|
||||
.then(list => {
|
||||
setAllUsers(list.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "")));
|
||||
setAllUsers(list.sort((a, b) => (a.name || "").localeCompare(b.name || "")));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
@@ -207,32 +220,36 @@ export default function FormsBrowserPage() {
|
||||
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
|
||||
if (append && nextCursor) qs.set("cursor", nextCursor);
|
||||
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
|
||||
const res = await apiFetch<{ items: any[]; nextCursor?: string }>(url, { authToken: token });
|
||||
const res = await apiFetch<{ items: FormResponse[]; nextCursor?: string }>(url, { authToken: token });
|
||||
const newItems = Array.isArray(res?.items) ? res!.items : [];
|
||||
setItems(prev => append ? [...prev, ...newItems] : newItems);
|
||||
setNextCursor(res?.nextCursor || null);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load form responses");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load form responses");
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load once the token is available; after that, searching is on demand (the
|
||||
// filters don't refetch as they're typed). The ref keeps the latest `search`.
|
||||
const searchRef = useRef(search);
|
||||
useEffect(() => { searchRef.current = search; });
|
||||
useEffect(() => {
|
||||
if (token) search(false);
|
||||
if (token) searchRef.current(false);
|
||||
}, [token]);
|
||||
|
||||
const onPrint = () => {
|
||||
if (!items || items.length === 0) return;
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const formPages = items.map((r: any) => {
|
||||
const formPages = items.map((r) => {
|
||||
const eventTitle = r.registration?.event?.title || "Event";
|
||||
const attendeeName = r.registration?.user?.name || "Attendee";
|
||||
const attendeeEmail = r.registration?.user?.email || "";
|
||||
const regId = String(r.registrationId || r.id || "").slice(0, 8);
|
||||
const createdAt = r.createdAt ? new Date(r.createdAt).toLocaleDateString("en-ZA") : "";
|
||||
const answers = (r.answers || []).map((a: any) => `
|
||||
const answers = (r.answers || []).map((a) => `
|
||||
<div class="answer">
|
||||
<div class="label">${a.field?.label || a.fieldId || "Field"}</div>
|
||||
<div class="value">${a.value ?? ""}</div>
|
||||
@@ -285,58 +302,53 @@ export default function FormsBrowserPage() {
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
|
||||
const [formLoadedFor, setFormLoadedFor] = useState<string>("");
|
||||
const loadFormForEvent = async (eventId: string) => {
|
||||
const loadFormForEvent = useCallback(async (eventId: string) => {
|
||||
if (!eventId) return;
|
||||
try {
|
||||
setFormLoading(true);
|
||||
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
const f = evFull?.form;
|
||||
if (f && Array.isArray(f.fields)) {
|
||||
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) });
|
||||
} else {
|
||||
setFormDef({ isRequired: false, fields: [] });
|
||||
}
|
||||
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
setFormDef(toFormDef(evFull?.form));
|
||||
setFormLoadedFor(eventId);
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to load form');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load form');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
}, [setError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'manage' && selectedEventId && selectedEventId !== formLoadedFor) {
|
||||
loadFormForEvent(selectedEventId);
|
||||
}
|
||||
}, [mode, selectedEventId]);
|
||||
}, [mode, selectedEventId, formLoadedFor, loadFormForEvent]);
|
||||
|
||||
const saveForm = async () => {
|
||||
if (!token || !selectedEventId) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o: any) => String(o).trim()).filter(Boolean) : undefined }));
|
||||
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o) => String(o).trim()).filter(Boolean) : undefined }));
|
||||
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
|
||||
setInfo('Form saved');
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to save form');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save form');
|
||||
}
|
||||
};
|
||||
|
||||
// Fill/edit responses state
|
||||
const [fillEventId, setFillEventId] = useState<string>("");
|
||||
const [fillRegistrations, setFillRegistrations] = useState<any[]>([]);
|
||||
const [fillRegistrations, setFillRegistrations] = useState<Registration[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [fillRegistrationId, setFillRegistrationId] = useState<string>("");
|
||||
const [fillRegistration, setFillRegistration] = useState<any | null>(null);
|
||||
const [fillRegistration, setFillRegistration] = useState<Registration | null>(null);
|
||||
const [fillForm, setFillForm] = useState<EventFormDef>({ isRequired: false, fields: [] });
|
||||
const [existingResponses, setExistingResponses] = useState<any[]>([]);
|
||||
const [existingResponses, setExistingResponses] = useState<FormResponse[]>([]);
|
||||
const [savingFill, setSavingFill] = useState(false);
|
||||
|
||||
const loadRegsForEvent = async (evId: string) => {
|
||||
const loadRegsForEvent = useCallback(async (evId: string) => {
|
||||
if (!token || !evId) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
|
||||
const sorted = (Array.isArray(regs) ? regs : []).sort((a, b) => (a.user?.name || '').toLowerCase().localeCompare((b.user?.name || '').toLowerCase()));
|
||||
setFillRegistrations(sorted);
|
||||
} catch {
|
||||
@@ -344,54 +356,48 @@ export default function FormsBrowserPage() {
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'fill') setFillEventId(prev => prev || selectedEventId || "");
|
||||
}, [mode]);
|
||||
}, [mode, selectedEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'fill') return;
|
||||
if (fillEventId) loadRegsForEvent(fillEventId);
|
||||
setFillRegistrationId(""); setFillRegistration(null); setExistingResponses([]); setFillForm({ isRequired: false, fields: [] });
|
||||
}, [fillEventId, mode]);
|
||||
}, [fillEventId, mode, loadRegsForEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (mode !== 'fill' || !fillRegistrationId) return;
|
||||
try {
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
setFillRegistration(reg);
|
||||
if (reg?.event?.id) {
|
||||
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.event.id)}`);
|
||||
const f = evFull?.form;
|
||||
if (f && Array.isArray(f.fields)) {
|
||||
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'', options: x.options || undefined })) });
|
||||
} else {
|
||||
setFillForm({ isRequired: false, fields: [] });
|
||||
}
|
||||
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(reg.event.id)}`);
|
||||
setFillForm(toFormDef(evFull?.form));
|
||||
}
|
||||
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
|
||||
setExistingResponses(items);
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to load registration/form');
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration/form');
|
||||
}
|
||||
})();
|
||||
}, [fillRegistrationId, mode]);
|
||||
}, [fillRegistrationId, mode, token, setError]);
|
||||
|
||||
const mainTicketCount = useMemo(() => {
|
||||
const ros = fillRegistration?.registrationOptions || [];
|
||||
return ros.filter((ro:any)=> ro?.eventOption?.isMainTicket).reduce((s:number, ro:any)=> s + (ro.quantity||0), 0);
|
||||
return ros.filter((ro)=> ro?.eventOption?.isMainTicket).reduce((s, ro)=> s + (ro.quantity||0), 0);
|
||||
}, [fillRegistration]);
|
||||
|
||||
const [editableResponses, setEditableResponses] = useState<{ answers: Record<string,string> }[]>([]);
|
||||
useEffect(() => {
|
||||
if (mode !== 'fill') return;
|
||||
const fields = (fillForm.fields || []).filter(f => f.type !== 'statement' && f.type !== 'paragraph');
|
||||
const mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r:any)=>{
|
||||
const mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r)=>{
|
||||
const ans: Record<string,string> = {};
|
||||
(r.answers || []).forEach((a:any)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
|
||||
(r.answers || []).forEach((a)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
|
||||
for (const f of fields) if (!(f.id! in ans)) ans[f.id!] = '';
|
||||
return { answers: ans };
|
||||
});
|
||||
@@ -408,7 +414,7 @@ export default function FormsBrowserPage() {
|
||||
const setAnswer = (respIdx: number, fieldId: string, value: string) => {
|
||||
setEditableResponses(prev => {
|
||||
const copy = prev.slice();
|
||||
if (!copy[respIdx]) copy[respIdx] = { answers: {} } as any;
|
||||
if (!copy[respIdx]) copy[respIdx] = { answers: {} };
|
||||
copy[respIdx] = { answers: { ...copy[respIdx].answers, [fieldId]: value } };
|
||||
return copy;
|
||||
});
|
||||
@@ -431,11 +437,10 @@ export default function FormsBrowserPage() {
|
||||
method: 'PUT', authToken: token, body: { responses: editableResponses }
|
||||
});
|
||||
setInfo('Responses saved');
|
||||
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
|
||||
setExistingResponses(items);
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to save responses');
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save responses');
|
||||
} finally {
|
||||
setSavingFill(false);
|
||||
}
|
||||
@@ -448,10 +453,12 @@ export default function FormsBrowserPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<FileText className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Forms</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Forms</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
|
||||
{mode === 'view' && (
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={onPrint} disabled={items.length === 0}>
|
||||
Print forms
|
||||
@@ -573,7 +580,7 @@ export default function FormsBrowserPage() {
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y print-form-wrapper">
|
||||
{items.map((r: any) => (
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="print-page-break">
|
||||
<div className="p-4 print-response">
|
||||
{/* Header */}
|
||||
@@ -593,7 +600,7 @@ export default function FormsBrowserPage() {
|
||||
<div className="text-xs text-gray-400 italic">No answers submitted.</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 gap-2 print-answers">
|
||||
{r.answers.map((a: any) => (
|
||||
{r.answers.map((a) => (
|
||||
<div key={a.id} className="bg-gray-50 border rounded p-2 print-answer">
|
||||
<div className="text-[10px] text-gray-500 mb-0.5 print-answer-label">{a.field?.label || a.fieldId}</div>
|
||||
<div className="font-medium text-sm break-words print-answer-value">{a.value}</div>
|
||||
@@ -680,7 +687,7 @@ export default function FormsBrowserPage() {
|
||||
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillRegistrationId} onChange={e => setFillRegistrationId(e.target.value)} disabled={!fillEventId || loadingRegs}>
|
||||
<option value="">{loadingRegs ? 'Loading registrations…' : 'Select registration…'}</option>
|
||||
{fillRegistrations.map((r:any)=>{
|
||||
{fillRegistrations.map((r)=>{
|
||||
const label = `${r.user?.name || r.userId} — ${r.user?.email || ''} — #${String(r.id).slice(0,8)}`;
|
||||
return <option key={r.id} value={r.id}>{label}</option>;
|
||||
})}
|
||||
@@ -702,7 +709,7 @@ export default function FormsBrowserPage() {
|
||||
<div key={idx} className="border rounded-lg p-3 bg-white shadow-sm">
|
||||
<div className="text-sm font-semibold mb-3 text-gray-700">Attendee #{idx+1}</div>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{fillForm.fields.filter(f=>f.type!=='statement' && f.type !== 'paragraph').map((f:any)=> (
|
||||
{fillForm.fields.filter((f): f is typeof f & { id: string } => !!f.id && f.type!=='statement' && f.type !== 'paragraph').map((f)=> (
|
||||
<div key={f.id}>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{f.type === 'text' && (
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -21,7 +24,7 @@ export default function ManualRegistrationPage() {
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
||||
const [createdReg, setCreatedReg] = useState<Registration | null>(null);
|
||||
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
||||
|
||||
@@ -36,7 +39,7 @@ export default function ManualRegistrationPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/registrations/manual", {
|
||||
const res = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -48,12 +51,12 @@ export default function ManualRegistrationPage() {
|
||||
setCreatedReg(res);
|
||||
// Load form definition for this event (if any)
|
||||
try {
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
if (ev?.form) setForm(ev.form);
|
||||
} catch {}
|
||||
alert("Manual registration created");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create manual registration");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create manual registration");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -65,7 +68,10 @@ export default function ManualRegistrationPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<UserPlus className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Manual Registration</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">Manual Registration</h1>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
@@ -110,12 +116,14 @@ export default function ManualRegistrationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
|
||||
type FormsData = Record<number, Record<string, string>>;
|
||||
|
||||
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: Registration; form: { isRequired: boolean; fields: FormField[] }; formsData: FormsData; setFormsData: React.Dispatch<React.SetStateAction<FormsData>>; }) {
|
||||
const { token } = useAuth();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
||||
const mainTickets = (registration?.registrationOptions || []).filter((o) => o?.eventOption?.isMainTicket).reduce((s, o) => s + (o.quantity || 0), 0);
|
||||
const count = Math.max(0, mainTickets);
|
||||
|
||||
const canSubmit = React.useMemo(() => {
|
||||
@@ -136,7 +144,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
}, [form, formsData, count]);
|
||||
|
||||
const update = (idx: number, fieldId: string, value: string) => {
|
||||
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
||||
setFormsData((prev) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -144,14 +152,14 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null); setInfo(null);
|
||||
const payload = [] as any[];
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
|
||||
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
|
||||
method: 'POST', authToken: token, body: { responses: payload }
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to submit forms');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -9,35 +9,13 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function effectiveOptionUnit(opt: any): number {
|
||||
const base = opt.price || 0;
|
||||
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
const now = new Date();
|
||||
const applicable = tiers
|
||||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t: any) => now < t.deadline)
|
||||
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
|
||||
return applicable.length > 0 ? applicable[0].price : base;
|
||||
}
|
||||
|
||||
function effectiveVariantUnit(opt: any, variant: any): number {
|
||||
const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0;
|
||||
const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : [];
|
||||
const variantTiers = allTiers.filter((t: any) => t.variantId === variant.id);
|
||||
const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter((t: any) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
const now = new Date();
|
||||
const applicable = tiers
|
||||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t: any) => now < t.deadline)
|
||||
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
|
||||
return applicable.length > 0 ? applicable[0].price : base;
|
||||
}
|
||||
|
||||
function fmtPrice(n: number) {
|
||||
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
|
||||
}
|
||||
@@ -45,13 +23,14 @@ function fmtPrice(n: number) {
|
||||
// Effective unit price for an existing registration option (as returned by /api/registrations).
|
||||
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
|
||||
// back to a live early-bird calculation for legacy rows without a snapshot.
|
||||
function optionUnitPrice(opt: any): number {
|
||||
function optionUnitPrice(opt: RegistrationOption): number {
|
||||
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
|
||||
return Number(opt.priceSnapshot);
|
||||
}
|
||||
const eo = opt.eventOption;
|
||||
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
|
||||
return effectiveOptionUnit(eo);
|
||||
if (!eo) return 0;
|
||||
if (opt.variant) return variantUnitNow(eo, opt.variant);
|
||||
return optionUnitNow(eo);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
@@ -70,12 +49,12 @@ export default function ManualRegistrationPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
const [options, setOptions] = useState<EventOption[]>([]);
|
||||
|
||||
// All system users for fuzzy lookup
|
||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<User[]>([]);
|
||||
|
||||
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
@@ -94,7 +73,7 @@ export default function ManualRegistrationPage() {
|
||||
const [tab, setTab] = useState<"register" | "payment">("register");
|
||||
|
||||
// ── Record Payment tab state ─────────────────────────────────────────────
|
||||
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
|
||||
const [allRegistrations, setAllRegistrations] = useState<Registration[]>([]);
|
||||
const [payUserId, setPayUserId] = useState<string>("");
|
||||
const [payUserQuery, setPayUserQuery] = useState("");
|
||||
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
|
||||
@@ -119,7 +98,7 @@ export default function ManualRegistrationPage() {
|
||||
// balances can be computed without a per-registration fetch loop).
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<any[]>("/api/registrations", { authToken: token })
|
||||
apiFetch<Registration[]>("/api/registrations", { authToken: token })
|
||||
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
@@ -127,8 +106,8 @@ export default function ManualRegistrationPage() {
|
||||
const regOutstanding = useMemo(() => {
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of allRegistrations) {
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
return map;
|
||||
@@ -144,7 +123,7 @@ export default function ManualRegistrationPage() {
|
||||
.map(x => x.u);
|
||||
}, [payUserQuery, allUsers]);
|
||||
|
||||
const selectPayUser = (u: any) => {
|
||||
const selectPayUser = (u: User) => {
|
||||
setPayUserId(u.id);
|
||||
setPayUserQuery(u.name || "");
|
||||
setPayDropdownOpen(false);
|
||||
@@ -165,9 +144,9 @@ export default function ManualRegistrationPage() {
|
||||
const regsForPayUser = useMemo(() => {
|
||||
if (!payUserId) return [];
|
||||
return allRegistrations
|
||||
.filter((r: any) => String(r.userId || r.user?.id) === String(payUserId))
|
||||
.filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
.filter((r) => String(r.userId || r.user?.id) === String(payUserId))
|
||||
.filter((r) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [allRegistrations, payUserId, regOutstanding]);
|
||||
|
||||
const createManualPayment = async () => {
|
||||
@@ -179,7 +158,7 @@ export default function ManualRegistrationPage() {
|
||||
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
|
||||
try {
|
||||
setPaySubmitting(true);
|
||||
await apiFetch<any>("/api/payments", {
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -196,11 +175,11 @@ export default function ManualRegistrationPage() {
|
||||
setPayPaidAtLocal("");
|
||||
// Refresh registrations so the displayed outstanding balance updates
|
||||
try {
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
setAllRegistrations(Array.isArray(regs) ? regs : []);
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
setPayError(e?.message || "Failed to record payment");
|
||||
} catch (e) {
|
||||
setPayError(errorMessage(e) || "Failed to record payment");
|
||||
} finally {
|
||||
setPaySubmitting(false);
|
||||
}
|
||||
@@ -229,7 +208,7 @@ export default function ManualRegistrationPage() {
|
||||
const selectedEventAllowsNames = !!selectedEv?.allowNamedAttendees || selectedIsYouth;
|
||||
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
|
||||
|
||||
const selectUser = (u: any) => {
|
||||
const selectUser = (u: User) => {
|
||||
setPickedUserId(u.id || null);
|
||||
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
|
||||
setUserQuery(u.name || "");
|
||||
@@ -251,7 +230,7 @@ export default function ManualRegistrationPage() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter(ev => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -261,7 +240,7 @@ export default function ManualRegistrationPage() {
|
||||
});
|
||||
active.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
setEvents(active);
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -273,9 +252,9 @@ export default function ManualRegistrationPage() {
|
||||
const opts = ev.options || ev.eventOptions || [];
|
||||
setOptions(opts);
|
||||
const map: Record<string, number> = {};
|
||||
opts.forEach((o: any) => {
|
||||
opts.forEach((o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
(o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
|
||||
(o.variants || []).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
|
||||
} else {
|
||||
map[o.id] = 0;
|
||||
}
|
||||
@@ -290,9 +269,9 @@ export default function ManualRegistrationPage() {
|
||||
const totalDue = useMemo(() => {
|
||||
return options.reduce((sum, o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
return sum + (o.variants as any[]).reduce((vs: number, v: any) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
|
||||
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
}, 0);
|
||||
}, [options, quantities]);
|
||||
|
||||
@@ -319,7 +298,7 @@ export default function ManualRegistrationPage() {
|
||||
const hasEmail = !!guest.email.trim();
|
||||
const hasPhone = !!guest.phoneNumber.trim();
|
||||
const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email";
|
||||
const res = await apiFetch<any>("/api/registrations/manual", {
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -339,7 +318,7 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
// Pre-load the Record Payment tab with this registration so it's ready to go the
|
||||
// moment a supervisor clicks over — the tab itself is never switched to automatically.
|
||||
setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]);
|
||||
setAllRegistrations(prev => [res, ...prev.filter((r) => r.id !== res.id)]);
|
||||
setPayUserId(res.userId || res.user?.id || "");
|
||||
setPayUserQuery(res.user?.name || guest.name || "");
|
||||
setPayRegistrationId(res.id);
|
||||
@@ -356,8 +335,8 @@ export default function ManualRegistrationPage() {
|
||||
setAttendeeEntries({});
|
||||
setDelivery("payer");
|
||||
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create manual registration");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create manual registration");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -370,9 +349,11 @@ export default function ManualRegistrationPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<UserPlus className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Manual registration</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Manual registration</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -558,8 +539,8 @@ export default function ManualRegistrationPage() {
|
||||
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-800">
|
||||
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span> : null}
|
||||
</div>
|
||||
{(opt.variants as any[]).map((v: any) => {
|
||||
const unit = effectiveVariantUnit(opt, v);
|
||||
{(opt.variants || []).map((v) => {
|
||||
const unit = variantUnitNow(opt, v);
|
||||
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
return (
|
||||
@@ -586,7 +567,7 @@ export default function ManualRegistrationPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const unit = effectiveOptionUnit(opt);
|
||||
const unit = optionUnitNow(opt);
|
||||
return (
|
||||
<div key={opt.id} className="border rounded p-3">
|
||||
<div className="font-medium text-sm">{opt.name}</div>
|
||||
@@ -718,7 +699,7 @@ export default function ManualRegistrationPage() {
|
||||
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
||||
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={payRegistrationId} onChange={e => setPayRegistrationId(e.target.value)} disabled={!payUserId}>
|
||||
<option value="">Select registration…</option>
|
||||
{regsForPayUser.map((r: any) => {
|
||||
{regsForPayUser.map((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return <option key={r.id} value={r.id} title={label}>{label}</option>;
|
||||
|
||||
@@ -7,6 +7,9 @@ import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
|
||||
type Report = {
|
||||
@@ -28,7 +31,7 @@ export default function OfflineScansPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [locks, setLocks] = useState<Lock[]>([]);
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
@@ -38,7 +41,7 @@ export default function OfflineScansPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
|
||||
setEvents(recent);
|
||||
@@ -57,8 +60,8 @@ export default function OfflineScansPage() {
|
||||
]);
|
||||
setLocks(l);
|
||||
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load offline scanning");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load offline scanning");
|
||||
}
|
||||
}, [token, eventId, onlyOpen, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
@@ -68,13 +71,13 @@ export default function OfflineScansPage() {
|
||||
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
|
||||
setInfo(res.message);
|
||||
load();
|
||||
} catch (e: any) { setError(e?.message || "Failed"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed"); }
|
||||
};
|
||||
const review = async (r: Report) => {
|
||||
try {
|
||||
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
|
||||
load();
|
||||
} catch (e: any) { setError(e?.message || "Failed"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed"); }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,7 +87,7 @@ export default function OfflineScansPage() {
|
||||
<WifiOff className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Offline scanning</h1>
|
||||
<p className="text-sm text-gray-500">Devices scanning without a connection, and their synced scans</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -66,38 +67,38 @@ export default function SupervisorDashboardPage() {
|
||||
// /api/stats/supervisor covers scan activity + today's payments (unchanged from before);
|
||||
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared
|
||||
// with the admin dashboard since supervisors already have full revenue/donation visibility.
|
||||
const [scanStats, setScanStats] = useStableState<any | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
||||
const [scanStats, setScanStats] = useStableState<ScanStats | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||
const [overview, setOverview] = useStableState<Overview | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [data, overviewData] = await Promise.all([
|
||||
apiFetch<any>("/api/stats/supervisor", { authToken: token }),
|
||||
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
|
||||
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||
]);
|
||||
setScanStats(data.scanStats);
|
||||
setPaymentStats(data.paymentStats);
|
||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||
setOverview(overviewData);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 15s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -184,7 +185,7 @@ export default function SupervisorDashboardPage() {
|
||||
{loadingStats && <span className="text-xs text-gray-500">Refreshing…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
|
||||
{recentScans.map((u: any) => (
|
||||
{recentScans.map((u) => (
|
||||
<li key={u.id} className="border rounded p-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
|
||||
@@ -236,11 +237,11 @@ export default function SupervisorDashboardPage() {
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
|
||||
{scanStats?.byStaff?.length > 0 && (
|
||||
{scanStats && scanStats.byStaff?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-sm font-medium mb-1">Today by staff</div>
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{scanStats.byStaff.map((s: any) => (
|
||||
{scanStats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import type { Event, Payment, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||
// That leg is not new money: it just re-labels part of an already-counted donation as applied
|
||||
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
|
||||
// excluded — the money was already counted via the original donation row.
|
||||
function isDonationLeg(p: any): boolean {
|
||||
function isDonationLeg(p: Payment): boolean {
|
||||
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
|
||||
}
|
||||
|
||||
@@ -29,13 +33,27 @@ function refundMethodForOriginal(method: string | null | undefined): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
type Outstanding = Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
|
||||
// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the
|
||||
// webhook as Yoco sent it; its checkout metadata says who/what the payment was for.
|
||||
type YocoTx = {
|
||||
id: string;
|
||||
amount: number;
|
||||
checkoutId?: string | null;
|
||||
externalId?: string | null;
|
||||
methodType?: string | null;
|
||||
createdDate?: string | null;
|
||||
raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null;
|
||||
};
|
||||
|
||||
function RegistrationOptions({ regs, regOutstanding }: {
|
||||
regs: any[];
|
||||
regs: Registration[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{regs.map((r: any) => {
|
||||
{regs.map((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — ${r.user?.name || r.userId || 'User'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return (
|
||||
@@ -49,7 +67,7 @@ function RegistrationOptions({ regs, regOutstanding }: {
|
||||
}
|
||||
|
||||
function UserSearchField({ allUsers, value, onChange, placeholder = "Search by name, email or phone…", disabled = false }: {
|
||||
allUsers: any[];
|
||||
allUsers: User[];
|
||||
value: string;
|
||||
onChange: (userId: string) => void;
|
||||
placeholder?: string;
|
||||
@@ -136,87 +154,59 @@ function PaymentsContent() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [payments, setPayments] = useState<any[]>([]);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
|
||||
const [regOutstanding, setRegOutstanding] = useState<Outstanding>({});
|
||||
|
||||
const loadPayments = async () => {
|
||||
const loadPayments = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingList(true);
|
||||
const list = await fetchAllPayments(token);
|
||||
setPayments(list.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load payments");
|
||||
setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load payments");
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
useEffect(() => { loadPayments(); }, [token]);
|
||||
|
||||
// Compute effective unit price for a registration option.
|
||||
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
|
||||
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
|
||||
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
|
||||
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
|
||||
return Number(opt.priceSnapshot);
|
||||
}
|
||||
const eo = opt.eventOption;
|
||||
const variantId: string | null = opt.variantId || null;
|
||||
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
|
||||
? Number(opt.variant.price)
|
||||
: Number(eo?.price || 0);
|
||||
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
|
||||
const tiers = variantId
|
||||
? allTiers.filter((t: any) => t.variantId === variantId)
|
||||
: allTiers.filter((t: any) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
const t = atTime ? new Date(atTime) : new Date();
|
||||
const ref = referenceTime ? new Date(referenceTime) : t;
|
||||
const applicable = tiers
|
||||
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
|
||||
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
|
||||
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
|
||||
if (applicable.length === 0) return base;
|
||||
const price = Number(applicable[0].price);
|
||||
return (price >= 0) ? price : base;
|
||||
};
|
||||
useEffect(() => { loadPayments(); }, [loadPayments]);
|
||||
|
||||
// Load registrations for dropdowns.
|
||||
// GET /api/registrations already embeds each registration's `payments`, so outstanding
|
||||
// balances are computed from that in one pass — no per-registration follow-up requests.
|
||||
const loadRegistrations = async () => {
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const list = Array.isArray(regs) ? regs : [];
|
||||
const now = new Date();
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
const map: Outstanding = {};
|
||||
for (const r of list) {
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
setRegOutstanding(map);
|
||||
// Sort by createdAt desc
|
||||
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
setRegistrations(sorted);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadRegistrations(); }, [token]);
|
||||
useEffect(() => { loadRegistrations(); }, [loadRegistrations]);
|
||||
|
||||
// Load all users for Refund section
|
||||
useEffect(() => {
|
||||
@@ -226,7 +216,7 @@ function PaymentsContent() {
|
||||
setLoadingUsers(true);
|
||||
const users = await fetchAllUsers(token);
|
||||
setAllUsers(users);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore; fallback to derived users from registrations
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
@@ -240,12 +230,12 @@ function PaymentsContent() {
|
||||
const [registrationId, setRegistrationId] = useState<string>("");
|
||||
const [isDonation, setIsDonation] = useState<boolean>(false);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Backdate field
|
||||
const [paidAtLocal, setPaidAtLocal] = useState<string>("");
|
||||
// Users for Refund section (load all users with supervisor rights)
|
||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<User[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
|
||||
// New: user selection states for split dropdowns
|
||||
@@ -253,33 +243,12 @@ function PaymentsContent() {
|
||||
|
||||
// Derived lists for users and outstanding registrations
|
||||
const outstandingRegs = useMemo(() => {
|
||||
return registrations.filter((r: any) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
|
||||
return registrations.filter((r) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
|
||||
}, [registrations, regOutstanding]);
|
||||
|
||||
// Users list for UI dropdowns (use all users if loaded; fallback to outstanding registrations)
|
||||
const usersList = useMemo(() => {
|
||||
if (allUsers && allUsers.length > 0) {
|
||||
return [...allUsers]
|
||||
//.filter(u => u?.isActive !== false)
|
||||
.map(u => ({ id: String(u.id), name: String(u.name || u.email || u.id) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
for (const r of outstandingRegs) {
|
||||
const uid = r.user?.id || r.userId;
|
||||
if (!uid) continue;
|
||||
const name = r.user?.name || r.user?.email || String(r.userId || uid);
|
||||
if (!map.has(String(uid))) {
|
||||
map.set(String(uid), String(name));
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
|
||||
}, [allUsers, outstandingRegs]);
|
||||
|
||||
const regsForSelectedUser = useMemo(() => {
|
||||
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(selectedUserId)));
|
||||
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(selectedUserId)));
|
||||
}, [outstandingRegs, selectedUserId]);
|
||||
|
||||
// Payment link tab state
|
||||
@@ -292,7 +261,7 @@ function PaymentsContent() {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
const regsForLinkUser = useMemo(() => {
|
||||
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(linkUserId)));
|
||||
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(linkUserId)));
|
||||
}, [outstandingRegs, linkUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -327,8 +296,8 @@ function PaymentsContent() {
|
||||
if (!res.redirectUrl) { setError("Failed to generate link"); return; }
|
||||
setLinkResult({ redirectUrl: res.redirectUrl, amount: res.amount ?? amt, registrationId: linkRegistrationId });
|
||||
setInfo("Payment link generated");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to generate payment link");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to generate payment link");
|
||||
} finally {
|
||||
setLinkGenerating(false);
|
||||
}
|
||||
@@ -356,8 +325,8 @@ function PaymentsContent() {
|
||||
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
|
||||
});
|
||||
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || `Failed to send link via ${channel}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || `Failed to send link via ${channel}`);
|
||||
} finally {
|
||||
setLinkSending(null);
|
||||
}
|
||||
@@ -371,7 +340,7 @@ function PaymentsContent() {
|
||||
// If a registration is preselected via query, infer and set its user
|
||||
useEffect(() => {
|
||||
if (!registrationId) return;
|
||||
const reg = registrations.find((r: any) => String(r.id) === String(registrationId));
|
||||
const reg = registrations.find((r) => String(r.id) === String(registrationId));
|
||||
const uid = reg?.user?.id || reg?.userId;
|
||||
if (uid) setSelectedUserId(String(uid));
|
||||
}, [registrationId, registrations]);
|
||||
@@ -387,7 +356,7 @@ function PaymentsContent() {
|
||||
if (eventsIncludePast) params.set("includePast", "true");
|
||||
if (eventsIncludeInactive) params.set("includeInactive", "true");
|
||||
const qs = params.toString() ? `?${params.toString()}` : "";
|
||||
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch {}
|
||||
})();
|
||||
@@ -417,7 +386,7 @@ function PaymentsContent() {
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await apiFetch<any>("/api/payments", {
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -433,8 +402,8 @@ function PaymentsContent() {
|
||||
setInfo(`Payment created (R ${amt.toFixed(2)})`);
|
||||
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
|
||||
await Promise.all([loadPayments(), loadRegistrations()]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create payment");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create payment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -454,38 +423,38 @@ function PaymentsContent() {
|
||||
|
||||
// Yoco unreconciled list and actions
|
||||
const [yocoLoading, setYocoLoading] = useState(false);
|
||||
const [yocoTxs, setYocoTxs] = useState<any[]>([]);
|
||||
const loadYocoUnreconciled = async () => {
|
||||
const [yocoTxs, setYocoTxs] = useState<YocoTx[]>([]);
|
||||
const loadYocoUnreconciled = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setYocoLoading(true);
|
||||
const res = await apiFetch<any>("/api/yoco-transactions/unreconciled", { authToken: token });
|
||||
const items = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
|
||||
const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token });
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
|
||||
setYocoTxs(items);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore silently, shown on demand via error state if needed
|
||||
} finally {
|
||||
setYocoLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { loadYocoUnreconciled(); }, [token]);
|
||||
}, [token]);
|
||||
useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]);
|
||||
|
||||
// Inline reconcile form state per-row
|
||||
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
|
||||
|
||||
const openReconcileToRegistration = (tx: any) => {
|
||||
const openReconcileToRegistration = (tx: YocoTx) => {
|
||||
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
|
||||
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
|
||||
};
|
||||
|
||||
const openReconcileAsDonation = (tx: any) => {
|
||||
const openReconcileAsDonation = (tx: YocoTx) => {
|
||||
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
|
||||
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
|
||||
};
|
||||
|
||||
const regsForUser = (uid: string) => registrations
|
||||
.filter((r: any) => String(r.user?.id || r.userId) === String(uid))
|
||||
.filter((r: any) => {
|
||||
.filter((r) => String(r.user?.id || r.userId) === String(uid))
|
||||
.filter((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
return out > 0.000001; // show only registrations with an outstanding balance
|
||||
});
|
||||
@@ -515,8 +484,8 @@ function PaymentsContent() {
|
||||
}
|
||||
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
|
||||
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to reconcile');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to reconcile');
|
||||
} finally {
|
||||
setReconcileForm(prev => ({ ...prev, submitting: false }));
|
||||
}
|
||||
@@ -531,9 +500,11 @@ function PaymentsContent() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Wallet className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Payments</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Payments</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -618,8 +589,8 @@ function PaymentsContent() {
|
||||
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
|
||||
setInfo('Transaction ignored');
|
||||
await loadYocoUnreconciled();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to ignore');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to ignore');
|
||||
}
|
||||
}}>Ignore</button>
|
||||
</td>
|
||||
@@ -637,7 +608,7 @@ function PaymentsContent() {
|
||||
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.registrationId} onChange={e => setReconcileForm(prev => ({ ...prev, registrationId: e.target.value }))} disabled={!reconcileForm.userId}>
|
||||
<option value="">Select registration…</option>
|
||||
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r: any) => {
|
||||
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return <option key={r.id} value={r.id} title={label}>{label}</option>;
|
||||
@@ -753,8 +724,7 @@ function PaymentsContent() {
|
||||
<RefundSection
|
||||
payments={payments}
|
||||
allUsers={allUsers}
|
||||
usersList={usersList}
|
||||
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
|
||||
regsForUser={(uid:string)=> registrations.filter((r)=> String(r.user?.id||r.userId)===String(uid))}
|
||||
regOutstanding={regOutstanding}
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
|
||||
/>
|
||||
@@ -875,15 +845,14 @@ function PaymentsContent() {
|
||||
}
|
||||
|
||||
type RefundSectionProps = {
|
||||
payments: any[];
|
||||
allUsers: any[];
|
||||
usersList: { id: string; name: string }[];
|
||||
regsForUser: (userId: string) => any[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
payments: Payment[];
|
||||
allUsers: User[];
|
||||
regsForUser: (userId: string) => Registration[];
|
||||
regOutstanding: Outstanding;
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
|
||||
function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
|
||||
const { token } = useAuth();
|
||||
const [userId, setUserId] = useState<string>("");
|
||||
const [target, setTarget] = useState<'payment'|'registration'>('payment');
|
||||
@@ -896,7 +865,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const paymentsForUser = useMemo(() => {
|
||||
if (!userId) return [] as any[];
|
||||
if (!userId) return [];
|
||||
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
|
||||
}, [payments, userId]);
|
||||
|
||||
@@ -946,8 +915,8 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
});
|
||||
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to create refund');
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || 'Failed to create refund');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -970,7 +939,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
<label className="text-xs text-gray-600 mt-2">Payment</label>
|
||||
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={paymentId} onChange={e => setPaymentId(e.target.value)} disabled={!userId}>
|
||||
<option value="">Select payment…</option>
|
||||
{paymentsForUser.map((p:any)=>{
|
||||
{paymentsForUser.map((p)=>{
|
||||
const label = `R ${(p.amount||0).toFixed(2)} — ${p.registrationId ? `Registration ${String(p.registrationId).slice(0,8)}` : (p.event?.title || p.eventId || 'Event')} — #${String(p.id).slice(0,8)}`;
|
||||
return <option key={p.id} value={p.id} title={label}>{label}</option>;
|
||||
})}
|
||||
@@ -981,7 +950,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
<label className="text-xs text-gray-600 mt-2">Registration</label>
|
||||
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={registrationId} onChange={e => setRegistrationId(e.target.value)} disabled={!userId}>
|
||||
<option value="">Select registration…</option>
|
||||
{regs.map((r:any)=>{
|
||||
{regs.map((r)=>{
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return <option key={r.id} value={r.id} title={label}>{label}</option>;
|
||||
@@ -1003,7 +972,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
<option value="eft-refund">EFT refund</option>
|
||||
<option value="voucher-refund">Voucher refund</option>
|
||||
</select>
|
||||
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded — this is what nets against that method's total in reports.</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded — this is what nets against that method's total in reports.</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
|
||||
@@ -1019,7 +988,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
// stats both deliberately filter them out (they're not new money, see isDonationLeg's comment) —
|
||||
// so this is the only place staff can see and reverse an assignment.
|
||||
type AssignedDonationsListProps = {
|
||||
payments: any[];
|
||||
payments: Payment[];
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1033,12 +1002,12 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
const allLegs = useMemo(() => {
|
||||
return payments
|
||||
.filter(isDonationLeg)
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [payments]);
|
||||
|
||||
const donationById = useMemo(() => {
|
||||
const m = new Map<string, any>();
|
||||
payments.forEach((p: any) => { if (p.isDonation) m.set(p.id, p); });
|
||||
const m = new Map<string, Payment>();
|
||||
payments.forEach((p) => { if (p.isDonation) m.set(p.id, p); });
|
||||
return m;
|
||||
}, [payments]);
|
||||
|
||||
@@ -1046,7 +1015,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
// never shows an event with nothing to filter down to.
|
||||
const eventOptions = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
allLegs.forEach((leg: any) => {
|
||||
allLegs.forEach((leg) => {
|
||||
const ev = leg.registration?.event;
|
||||
if (ev?.id) m.set(String(ev.id), ev.title || String(ev.id));
|
||||
});
|
||||
@@ -1055,10 +1024,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
|
||||
const legs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return allLegs.filter((leg: any) => {
|
||||
return allLegs.filter((leg) => {
|
||||
if (eventFilter && String(leg.registration?.eventId || leg.registration?.event?.id) !== eventFilter) return false;
|
||||
if (!q) return true;
|
||||
const donation = donationById.get(leg.originalPaymentId);
|
||||
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
|
||||
const haystack = [
|
||||
leg.registration?.user?.name,
|
||||
leg.registration?.user?.email,
|
||||
@@ -1072,7 +1041,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
});
|
||||
}, [allLegs, donationById, query, eventFilter]);
|
||||
|
||||
const unassign = async (leg: any) => {
|
||||
const unassign = async (leg: Payment) => {
|
||||
if (!token) return;
|
||||
setErr(null);
|
||||
const ok = window.confirm("Unassign this donation? The registration's balance will increase, and any tickets issued only because this payment completed it may be revoked.");
|
||||
@@ -1085,8 +1054,8 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
body: { legId: leg.id }
|
||||
});
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to unassign donation');
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || 'Failed to unassign donation');
|
||||
} finally {
|
||||
setUnassigningId(null);
|
||||
}
|
||||
@@ -1120,8 +1089,8 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
<div className="text-sm text-gray-500">No assigned donations match your search.</div>
|
||||
) : (
|
||||
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
|
||||
{legs.map((leg: any) => {
|
||||
const donation = donationById.get(leg.originalPaymentId);
|
||||
{legs.map((leg) => {
|
||||
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
|
||||
return (
|
||||
<li key={leg.id} className="border rounded p-2">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
@@ -1153,10 +1122,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
}
|
||||
|
||||
type DonationAssignSectionProps = {
|
||||
payments: any[];
|
||||
allUsers: any[];
|
||||
registrations: any[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
payments: Payment[];
|
||||
allUsers: User[];
|
||||
registrations: Registration[];
|
||||
regOutstanding: Outstanding;
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1180,8 +1149,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
// Registrations for the selected user that can actually receive a donation: an outstanding
|
||||
// balance to apply it to, and an event that isn't closed.
|
||||
const regsForUser = useMemo(() => {
|
||||
if (!userId) return [] as any[];
|
||||
return registrations.filter((r: any) => {
|
||||
if (!userId) return [];
|
||||
return registrations.filter((r) => {
|
||||
if (String(r.user?.id || r.userId) !== String(userId)) return false;
|
||||
if (r.status === 'cancelled') return false;
|
||||
if (r.event?.cashupStatus === 'closed') return false;
|
||||
@@ -1191,7 +1160,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
}, [registrations, regOutstanding, userId]);
|
||||
|
||||
const selectedRegistration = useMemo(
|
||||
() => registrations.find((r: any) => String(r.id) === String(registrationId)),
|
||||
() => registrations.find((r) => String(r.id) === String(registrationId)),
|
||||
[registrations, registrationId]
|
||||
);
|
||||
|
||||
@@ -1204,7 +1173,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
// adding the refund back on top of what's left to allocate).
|
||||
const legsById = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
payments.forEach((p: any) => {
|
||||
payments.forEach((p) => {
|
||||
if (p.originalPaymentId && !p.isDonation) {
|
||||
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0));
|
||||
}
|
||||
@@ -1215,9 +1184,9 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
// Donations "relevant to that event" — donations logged against the same event as the
|
||||
// chosen registration that still have a remaining, unused balance.
|
||||
const donationsForEvent = useMemo(() => {
|
||||
if (!selectedRegistration) return [] as any[];
|
||||
if (!selectedRegistration) return [];
|
||||
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
|
||||
return payments.filter((p: any) => {
|
||||
return payments.filter((p) => {
|
||||
if (!p.isDonation || p.registrationId) return false;
|
||||
if (String(p.eventId) !== String(eventId)) return false;
|
||||
if (sponsorshipsOnly && !p.isSponsorship) return false;
|
||||
@@ -1227,7 +1196,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
}, [payments, selectedRegistration, legsById, sponsorshipsOnly]);
|
||||
|
||||
const selectedDonation = useMemo(
|
||||
() => payments.find((p: any) => String(p.id) === String(paymentId)),
|
||||
() => payments.find((p) => String(p.id) === String(paymentId)),
|
||||
[payments, paymentId]
|
||||
);
|
||||
|
||||
@@ -1269,8 +1238,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
});
|
||||
setUserId(""); setRegistrationId(""); setPaymentId(""); setAmountStr("");
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to assign donation');
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || 'Failed to assign donation');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
@@ -1306,7 +1275,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
disabled={!registrationId}
|
||||
>
|
||||
<option value="">Select donation…</option>
|
||||
{donationsForEvent.map((p: any) => {
|
||||
{donationsForEvent.map((p) => {
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
const donor = p.isSponsorship ? `Sponsorship: ${p.sponsorAnonymous ? 'anonymous' : (p.sponsorName || p.user?.name || 'sponsor')}` : (p.user?.name || p.userId || 'Donor');
|
||||
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${donor} — #${String(p.id).slice(0,8)}`;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ReportsV2 from "@/components/reports/ReportsV2";
|
||||
|
||||
export default function SupervisorReportsPage() {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="w-full p-6">
|
||||
<Suspense fallback={<div className="text-sm text-gray-400">Loading…</div>}>
|
||||
<ReportsV2 onBack={() => router.push('/dashboard')} />
|
||||
<ReportsV2 backHref="/dashboard" />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,9 @@ import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -43,7 +46,7 @@ export default function WaitlistPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -52,7 +55,7 @@ export default function WaitlistPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all", { authToken: token })
|
||||
.then(evs => {
|
||||
const withList = (evs || []).filter(e => e.waitlistEnabled);
|
||||
setEvents(withList);
|
||||
@@ -67,22 +70,22 @@ export default function WaitlistPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the waitlist");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the waitlist");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (path: string, body?: any) => {
|
||||
const act = async (path: string, body?: { direction: "up" | "down" }) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
|
||||
if (res?.message) setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -98,7 +101,7 @@ export default function WaitlistPage() {
|
||||
<ListChecks className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Waitlist</h1>
|
||||
<p className="text-sm text-gray-500">People waiting for sold-out tickets</p>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
type UserEntry = { id: string; name: string; phone: string; pref: string };
|
||||
type TemplateKey = "custom" | "payment_reminder" | "event_reminder" | "tickets";
|
||||
type StatusFilter = "any" | "paid" | "unpaid" | "partial_paid" | "cancelled";
|
||||
type MessageFilter = { status?: Exclude<StatusFilter, "any">; attendeeIds?: string[]; allocationGroupId?: string };
|
||||
|
||||
const WA_TAB_ICONS: Record<"attendees" | "automations" | "broadcasts" | "scheduled", LucideIcon> = {
|
||||
attendees: Users,
|
||||
@@ -160,16 +166,14 @@ function AttendeesCheckboxDropdown({
|
||||
|
||||
// ─── Preference warning banner ────────────────────────────────────────────────
|
||||
function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee|UserEntry)[]; selectedIds: string[]; channel: "whatsapp" | "email" }) {
|
||||
const prefMatch = (pref: string) =>
|
||||
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
|
||||
|
||||
const mismatched = useMemo(
|
||||
() => selectedIds.filter((id) => {
|
||||
const mismatched = useMemo(() => {
|
||||
const prefMatch = (pref: string) =>
|
||||
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
|
||||
return selectedIds.filter((id) => {
|
||||
const a = attendees.find((x) => x.id === id);
|
||||
return a && !prefMatch(a.pref);
|
||||
}),
|
||||
[attendees, selectedIds, channel]
|
||||
);
|
||||
});
|
||||
}, [attendees, selectedIds, channel]);
|
||||
|
||||
if (mismatched.length === 0) return null;
|
||||
return (
|
||||
@@ -188,9 +192,9 @@ function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee
|
||||
}
|
||||
|
||||
// ─── WhatsApp Automations Panel ───────────────────────────────────────────────
|
||||
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
|
||||
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[]; token?: string | null; onInfo: (s: string | null) => void; onError: (s: string | null) => void }) {
|
||||
const [autoEventId, setAutoEventId] = React.useState<string>("");
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e: any) => e.id === autoEventId), [events, autoEventId]);
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e) => e.id === autoEventId), [events, autoEventId]);
|
||||
|
||||
const preWeekDefault = `Hi {{name}}\n\nJust a friendly reminder that {{event.title}} is one week away!\n\nEvent details: {{event.link}}`;
|
||||
const finalDefault = `Hi {{name}}\n\nFinal reminder for {{event.title}}.\nPlease have your QR code/ticket ready at entry.\n\nEvent details: {{event.link}}`;
|
||||
@@ -230,10 +234,10 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
|
||||
const onSchedule = async () => {
|
||||
try {
|
||||
onError(null as any); onInfo(null as any);
|
||||
onError(null); onInfo(null);
|
||||
if (!token) { onError("Not authenticated"); return; }
|
||||
if (!autoEventId) { onError("Please select an event"); return; }
|
||||
const jobs: any[] = [];
|
||||
const jobs: { message: string; scheduledAt: string; promoEventId?: string }[] = [];
|
||||
if (enablePre && preBody.trim() && preWhen) jobs.push({ message: preBody, scheduledAt: new Date(preWhen).toISOString() });
|
||||
if (enableFinal && finalBody.trim() && finalWhen) jobs.push({ message: finalBody, scheduledAt: new Date(finalWhen).toISOString() });
|
||||
if (enableThanks && thanksBody.trim() && thanksWhen) jobs.push({ message: thanksBody, scheduledAt: new Date(thanksWhen).toISOString() });
|
||||
@@ -248,8 +252,8 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
scheduled++;
|
||||
}
|
||||
onInfo(`Scheduled ${scheduled} WhatsApp automation(s).`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Failed to schedule automations");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Failed to schedule automations");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,7 +263,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{(events || []).map((ev: any) => (
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -286,7 +290,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
<input type="checkbox" checked={enableFinal} onChange={(e) => setEnableFinal(e.target.checked)} /> Enable
|
||||
</label>
|
||||
<label className="text-xs text-gray-600">Hours before:</label>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as any)}>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as "24" | "48")}>
|
||||
<option value="24">24</option>
|
||||
<option value="48">48</option>
|
||||
</select>
|
||||
@@ -324,7 +328,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
|
||||
<option value="">Select event to promote…</option>
|
||||
{(events || []).map((ev: any) => (
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -372,7 +376,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
|
||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
@@ -385,24 +389,26 @@ function WhatsAppAttendeesPageInner() {
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
setAllEvents((evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
setAllEvents((evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
};
|
||||
loadEvents();
|
||||
}, [user, token]);
|
||||
}, [user, token, setError]);
|
||||
|
||||
// ── Attendees tab ──────────────────────────────────────────────────────────
|
||||
const [eventId, setEventId] = useState<string>(preselectEventId);
|
||||
useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]);
|
||||
const [templateKey, setTemplateKey] = useState<"custom" | "payment_reminder" | "event_reminder" | "tickets">("custom");
|
||||
const [templateKey, setTemplateKey] = useState<TemplateKey>("custom");
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageDirty, setMessageDirty] = useState(false);
|
||||
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
|
||||
// Set once the supervisor edits the message, so picking a template doesn't overwrite it.
|
||||
// A ref, not state: it only gates the template effect below and is never rendered.
|
||||
const messageDirtyRef = useRef(false);
|
||||
const [status, setStatus] = useState<StatusFilter>("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
|
||||
@@ -418,11 +424,11 @@ function WhatsAppAttendeesPageInner() {
|
||||
useEffect(() => {
|
||||
const title = currentEvent?.title || "the event";
|
||||
if (templateKey === "payment_reminder") {
|
||||
if (!messageDirty) setMessage(`Hi {{name}}\n\nFriendly reminder: you have an outstanding balance for ${title}.\n\nPlease settle your balance to secure your tickets. Thank you!`);
|
||||
if (!messageDirtyRef.current) setMessage(`Hi {{name}}\n\nFriendly reminder: you have an outstanding balance for ${title}.\n\nPlease settle your balance to secure your tickets. Thank you!`);
|
||||
} else if (templateKey === "event_reminder") {
|
||||
if (!messageDirty) setMessage(`Hi {{name}}\n\nA quick reminder about ${title}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`);
|
||||
if (!messageDirtyRef.current) setMessage(`Hi {{name}}\n\nA quick reminder about ${title}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`);
|
||||
} else if (templateKey === "tickets") {
|
||||
setMessageDirty(false); setMessage("");
|
||||
messageDirtyRef.current = false; setMessage("");
|
||||
}
|
||||
}, [templateKey, currentEvent]);
|
||||
|
||||
@@ -431,10 +437,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
try {
|
||||
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r: any) => {
|
||||
const u = r?.user;
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = r?.user;
|
||||
if (u?.id && u?.phoneNumber && !u.email?.endsWith("@guest.local") && u.isActive !== false) {
|
||||
uniq.set(u.id, { id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" });
|
||||
}
|
||||
@@ -450,7 +456,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
}, [eventId, token]);
|
||||
|
||||
const buildPayload = (dryRun?: boolean) => {
|
||||
const payload: any = {
|
||||
const payload: { filter: MessageFilter; template: TemplateKey; dryRun?: boolean; message?: string } = {
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
dryRun: dryRun || undefined,
|
||||
@@ -465,15 +471,15 @@ function WhatsAppAttendeesPageInner() {
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
if (!eventId) { setError("Please select an event"); return; }
|
||||
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
|
||||
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
|
||||
setPreviewCount(res?.matched ?? 0);
|
||||
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
|
||||
setInfo(`Matched ${res?.matched ?? 0} recipient(s) with a phone number.`);
|
||||
} catch (e: any) { setError(e?.message || "Failed to preview"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to preview"); }
|
||||
};
|
||||
|
||||
const resetAttendeesForm = () => {
|
||||
setTemplateKey("custom"); setMessage(""); setMessageDirty(false);
|
||||
setTemplateKey("custom"); setMessage(""); messageDirtyRef.current = false;
|
||||
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
|
||||
setSelectedAttendeeIds(attendees.map((a) => a.id));
|
||||
};
|
||||
@@ -485,10 +491,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
if (!eventId) { setError("Please select an event"); return; }
|
||||
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
|
||||
setSending(true);
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
|
||||
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
|
||||
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
|
||||
resetAttendeesForm();
|
||||
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to send"); } finally { setSending(false); }
|
||||
};
|
||||
|
||||
const onScheduleSend = async () => {
|
||||
@@ -499,15 +505,15 @@ function WhatsAppAttendeesPageInner() {
|
||||
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
|
||||
if (!scheduledAtLocal) { setError("Scheduled time is required"); return; }
|
||||
const whenIso = new Date(scheduledAtLocal).toISOString();
|
||||
const payload: any = {
|
||||
const payload: { message?: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } = {
|
||||
message: message || undefined,
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey, scheduledAt: whenIso,
|
||||
};
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
|
||||
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
|
||||
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
|
||||
resetAttendeesForm();
|
||||
} catch (e: any) { setError(e?.message || "Failed to schedule"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to schedule"); }
|
||||
};
|
||||
|
||||
// ── Broadcasts tab ─────────────────────────────────────────────────────────
|
||||
@@ -528,9 +534,9 @@ function WhatsAppAttendeesPageInner() {
|
||||
setLoadingUsers(true);
|
||||
const allUsers = await fetchAllUsers(token);
|
||||
const mapped = allUsers
|
||||
.filter((u: any) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
|
||||
.map((u: any) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" }))
|
||||
.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
|
||||
.filter((u) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
|
||||
.map((u) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber || "", pref: u.notificationPreference || "email" }))
|
||||
.sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
|
||||
setUsers(mapped);
|
||||
} catch { } finally { setLoadingUsers(false); }
|
||||
};
|
||||
@@ -552,30 +558,30 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [editWhen, setEditWhen] = useState<string>("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
const loadScheduled = async () => {
|
||||
const loadScheduled = useCallback(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingScheduled(true);
|
||||
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
|
||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||
} catch { } finally { setLoadingScheduled(false); }
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, token]);
|
||||
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, loadScheduled]);
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
const body: any = {};
|
||||
const body: { scheduledAt?: string; message?: string } = {};
|
||||
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
|
||||
if (editMessage.trim()) body.message = editMessage;
|
||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
|
||||
setInfo("Scheduled message updated.");
|
||||
setEditing(null);
|
||||
loadScheduled();
|
||||
} catch (e: any) { setError(e?.message || "Failed to update"); } finally { setSavingEdit(false); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to update"); } finally { setSavingEdit(false); }
|
||||
};
|
||||
|
||||
const removeJob = async (job: ScheduledJob) => {
|
||||
@@ -584,7 +590,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: "DELETE", authToken: token });
|
||||
setInfo("Scheduled message removed.");
|
||||
loadScheduled();
|
||||
} catch (e: any) { setError(e?.message || "Failed to remove"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to remove"); }
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
@@ -603,11 +609,11 @@ function WhatsAppAttendeesPageInner() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">WhatsApp Attendees</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">WhatsApp Attendees</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
@@ -663,7 +669,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
Info
|
||||
</button>
|
||||
</div>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as any)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as TemplateKey)}>
|
||||
<option value="custom">Custom message</option>
|
||||
<option value="payment_reminder">Payment reminder</option>
|
||||
<option value="event_reminder">Event reminder</option>
|
||||
@@ -693,7 +699,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<li><code>{"{{event.title}}"}</code> — the event title.</li>
|
||||
<li><code>{"{{event.start}}"}</code> — the event start date/time.</li>
|
||||
<li><code>{"{{balance}}"}</code> — outstanding balance for the event.</li>
|
||||
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
</ul>
|
||||
<p className="text-[11px] text-gray-500 mt-2">
|
||||
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
|
||||
@@ -709,7 +715,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Payment status filter</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as any)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as StatusFilter)}>
|
||||
<option value="any">Any</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="unpaid">Unpaid (pending or partial)</option>
|
||||
@@ -724,7 +730,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Message</label>
|
||||
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={message}
|
||||
onChange={(e) => { setMessage(e.target.value); setMessageDirty(true); }}
|
||||
onChange={(e) => { setMessage(e.target.value); messageDirtyRef.current = true; }}
|
||||
placeholder="Write your WhatsApp message to attendees..." />
|
||||
</div>
|
||||
) : (
|
||||
@@ -837,11 +843,11 @@ function WhatsAppAttendeesPageInner() {
|
||||
try {
|
||||
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
const res = await apiFetch(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
|
||||
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
|
||||
setBroadcastPreviewCount(res?.matched ?? 0);
|
||||
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
|
||||
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
|
||||
} catch (e: any) { setError(e?.message || "Failed to preview"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to preview"); }
|
||||
}}>Preview recipients</button>
|
||||
|
||||
<button type="button" className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={async () => {
|
||||
@@ -849,10 +855,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
setError(null); setInfo(null);
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
|
||||
const res = await apiFetch(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
|
||||
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
|
||||
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
|
||||
resetBroadcastForm();
|
||||
} catch (e: any) { setError(e?.message || "Failed to send"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to send"); }
|
||||
}}>Send now</button>
|
||||
|
||||
<button type="button" disabled={!broadcastScheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
|
||||
@@ -861,10 +867,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
|
||||
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
|
||||
const res = await apiFetch(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
|
||||
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
|
||||
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
|
||||
resetBroadcastForm();
|
||||
} catch (e: any) { setError(e?.message || "Failed to schedule broadcast"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
|
||||
}}>Schedule send</button>
|
||||
|
||||
{broadcastPreviewCount != null && <span className="text-xs text-gray-600">Preview: {broadcastPreviewCount} recipient(s)</span>}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
export default function DonatePage() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -17,14 +20,14 @@ export default function DonatePage() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events");
|
||||
const evs = await apiFetch<Event[]>("/api/events");
|
||||
setEvents(evs);
|
||||
if (evs.length > 0) setEventId(evs[0].id);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}, [setError]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!token) { setError("Please login"); return; }
|
||||
@@ -49,8 +52,8 @@ export default function DonatePage() {
|
||||
});
|
||||
setInfo("Redirecting to payment...");
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to start donation checkout");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to start donation checkout");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -63,7 +66,8 @@ export default function DonatePage() {
|
||||
<HandHeart className="w-5 h-5 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Make a donation</h1>
|
||||
<BackLink href="/dashboard/user" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Make a donation</h1>
|
||||
<p className="text-sm text-gray-500">Support an event or the ministry directly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -16,7 +19,7 @@ function FormsContent() {
|
||||
const { token } = useAuth();
|
||||
const registrationId = search.get("registrationId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [registration, setRegistration] = useState<Registration | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -32,22 +35,22 @@ function FormsContent() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft
|
||||
try {
|
||||
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
|
||||
const draft = await apiFetch<{ data?: Record<number, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load registration');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, registrationId]);
|
||||
}, [token, registrationId, setError, setInfo]);
|
||||
|
||||
const submittedCount = useMemo(() => {
|
||||
return (registration?.formResponses || []).length;
|
||||
@@ -57,8 +60,8 @@ function FormsContent() {
|
||||
if (!registration) return 0;
|
||||
try {
|
||||
return (registration.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
@@ -88,7 +91,7 @@ function FormsContent() {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
const payload: any[] = [];
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
payload.push({ answers: formsData[i] || {} });
|
||||
}
|
||||
@@ -103,13 +106,13 @@ function FormsContent() {
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
// reload registration to reflect new responses
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
setFormsData({});
|
||||
// On success, redirect to the user dashbaord
|
||||
router.push('/dashboard/user');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to submit forms');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,8 +126,8 @@ function FormsContent() {
|
||||
body: { data: formsData }
|
||||
});
|
||||
setInfo('Draft saved. You can return later to finish.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save draft');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,9 +140,11 @@ function FormsContent() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<FileText className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Attendee forms</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard/user" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Attendee forms</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard/user')}>Back</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-gray-600 mb-2">Loading…</div>}
|
||||
@@ -159,14 +164,14 @@ function FormsContent() {
|
||||
<div className="border rounded p-3 mb-4 bg-gray-50">
|
||||
<div className="text-sm font-medium mb-2">Submitted responses</div>
|
||||
<ul className="space-y-2">
|
||||
{(registration.formResponses || []).map((resp: any, idx: number) => (
|
||||
{(registration.formResponses || []).map((resp, idx) => (
|
||||
<li key={resp.id} className="bg-white border rounded p-2">
|
||||
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
|
||||
{(resp.answers || []).length === 0 ? (
|
||||
<div className="text-xs text-gray-600">No answers recorded.</div>
|
||||
) : (
|
||||
<ul className="text-xs list-disc pl-5 space-y-0.5">
|
||||
{resp.answers.map((a: any) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<li key={a.id}>
|
||||
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
|
||||
</li>
|
||||
|
||||
@@ -12,16 +12,22 @@ import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
|
||||
import { MyWaitlists } from "@/components/shared/MyWaitlists";
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
|
||||
|
||||
// Helper formatters
|
||||
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
||||
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
|
||||
const isEventOver = (ev: any) =>
|
||||
type EventStatusFields = Pick<Event, "endDate" | "cashupStatus" | "isActive">;
|
||||
type Billing = { totalDue: number; totalPaid: number; outstanding: number; payments: Payment[] };
|
||||
|
||||
const isEventOver = (ev: EventStatusFields | null | undefined) =>
|
||||
!!ev && ((ev.endDate && new Date(ev.endDate).getTime() < Date.now()) || ev.cashupStatus === 'closed');
|
||||
|
||||
// Status badge for an event, shown wherever an event title is displayed on this page.
|
||||
// Precedence when more than one applies: Closed > Past > Inactive.
|
||||
function EventStatusBadge({ event }: { event: any }) {
|
||||
function EventStatusBadge({ event }: { event: EventStatusFields | null | undefined }) {
|
||||
if (!event) return null;
|
||||
let label: string | null = null;
|
||||
let className = '';
|
||||
@@ -53,26 +59,26 @@ function RegistrationStatusBadge({ status }: { status: string }) {
|
||||
// Effective unit price for an event option (or one of its variants), early-bird aware.
|
||||
// Used by the registration editor, which works off raw /api/events/:id data rather than
|
||||
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
|
||||
function effectiveEventOptionUnitPrice(eo: any, variantId: string | null): number {
|
||||
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null): number {
|
||||
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
|
||||
const base = (variant && variant.price !== null && variant.price !== undefined)
|
||||
? Number(variant.price)
|
||||
: Number(eo?.price || 0);
|
||||
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers : [];
|
||||
const tiers = variantId ? allTiers.filter((t: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId);
|
||||
const tiers = variantId ? allTiers.filter((t) => t.variantId === variantId) : allTiers.filter((t) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
const now = new Date();
|
||||
const applicable = tiers
|
||||
.map((t: any) => ({ ...t, _d: new Date(t.deadline) }))
|
||||
.filter((t: any) => now < t._d)
|
||||
.sort((a: any, b: any) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||||
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
|
||||
.filter((t) => now < t._d)
|
||||
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||||
return applicable.length > 0 ? applicable[0].price : base;
|
||||
}
|
||||
|
||||
export default function UserDashboardPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@@ -81,7 +87,7 @@ export default function UserDashboardPage() {
|
||||
const [showPast, setShowPast] = useState<boolean>(false);
|
||||
|
||||
// Billing map: regId -> { totalDue, totalPaid, outstanding, payments }
|
||||
const [billing, setBilling] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }>>({});
|
||||
const [billing, setBilling] = useState<Record<string, Billing>>({});
|
||||
|
||||
// Forms status cache for registrations
|
||||
// regId -> { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }
|
||||
@@ -99,35 +105,6 @@ export default function UserDashboardPage() {
|
||||
// Track whether the active registration's event has attendee forms
|
||||
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
|
||||
|
||||
// Compute effective unit price for a registration option.
|
||||
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
|
||||
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
|
||||
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
|
||||
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
|
||||
return Number(opt.priceSnapshot);
|
||||
}
|
||||
const eo = opt.eventOption;
|
||||
const variantId: string | null = opt.variantId || null;
|
||||
// Use variant price as base if available, otherwise option base
|
||||
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
|
||||
? Number(opt.variant.price)
|
||||
: Number(eo?.price || 0);
|
||||
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
|
||||
// Filter: variant options use only their variant-specific tiers; plain options use option-level tiers
|
||||
const tiers = variantId
|
||||
? allTiers.filter((t: any) => t.variantId === variantId)
|
||||
: allTiers.filter((t: any) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
const t = atTime ? new Date(atTime) : new Date();
|
||||
const ref = referenceTime ? new Date(referenceTime) : t;
|
||||
const applicable = tiers
|
||||
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
|
||||
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
|
||||
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
|
||||
if (applicable.length === 0) return base;
|
||||
const price = Number(applicable[0].price);
|
||||
return (price >= 0) ? price : base;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -139,29 +116,29 @@ export default function UserDashboardPage() {
|
||||
// waterfalling — this was previously fetched only after registrations + all
|
||||
// their payments had already resolved.
|
||||
const [myRegs, myTicks] = await Promise.all([
|
||||
apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
|
||||
apiFetch<any[]>("/api/tickets/mytickets", { authToken: token }),
|
||||
apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
|
||||
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
|
||||
]);
|
||||
setRegistrations(myRegs);
|
||||
setTickets(myTicks);
|
||||
|
||||
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
const totals: Record<string, Billing> = {};
|
||||
for (const r of myRegs) {
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
|
||||
}
|
||||
setBilling(totals);
|
||||
|
||||
// Fetch payments per registration to compute outstanding
|
||||
await Promise.all(
|
||||
myRegs.map(async (r: any) => {
|
||||
myRegs.map(async (r) => {
|
||||
try {
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDueComputed is not time-dependent, no need to recompute per payment time
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const outstanding = Math.max(0, totalDue - totalPaid);
|
||||
setBilling(prev => ({
|
||||
...prev,
|
||||
@@ -178,13 +155,13 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load data");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, showPast]);
|
||||
}, [token, showPast, setError]);
|
||||
|
||||
// After registrations load, compute forms status for each registration.
|
||||
// `myregistrations` now embeds event.form (with fields) directly, so this no longer
|
||||
@@ -195,7 +172,7 @@ export default function UserDashboardPage() {
|
||||
if (!token || registrations.length === 0) return;
|
||||
const next: Record<string, { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }> = {};
|
||||
await Promise.all(
|
||||
registrations.map(async (r: any) => {
|
||||
registrations.map(async (r) => {
|
||||
try {
|
||||
const ev = r.event;
|
||||
const hasForm = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
|
||||
@@ -204,12 +181,12 @@ export default function UserDashboardPage() {
|
||||
return;
|
||||
}
|
||||
// Load registration to get number of submitted responses
|
||||
const regFull = await apiFetch<any>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const regFull = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const submittedCount = (regFull?.formResponses || []).length;
|
||||
// Required count equals the number of main tickets purchased
|
||||
const requiredCount = (r.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
const complete = submittedCount >= requiredCount && requiredCount > 0 ? true : (submittedCount >= requiredCount);
|
||||
next[r.id] = {
|
||||
hasForm: true,
|
||||
@@ -218,7 +195,7 @@ export default function UserDashboardPage() {
|
||||
requiredCount,
|
||||
complete,
|
||||
};
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// On any error, do not show forms status to avoid noisy UI
|
||||
next[r.id] = { hasForm: false };
|
||||
}
|
||||
@@ -245,16 +222,16 @@ export default function UserDashboardPage() {
|
||||
// Open loading dialog immediately
|
||||
setDialog({ open: true, message: "Sending tickets…", loading: true });
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
|
||||
method: "POST",
|
||||
body: { ticketIds },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) emailed successfully.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to email tickets");
|
||||
setError(errorMessage(e) || "Failed to email tickets");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,16 +242,16 @@ export default function UserDashboardPage() {
|
||||
setInfo(null);
|
||||
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
|
||||
method: "POST",
|
||||
body: { ticketIds, channel: "whatsapp" },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) sent to WhatsApp.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send tickets to WhatsApp");
|
||||
setError(errorMessage(e) || "Failed to send tickets to WhatsApp");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,15 +265,15 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your invoice.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send invoice");
|
||||
setError(errorMessage(e) || "Failed to send invoice");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -310,15 +287,15 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your receipt.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
setError(errorMessage(e) || "Failed to send receipt");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,10 +324,10 @@ export default function UserDashboardPage() {
|
||||
// Refresh this registration's billing so the displayed outstanding reflects the new price
|
||||
try {
|
||||
const now = new Date();
|
||||
const r = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
|
||||
} catch {}
|
||||
return;
|
||||
@@ -361,14 +338,14 @@ export default function UserDashboardPage() {
|
||||
return;
|
||||
}
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to create checkout");
|
||||
setError(errorMessage(e) || "Failed to create checkout");
|
||||
}
|
||||
};
|
||||
|
||||
// Print helpers
|
||||
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
|
||||
const buildTicketHtmlCard = (t: TicketRecord, buyerName?: string) => {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
@@ -439,7 +416,7 @@ export default function UserDashboardPage() {
|
||||
w.focus();
|
||||
};
|
||||
|
||||
const printTickets = (ticketList: any[]) => {
|
||||
const printTickets = (ticketList: TicketRecord[]) => {
|
||||
if (!ticketList || ticketList.length === 0) return;
|
||||
const cards = ticketList.map(t => buildTicketHtmlCard(t, user?.name)).join("");
|
||||
openPrintWindow(`Tickets (${ticketList.length})`, cards);
|
||||
@@ -447,7 +424,7 @@ export default function UserDashboardPage() {
|
||||
|
||||
// Upcoming events derived from registrations
|
||||
const upcomingEvents = useMemo(() => {
|
||||
const map = new Map<string, any>();
|
||||
const map = new Map<string, Event>();
|
||||
for (const r of registrations) {
|
||||
if (r.event && isFuture(r.event.startDate)) {
|
||||
if (!map.has(r.event.id)) map.set(r.event.id, r.event);
|
||||
@@ -456,16 +433,6 @@ export default function UserDashboardPage() {
|
||||
return Array.from(map.values()).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
}, [registrations]);
|
||||
|
||||
// Tickets by grouping
|
||||
const ticketsByEvent = useMemo(() => {
|
||||
const m: Record<string, any[]> = {};
|
||||
for (const t of tickets) {
|
||||
const eid = t.eventId;
|
||||
if (!m[eid]) m[eid] = [];
|
||||
m[eid].push(t);
|
||||
}
|
||||
return m;
|
||||
}, [tickets]);
|
||||
|
||||
// Registration click -> open modal
|
||||
const activeReg = useMemo(() => registrations.find(r => r.id === activeRegId) || null, [registrations, activeRegId]);
|
||||
@@ -484,7 +451,7 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(ar.eventId)}`);
|
||||
const ev = await apiFetch<Event>(`/api/events/${encodeURIComponent(ar.eventId)}`);
|
||||
const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
|
||||
setActiveEventHasForm(has);
|
||||
} catch {
|
||||
@@ -515,35 +482,35 @@ export default function UserDashboardPage() {
|
||||
authToken: token,
|
||||
body: { code: codeInput.trim() },
|
||||
});
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
const updated = myRegs.find(r => r.id === activeRegId);
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = updated?.totalDueComputed ?? 0;
|
||||
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
|
||||
try {
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
setCodeInput("");
|
||||
setInfo(res?.message || 'Code applied.');
|
||||
} catch (e: any) {
|
||||
setCodeError(e?.message || "Couldn't apply that code");
|
||||
} catch (e) {
|
||||
setCodeError(errorMessage(e) || "Couldn't apply that code");
|
||||
} finally {
|
||||
setCodeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
|
||||
|
||||
// Re-fetch registrations and tickets after something on one changes (attendee names).
|
||||
const reloadAfterAttendees = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
};
|
||||
@@ -560,13 +527,13 @@ export default function UserDashboardPage() {
|
||||
method: 'DELETE',
|
||||
authToken: token,
|
||||
});
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
setActiveRegId(null);
|
||||
setShowCancelConfirm(false);
|
||||
setInfo('Registration cancelled.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to cancel registration');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to cancel registration');
|
||||
setShowCancelConfirm(false);
|
||||
} finally {
|
||||
setCancelLoading(false);
|
||||
@@ -577,7 +544,7 @@ export default function UserDashboardPage() {
|
||||
// "eventOptionId" for plain options, "eventOptionId:variantId" for variant options.
|
||||
const [editMode, setEditMode] = useState<boolean>(false);
|
||||
const [editLoading, setEditLoading] = useState<boolean>(false);
|
||||
const [editEventOptions, setEditEventOptions] = useState<any[]>([]);
|
||||
const [editEventOptions, setEditEventOptions] = useState<EventOption[]>([]);
|
||||
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
|
||||
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
|
||||
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
|
||||
@@ -591,9 +558,9 @@ export default function UserDashboardPage() {
|
||||
}, [activeReg]);
|
||||
|
||||
const editTotal = useMemo(() => {
|
||||
return editEventOptions.reduce((sum: number, eo: any) => {
|
||||
return editEventOptions.reduce((sum, eo) => {
|
||||
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
|
||||
return sum + eo.variants.reduce((vsum: number, v: any) => {
|
||||
return sum + eo.variants.reduce((vsum, v) => {
|
||||
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
|
||||
return vsum + qty * effectiveEventOptionUnitPrice(eo, v.id);
|
||||
}, 0);
|
||||
@@ -608,10 +575,10 @@ export default function UserDashboardPage() {
|
||||
setEditLoading(true);
|
||||
try {
|
||||
// Load event options (with variants) for this event
|
||||
const eventData = await apiFetch<any>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
||||
const eventOptions = (eventData?.eventOptions || []) as any[];
|
||||
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
||||
const eventOptions = eventData?.eventOptions || [];
|
||||
|
||||
const regOptById = new Map<string, any>((activeReg.registrationOptions || []).map((o: any) => [o.id, o]));
|
||||
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
|
||||
|
||||
// Prefill quantities from the current registration
|
||||
const qtyMap: Record<string, number> = {};
|
||||
@@ -624,16 +591,16 @@ export default function UserDashboardPage() {
|
||||
// (tickets are never deleted or shrunk, only ever grown, once paid).
|
||||
const minQtyMap: Record<string, number> = {};
|
||||
for (const t of tickets) {
|
||||
const ro = regOptById.get(t.registrationOptionId);
|
||||
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
|
||||
if (!ro) continue;
|
||||
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
|
||||
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
|
||||
}
|
||||
|
||||
// Ensure every option/variant has a (possibly zero) entry so they all render
|
||||
eventOptions.forEach((eo: any) => {
|
||||
eventOptions.forEach((eo) => {
|
||||
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
|
||||
eo.variants.forEach((v: any) => {
|
||||
eo.variants.forEach((v) => {
|
||||
const key = `${eo.id}:${v.id}`;
|
||||
if (!(key in qtyMap)) qtyMap[key] = 0;
|
||||
});
|
||||
@@ -648,7 +615,7 @@ export default function UserDashboardPage() {
|
||||
setEditMinQty(minQtyMap);
|
||||
setEditMode(true);
|
||||
} catch (e) {
|
||||
setEditError((e as any)?.message || 'Failed to load event options');
|
||||
setEditError(errorMessage(e) || 'Failed to load event options');
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
@@ -662,8 +629,8 @@ export default function UserDashboardPage() {
|
||||
if (minQty <= 0) continue;
|
||||
if ((editQuantities[key] || 0) < minQty) {
|
||||
const [eventOptionId, variantId] = key.includes(':') ? key.split(':') : [key, undefined];
|
||||
const eo = editEventOptions.find((o: any) => o.id === eventOptionId);
|
||||
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const eo = editEventOptions.find((o) => o.id === eventOptionId);
|
||||
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
|
||||
const label = variant ? `${eo?.name || 'item'} (${variant.name})` : (eo?.name || 'item');
|
||||
setEditError(`Cannot reduce "${label}" below the ${minQty} already issued`);
|
||||
return;
|
||||
@@ -698,22 +665,22 @@ export default function UserDashboardPage() {
|
||||
body: { options: payloadOptions },
|
||||
});
|
||||
// Reload registrations to reflect changes
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
try {
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
// Recompute billing totals using the backend's tranche-aware totalDueComputed
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
const totals: Record<string, Billing> = {};
|
||||
for (const r of myRegs) {
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
|
||||
}
|
||||
setBilling(totals);
|
||||
// Payments will be refreshed lazily or on next open; fetch for this one immediately
|
||||
try {
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = totals[activeReg.id]?.totalDue ?? 0;
|
||||
setBilling(prev => ({
|
||||
@@ -728,8 +695,8 @@ export default function UserDashboardPage() {
|
||||
} catch {}
|
||||
setInfo('Registration updated successfully');
|
||||
setEditMode(false);
|
||||
} catch (e: any) {
|
||||
setEditError(e?.message || 'Failed to update registration');
|
||||
} catch (e) {
|
||||
setEditError(errorMessage(e) || 'Failed to update registration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -787,9 +754,9 @@ export default function UserDashboardPage() {
|
||||
<ul className="space-y-2">
|
||||
{registrations
|
||||
// Hide cancelled always; hide past unless showPast
|
||||
.filter((r: any) => r.status !== 'cancelled')
|
||||
.filter((r: any) => showPast || !isEventOver(r.event))
|
||||
.map((r: any) => {
|
||||
.filter((r) => r.status !== 'cancelled')
|
||||
.filter((r) => showPast || !isEventOver(r.event))
|
||||
.map((r) => {
|
||||
const bill = billing[r.id];
|
||||
const isCancelled = r.status === 'cancelled';
|
||||
const outstanding = isCancelled ? 0 : (bill?.outstanding ?? 0);
|
||||
@@ -829,18 +796,18 @@ export default function UserDashboardPage() {
|
||||
{(() => {
|
||||
if (r.status == 'paid') return null;
|
||||
// Early-bird notice: only for events with tiers that haven't passed
|
||||
const allTiers = (r.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
|
||||
const allTiers = (r.registrationOptions || []).flatMap((ro) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
|
||||
const upcoming = allTiers
|
||||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t: any) => new Date() < t.deadline)
|
||||
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
|
||||
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t) => new Date() < t.deadline)
|
||||
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
|
||||
if (upcoming.length === 0) return null;
|
||||
const nextDeadline = upcoming[0].deadline;
|
||||
const pays = (billing[r.id]?.payments || []);
|
||||
const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p: any) => new Date(p.createdAt).getTime()))) : null;
|
||||
const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p) => new Date(p.createdAt).getTime()))) : null;
|
||||
const now = new Date();
|
||||
const priceNow = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
|
||||
const afterPrice = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0);
|
||||
const priceNow = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
|
||||
const afterPrice = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0);
|
||||
const dateStr = nextDeadline.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
|
||||
if (afterPrice > priceNow) {
|
||||
return (
|
||||
@@ -971,8 +938,8 @@ export default function UserDashboardPage() {
|
||||
) : (
|
||||
<ul className="grid sm:grid-cols-2 gap-3">
|
||||
{tickets
|
||||
.filter((t: any) => showPast || !isEventOver(t.event))
|
||||
.map((t: any) => {
|
||||
.filter((t) => showPast || !isEventOver(t.event))
|
||||
.map((t) => {
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variantName = t.registrationOption?.variant?.name;
|
||||
@@ -997,7 +964,7 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
{(t.attendee?.allocations || []).length > 0 && (
|
||||
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
|
||||
{[...t.attendee.allocations].sort((a: any, b: any) => (a.set?.order || 0) - (b.set?.order || 0)).map((a: any) => `${a.set.name}: ${a.group.name}`).join(" · ")}
|
||||
{[...(t.attendee?.allocations || [])].sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0)).map((a) => `${a.set.name}: ${a.group.name}`).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
|
||||
@@ -1013,13 +980,13 @@ export default function UserDashboardPage() {
|
||||
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
|
||||
{(t.transferRequests || [])[0] && (
|
||||
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
|
||||
<span>Waiting for {t.transferRequests[0].toName} to accept</span>
|
||||
<span>Waiting for {t.transferRequests?.[0]?.toName} to accept</span>
|
||||
<button className="underline" onClick={async () => {
|
||||
try {
|
||||
const r: any = await apiFetch(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
|
||||
const r = await apiFetch<{ message?: string }>(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
|
||||
setInfo(r?.message || "Transfer cancelled");
|
||||
reloadAfterAttendees();
|
||||
} catch (e: any) { setError(e?.message || "Couldn't cancel"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Couldn't cancel"); }
|
||||
}}>Cancel</button>
|
||||
</span>
|
||||
)}
|
||||
@@ -1080,7 +1047,7 @@ export default function UserDashboardPage() {
|
||||
// early-bird rows separately from standard-price rows so repeat purchases
|
||||
// of the same item don't show as a wall of near-duplicate lines.
|
||||
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
|
||||
(activeReg.registrationOptions || []).forEach((opt: any) => {
|
||||
(activeReg.registrationOptions || []).forEach((opt) => {
|
||||
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
|
||||
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
|
||||
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
|
||||
@@ -1092,7 +1059,7 @@ export default function UserDashboardPage() {
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
|
||||
appliedTierId: opt.appliedTierId,
|
||||
}];
|
||||
tranches.forEach((t: any) => {
|
||||
tranches.forEach((t) => {
|
||||
const unitPrice = Number(t.priceSnapshot || 0);
|
||||
const isEarlyBird = !!t.appliedTierId;
|
||||
const key = `${label}__${isEarlyBird}__${unitPrice}`;
|
||||
@@ -1163,7 +1130,7 @@ export default function UserDashboardPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{editEventOptions.map((eo: any) => {
|
||||
{editEventOptions.map((eo) => {
|
||||
const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0;
|
||||
|
||||
const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
|
||||
@@ -1188,12 +1155,12 @@ export default function UserDashboardPage() {
|
||||
};
|
||||
|
||||
if (hasVariants) {
|
||||
const variants = eo.variants.slice().sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
|
||||
const variants = (eo.variants || []).slice().sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
return (
|
||||
<div key={eo.id} className="border rounded bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 bg-gray-100 text-sm font-medium">{eo.name}</div>
|
||||
<ul className="divide-y">
|
||||
{variants.map((v: any) => {
|
||||
{variants.map((v) => {
|
||||
const key = `${eo.id}:${v.id}`;
|
||||
const minQty = editMinQty[key] || 0;
|
||||
const unitPrice = effectiveEventOptionUnitPrice(eo, v.id);
|
||||
@@ -1271,7 +1238,7 @@ export default function UserDashboardPage() {
|
||||
<div>
|
||||
<div className="font-medium mb-1">Payments</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{activeBill.payments.map((p: any) => (
|
||||
{activeBill.payments.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
|
||||
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
|
||||
@@ -1298,8 +1265,8 @@ export default function UserDashboardPage() {
|
||||
>Make payment</button>
|
||||
)}
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
|
||||
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o) => o.id));
|
||||
const list = tickets.filter(t => !!t.registrationOptionId && regOptIds.has(t.registrationOptionId));
|
||||
printTickets(list);
|
||||
}}>Print all tickets</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { formatDateTime } from "@/lib/date";
|
||||
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||
import { Receipt } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
interface PaymentItem {
|
||||
id: string;
|
||||
@@ -49,14 +52,14 @@ export default function UserPaymentsPage() {
|
||||
setInfo(null);
|
||||
setSendingReceipt(true);
|
||||
try {
|
||||
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setInfo((res && res.message) || "Receipt sent.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to send receipt");
|
||||
} finally {
|
||||
setSendingReceipt(false);
|
||||
}
|
||||
@@ -82,19 +85,20 @@ export default function UserPaymentsPage() {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
|
||||
const res = await apiFetch<Paginated<PaymentItem>>(buildQuery(p), { authToken: token });
|
||||
setPayments(Array.isArray(res?.data) ? res.data : []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setTotalPages(res?.pages ?? 1);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load payments");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load payments");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, setError]);
|
||||
|
||||
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
|
||||
// loadPayments changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
|
||||
useEffect(() => { if (token) loadPayments(1); }, [token, loadPayments]);
|
||||
|
||||
const goToPage = (p: number) => loadPayments(p);
|
||||
|
||||
@@ -105,12 +109,11 @@ export default function UserPaymentsPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Receipt className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">My Payments</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard/user" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">My Payments</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user")}
|
||||
>Back to dashboard</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
@@ -150,7 +153,7 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as "" | "payment" | "refund")}>
|
||||
<option value="">Payments & refunds</option>
|
||||
<option value="payment">Payments only</option>
|
||||
<option value="refund">Refunds only</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -8,6 +8,8 @@ import { isValidZAPhone } from "@/lib/phone";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
|
||||
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
|
||||
|
||||
@@ -54,7 +56,7 @@ export default function UserProfilePage() {
|
||||
setSavingProfile(true);
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -67,8 +69,8 @@ export default function UserProfilePage() {
|
||||
// Backend returns a refreshed token — save it so the session stays valid
|
||||
if (res?.token) updateToken(res.token);
|
||||
setProfileMsg({ type: "ok", text: "Profile updated." });
|
||||
} catch (e: any) {
|
||||
setProfileMsg({ type: "err", text: e?.message || "Failed to update profile." });
|
||||
} catch (e) {
|
||||
setProfileMsg({ type: "err", text: errorMessage(e) || "Failed to update profile." });
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
@@ -95,7 +97,7 @@ export default function UserProfilePage() {
|
||||
setSavingPw(true);
|
||||
setPwMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { currentPassword, password: newPassword },
|
||||
@@ -106,8 +108,8 @@ export default function UserProfilePage() {
|
||||
setConfirmPassword("");
|
||||
setPwMsg({ type: "ok", text: "Password changed." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
|
||||
} catch (e) {
|
||||
setPwMsg({ type: "err", text: errorMessage(e) || "Failed to change password." });
|
||||
} finally {
|
||||
setSavingPw(false);
|
||||
}
|
||||
@@ -123,7 +125,7 @@ export default function UserProfilePage() {
|
||||
setRevoking(true);
|
||||
setRevokeMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/revoke-sessions", {
|
||||
const res = await apiFetch<{ token?: string; message?: string }>("/api/users/revoke-sessions", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
});
|
||||
@@ -131,8 +133,8 @@ export default function UserProfilePage() {
|
||||
// save the freshly issued one so this device stays signed in.
|
||||
if (res?.token) updateToken(res.token);
|
||||
setRevokeMsg({ type: "ok", text: res?.message || "All other sessions signed out." });
|
||||
} catch (e: any) {
|
||||
setRevokeMsg({ type: "err", text: e?.message || "Failed to revoke sessions." });
|
||||
} catch (e) {
|
||||
setRevokeMsg({ type: "err", text: errorMessage(e) || "Failed to revoke sessions." });
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
@@ -142,20 +144,20 @@ export default function UserProfilePage() {
|
||||
const [activity, setActivity] = useState<ActivityEvent[]>([]);
|
||||
const [loadingActivity, setLoadingActivity] = useState(false);
|
||||
|
||||
const loadActivity = async () => {
|
||||
const loadActivity = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingActivity(true);
|
||||
try {
|
||||
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
|
||||
setActivity(Array.isArray(res) ? res : []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Non-critical — just leave the list empty rather than showing an error banner.
|
||||
} finally {
|
||||
setLoadingActivity(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadActivity(); }, [token]);
|
||||
useEffect(() => { loadActivity(); }, [loadActivity]);
|
||||
|
||||
// ── Two-factor authentication ─────────────────────────────────────────────
|
||||
// Mirrors the saved account state locally: the auth context's `user` is only
|
||||
@@ -203,8 +205,8 @@ export default function UserProfilePage() {
|
||||
setTfCode("");
|
||||
setTfPassword("");
|
||||
setTfStep("code");
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to send a verification code." });
|
||||
} catch (e) {
|
||||
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to send a verification code." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
@@ -227,8 +229,8 @@ export default function UserProfilePage() {
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
|
||||
} catch (e) {
|
||||
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to verify the code." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
@@ -249,8 +251,8 @@ export default function UserProfilePage() {
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: "Two-factor authentication is off." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to turn off two-factor authentication." });
|
||||
} catch (e) {
|
||||
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to turn off two-factor authentication." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
@@ -269,7 +271,7 @@ export default function UserProfilePage() {
|
||||
setClosing(true);
|
||||
setCloseMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/close-account", {
|
||||
const res = await apiFetch<{ message?: string }>("/api/users/close-account", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { password: closePassword, deleteData },
|
||||
@@ -277,8 +279,8 @@ export default function UserProfilePage() {
|
||||
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
|
||||
// Slight delay so the user can read the message, then log out
|
||||
setTimeout(() => logout(), 2500);
|
||||
} catch (e: any) {
|
||||
setCloseMsg({ type: "err", text: e?.message || "Failed to close account." });
|
||||
} catch (e) {
|
||||
setCloseMsg({ type: "err", text: errorMessage(e) || "Failed to close account." });
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
@@ -308,7 +310,8 @@ export default function UserProfilePage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Profile & Security</h1>
|
||||
<BackLink href="/dashboard" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Profile & Security</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage your personal information and keep your account secure.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { token } = useAuth();
|
||||
@@ -35,8 +37,8 @@ export default function ResetPasswordPage() {
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setTimeout(() => router.push("/dashboard/user"), 1200);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to update password");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Failed to update password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -49,12 +51,11 @@ export default function ResetPasswordPage() {
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<KeyRound className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Reset password</h1>
|
||||
<div>
|
||||
<BackLink href="/dashboard/user" />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Reset password</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
|
||||
onClick={() => router.push("/dashboard/user")}
|
||||
>Back to dashboard</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Share2, QrCode, CalendarPlus } from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
export default function ClientActions({ event }: { event: any }) {
|
||||
type Props = { event: { id: string; title: string; description?: string | null } };
|
||||
|
||||
export default function ClientActions({ event }: Props) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -12,7 +14,7 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: event.title,
|
||||
text: event.description,
|
||||
text: event.description ?? undefined,
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number };
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
|
||||
type EventOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -283,7 +283,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
|
||||
{opt.variants!.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).map((v) => {
|
||||
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
|
||||
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { MessageSquareHeart, CheckCircle2 } from "lucide-react";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
|
||||
type FeedbackInfo = {
|
||||
@@ -46,8 +47,8 @@ export default function FeedbackPage() {
|
||||
try {
|
||||
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
|
||||
setDone(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't send your feedback");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't send your feedback");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -19,7 +21,7 @@ function FormsContent() {
|
||||
// Set when a named attendee opens the link sent to them — show only their form.
|
||||
const onlyAttendeeId = search.get("attendeeId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [registration, setRegistration] = useState<Registration | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -36,17 +38,17 @@ function FormsContent() {
|
||||
setInfo(null);
|
||||
try {
|
||||
// authToken optional — guests access via registrationId alone
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
if (ev?.form) setEventForm(ev.form);
|
||||
// Load any saved draft to prefill fields
|
||||
try {
|
||||
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||||
const draft = await apiFetch<{ data?: Record<string, Record<string, string>> } | null>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||||
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load registration');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,8 +63,8 @@ function FormsContent() {
|
||||
if (!registration) return 0;
|
||||
try {
|
||||
return (registration.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
@@ -75,13 +77,13 @@ function FormsContent() {
|
||||
const blocks = useMemo((): FormBlock[] => {
|
||||
if (!registration) return [];
|
||||
const responses = registration.formResponses || [];
|
||||
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
|
||||
const attendees = (registration.attendees || []) as any[];
|
||||
const answered = new Set(responses.map((r) => r.attendeeId).filter(Boolean));
|
||||
const attendees = registration.attendees || [];
|
||||
const named = attendees
|
||||
.filter(a => !answered.has(a.id))
|
||||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
|
||||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id }));
|
||||
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
|
||||
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
|
||||
const unnamedAnswered = responses.filter((r) => !r.attendeeId).length;
|
||||
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
|
||||
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
|
||||
}));
|
||||
@@ -89,7 +91,7 @@ function FormsContent() {
|
||||
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
|
||||
}, [registration, requiredCount, onlyAttendeeId]);
|
||||
const remaining = blocks.length;
|
||||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
|
||||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a) => a.id === id)?.name;
|
||||
|
||||
// Determine if all required fields are filled for each attendee form to be submitted
|
||||
const canSubmit = useMemo(() => {
|
||||
@@ -116,7 +118,7 @@ function FormsContent() {
|
||||
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
|
||||
if (payload.length === 0) {
|
||||
// Nothing left to submit – still go to success page to continue the flow
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
return;
|
||||
}
|
||||
@@ -126,10 +128,10 @@ function FormsContent() {
|
||||
body: { responses: payload }
|
||||
});
|
||||
// On success, redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to submit forms');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,13 +148,13 @@ function FormsContent() {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
// Redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failded to go to next step');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failded to go to next step');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save draft');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,10 +162,10 @@ function FormsContent() {
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
// Redirect to the success page
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failded to skip');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failded to skip');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,14 +212,14 @@ function FormsContent() {
|
||||
<div className="border rounded p-3 mb-4 bg-gray-50">
|
||||
<div className="text-sm font-medium mb-2">Submitted responses</div>
|
||||
<ul className="space-y-2">
|
||||
{(registration.formResponses || []).map((resp: any, idx: number) => (
|
||||
{(registration.formResponses || []).map((resp, idx) => (
|
||||
<li key={resp.id} className="bg-white border rounded p-2">
|
||||
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `Attendee ${idx + 1}`}</div>
|
||||
{(resp.answers || []).length === 0 ? (
|
||||
<div className="text-xs text-gray-600">No answers recorded.</div>
|
||||
) : (
|
||||
<ul className="text-xs list-disc pl-5 space-y-0.5">
|
||||
{resp.answers.map((a: any) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<li key={a.id}>
|
||||
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
|
||||
</li>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
|
||||
@@ -14,16 +13,6 @@ import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
@@ -71,10 +72,10 @@ export default async function HomePage() {
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {displayName}</h1>
|
||||
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||
<Link href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||
<Calendar className="w-4 h-4" />
|
||||
View Events
|
||||
</a>
|
||||
</Link>
|
||||
<JoinUsButton />
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,10 +84,10 @@ export default async function HomePage() {
|
||||
<section className="py-12 px-4 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Upcoming Events</h2>
|
||||
<a href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
|
||||
<Link href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-xl bg-gray-50">
|
||||
@@ -101,10 +102,10 @@ export default async function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center mt-8 sm:hidden">
|
||||
<a href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
|
||||
<Link href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { analyticsSessionId } from "@/lib/analytics";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
|
||||
function lowStockThreshold(stockLimit: number): number {
|
||||
@@ -18,7 +19,7 @@ function lowStockThreshold(stockLimit: number): number {
|
||||
return Math.round(stockLimit * pct);
|
||||
}
|
||||
|
||||
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; order?: number };
|
||||
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; availableCount?: number; order?: number };
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
|
||||
type EventOption = {
|
||||
id: string;
|
||||
@@ -31,7 +32,7 @@ type EventOption = {
|
||||
variants?: OptionVariant[];
|
||||
};
|
||||
|
||||
type Event = {
|
||||
export type RegisterEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -41,7 +42,8 @@ type Event = {
|
||||
price: number;
|
||||
picture?: string;
|
||||
eventOptions?: EventOption[];
|
||||
form?: { isRequired: boolean; fields?: any[] } | null;
|
||||
form?: { isRequired: boolean; fields?: unknown[] } | null;
|
||||
requiresRegistration?: boolean;
|
||||
requiresAuth?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
@@ -61,7 +63,7 @@ function effectiveUnitForOption(opt: EventOption): number {
|
||||
const now = new Date();
|
||||
const applicable = tiers
|
||||
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
|
||||
.filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || (t as any).availableCount === undefined || (t as any).availableCount > 0))
|
||||
.filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || t.availableCount === undefined || t.availableCount > 0))
|
||||
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||||
return applicable.length > 0 ? applicable[0].price : base;
|
||||
}
|
||||
@@ -252,7 +254,7 @@ function OptionCard({
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
|
||||
const { token, user } = useAuth();
|
||||
const eventId = baseEvent.id;
|
||||
|
||||
@@ -271,9 +273,9 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
setOffer(res);
|
||||
setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity }));
|
||||
})
|
||||
.catch((e: any) => setOfferError(e?.message || "This waitlist link is not valid."));
|
||||
.catch((e: unknown) => setOfferError(errorMessage(e) || "This waitlist link is not valid."));
|
||||
}, []);
|
||||
const event = useMemo<Event>(() => {
|
||||
const event = useMemo<RegisterEvent>(() => {
|
||||
if (!offer) return baseEvent;
|
||||
return {
|
||||
...baseEvent,
|
||||
@@ -300,8 +302,6 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
return initial;
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [registrationId, setRegistrationId] = useState<string | null>(null);
|
||||
const [creatingCheckout, setCreatingCheckout] = useState(false);
|
||||
const [creatingRegistration, setCreatingRegistration] = useState(false);
|
||||
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [namingOthers, setNamingOthers] = useState(false);
|
||||
@@ -397,8 +397,8 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
|
||||
});
|
||||
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Registration failed");
|
||||
} finally {
|
||||
setCreatingRegistration(false);
|
||||
}
|
||||
@@ -441,8 +441,8 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
}
|
||||
window.location.href = next;
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Registration failed");
|
||||
} finally {
|
||||
setCreatingRegistration(false);
|
||||
}
|
||||
@@ -477,34 +477,13 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=0.00`;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Registration failed");
|
||||
} finally {
|
||||
setCreatingRegistration(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createYocoCheckout = async () => {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setCreatingCheckout(true);
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
|
||||
method: "POST",
|
||||
body: {
|
||||
registrationId,
|
||||
successUrl: window.location.origin + "/payment/success",
|
||||
cancelUrl: window.location.origin + "/payment/cancel",
|
||||
failureUrl: window.location.origin + "/payment/failure",
|
||||
},
|
||||
authToken: token,
|
||||
});
|
||||
window.open(res.redirectUrl, "_blank");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create checkout");
|
||||
} finally {
|
||||
setCreatingCheckout(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -582,11 +561,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
const now = new Date();
|
||||
const upcomingTiers = (event.eventOptions || []).flatMap(opt =>
|
||||
(opt.earlyBirdTiers || [])
|
||||
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t: any) => t.deadline > now)
|
||||
).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
|
||||
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t) => t.deadline > now)
|
||||
).sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
|
||||
if (upcomingTiers.length === 0) return null;
|
||||
const soonest = upcomingTiers[0].deadline as Date;
|
||||
const soonest = upcomingTiers[0].deadline;
|
||||
const dateStr = soonest.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
return (
|
||||
<div className="mt-4 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
|
||||
@@ -628,12 +607,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
)}
|
||||
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
|
||||
</div>
|
||||
{!registrationId ? (
|
||||
eventSoldOut ? (
|
||||
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
|
||||
Sold Out
|
||||
</button>
|
||||
) : (
|
||||
{eventSoldOut ? (
|
||||
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
|
||||
Sold Out
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={submitRegistration}
|
||||
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible && !waitlistToken)}
|
||||
@@ -641,15 +619,6 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
>
|
||||
{creatingRegistration ? "Registering..." : "Register"}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={createYocoCheckout}
|
||||
disabled={creatingCheckout}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
|
||||
>
|
||||
{creatingCheckout ? "Creating checkout..." : "Pay with Yoco"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,18 +2,18 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import RegisterForm from "./RegisterForm";
|
||||
import RegisterForm, { type RegisterEvent } from "./RegisterForm";
|
||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function RegisterPage({ params }: { params: Promise<{ eventId: string }> }) {
|
||||
const { eventId } = await params;
|
||||
let event: any;
|
||||
let event: RegisterEvent;
|
||||
try {
|
||||
// Fetched server-side (same pattern as /events/[id]) so the form's content is part of
|
||||
// the initial HTML instead of showing a loading skeleton after a client-side fetch.
|
||||
event = await apiFetch<any>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
|
||||
event = await apiFetch<RegisterEvent>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) notFound();
|
||||
throw e;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import React, { Suspense } from "react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -20,37 +21,25 @@ function RegistrationSuccessContent() {
|
||||
return isNaN(n) ? 0 : n;
|
||||
}, [fallbackTotalParam]);
|
||||
|
||||
const [reg, setReg] = React.useState<any | null>(null);
|
||||
const [reg, setReg] = React.useState<Registration | null>(null);
|
||||
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [formsData, setFormsData] = React.useState<Record<number, Record<string, string>>>({});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [info, setInfo] = React.useState<string | null>(null);
|
||||
const [payLoading, setPayLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
// Try to fetch registration and event form
|
||||
const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||
const r = token ? await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||
if (r) {
|
||||
setReg(r);
|
||||
const ev = await (await import('@/lib/api')).apiFetch(`/api/events/${encodeURIComponent(r.eventId)}`);
|
||||
const ev = await (await import('@/lib/api')).apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/api/events/${encodeURIComponent(r.eventId)}`);
|
||||
if (ev?.form) setForm(ev.form);
|
||||
// Load any saved draft
|
||||
try {
|
||||
const d = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
|
||||
if (d && d.data && typeof d.data === 'object') setFormsData(d.data);
|
||||
} catch {}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load details');
|
||||
}
|
||||
})();
|
||||
}, [registrationId, token]);
|
||||
@@ -61,7 +50,7 @@ function RegistrationSuccessContent() {
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
|
||||
try {
|
||||
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
|
||||
return (reg.registrationOptions || []).reduce((s, ro) => {
|
||||
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
|
||||
? Number(ro.priceSnapshot)
|
||||
: (ro.variant?.price ?? ro.eventOption?.price ?? 0);
|
||||
@@ -98,15 +87,15 @@ function RegistrationSuccessContent() {
|
||||
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
|
||||
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
|
||||
try {
|
||||
const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
if (r) setReg(r);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (!res.redirectUrl) { setError('Failed to create checkout'); return; }
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to create checkout');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to create checkout');
|
||||
} finally {
|
||||
setPayLoading(false);
|
||||
}
|
||||
@@ -161,119 +150,6 @@ function RegistrationSuccessContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }: { reg: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; setError: any; setInfo: any; }) {
|
||||
const { token } = useAuth();
|
||||
const registrationId = reg?.id;
|
||||
const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
||||
const count = Math.max(0, mainTickets);
|
||||
|
||||
const canSubmit = React.useMemo(() => {
|
||||
if (!form || count <= 0) return false;
|
||||
const reqFields = (form.fields || [])
|
||||
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
|
||||
.map(f => f.id);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const data = formsData[i] || {};
|
||||
for (const fid of reqFields) {
|
||||
const v = data[fid];
|
||||
if (v === undefined || v === null || String(v).trim() === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, [form, formsData, count]);
|
||||
|
||||
const update = (idx: number, fieldId: string, value: string) => {
|
||||
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
if (!token) { setError('Please login to submit forms.'); return; }
|
||||
const payload = [] as any[];
|
||||
for (let i = 0; i < count; i++) {
|
||||
payload.push({ answers: formsData[i] || {} });
|
||||
}
|
||||
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
|
||||
method: 'POST',
|
||||
authToken: token,
|
||||
body: { responses: payload }
|
||||
});
|
||||
setInfo('Attendee forms submitted. You will receive tickets once payment is confirmed.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to submit forms');
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
if (!token) { setError('Please login to save drafts.'); return; }
|
||||
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
|
||||
method: 'PUT',
|
||||
authToken: token,
|
||||
body: { data: formsData }
|
||||
});
|
||||
setInfo('Draft saved. You can finish later from your dashboard.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
if (!form || !Array.isArray(form.fields) || count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border rounded p-3 bg-gray-50 mb-4">
|
||||
<div className="text-sm font-medium mb-2">Attendee details</div>
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: count }, (_, idx) => (
|
||||
<div key={idx} className="bg-white border rounded p-3">
|
||||
<div className="font-medium mb-2">Attendee {idx + 1}</div>
|
||||
{form.fields.map((f) => (
|
||||
<div key={f.id} className="mb-2">
|
||||
{f.type === 'statement' ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
|
||||
) : f.type === 'paragraph' ? (
|
||||
<div className="text-sm text-gray-700">
|
||||
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
|
||||
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => update(idx, f.id, v)} />
|
||||
) : f.type === 'yes_no' ? (
|
||||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
) : f.type === 'date' ? (
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
|
||||
) : f.type === 'numeric' ? (
|
||||
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
|
||||
) : (
|
||||
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
|
||||
)}
|
||||
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit attendee forms</button>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegistrationSuccessPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -26,8 +27,8 @@ function ResetPasswordContent() {
|
||||
await apiFetch("/api/users/reset", { method: "POST", body: { token, password } });
|
||||
setStatus("Your password has been reset. You can now log in.");
|
||||
setTimeout(() => router.push("/login"), 1500);
|
||||
} catch (err: any) {
|
||||
setStatus(err?.message || "Reset failed");
|
||||
} catch (err) {
|
||||
setStatus(errorMessage(err) || "Reset failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -25,7 +27,7 @@ type FormField = {
|
||||
isRequired: boolean;
|
||||
order: number;
|
||||
helpText?: string | null;
|
||||
options?: any;
|
||||
options?: unknown;
|
||||
};
|
||||
type EventForm = {
|
||||
id: string;
|
||||
@@ -187,7 +189,6 @@ export default function SelfServicePage() {
|
||||
|
||||
// ── Event form (attendee form) state ────────────────────────────
|
||||
const [currentRegistrationId, setCurrentRegistrationId] = useState<string | null>(null);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [currentTicketIndex, setCurrentTicketIndex] = useState(0);
|
||||
const [allFormAnswers, setAllFormAnswers] = useState<Record<string, string>[]>([]);
|
||||
const [currentTicketAnswers, setCurrentTicketAnswers] = useState<Record<string, string>>({});
|
||||
@@ -294,11 +295,11 @@ export default function SelfServicePage() {
|
||||
// Second pass for accounts with two-factor on: exchange the code that was
|
||||
// sent after the password step. The kiosk never asks to be remembered.
|
||||
const data = setupChallenge
|
||||
? await apiFetch("/api/users/login/2fa", {
|
||||
? await apiFetch<AuthResponse>("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
|
||||
})
|
||||
: await apiFetch("/api/users/login", {
|
||||
: await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: setupPassword },
|
||||
});
|
||||
@@ -319,8 +320,8 @@ export default function SelfServicePage() {
|
||||
setSetupCode("");
|
||||
setSupervisorToken(data.token);
|
||||
await loadEvents(data.token);
|
||||
} catch (err: any) {
|
||||
setSetupError(err?.message || "Login failed. Please check your credentials.");
|
||||
} catch (err) {
|
||||
setSetupError(errorMessage(err) || "Login failed. Please check your credentials.");
|
||||
} finally {
|
||||
setSetupLoading(false);
|
||||
}
|
||||
@@ -362,8 +363,8 @@ export default function SelfServicePage() {
|
||||
setScreen("setup");
|
||||
setSelectedEventId("");
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
setChangeError(err?.message || "Incorrect password.");
|
||||
} catch (err) {
|
||||
setChangeError(errorMessage(err) || "Incorrect password.");
|
||||
} finally {
|
||||
setChangeLoading(false);
|
||||
}
|
||||
@@ -405,7 +406,15 @@ export default function SelfServicePage() {
|
||||
async function handleRegister() {
|
||||
setFormLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
const user: { name: string; email?: string; phoneNumber?: string; password?: string } = {
|
||||
name: visitorName.trim(),
|
||||
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
||||
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
||||
};
|
||||
if (createAccount && visitorPassword) {
|
||||
user.password = visitorPassword;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
eventId: selectedEventId,
|
||||
options: Object.entries(quantities)
|
||||
.filter(([, qty]) => qty > 0)
|
||||
@@ -413,11 +422,7 @@ export default function SelfServicePage() {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
}),
|
||||
user: {
|
||||
name: visitorName.trim(),
|
||||
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
||||
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
||||
},
|
||||
user,
|
||||
// If the visitor declined to create an account, don't push an unsolicited
|
||||
// activation email/WhatsApp at them afterward.
|
||||
skipActivationNotice: !createAccount,
|
||||
@@ -431,18 +436,13 @@ export default function SelfServicePage() {
|
||||
payload.attendees = kioskNames;
|
||||
payload.ticketDelivery = delivery;
|
||||
}
|
||||
if (createAccount && visitorPassword) {
|
||||
payload.user.password = visitorPassword;
|
||||
}
|
||||
|
||||
const result: any = await apiFetch("/api/registrations/manual", {
|
||||
const result = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
authToken: supervisorToken,
|
||||
});
|
||||
|
||||
setCurrentRegistrationId(result?.id || null);
|
||||
setCurrentUserId(result?.userId || null);
|
||||
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
|
||||
setConfirmationName(visitorName.trim());
|
||||
|
||||
@@ -457,8 +457,8 @@ export default function SelfServicePage() {
|
||||
setScreen("confirmation");
|
||||
startCountdown();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFormError(err?.message || "Registration failed. Please try again.");
|
||||
} catch (err) {
|
||||
setFormError(errorMessage(err) || "Registration failed. Please try again.");
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
@@ -505,8 +505,8 @@ export default function SelfServicePage() {
|
||||
|
||||
setScreen("confirmation");
|
||||
startCountdown();
|
||||
} catch (err: any) {
|
||||
setFormSubmitError(err?.message || "Form submission failed. Please try again.");
|
||||
} catch (err) {
|
||||
setFormSubmitError(errorMessage(err) || "Form submission failed. Please try again.");
|
||||
} finally {
|
||||
setFormSubmitLoading(false);
|
||||
}
|
||||
@@ -562,7 +562,6 @@ export default function SelfServicePage() {
|
||||
setLookupMessage(null);
|
||||
setFormError(null);
|
||||
setCurrentRegistrationId(null);
|
||||
setCurrentUserId(null);
|
||||
setCurrentTicketIndex(0);
|
||||
setAllFormAnswers([]);
|
||||
setCurrentTicketAnswers({});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type BannerType = "info" | "warning" | "success" | "danger";
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function SetBannerPage() {
|
||||
|
||||
// Load current banner
|
||||
useEffect(() => {
|
||||
apiFetch<any>("/api/banner")
|
||||
apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner")
|
||||
.then(b => {
|
||||
if (!b) return;
|
||||
setMessage(b.message || "");
|
||||
@@ -75,8 +76,8 @@ export default function SetBannerPage() {
|
||||
},
|
||||
});
|
||||
setStatus("Banner saved.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save banner.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save banner.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -98,8 +99,8 @@ export default function SetBannerPage() {
|
||||
setLiveFrom("");
|
||||
setLiveTill("");
|
||||
setStatus("Banner cleared.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to clear banner.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to clear banner.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -100,7 +101,7 @@ export default function SetupPage() {
|
||||
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
|
||||
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [router]);
|
||||
|
||||
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
|
||||
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
|
||||
@@ -144,8 +145,8 @@ export default function SetupPage() {
|
||||
});
|
||||
setSetupToken(res.token);
|
||||
next();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create admin account. Please try again.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create admin account. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -173,8 +174,8 @@ export default function SetupPage() {
|
||||
},
|
||||
});
|
||||
setSmtpTestResult({ ok: true, message: res.message || "Connection successful." });
|
||||
} catch (e: any) {
|
||||
setSmtpTestResult({ ok: false, message: e?.message || "Connection failed." });
|
||||
} catch (e) {
|
||||
setSmtpTestResult({ ok: false, message: errorMessage(e) || "Connection failed." });
|
||||
} finally {
|
||||
setSmtpTesting(false);
|
||||
}
|
||||
@@ -260,8 +261,8 @@ export default function SetupPage() {
|
||||
});
|
||||
|
||||
next(); // → step 6 (done)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Setup failed. Please try again.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Setup failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -386,6 +387,8 @@ export default function SetupPage() {
|
||||
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
|
||||
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
|
||||
{logoPreview && (
|
||||
// Local file preview (data: URL) — nothing for next/image to optimize.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
|
||||
)}
|
||||
{suggestedColors.length > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { Ticket, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Offer = {
|
||||
status: "pending" | "accepted" | "declined" | "cancelled" | "expired";
|
||||
@@ -46,8 +47,8 @@ export default function TransferOfferPage() {
|
||||
try {
|
||||
const r = await apiFetch<{ message: string }>(`/api/tickets/transfer-offer/${encodeURIComponent(token!)}/${action}`, { method: "POST" });
|
||||
setDone({ ok: action === "accept", message: r.message });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { DatabaseBackup, Download } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
interface BackupEntry {
|
||||
filename: string;
|
||||
@@ -38,12 +39,12 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
]);
|
||||
setBackups(list || []);
|
||||
setRetainCount(allSettings?.backup_retain_count || "14");
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to load backups" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load backups" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
}, [token, setMessage]);
|
||||
|
||||
const saveRetainCount = async () => {
|
||||
if (!token) return;
|
||||
@@ -51,8 +52,8 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
try {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
|
||||
setMessage({ type: "ok", text: "Retention setting saved." });
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to save retention setting" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to save retention setting" });
|
||||
} finally {
|
||||
setSavingRetain(false);
|
||||
}
|
||||
@@ -68,8 +69,8 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
|
||||
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Backup failed" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Backup failed" });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Copy, CreditCard, KeyRound, Trash2, Webhook } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Source = "db" | "env" | "none";
|
||||
|
||||
@@ -99,9 +100,9 @@ export function YocoTab({ active }: { active: boolean }) {
|
||||
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
|
||||
setHooks(res.webhooks || []);
|
||||
setHooksError(null);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setHooks([]);
|
||||
setHooksError(e?.message || "Could not load webhooks from Yoco");
|
||||
setHooksError(errorMessage(e) || "Could not load webhooks from Yoco");
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
@@ -114,8 +115,8 @@ export function YocoTab({ active }: { active: boolean }) {
|
||||
setHookUrl((prev) => prev || c.webhookUrl);
|
||||
if (c.secretKeySource !== "none") await loadHooks();
|
||||
else { setHooks([]); setHooksError(null); }
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to load Yoco settings" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load Yoco settings" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -130,8 +131,8 @@ export function YocoTab({ active }: { active: boolean }) {
|
||||
const text = await fn();
|
||||
setMessage({ type: "ok", text });
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Something went wrong" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Something went wrong" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
// Matches the backend's minimum gap between code sends.
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
@@ -42,24 +43,16 @@ export function LoginForm() {
|
||||
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
||||
};
|
||||
|
||||
const getErrorMessage = (err: any) => {
|
||||
if (!err) return "Login failed";
|
||||
|
||||
// If it's already a normal Error
|
||||
if (err.message && typeof err.message === "string") {
|
||||
// Handle case where message is accidentally JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
return parsed.message || err.message;
|
||||
} catch {
|
||||
return err.message;
|
||||
}
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
const msg = errorMessage(err);
|
||||
if (!msg) return "Login failed";
|
||||
// Handle case where message is accidentally a JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
return parsed?.message || msg;
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// If API response object was thrown directly
|
||||
if (err.message) return err.message;
|
||||
|
||||
return "Login failed";
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
@@ -77,7 +70,7 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
goToDashboard(result);
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Login failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -92,7 +85,7 @@ export function LoginForm() {
|
||||
|
||||
try {
|
||||
goToDashboard(await verifyTwoFactor(challenge.challengeId, code, rememberDevice));
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Verification failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -108,7 +101,7 @@ export function LoginForm() {
|
||||
setChallenge({ ...next, enrolling: challenge.enrolling });
|
||||
setCode("");
|
||||
setResendIn(RESEND_COOLDOWN_SECONDS);
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Couldn't send a new code. Please try again.");
|
||||
}
|
||||
};
|
||||
@@ -225,19 +218,19 @@ export function LoginForm() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<a
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href={registerHref}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Don't have an account? Sign up here.
|
||||
Don't have an account? Sign up here.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { isValidZAPhone } from "@/lib/phone";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export function RegisterForm() {
|
||||
const { register } = useAuth();
|
||||
@@ -23,24 +24,16 @@ export function RegisterForm() {
|
||||
|
||||
const hasValidPhone = isValidZAPhone(phoneNumber);
|
||||
|
||||
const getErrorMessage = (err: any) => {
|
||||
if (!err) return "Login failed";
|
||||
|
||||
// If it's already a normal Error
|
||||
if (err.message && typeof err.message === "string") {
|
||||
// Handle case where message is accidentally JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
return parsed.message || err.message;
|
||||
} catch {
|
||||
return err.message;
|
||||
}
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
const msg = errorMessage(err);
|
||||
if (!msg) return "Registration failed. Please try again.";
|
||||
// Handle case where message is accidentally a JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
return parsed?.message || msg;
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// If API response object was thrown directly
|
||||
if (err.message) return err.message;
|
||||
|
||||
return "Registration failed. Please try again.";
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
@@ -62,7 +55,7 @@ export function RegisterForm() {
|
||||
notificationPreference: hasValidPhone ? notificationPreference : "email",
|
||||
});
|
||||
window.location.href = redirectTo;
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Registration failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { HeartHandshake, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
|
||||
|
||||
@@ -63,8 +64,8 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
},
|
||||
});
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't start the payment");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't start the payment");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void }) {
|
||||
const { token } = useAuth();
|
||||
@@ -23,8 +24,8 @@ export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void
|
||||
authToken: token,
|
||||
});
|
||||
onUploaded(data.url);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Upload failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import Webcam from "react-webcam";
|
||||
import { useQRScanner } from "@/hooks/useQRScanner";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export type QRScannerProps = {
|
||||
onResult?: (text: string) => void;
|
||||
@@ -27,11 +28,10 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
// Track the underlying HTMLVideoElement from react-webcam reliably
|
||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
let id: any;
|
||||
let id: ReturnType<typeof setInterval> | undefined;
|
||||
if (showCam) {
|
||||
id = setInterval(() => {
|
||||
const anyCam = webcamRef.current as any;
|
||||
const v: HTMLVideoElement | null = anyCam?.video ?? null;
|
||||
const v = webcamRef.current?.video ?? null;
|
||||
if (v) { setVideoEl(v); clearInterval(id); }
|
||||
}, 100);
|
||||
} else {
|
||||
@@ -42,13 +42,14 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
|
||||
// Beep + cooldown state
|
||||
const lastScanRef = useRef<number>(0);
|
||||
const audioCtxRef = useRef<any>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const playBeep = () => {
|
||||
try {
|
||||
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
// Older Safari only has the prefixed constructor.
|
||||
const AC = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AC) return;
|
||||
if (!audioCtxRef.current) audioCtxRef.current = new AC();
|
||||
const ctx = audioCtxRef.current as AudioContext;
|
||||
const ctx = audioCtxRef.current;
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
oscillator.type = "sine";
|
||||
@@ -116,18 +117,20 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
async function startScan() {
|
||||
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
||||
const isLocal = host === "localhost" || host === "127.0.0.1";
|
||||
if (!(navigator as any)?.mediaDevices?.getUserMedia) {
|
||||
// mediaDevices is missing on insecure origins and some older browsers.
|
||||
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
||||
setLocalError("Camera API is not available in this browser.");
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
try {
|
||||
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false } as any);
|
||||
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false });
|
||||
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
|
||||
setShowCam(true);
|
||||
if (!active) toggle();
|
||||
} catch (e: any) {
|
||||
const name = e?.name || e?.message;
|
||||
} catch (e) {
|
||||
// getUserMedia rejects with a DOMException whose `name` says why (NotAllowedError, ...).
|
||||
const name = (e as { name?: string } | null)?.name || errorMessage(e);
|
||||
if (name === "NotAllowedError") {
|
||||
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
@@ -171,7 +174,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
onUserMedia={() => setLocalError(null)}
|
||||
onUserMediaError={(e) => {
|
||||
const name = (e as any)?.name || (e as any)?.message;
|
||||
const name = typeof e === "string" ? e : e.name || e.message;
|
||||
const protocol = typeof window !== "undefined" ? window.location.protocol : "";
|
||||
const host = typeof window !== "undefined" ? window.location.hostname : "";
|
||||
const isLocal = host === "localhost" || host === "127.0.0.1";
|
||||
@@ -190,7 +193,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-white/80 text-sm p-4 text-center">
|
||||
Camera is off. Tap "Scan Ticket" to start using the back camera.
|
||||
Camera is off. Tap "Scan Ticket" to start using the back camera.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { ArrowLeft, Search } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { REPORTS, REPORT_CATEGORIES, type ReportKey, type ReportCategory } from "./ReportCatalog";
|
||||
import EventsDropdown from "./EventsDropdown";
|
||||
|
||||
@@ -17,7 +18,7 @@ export default function ReportsShell({
|
||||
dateFrom, setDateFrom, dateTo, setDateTo,
|
||||
report, setReport,
|
||||
onViewReport, busy,
|
||||
onBack,
|
||||
backHref,
|
||||
}: {
|
||||
events: EventLite[]; isAdmin: boolean;
|
||||
showPastEvents: boolean; setShowPastEvents: (v: boolean) => void;
|
||||
@@ -27,7 +28,7 @@ export default function ReportsShell({
|
||||
dateFrom: string; setDateFrom: (v: string) => void; dateTo: string; setDateTo: (v: string) => void;
|
||||
report: ReportKey; setReport: (r: ReportKey) => void;
|
||||
onViewReport: () => void; busy: boolean;
|
||||
onBack?: () => void;
|
||||
backHref?: string;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [category, setCategory] = useState<"All" | ReportCategory>("All");
|
||||
@@ -67,7 +68,8 @@ export default function ReportsShell({
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 sm:gap-4 mb-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Reports</h1>
|
||||
{backHref && <BackLink href={backHref} />}
|
||||
<h1 className={"text-2xl font-semibold text-gray-900" + (backHref ? " mt-0.5" : "")}>Reports</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">View, export, or email operational reports for events.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
@@ -80,11 +82,6 @@ export default function ReportsShell({
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{onBack && (
|
||||
<button type="button" className="shrink-0 flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4" /> Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { Wifi, WifiOff, CloudUpload, Lock } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { OtherLock } from "./useOfflineScanning";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Offline = ReturnType<typeof import("./useOfflineScanning").useOfflineScanning>;
|
||||
|
||||
@@ -38,8 +39,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
|
||||
try {
|
||||
const n = await offline.enable(eventId, eventTitle, sectionId, sectionName);
|
||||
onInfo(`Offline mode on — ${n} ticket(s) downloaded. You can keep scanning without a connection.`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Couldn't turn on offline mode");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Couldn't turn on offline mode");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -49,8 +50,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
|
||||
try {
|
||||
await offline.release();
|
||||
onInfo("Offline mode ended and the section is unlocked.");
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Couldn't end offline mode");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Couldn't end offline mode");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -59,8 +60,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
|
||||
try {
|
||||
const r = await offline.sync();
|
||||
if (r) onInfo(`Synced ${r.accepted} scan(s)${r.conflicts ? ` — ${r.conflicts} need a supervisor to review` : ""}.`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Sync failed — scans are kept on this device and will retry.");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Sync failed — scans are kept on this device and will retry.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useOfflineScanning(token: string | null) {
|
||||
queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q]));
|
||||
let accepted = 0, conflicts = 0;
|
||||
for (const [lockId, scans] of byLock) {
|
||||
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: any[] }>(`/api/scan-locks/${lockId}/sync`, {
|
||||
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: unknown[] }>(`/api/scan-locks/${lockId}/sync`, {
|
||||
method: "POST", authToken: token, body: { deviceId: deviceId(), scans },
|
||||
});
|
||||
accepted += res.accepted;
|
||||
|
||||
@@ -34,5 +34,8 @@ export function ApiImage({ src, alt = "", className, style }: Props) {
|
||||
// and then jump once resolved — same class of flash as an unauthenticated nav link showing too early.
|
||||
if (!resolved) return <div className={`${className || ""} bg-gray-100 animate-pulse`} style={style} aria-hidden="true" />;
|
||||
|
||||
// A plain <img>: the API host is only known at runtime and the images have no fixed size,
|
||||
// so next/image's optimizer (which needs both up front) doesn't fit here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={resolved} alt={alt} className={className} style={style} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useMemo, useState } from "react";
|
||||
import { Users, Send } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Attendee } from "@/types";
|
||||
|
||||
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
pending: { text: "consent pending", cls: "text-amber-700" },
|
||||
@@ -11,31 +13,47 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
declined: { text: "consent declined", cls: "text-red-700" },
|
||||
};
|
||||
|
||||
type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
||||
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
};
|
||||
|
||||
// The parts of a registration (with its attendees, options and event) this panel reads.
|
||||
export type AttendeesRegistration = {
|
||||
id: string;
|
||||
ticketDelivery?: "payer" | "each";
|
||||
attendees?: Attendee[];
|
||||
event?: {
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
startDate?: string;
|
||||
} | null;
|
||||
registrationOptions?: {
|
||||
eventOptionId: string;
|
||||
variantId?: string | null;
|
||||
quantity: number;
|
||||
eventOption?: { name?: string; isMainTicket?: boolean } | null;
|
||||
variant?: { name?: string } | null;
|
||||
}[];
|
||||
formResponses?: { attendeeId?: string | null }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Named attendees on a registration (group tickets): shows who each main-ticket place
|
||||
* is for, and lets the payer (or staff) name places, change details, choose where the
|
||||
* tickets go, and send an attendee the link to their own form.
|
||||
*/
|
||||
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
|
||||
registration: any;
|
||||
registration: AttendeesRegistration;
|
||||
token: string;
|
||||
canEdit: boolean;
|
||||
hasForm: boolean;
|
||||
onSaved: () => void;
|
||||
onMessage: (msg: string) => void;
|
||||
}) {
|
||||
const attendees: Attendee[] = registration.attendees || [];
|
||||
const attendees = useMemo<Attendee[]>(() => registration.attendees || [], [registration.attendees]);
|
||||
const youth = !!registration.event?.requiresParentalConsent;
|
||||
const collectGender = !!registration.event?.collectGender;
|
||||
const eventStart: string | undefined = registration.event?.startDate;
|
||||
@@ -100,8 +118,8 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
setEditing(false);
|
||||
onMessage("Attendees saved.");
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't save attendees");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't save attendees");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -111,19 +129,19 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
|
||||
onMessage(res.message);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't send the form link");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't send the form link");
|
||||
}
|
||||
};
|
||||
|
||||
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
|
||||
const answered = new Set((registration.formResponses || []).map((r) => r.attendeeId).filter(Boolean));
|
||||
|
||||
const resendConsent = async (requestId: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
|
||||
onMessage(res.message);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't re-send the consent request");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't re-send the consent request");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* The site-wide "← Back to …" link, shown above a page's title (next to its icon tile).
|
||||
* Every dashboard page uses this one so back navigation looks and sits the same everywhere.
|
||||
*/
|
||||
export function BackLink({ href, label = "Back to dashboard" }: { href: string; label?: string }) {
|
||||
return (
|
||||
<Link href={href} className="inline-block text-xs text-brand-600 hover:underline">
|
||||
← {label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -30,8 +31,8 @@ export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m
|
||||
const res = await apiFetch<{ message: string }>(`/api/waitlist/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token });
|
||||
onMessage(res.message);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
onMessage(e?.message || "Couldn't leave the waitlist");
|
||||
} catch (e) {
|
||||
onMessage(errorMessage(e) || "Couldn't leave the waitlist");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,5 +27,7 @@ export function QrImage({ value, size = 120, alt = "QR", className }: Props) {
|
||||
}, [value, size]);
|
||||
|
||||
if (!dataUrl) return <div className={`${className || ""} bg-gray-100 animate-pulse`} aria-hidden="true" />;
|
||||
// A data: URL generated in the browser — nothing for next/image to optimize.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={dataUrl} alt={alt} className={className} />;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SetupGuard() {
|
||||
}
|
||||
})
|
||||
.catch(() => {/* API down — don't block */});
|
||||
}, [pathname]);
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,9 +4,18 @@ import React, { useState } from "react";
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type TransferTicket = { id: string; isUsed?: boolean; quantity?: number; usages?: unknown[] };
|
||||
type TransferEvent = {
|
||||
startDate?: string;
|
||||
allowTicketTransfer?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
};
|
||||
|
||||
/** Whether to offer "Transfer" on a ticket (the server checks the rules again). */
|
||||
export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): boolean {
|
||||
export function canOfferTransfer(ticket: TransferTicket | null | undefined, event: TransferEvent | null | undefined, isSupervisor = false): boolean {
|
||||
if (!ticket || !event) return false;
|
||||
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
|
||||
if (event.startDate && new Date(event.startDate) <= new Date()) return false;
|
||||
@@ -19,8 +28,8 @@ export function canOfferTransfer(ticket: any, event: any, isSupervisor = false):
|
||||
* to the details typed, and the ticket only moves (with a new QR code) once they accept.
|
||||
*/
|
||||
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
ticket: any;
|
||||
event: any;
|
||||
ticket: TransferTicket;
|
||||
event: TransferEvent | null | undefined;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
@@ -42,8 +51,8 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
|
||||
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
|
||||
onDone(res.message);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't transfer the ticket");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't transfer the ticket");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -43,13 +43,23 @@ export const dashboardAdminCashupDetailHelpContent: HelpContent = {
|
||||
icon: ClipboardCheck,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>A per-method table (cash, card, EFT, other) compares income, costs charged to that method, and expected vs actual amounts.</p>
|
||||
<p>For each payment method (cash, card, EFT, other) you say where the expected money ended up. Expected is what was taken, less any costs paid out of that method.</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li><strong>In bank</strong>: card settlements, EFTs on the statement, cash that was deposited.</li>
|
||||
<li><strong>Cash on hand</strong>: cash not deposited yet.</li>
|
||||
<li><strong>Pending</strong>: on the card machine or Yoco report, or you have a proof of payment, but it isn't in the bank yet. Add the report or POP reference.</li>
|
||||
<li><strong>Write-offs</strong>: money that's gone, such as card fees, bank charges or a cash shortfall. Write-offs come off net profit and show as "Bank & card charges" in the reports.</li>
|
||||
</ul>
|
||||
<p>Each method shows what's still <strong>unaccounted</strong>. You can close with an unaccounted amount, but you'll be warned and it comes off profit.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={ClipboardCheck} title="Cash accountability" tone="amber">
|
||||
Cash's "actual" figure isn't typed directly — it's the live total of the staff cash-count panel below, where each staff member enters their physical cash by denomination.
|
||||
Counted cash comes from the staff cash-count panel below, where each staff member enters their physical cash by denomination. When counts are entered, cash on hand is the count less what reached the bank and any deposit fee (written off as a bank charge).
|
||||
</GuideItem>
|
||||
<GuideItem icon={Wallet} title="Pending money after closing" tone="blue">
|
||||
Once the event is closed, pending money shows as outstanding. When it reaches the bank, use Mark received; if it never will, use Write off. You don't need to reopen the event.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Lock} title="Closing the event" tone="rose">
|
||||
Save draft keeps working on it later; Quick close skips detailed reconciliation; Close event with cashup locks everything in. A closed event can be Reopened if something needs fixing — both actions are logged in the History panel.
|
||||
Save draft keeps working on it later; Quick close skips the settlement; Close event with cashup locks everything in. A closed event can be Reopened if something needs fixing, and the next cashup starts from the last one. Both actions are logged in the History panel.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,7 +71,7 @@ export const dashboardAdminCashupDetailHelpContent: HelpContent = {
|
||||
icon: FileBarChart,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>A read-only summary of revenue by method, denomination breakdown, staff cash accountability, costs, donations, and net profit. This becomes the default view automatically once the event is closed.</p>
|
||||
<p>A read-only summary of where the money ended up by method (in bank, on hand, pending, written off, unaccounted), write-offs, denomination breakdown, staff cash accountability, costs, donations, and net profit. This becomes the default view automatically once the event is closed.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export const dashboardAdminHelpContent: HelpContent = {
|
||||
A day-by-day revenue chart for the past month, and a ranking of your best-performing events by revenue, registrations, and tickets sold.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Zap} title="Payments overview" tone="amber">
|
||||
Today, past week, and past month totals — a quicker check than opening Reports for a simple "how much came in" question.
|
||||
Today, past week, and past month totals — a quicker check than opening Reports for a simple "how much came in" question.
|
||||
</GuideItem>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">For a deeper breakdown, filters, and exports, use Reports instead — this page is a summary, not a substitute.</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { FileText, Eye, Edit3, ListChecks } from "lucide-react";
|
||||
import { Eye, Edit3, ListChecks } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AuthUser | (TwoFactorChallenge & { twoFactorRequired: true })>;
|
||||
login: (email: string, password: string) => Promise<AuthUser | TwoFactorChallenge>;
|
||||
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
|
||||
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
|
||||
|
||||
@@ -42,7 +42,7 @@ function addToast(toast: ToastInput) {
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
open: true as any,
|
||||
open: true,
|
||||
}
|
||||
|
||||
// Ensure limit
|
||||
@@ -62,11 +62,11 @@ function updateToast(id: string, update: Partial<Toast>) {
|
||||
function dismissToast(id?: string) {
|
||||
if (id) {
|
||||
queueRemoval(id)
|
||||
updateToast(id, { open: false } as any)
|
||||
updateToast(id, { open: false })
|
||||
} else {
|
||||
state.toasts.forEach((t) => {
|
||||
queueRemoval(t.id)
|
||||
updateToast(t.id, { open: false } as any)
|
||||
updateToast(t.id, { open: false })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
export function useDismissingState<T>(initial: T, ms = 7000) {
|
||||
const [value, setValue] = useState<T>(initial);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Held in a ref so the setter keeps one identity across renders (like useState's),
|
||||
// even when the caller passes a fresh object/array literal as `initial`.
|
||||
const initialRef = useRef(initial);
|
||||
|
||||
const set = useCallback((next: T) => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setValue(next);
|
||||
if (next) {
|
||||
timeoutRef.current = setTimeout(() => setValue(initial), ms);
|
||||
timeoutRef.current = setTimeout(() => setValue(initialRef.current), ms);
|
||||
}
|
||||
}, [ms, initial]);
|
||||
}, [ms]);
|
||||
|
||||
useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ export function useQRScanner(videoEl: HTMLVideoElement | null, opts: UseQRScanne
|
||||
try {
|
||||
if (!videoEl) return;
|
||||
const result = await reader.decodeOnceFromVideoElement(videoEl);
|
||||
const text = (result as any)?.getText ? (result as any).getText() : (result as any)?.text ?? String(result ?? "");
|
||||
const text = result?.getText() ?? "";
|
||||
if (text) onResult?.(text);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Ignore transient decode errors and retry
|
||||
} finally {
|
||||
if (!cancelled && active) setTimeout(scan, 200);
|
||||
@@ -57,10 +57,12 @@ export function useQRScanner(videoEl: HTMLVideoElement | null, opts: UseQRScanne
|
||||
cancelRef.current = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
} catch (err) {
|
||||
// getUserMedia rejects with a DOMException whose `name` says why.
|
||||
const name = (err as { name?: string } | null)?.name;
|
||||
if (name === "NotAllowedError") {
|
||||
setPermissionError("Camera permission denied. Please enable camera access.");
|
||||
} else if (err?.name === "NotFoundError") {
|
||||
} else if (name === "NotFoundError") {
|
||||
setPermissionError("No suitable camera found. Try a device with a back camera.");
|
||||
} else {
|
||||
setPermissionError("Unable to access camera.");
|
||||
|
||||
+24
-16
@@ -1,7 +1,9 @@
|
||||
import type { Paginated, Payment, User } from "@/types";
|
||||
|
||||
export type ApiOptions = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
body?: unknown;
|
||||
authToken?: string | null;
|
||||
nextOptions?: RequestInit;
|
||||
};
|
||||
@@ -72,14 +74,18 @@ export function resolveToApiOrigin(urlOrPath: string | null | undefined): string
|
||||
return API_BASE ? `${API_BASE}${raw}` : raw;
|
||||
}
|
||||
|
||||
// The parsed JSON body of a failed request — the backend always sends `message`, and
|
||||
// some routes add a machine-readable `code` or extra detail fields.
|
||||
export type ApiErrorData = { message?: string; code?: string; [key: string]: unknown };
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public readonly status: number, message: string, public readonly data?: any) {
|
||||
constructor(public readonly status: number, message: string, public readonly data?: ApiErrorData) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = any>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
export async function apiFetch<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = "GET", headers = {}, body, authToken, nextOptions } = options;
|
||||
|
||||
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
||||
@@ -110,7 +116,7 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
let message = text || `Request failed with status ${res.status}`;
|
||||
let data: any;
|
||||
let data: ApiErrorData | undefined;
|
||||
// Backend returns { message: "..." } — extract it so callers get a plain string
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
@@ -138,8 +144,8 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
if (contentType.includes("application/json")) {
|
||||
return res.json();
|
||||
}
|
||||
// @ts-ignore
|
||||
return res.text();
|
||||
// Non-JSON responses come back as text; callers that expect that pass T = string.
|
||||
return (await res.text()) as T;
|
||||
}
|
||||
|
||||
// For server components that prerender a public list: during `next build` the
|
||||
@@ -159,10 +165,10 @@ export async function apiFetchListForPrerender<T>(path: string, options: ApiOpti
|
||||
// Several list endpoints (/api/users, /api/payments) are paginated and cap
|
||||
// `limit` at 200 server-side, so a single request can silently miss records
|
||||
// beyond the first page. This walks every page and returns the full list.
|
||||
async function fetchAllPages(path: string, token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
async function fetchAllPages<T>(path: string, token: string, params: Record<string, string> = {}): Promise<T[]> {
|
||||
const query = new URLSearchParams({ ...params, limit: "200", page: "1" });
|
||||
const first = await apiFetch<any>(`${path}?${query.toString()}`, { authToken: token });
|
||||
const items: any[] = Array.isArray(first?.data) ? first.data : [];
|
||||
const first = await apiFetch<Paginated<T>>(`${path}?${query.toString()}`, { authToken: token });
|
||||
const items: T[] = Array.isArray(first?.data) ? first.data : [];
|
||||
const pages = first?.pages || 1;
|
||||
|
||||
if (pages > 1) {
|
||||
@@ -173,7 +179,7 @@ async function fetchAllPages(path: string, token: string, params: Record<string,
|
||||
Array.from({ length: pages - 1 }, (_, i) => {
|
||||
const page = i + 2;
|
||||
const pageQuery = new URLSearchParams({ ...params, limit: "200", page: String(page) });
|
||||
return apiFetch<any>(`${path}?${pageQuery.toString()}`, { authToken: token });
|
||||
return apiFetch<Paginated<T>>(`${path}?${pageQuery.toString()}`, { authToken: token });
|
||||
})
|
||||
);
|
||||
for (const res of remaining) {
|
||||
@@ -184,19 +190,21 @@ async function fetchAllPages(path: string, token: string, params: Record<string,
|
||||
return items;
|
||||
}
|
||||
|
||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
return fetchAllPages("/api/users", token, params);
|
||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
|
||||
return fetchAllPages<User>("/api/users", token, params);
|
||||
}
|
||||
|
||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
return fetchAllPages("/api/payments", token, params);
|
||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
|
||||
return fetchAllPages<Payment>("/api/payments", token, params);
|
||||
}
|
||||
|
||||
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
|
||||
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
|
||||
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }> {
|
||||
return apiFetch("/api/payments/yoco-checkout", {
|
||||
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
||||
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<CheckoutResponse> {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
|
||||
@@ -30,7 +30,7 @@ export type TwoFactorChallenge = {
|
||||
enrolling?: boolean;
|
||||
};
|
||||
|
||||
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } {
|
||||
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge {
|
||||
return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TableRow = Record<string, any>;
|
||||
export type TableRow = Record<string, unknown>;
|
||||
|
||||
export function downloadCsv(filename: string, rows: TableRow[], columns?: { key: string; label?: string }[]) {
|
||||
const cols = columns && columns.length > 0 ? columns : Object.keys(rows[0] || {}).map(k => ({ key: k, label: k }));
|
||||
@@ -24,7 +24,7 @@ function escapeCsv(v: string) {
|
||||
return v;
|
||||
}
|
||||
|
||||
function valueToString(v: any): string {
|
||||
function valueToString(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
|
||||
@@ -15,7 +15,9 @@ export function editDistance(a: string, b: string): number {
|
||||
}
|
||||
|
||||
/** Return a score 0–1 for how well a user matches a search query */
|
||||
export function scoreUser(u: any, raw: string): number {
|
||||
export type MatchableUser = { name?: string | null; email?: string | null; phoneNumber?: string | null };
|
||||
|
||||
export function scoreUser(u: MatchableUser, raw: string): number {
|
||||
const q = raw.toLowerCase().trim();
|
||||
if (!q || q.length < 2) return 0;
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export function currentImpersonation(): Impersonation | null {
|
||||
export const isImpersonating = () => !!currentImpersonation();
|
||||
|
||||
/** Switches this browser to the impersonation token and opens the user's dashboard. */
|
||||
export function beginImpersonation(res: { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } }) {
|
||||
export type ImpersonationStart = { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } };
|
||||
|
||||
export function beginImpersonation(res: ImpersonationStart) {
|
||||
try {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, localStorage.getItem(TOKEN_KEY) || "");
|
||||
localStorage.setItem(ADMIN_CACHE_KEY, localStorage.getItem(USER_CACHE_KEY) || "");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user