Compare commits

...
32 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 2e7bf263ee Merge branch 'feat/registration-donation'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:17 +02:00
joshuaandClaude Opus 5.5 377630473d Bump to 1.30.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:29:11 +02:00
joshuaandClaude Opus 5.5 286cf09dd8 Add optional donation to registration; fix soldOutLines server crash
The register form now offers a "Donation amount" (suggested amounts,
custom, or none) under the ticket options. The donation is added to the
Yoco checkout as its own line and the webhook records it as a separate
donation payment linked to the registration payment.

Move soldOutLines() out of the "use client" JoinWaitlistButton module
into lib/waitlist.ts so the server-rendered event page can call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:27:47 +02:00
joshuaandClaude Opus 5.5 27c870d46d Bump to 1.29.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:17 +02:00
joshuaandClaude Opus 5.5 a0c5a6a98c Merge branch 'feat/coupons-redesign'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
joshuaandClaude Opus 5.5 e7969429b6 Redesign the Coupon codes page to match Bundle deals
Cards instead of the table, phone-friendly sheets for the form and the uses list,
inactive codes hidden by default, and the events picker shows upcoming events only
(with search and a Show past events option).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:36:16 +02:00
joshuaandClaude Opus 5.5 4a492a8b16 Bump to 1.29.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 294fff0b41 Merge branch 'feat/bundle-types'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 f33d04a57c Add bundle types: pick any, buy X get Y free, group discount
Bundles get a kind: exact (as before), mix (any setSize of the qualifying tickets for
a set price), free (cheapest freeCount in each set free) and group (setSize or more
qualifying tickets at a per-ticket price or % off, applied once). The supervisor page
has a type picker, buyers see a one-line description of each deal, and the Complete
bundle cards handle every type. Demo: Family Fun Day adds a Lunch for four deal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:05 +02:00
joshuaandClaude Opus 5.5 6b186b3481 Bump to 1.28.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:22 +02:00
joshuaandClaude Opus 5.5 7ba40163db Fix the bundle edit form's ticket rows
The quantity input had both w-full and w-16; w-full won, filling the row and pushing
the ticket picker off the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:03:21 +02:00
joshuaandClaude Opus 5.5 428b438251 Bump to 1.28.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:04 +02:00
joshuaandClaude Opus 5.5 6ce59af0ea Redesign the Bundle deals page
Upcoming events only by default (with a Show past events option), bundles as cards
instead of a table, and a mobile-friendly edit form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:56:03 +02:00
joshuaandClaude Opus 5.5 53ed72d032 Add bundle deals to manual registration
Lists the event's deals and shows the Complete bundle / + cards, as at the door and
on the kiosk. Part of 1.28.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:32:17 +02:00
joshuaandClaude Opus 5.5 35d02c243b Bump to 1.28.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:49 +02:00
joshuaandClaude Opus 5.5 e2e6ec0e97 Merge branch 'feat/bundles-door-kiosk'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
joshuaandClaude Opus 5.5 92ab8065ef Add bundle deals to at the door and the kiosk
Both list the event's deals and show the Complete bundle / + cards from the register
form. At the door gets the standard Back to dashboard link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:22:48 +02:00
joshuaandClaude Opus 5.5 084e3c6679 Bump to 1.28.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 de196e34f2 Merge branch 'feat/bundle-deals'
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 763b66ff56 Add bundle deals
Sell a set of tickets together for one price (e.g. 5 Adult + 3 Child for R500).
Bundles apply automatically, as many times as a registration's tickets allow, and
only when cheaper than the prices actually charged. Coupons come off afterwards.

- EventBundle/EventBundleItem/RegistrationBundle models and Registration.bundleDiscount
- Supervisor "Bundle deals" page, quote endpoint, bundle lines on invoices,
  receipts, emails, WhatsApp and Yoco checkout, Discounts report rows
- Register form shows started bundles with "Complete bundle" and "+" buttons
- Demo: Family Fun Day sells a Family deal bundle instead of the Family pass ticket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:53 +02:00
joshuaandClaude Opus 5.5 8f92a8c3e9 Bump to 1.27.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:39:48 +02:00
joshua 6fc6c35214 Merge branch 'chore/demo-history' 2026-10-21 13:39:47 +02:00
joshuaandClaude Opus 5.5 be1d6f2e31 Add ten completed events to the demo data
set-demo now also stages ten past events with door sales, staff cash
counts, check-ins, refunds, donations and feedback. Their cashups run
through the real cashup code after the insert and cover every path:
quick close, full settlements (card fees, deposit fee, shortfall, cash
on hand, an unaccounted amount), close-reopen-close, and pending EFT
money received later or still outstanding. One recent event is left
open with the cash counted for a live demo. Funnel sessions older than
90 days are rolled up into daily totals, as the app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:39:47 +02:00
joshuaandClaude Opus 5.5 dc02f6b9ff Bump to 1.27.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:29 +02:00
joshua 103c328a6a Merge branch 'feat/cashup-settlement' 2026-10-21 13:29:28 +02:00
joshuaandClaude Opus 5.5 7446388bfa Take cash deposit fees out of the counted cash
A bank charge on a cash deposit comes out of cash that was counted and
banked, so cash on hand is now the count less what reached the bank and
any bank-charge write-off. Before, a deposit fee showed as money "more
than expected".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:29:17 +02:00
joshuaandClaude Opus 5.5 8a4c144f6d Add cashup settlement and back links to the changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:19 +02:00
joshuaandClaude Opus 5.5 3a68b24a0f Settle cashups: where the money ended up
A full cashup now records, per payment method, how much of the expected
takings is in the bank, still on hand (cash) or pending (on the card
report or with a POP), and writes off what was lost to card fees, bank
charges or a shortfall. Whatever is left is shown as unaccounted; closing
with an unaccounted amount is allowed after a warning.

Pending money can be marked received or written off after the event is
closed, without reopening it. Reopening starts the next cashup from the
last one. Write-offs come off net profit and show as "Bank & card
charges" in the cashup, finance and profit reports.

The reports page also moves to the shared back link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:04 +02:00
joshuaandClaude Opus 5.5 9be78c3c34 Use one back link style across the dashboards
Every dashboard page now uses the shared BackLink, the small
"← Back to dashboard" link above the page title that the Waitlist page
already had, instead of a mix of grey Back buttons in different places.
Pages that had no way back (site settings, profile, donate, manual
registration) get one too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:25:04 +02:00
joshuaandClaude Opus 5.5 d268236686 Bump to 1.26.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:51 +02:00
joshua 95a8bd0796 Merge branch 'chore/demo-data' 2026-10-21 12:21:03 +02:00
joshuaandClaude Opus 5.5 101632e758 Add set-demo script for staging demo data
npm run set-demo wipes the database, keeping 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 for showing the platform to other churches. Documented in
backend/DEMO_DATA.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:21:03 +02:00
83 changed files with 5705 additions and 736 deletions
+102
View File
@@ -7,6 +7,108 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.30.0] - 2026-10-22
### Added
- The register form asks for an optional **Donation amount** under the ticket options. Pick a suggested amount (worked out from the ticket total), enter a custom amount (min R15), or choose None. The donation shows in the total and is paid in the same Yoco checkout as the tickets. It's recorded as a separate donation to the event, so it never counts towards the registration's balance.
### Fixed
- Event pages with a waitlist crashed with "Attempted to call soldOutLines() from the server". The sold-out helper now lives in `lib/waitlist.ts` so the server page can call it.
## [1.29.1] - 2026-10-21
### Changed
- The Coupon codes page matches the Bundle deals page and works on phones. Codes show as cards with the discount, events, uses, restrictions, dates and limits. On phones, the edit form and the list of uses open full-width from the bottom, and the discount type is picked from three buttons.
- The coupons list hides codes that are off, expired or used up unless you tick "Show codes that are off, expired or used up". Search also matches event names.
- When creating or editing a code, the Events list shows upcoming events only, soonest first, with a search box and a "Show past events" option. Past events already on the code stay listed so they can be unticked.
## [1.29.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- Bundle deals now come in four types, chosen when you create one:
- **Exact set**: these tickets in these numbers for one price, e.g. 2 adults + 3 kids for R220. This is how bundles worked before.
- **Pick any**: any mix of the qualifying tickets for a set price, e.g. any 4 of Adult or Child for R250, so 2 + 2 and 4 + 0 both count.
- **Buy X get Y free**: in every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free.
- **Group discount**: from a group size up, every qualifying ticket costs less (a price per ticket or a percentage off), e.g. 10+ tickets: R70 each. It applies once, to the whole group, and never makes a ticket dearer.
- The deals list and the "Complete bundle" cards on the register form, at the door, manual registration and the kiosk describe each type in one line, e.g. "Any 4 of Adult, Child for R250". For a group discount the button reads "Complete group" and adds the tickets still needed.
- `npm run set-demo`: Family Fun Day also has a pick-any deal, "Lunch for four" (any 4 lunch packs for R220).
## [1.28.3] - 2026-10-21
### Fixed
- Bundle deals edit form: each ticket row's quantity box filled the whole row, which pushed the ticket picker off the side and added a sideways scrollbar. The quantity box is narrow again, with the ticket picker beside it.
## [1.28.2] - 2026-10-21
### Changed
- The Bundle deals page has a new layout that works on phones. It shows upcoming events only, soonest first, with a "Show past events" option. Bundles show as cards with their tickets, price, saving, uses and dates. On a phone, the edit form opens full-width from the bottom, and its price section shows the standard price, the bundle price and the saving side by side.
## [1.28.1] - 2026-10-21
### Added
- At the door, manual registration and the self-service kiosk now list the event's bundle deals and show the same bundle cards as the register form: once a ticket from a bundle is picked, **Complete bundle** tops up the ticket numbers, and **+** adds another full set.
### Fixed
- At the door now has the same "← Back to dashboard" link as the other dashboard pages.
- At the door's "less than already paid" warning shows the total after bundle deals.
## [1.28.0] - 2026-10-21
Run `npm run prisma:deploy` in the backend when deploying. This release adds a migration.
### Added
- **Bundle deals**: sell a set of tickets together for one price, e.g. 5 Adult + 3 Child for R500 instead of R550. Supervisors set them up per event under Dashboard → **Bundle deals**: the tickets and quantities (one variant or any variant), the price, optional dates, and on/off.
- Bundles apply automatically, with no code, as many times as a registration's tickets allow; leftover tickets are charged as usual. A bundle only applies when it's cheaper than the tickets at the prices actually charged, early-bird and staff prices included. When several compete, the one that saves the most is used first. This works for online registration, manual registration, at the door and on the kiosk.
- The event page and the register form list the event's deals, and every basket shows the saving live. On the register form, once a ticket from a bundle is picked, the bundle appears with what's missing and a **Complete bundle** button that tops up the ticket numbers. Once it's complete, **+** adds another full set. Invoices, receipts, emails, WhatsApp messages and the Yoco payment page show it as its own line, e.g. "Bundle: Family deal ×2".
- The Discounts report lists bundle deals next to coupon codes. Each bundle shows how often it's been used and the total saving given.
- `npm run set-demo`: Family Fun Day sells a **Family deal** bundle (2 adults + 3 kids for R220) in place of the old Family pass ticket.
### Changed
- Coupon codes now work out after bundle deals, on what's left. Registration.discountTotal now holds bundle and coupon discounts together, with the bundle part in the new `bundleDiscount`.
- Removing a code from a registration keeps its bundle deal. When an early-bird tier expires before payment and prices change, bundle deals are worked out again and coupon amounts are kept.
- Registrations keep a bundle they already have, even after it's turned off or its dates pass. Deleting a bundle that has been used turns it off instead.
## [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
+191
View File
@@ -0,0 +1,191 @@
# 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** | Adult and child tickets with a **bundle deal** (Family deal: 2 adults + 3 kids for R220 instead of R280, used by about a third of registrations) and a **pick-any** deal (Lunch for four: any 4 lunch packs for R220), 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()`. `addBundle(event, { name, price }, [[option, quantity], …])`
adds a bundle deal. Registrations added after it get the deal automatically, worked out
the same way as the app does it.
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.
+4
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.26.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.26.0",
"version": "1.30.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.26.0",
"version": "1.30.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -13,7 +13,8 @@
"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/adapter-pg": "^7.10.1",
@@ -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;
@@ -0,0 +1,83 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'bundle_deleted';
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "bundleDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "EventBundle" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"price" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventBundle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventBundleItem" (
"id" TEXT NOT NULL,
"bundleId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"quantity" INTEGER NOT NULL,
CONSTRAINT "EventBundleItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationBundle" (
"id" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"bundleId" TEXT NOT NULL,
"count" INTEGER NOT NULL,
"discount" DOUBLE PRECISION NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationBundle_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventBundle_eventId_idx" ON "EventBundle"("eventId");
-- CreateIndex
CREATE INDEX "EventBundleItem_bundleId_idx" ON "EventBundleItem"("bundleId");
-- CreateIndex
CREATE INDEX "EventBundleItem_eventOptionId_idx" ON "EventBundleItem"("eventOptionId");
-- CreateIndex
CREATE INDEX "EventBundleItem_variantId_idx" ON "EventBundleItem"("variantId");
-- CreateIndex
CREATE INDEX "RegistrationBundle_registrationId_idx" ON "RegistrationBundle"("registrationId");
-- CreateIndex
CREATE INDEX "RegistrationBundle_bundleId_idx" ON "RegistrationBundle"("bundleId");
-- AddForeignKey
ALTER TABLE "EventBundle" ADD CONSTRAINT "EventBundle_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventBundleItem" ADD CONSTRAINT "EventBundleItem_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationBundle" ADD CONSTRAINT "RegistrationBundle_bundleId_fkey" FOREIGN KEY ("bundleId") REFERENCES "EventBundle"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- CreateEnum
CREATE TYPE "BundleKind" AS ENUM ('exact', 'mix', 'free', 'group');
-- CreateEnum
CREATE TYPE "BundleGroupPricing" AS ENUM ('per_ticket', 'percent');
-- AlterTable
ALTER TABLE "EventBundle" ADD COLUMN "freeCount" INTEGER,
ADD COLUMN "groupPricing" "BundleGroupPricing",
ADD COLUMN "kind" "BundleKind" NOT NULL DEFAULT 'exact',
ADD COLUMN "setSize" INTEGER;
+150 -12
View File
@@ -88,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")
@@ -196,6 +197,7 @@ model Event {
consentRequests ConsentRequest[]
waitlistEntries WaitlistEntry[]
scanLocks ScanLock[]
bundles EventBundle[]
@@index([createdById])
@@index([closedById])
@@ -219,6 +221,7 @@ model EventOption {
costs EventCost[]
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
@@index([eventId])
}
@@ -254,6 +257,7 @@ model OptionVariant {
earlyBirdTiers EarlyBirdTier[]
couponScopes CouponOption[]
waitlistEntries WaitlistEntry[]
bundleItems EventBundleItem[]
@@index([eventOptionId])
}
@@ -264,10 +268,13 @@ model Registration {
eventId String
status RegistrationStatus @default(pending)
checkoutId String? @unique
// Sum of this registration's active coupon discounts, kept in step with
// couponRedemptions. Stored here so every total-due calculation sees it no matter
// which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue).
// Sum of this registration's discounts: bundle deals plus active coupon discounts,
// kept in step with bundleApplications and couponRedemptions. Stored here so every
// total-due calculation sees it no matter which relations the caller loaded (see
// utils/pricing.js computeRegistrationTotalDue).
discountTotal Float @default(0)
// The bundle-deal part of discountTotal (the rest is coupons).
bundleDiscount Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
analyticsSessionId String?
@@ -280,6 +287,7 @@ model Registration {
formResponses FormResponse[]
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
bundleApplications RegistrationBundle[]
attendees Attendee[]
waitlistEntries WaitlistEntry[]
@@ -494,6 +502,9 @@ enum AdminAuditAction {
coupon_updated
coupon_disabled
coupon_applied_manual
bundle_created
bundle_updated
bundle_deleted
consent_override
consent_rerequested
medical_list_viewed
@@ -708,20 +719,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
@@ -853,6 +912,85 @@ model CouponRedemption {
@@index([appliedById])
}
// ─── Bundle deals ─────────────────────────────────────────────────────────────
// How a bundle is made up (see utils/bundles.js):
// exact: these tickets in these numbers, for `price`, e.g. 5 Adult + 3 Child for R500
// mix: any `setSize` of the qualifying tickets, for `price`
// free: any `setSize` of the qualifying tickets, the cheapest `freeCount` free
// group: `setSize` or more qualifying tickets, all at `price` each (or `price`% off)
enum BundleKind {
exact
mix
free
group
}
enum BundleGroupPricing {
per_ticket
percent
}
// Tickets sold together at a better price. Applied automatically, as many times as a
// registration's items allow (a group discount once), and only when it beats what the
// items would cost anyway (see utils/bundles.js).
model EventBundle {
id String @id @default(uuid())
eventId String
name String
description String?
kind BundleKind @default(exact)
// exact/mix: the set's price. group: the per-ticket price, or the percentage off.
price Float
setSize Int?
freeCount Int?
groupPricing BundleGroupPricing?
isActive Boolean @default(true)
validFrom DateTime?
validUntil DateTime?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
items EventBundleItem[]
uses RegistrationBundle[]
@@index([eventId])
}
// One line of a bundle: for exact bundles a ticket and how many; for the others a
// qualifying ticket (quantity unused). variantId null = any variant of the option.
model EventBundleItem {
id String @id @default(uuid())
bundleId String
eventOptionId String
variantId String?
quantity Int
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
@@index([bundleId])
@@index([eventOptionId])
@@index([variantId])
}
// How many times a bundle applies to a registration and what it saved. Rebuilt each
// time the registration's discounts are recalculated.
model RegistrationBundle {
id String @id @default(uuid())
registrationId String
bundleId String
count Int
discount Float
createdAt DateTime @default(now())
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
bundle EventBundle @relation(fields: [bundleId], references: [id], onDelete: Restrict)
@@index([registrationId])
@@index([bundleId])
}
// ─── Group tickets ────────────────────────────────────────────────────────────
enum TicketDelivery {
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { KINDS, describeBundle, describeBundleItems, isBundleAvailable } = require('../utils/bundles');
const { previewBundles } = require('../utils/coupons');
const { basketBuyer, priceBasket } = require('./couponController');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const GROUP_PRICING = ['per_ticket', 'percent'];
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
/** Standard (variant or option) price of one unit of an item. */
function itemPrice(i) {
const variant = i.variantId ? (i.eventOption?.variants || []).find(v => v.id === i.variantId) : null;
return Number((variant && variant.price !== null && variant.price !== undefined ? variant.price : i.eventOption?.price) || 0);
}
/**
* What the deal is worth at standard prices: `standardValue` is what an exact set costs
* separately (or the dearest pick-any set); `savingText` is a short "save …" line, or
* null when the headline already says it.
*/
function valueOf(bundle) {
const kind = bundle.kind || 'exact';
const items = bundle.items || [];
const dearest = items.reduce((m, i) => Math.max(m, itemPrice(i)), 0);
const price = Number(bundle.price || 0);
if (kind === 'exact') {
const standard = round2(items.reduce((s, i) => s + itemPrice(i) * Number(i.quantity || 0), 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save ${fmtR(standard - price)}` : null };
}
if (kind === 'mix') {
const standard = round2(dearest * Number(bundle.setSize || 0));
return { standardValue: standard, savingText: standard - price > 0.005 ? `save up to ${fmtR(standard - price)}` : null };
}
if (kind === 'free') {
return { standardValue: null, savingText: dearest > 0 ? `save up to ${fmtR(dearest * Number(bundle.freeCount || 0))} a set` : null };
}
if (bundle.groupPricing === 'per_ticket' && dearest - price > 0.005) {
return { standardValue: null, savingText: `save up to ${fmtR(dearest - price)} a ticket` };
}
return { standardValue: null, savingText: null };
}
// Items with prices loaded, so the standard value can be shown next to the bundle price.
const DETAIL_INCLUDE = {
items: {
include: {
eventOption: { select: { id: true, name: true, price: true, variants: { select: { id: true, price: true } } } },
variant: { select: { id: true, name: true } },
},
},
};
function shape(bundle, now = new Date()) {
const items = (bundle.items || []).map(({ eventOption, ...i }) => ({ ...i, eventOption: eventOption ? { id: eventOption.id, name: eventOption.name, price: eventOption.price } : null }));
return {
...bundle,
items,
summary: describeBundleItems(bundle),
headline: describeBundle(bundle),
...valueOf(bundle),
available: isBundleAvailable(bundle, now),
};
}
const PRICING_FIELDS = ['kind', 'price', 'setSize', 'freeCount', 'groupPricing', 'items'];
/**
* Turns a create/update body into Prisma data plus the item list. On update, fields
* left out of the body are left alone; when any pricing field is sent, the bundle as a
* whole (saved values plus the new ones) is checked. Items must belong to the event.
*/
async function parseBundleBody(body, eventId, { isCreate, current = null }) {
const data = {};
if (isCreate || body.name !== undefined) {
const name = String(body.name || '').trim();
if (!name) throw new Error('Give the bundle a name');
if (name.length > 80) throw new Error('Keep the name under 80 characters');
data.name = name;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.isActive !== undefined) data.isActive = !!body.isActive;
if (body.order !== undefined) data.order = Number.isInteger(Number(body.order)) ? Number(body.order) : 0;
const validFrom = optionalDate(body.validFrom, 'Available from');
const validUntil = optionalDate(body.validUntil, 'Available until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Available from" must be before "Available until"');
let items;
if (!isCreate && !PRICING_FIELDS.some(f => body[f] !== undefined)) return { data, items };
const pick = (f) => (body[f] !== undefined ? body[f] : current?.[f]);
const kind = pick('kind') || 'exact';
if (!KINDS.includes(kind)) throw new Error('Unknown bundle type');
data.kind = kind;
const rawItems = body.items !== undefined ? body.items : (current?.items || []);
// The same option/variant twice becomes one line.
const merged = new Map();
for (const it of Array.isArray(rawItems) ? rawItems : []) {
if (!it || !it.eventOptionId) continue;
const quantity = kind === 'exact' ? Number(it.quantity) : 1;
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Each ticket needs a quantity of at least 1');
const key = `${it.eventOptionId}::${it.variantId || ''}`;
const prev = merged.get(key);
merged.set(key, { eventOptionId: it.eventOptionId, variantId: it.variantId || null, quantity: kind === 'exact' ? (prev?.quantity || 0) + quantity : 1 });
}
items = [...merged.values()];
if (items.length === 0) throw new Error(kind === 'exact' ? 'Add at least one ticket to the bundle' : 'Choose at least one qualifying ticket');
const price = Number(pick('price'));
const setSize = Number(pick('setSize'));
const freeCount = Number(pick('freeCount'));
const groupPricing = pick('groupPricing');
const priceGiven = pick('price') !== '' && pick('price') !== null && pick('price') !== undefined;
if (kind === 'exact') {
if (items.reduce((s, i) => s + i.quantity, 0) < 2) throw new Error('A bundle needs at least two tickets in it');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the bundle price (zero or more)');
Object.assign(data, { price: round2(price), setSize: null, freeCount: null, groupPricing: null });
} else if (kind === 'mix') {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A pick-any bundle needs at least 2 tickets per set');
if (!priceGiven || !(price >= 0)) throw new Error('Enter the price for the set (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing: null });
} else if (kind === 'free') {
if (!Number.isInteger(freeCount) || freeCount < 1) throw new Error('At least one ticket must be free');
if (!Number.isInteger(setSize) || setSize <= freeCount) throw new Error('Buy at least one ticket to get the free ones');
Object.assign(data, { price: 0, setSize, freeCount, groupPricing: null });
} else {
if (!Number.isInteger(setSize) || setSize < 2) throw new Error('A group needs at least 2 tickets');
if (!GROUP_PRICING.includes(groupPricing)) throw new Error('Choose a price per ticket or a percentage off');
if (groupPricing === 'percent' && !(price > 0 && price <= 100)) throw new Error('Enter a percentage between 1 and 100');
if (groupPricing === 'per_ticket' && (!priceGiven || !(price >= 0))) throw new Error('Enter the price per ticket (zero or more)');
Object.assign(data, { price: round2(price), setSize, freeCount: null, groupPricing });
}
const options = await prisma.eventOption.findMany({
where: { id: { in: items.map(i => i.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const i of items) {
const row = options.find(o => o.id === i.eventOptionId);
if (!row || row.eventId !== eventId) throw new Error('One of the tickets isn\'t part of this event');
if (i.variantId && !row.variants.some(v => v.id === i.variantId)) throw new Error('One of the selected variants no longer exists');
}
return { data, items };
}
async function eventOr404(eventId, res) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true } });
if (!event) { res.status(404); throw new Error('Event not found'); }
return event;
}
// @desc An event's bundle deals with how often each has been used
// @route GET /api/bundles/event/:eventId
// @access Private/Supervisor
const listBundles = async (req, res) => {
try {
await eventOr404(req.params.eventId, res);
const bundles = await prisma.eventBundle.findMany({
where: { eventId: req.params.eventId },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const totals = await prisma.registrationBundle.groupBy({
by: ['bundleId'],
where: { bundle: { eventId: req.params.eventId }, registration: { status: { not: 'cancelled' } } },
_sum: { count: true, discount: true },
_count: { _all: true },
});
const byId = Object.fromEntries(totals.map(t => [t.bundleId, t]));
const now = new Date();
res.json(bundles.map(b => ({
...shape(b, now),
uses: byId[b.id]?._sum?.count || 0,
registrations: byId[b.id]?._count?._all || 0,
totalDiscount: round2(byId[b.id]?._sum?.discount || 0),
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a bundle deal
// @route POST /api/bundles/event/:eventId
// @access Private/Supervisor
const createBundle = async (req, res) => {
try {
const event = await eventOr404(req.params.eventId, res);
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, event.id, { isCreate: true });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.create({
data: { ...parsed.data, eventId: event.id, items: { create: parsed.items } },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_created',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: event.id, name: bundle.name, kind: bundle.kind, deal: describeBundle(bundle) },
ip: getClientIp(req),
});
res.status(201).json({ ...shape(bundle), uses: 0, registrations: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Edit a bundle deal (including switching it off). Registrations that already
// have it are only re-worked when their own items change.
// @route PUT /api/bundles/:id
// @access Private/Supervisor
const updateBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id }, include: { items: true } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
let parsed;
try {
parsed = await parseBundleBody(req.body || {}, existing.eventId, { isCreate: false, current: existing });
} catch (e) { res.status(400); throw e; }
const bundle = await prisma.eventBundle.update({
where: { id: existing.id },
data: { ...parsed.data, ...(parsed.items ? { items: { deleteMany: {}, create: parsed.items } } : {}) },
include: DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_updated',
targetType: 'EventBundle', targetId: bundle.id,
metadata: { eventId: bundle.eventId, name: bundle.name, changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
res.json(shape(bundle));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Delete a bundle deal. One that's been used is switched off instead, so the
// registrations that got it keep their price.
// @route DELETE /api/bundles/:id
// @access Private/Supervisor
const deleteBundle = async (req, res) => {
try {
const existing = await prisma.eventBundle.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Bundle not found'); }
const used = await prisma.registrationBundle.count({ where: { bundleId: existing.id } });
if (used > 0) {
await prisma.eventBundle.update({ where: { id: existing.id }, data: { isActive: false } });
} else {
await prisma.eventBundle.delete({ where: { id: existing.id } });
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'bundle_deleted',
targetType: 'EventBundle', targetId: existing.id,
metadata: { eventId: existing.eventId, name: existing.name, switchedOff: used > 0 },
ip: getClientIp(req),
});
res.json(used > 0
? { switchedOff: true, message: 'This bundle has been used, so it\'s been switched off instead. Registrations that got it keep their price.' }
: { deleted: true, message: 'Bundle deleted' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc The bundle deals a basket gets, before any coupon code
// @route POST /api/bundles/quote
// @access Public. Body: { eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
const quoteBundles = async (req, res) => {
try {
const { eventId, options, forUserId } = req.body || {};
if (!eventId || !Array.isArray(options)) { res.status(400); throw new Error('eventId and options are required'); }
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const { buyer } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = round2(lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0));
const bundles = await previewBundles(eventId, lines);
res.json({
subtotal,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
total: round2(Math.max(0, subtotal - bundles.discount)),
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** The deals to show on an event's page: available bundles, without internal fields. */
async function publicBundlesForEvent(eventId) {
const bundles = await prisma.eventBundle.findMany({
where: { eventId, isActive: true },
include: DETAIL_INCLUDE,
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
});
const now = new Date();
return bundles.filter(b => isBundleAvailable(b, now)).map(b => {
const s = shape(b, now);
return {
id: s.id, name: s.name, description: s.description, validUntil: s.validUntil,
kind: s.kind, price: s.price, setSize: s.setSize, freeCount: s.freeCount, groupPricing: s.groupPricing,
summary: s.summary, headline: s.headline, standardValue: s.standardValue, savingText: s.savingText,
items: s.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId, quantity: i.quantity })),
};
});
}
module.exports = { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles, publicBundlesForEvent };
+126 -36
View File
@@ -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
};
+67 -28
View File
@@ -4,6 +4,7 @@ const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const { describeBundle } = require('../utils/bundles');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
@@ -231,6 +232,39 @@ const updateCoupon = async (req, res) => {
}
};
/**
* Who a basket is being priced for: the logged-in buyer, or, for staff, the attendee
* (`forUserId`) or a walk-in with no account yet (`forUserId: null`).
*/
async function basketBuyer(req, forUserId) {
const isStaff = !!(req.user && STAFF_ROLES.includes(req.user.role));
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
return { buyer, isStaff };
}
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
for (const o of options || []) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
return buildCouponLines({ registrationOptions });
}
// @desc Check a code against the items someone is about to register for
// @route POST /api/coupons/validate
// @access Public (login needed for restricted codes)
@@ -249,33 +283,12 @@ const validateCode = async (req, res) => {
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const isStaff = req.user && STAFF_ROLES.includes(req.user.role);
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
// Price the basket the same way registration will.
const registrationOptions = [];
for (const o of options) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
const lines = buildCouponLines({ registrationOptions });
const { buyer, isStaff } = await basketBuyer(req, forUserId);
const lines = await priceBasket(event, options, buyer);
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount } = await previewCoupon({
const { coupon, discount, bundles } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
@@ -287,8 +300,10 @@ const validateCode = async (req, res) => {
summary: describeCoupon(coupon),
description: coupon.description,
discount,
bundleDiscount: bundles.discount,
bundles: bundles.applications,
subtotal,
total: Math.max(0, subtotal - discount),
total: Math.max(0, subtotal - bundles.discount - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
@@ -299,7 +314,7 @@ const validateCode = async (req, res) => {
}
};
// @desc Coupon uses for the Discounts report
// @desc Coupon and bundle-deal uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
const listRedemptions = async (req, res) => {
@@ -322,10 +337,34 @@ const listRedemptions = async (req, res) => {
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
// Bundle deals, in the same shape. Their rows are rebuilt whenever a registration's
// discounts are recalculated, so dates go by when the registration was made.
const regDate = from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {};
const bundleRows = await prisma.registrationBundle.findMany({
where: { registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}), ...regDate } },
include: {
bundle: { include: { items: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } } } },
registration: { select: { id: true, status: true, createdAt: true, user: { select: { id: true, name: true, email: true } }, event: { select: { id: true, title: true } } } },
},
});
res.json([
...rows.map(r => ({ ...r, kind: 'coupon', uses: 1, summary: describeCoupon(r.coupon) })),
...bundleRows.map(b => ({
id: b.id,
kind: 'bundle',
uses: b.count,
discountAmount: b.discount,
createdAt: b.registration.createdAt,
summary: describeBundle(b.bundle),
coupon: { id: b.bundleId, code: b.bundle.name },
user: b.registration.user,
appliedBy: null,
registration: { id: b.registration.id, status: b.registration.status, event: b.registration.event },
})),
]);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions, basketBuyer, priceBasket };
@@ -457,6 +457,7 @@ const getEventById = async (req, res) => {
url: toAbsoluteUrl(req, att.url)
})),
form,
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
};
res.json(mapped);
} catch (error) {
+30 -13
View File
@@ -917,25 +917,27 @@ const unassignDonationFromRegistration = async (req, res) => {
/**
* Yoco line items for a registration: one per item at each price actually charged
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
* bundle saving and coupon discount as negative lines. Previous/partial payment lines are
* added by callers.
*/
function checkoutLineItems(registration) {
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
const { buildLineItems, discountLines } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
pricingDetails: { price: Math.round(row.unitPrice * 100) },
}));
const coupon = couponDiscountLine(registration);
if (coupon) {
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
for (const line of discountLines(registration)) {
items.push({ displayName: line.label, quantity: 1, pricingDetails: { price: -Math.round(line.amount * 100) } });
}
return items;
}
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
// donationAmount (optional) is added on top as its own line; the webhook splits it off into a
// separate donation payment using the amount carried in the checkout metadata.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount = 0 } = {}) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
@@ -947,7 +949,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -961,7 +963,8 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
if (remainingAmount <= 0) throw new Error('This registration is already paid in full');
const amountInCents = Math.round(remainingAmount * 100);
const donationInCents = Math.round((Number(donationAmount) || 0) * 100);
const amountInCents = Math.round(remainingAmount * 100) + donationInCents;
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
const lineItems = checkoutLineItems(registration);
@@ -974,11 +977,17 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
});
}
// Paying full remaining — no "Partial Payment" line needed
if (donationInCents > 0) {
lineItems.push({ displayName: 'Donation', quantity: 1, pricingDetails: { price: donationInCents } });
}
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata: { registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment' },
metadata: {
registrationId, userId, eventId: registration.eventId, eventTitle: registration.event.title, type: 'registration_payment',
...(donationInCents > 0 ? { donationAmount: (donationInCents / 100).toFixed(2) } : {})
},
successUrl,
cancelUrl,
failureUrl,
@@ -999,7 +1008,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
return {
checkoutId: response.data.id,
redirectUrl: response.data.redirectUrl,
amount: remainingAmount,
amount: amountInCents / 100,
registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title }
};
}
@@ -1009,7 +1018,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
// @access Private
const createYocoCheckout = async (req, res) => {
try {
const { registrationId, eventId, amount, successUrl, cancelUrl, failureUrl } = req.body;
const { registrationId, eventId, amount, donationAmount, successUrl, cancelUrl, failureUrl } = req.body;
const userId = req.user.id;
// Validate required parameters for either registration payment or donation
@@ -1067,7 +1076,7 @@ const createYocoCheckout = async (req, res) => {
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -1096,7 +1105,15 @@ const createYocoCheckout = async (req, res) => {
return res.status(200).json({ checkoutId: response.data.id, redirectUrl: response.data.redirectUrl, amount: charge, registration: { id: registration.id, eventId: registration.eventId, eventTitle: registration.event.title } });
}
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl });
// Optional donation added at registration ("Donation amount" on the register form)
let donation = 0;
if (donationAmount !== undefined && donationAmount !== null && donationAmount !== '') {
donation = Math.round(parseFloat(donationAmount) * 100) / 100;
if (!(donation >= 0) || donation > 100000) { res.status(400); throw new Error('Invalid donation amount'); }
if (donation > 0 && donation < 15) { res.status(400); throw new Error('Minimum donation is R15'); }
}
const result = await createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl, donationAmount: donation });
return res.status(200).json(result);
}
@@ -51,6 +51,7 @@ const ACTIVE_COUPONS_INCLUDE = {
where: { releasedAt: null },
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
@@ -429,9 +430,9 @@ const createRegistration = async (req, res) => {
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
}
// Apply the coupon (or re-work an existing one over the merged items).
// Bundle deals, then the coupon (or re-work an existing one over the merged items).
let couponError = null;
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
@@ -441,7 +442,7 @@ const createRegistration = async (req, res) => {
couponError = e.message;
}
}
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
if (couponCode || hasDiscount || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
@@ -449,6 +450,8 @@ const createRegistration = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
@@ -522,6 +525,8 @@ const getRegistrations = async (req, res) => {
try {
const include = {
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
@@ -601,6 +606,8 @@ const getUserRegistrations = async (req, res) => {
}
},
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
formResponses: { select: { id: true, attendeeId: true } },
// Nest the event's form so the frontend can tell whether attendee forms are
@@ -651,6 +658,8 @@ const getRegistrationById = async (req, res) => {
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
};
@@ -894,6 +903,8 @@ const getRegistrationsByEvent = async (req, res) => {
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
},
orderBy: { createdAt: 'asc' }
@@ -1225,6 +1236,8 @@ const createManualRegistration = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
},
});
registration.status = newExistingStatus;
@@ -1262,6 +1275,7 @@ const createManualRegistration = async (req, res) => {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
});
const hasDiscount = (await recalculateRegistrationDiscount(registration.id)) > 0;
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
@@ -1269,6 +1283,8 @@ const createManualRegistration = async (req, res) => {
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
if (couponCode || hasDiscount) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
@@ -1276,6 +1292,8 @@ const createManualRegistration = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
},
});
}
@@ -1717,6 +1735,8 @@ const updateRegistrationOptions = async (req, res) => {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
@@ -2123,6 +2143,8 @@ const applyCoupon = async (req, res) => {
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
bundleApplications: BUNDLE_APPS_INCLUDE,
},
});
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
+35 -1
View File
@@ -314,11 +314,18 @@ const handlePaymentSucceeded = async (webhookData) => {
} catch (e) { /* ignore — payment will fail below if still null */ }
}
// A registration checkout can carry a donation on top (metadata.donationAmount). It's
// recorded as its own donation payment so it never counts towards the registration.
const grossAmount = amount / 100; // Convert cents to your currency unit
const donationPart = registration?.id
? Math.min(Math.max(Number(metadata?.donationAmount) || 0, 0), grossAmount)
: 0;
// Create payment record
const payment = await prisma.payment.create({
data: {
id: uuidv4(), // Generate a UUID for the payment
amount: amount / 100, // Convert cents to your currency unit
amount: Math.round((grossAmount - donationPart) * 100) / 100,
method: method.type, // The type of payment from the webhook
status: status === 'succeeded' ? 'completed' : status,
externalId: yocoPaymentId,
@@ -358,6 +365,24 @@ const handlePaymentSucceeded = async (webhookData) => {
}
});
let donationPayment = null;
if (donationPart > 0) {
donationPayment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: donationPart,
method: method.type,
status: status === 'succeeded' ? 'completed' : status,
userId: resolvedUserId,
recordedById: resolvedUserId,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
originalPaymentId: payment.id,
}
});
}
// If this payment is for a registration, update the registration status
let updatedRegistration = null;
let generatedTickets = [];
@@ -392,6 +417,7 @@ const handlePaymentSucceeded = async (webhookData) => {
// Send emails: payment confirmation first, then tickets (guarantees order)
const _whPaymentId = payment.id;
const _whDonationId = donationPayment?.id;
const _whUserId = registration?.userId;
const _whRegId = registration?.id;
const _whTicketsGenerated = generatedTickets.length > 0;
@@ -402,6 +428,14 @@ const handlePaymentSucceeded = async (webhookData) => {
} catch (e) {
console.error('Failed to send webhook payment emails:', e);
}
if (_whDonationId) {
try {
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(_whDonationId);
} catch (e) {
console.error('Failed to send webhook donation emails:', e);
}
}
if (_whTicketsGenerated && _whUserId && _whRegId) {
const mockReq = { user: { id: _whUserId }, body: { registrationId: _whRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
+12 -1
View File
@@ -178,6 +178,7 @@ app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/coupons', require('./routes/couponRoutes'));
app.use('/api/bundles', require('./routes/bundleRoutes'));
app.use('/api/consent', require('./routes/consentRoutes'));
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
@@ -754,7 +755,7 @@ app.get('/docs', async (req, res) => {
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
{ status:200, desc:'Valid', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, subtotal:900, total:450 }},
{ status:200, desc:'Valid (bundle deals come off first, then the code)', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, bundleDiscount:0, bundles:[], subtotal:900, total:450 }},
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
]},
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
@@ -766,6 +767,16 @@ app.get('/docs', async (req, res) => {
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Bundle deals', base: '/api/bundles', endpoints: [
{ method:'POST', path:'/api/bundles/quote', auth:'public', desc:'The bundle deals a basket gets, applied automatically as many times as they fit and only when cheaper. Staff can pass forUserId (or null for a walk-in).',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }] }},
responses:[{ status:200, desc:'Success', body:{ subtotal:550, bundleDiscount:50, bundles:[{ bundleId:'bnd-uuid-...', name:'Family deal', count:1, discount:50 }], total:500 }}]},
{ method:'GET', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'An event\'s bundles with uses and total discount given' },
{ method:'POST', path:'/api/bundles/event/:eventId', auth:'supervisor+', desc:'Create a bundle. kind: exact (items with quantities, price for the set) | mix (any setSize of the items for price) | free (any setSize, cheapest freeCount free) | group (setSize or more of the items: groupPricing per_ticket price each, or percent off). items: [{ eventOptionId, variantId?, quantity }] (variantId left out = any variant; quantity only for exact).',
request:{ body:{ name:'Family deal', price:500, items:[{ eventOptionId:'opt-adult', quantity:5 }, { eventOptionId:'opt-child', quantity:3 }], validUntil:null }}},
{ method:'PUT', path:'/api/bundles/:id', auth:'supervisor+', desc:'Edit a bundle or switch it off ({ isActive:false }). Registrations keep a bundle they already have.' },
{ method:'DELETE', path:'/api/bundles/:id', auth:'supervisor+', desc:'Delete a bundle; one that has been used is switched off instead' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
+14
View File
@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { listBundles, createBundle, updateBundle, deleteBundle, quoteBundles } = require('../controllers/bundleController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// What a basket gets off before registering (guests included)
router.post('/quote', optionalAuth, quoteBundles);
router.get('/event/:eventId', protect, supervisor, listBundles);
router.post('/event/:eventId', protect, supervisor, createBundle);
router.put('/:id', protect, supervisor, updateBundle);
router.delete('/:id', protect, supervisor, deleteBundle);
module.exports = router;
+2 -1
View File
@@ -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;
+255
View File
@@ -0,0 +1,255 @@
/**
* Bundle deals — tickets sold together at a better price. Four kinds:
* - exact: these tickets in these numbers for a set price, e.g. 5 Adult + 3 Child for R500.
* - mix: any `setSize` of the qualifying tickets for a set price, e.g. any 4 for R250.
* - free: any `setSize` qualifying tickets with the cheapest `freeCount` free, e.g.
* buy 3 get 1 free (setSize 4, freeCount 1).
* - group: `setSize` or more qualifying tickets, all of them at `price` each
* (groupPricing per_ticket) or `price`% off (percent).
*
* Rules:
* - Applied automatically: no code needed. exact, mix and free apply as many times as
* the registration's items allow; leftover items are charged as usual. A group
* discount applies once, to every qualifying ticket.
* - Best price wins: a bundle only counts when it beats what its items cost at the
* prices actually charged (early-bird, staff price), so it never makes anything
* dearer. A group discount never raises a ticket's price. The most expensive matching
* units go into a bundle first, and when several bundles compete the one saving the
* most is applied first.
* - A bundle item with no variant matches any variant of its option.
* - Coupons are worked out after bundles, on what's left: bundled units are passed on
* to the coupon code at their share of the bundle price (see utils/coupons.js).
* - The discount is stored on Registration.bundleDiscount (and included in
* discountTotal), with one RegistrationBundle row per bundle used.
* - A registration keeps any bundle it already has, even after the bundle is switched
* off or its dates pass, so editing a registration never takes a deal away.
*/
const prisma = require('../config/db');
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
const fmtR = (n) => `R${Number(n || 0).toFixed(2).replace(/\.00$/, '')}`;
const KINDS = ['exact', 'mix', 'free', 'group'];
/** Whether a bundle's settings make sense (bad rows are skipped, never applied). */
function isBundleUsable(bundle) {
const items = (bundle?.items || []).filter(i => i && i.eventOptionId);
if (items.length === 0 || !(Number(bundle.price) >= 0)) return false;
const kind = bundle.kind || 'exact';
const size = Number(bundle.setSize);
if (kind === 'exact') return items.some(i => Number(i.quantity) > 0);
if (!Number.isInteger(size) || size < 1) return false;
if (kind === 'free') return Number.isInteger(Number(bundle.freeCount)) && bundle.freeCount >= 1 && bundle.freeCount < size;
if (kind === 'group' && bundle.groupPricing === 'percent') return Number(bundle.price) > 0 && Number(bundle.price) <= 100;
return KINDS.includes(kind);
}
/** Whether a bundle can be offered right now (switched on, within its dates, set up properly). */
function isBundleAvailable(bundle, now = new Date()) {
if (!bundle || !bundle.isActive) return false;
if (bundle.validFrom && now < new Date(bundle.validFrom)) return false;
if (bundle.validUntil && now > new Date(bundle.validUntil)) return false;
return isBundleUsable(bundle);
}
const itemMatches = (item, line) => item.eventOptionId === line.eventOptionId && (!item.variantId || item.variantId === line.variantId);
const qualifies = (bundle, line) => (bundle.items || []).some(i => itemMatches(i, line));
/** Takes up to `need` units from matching pool lines, dearest first. Mutates `left`. */
function takeDearest(pool, left, need, matches) {
const takes = [];
const candidates = pool
.map((p, index) => ({ p, index }))
.filter(({ p, index }) => left[index] > 0 && matches(p.line))
.sort((a, b) => b.p.line.unitPrice - a.p.line.unitPrice);
for (const { p, index } of candidates) {
if (need === 0) break;
const q = Math.min(need, left[index]);
left[index] -= q;
need -= q;
takes.push({ index, quantity: q, unitPrice: p.line.unitPrice });
}
return need > 0 ? null : takes;
}
// Every unit at the same share of a set price.
const atSetPrice = (takes, price) => {
const charged = takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0);
const ratio = charged > 0 ? price / charged : 0;
return { charged, takes: takes.map(t => ({ ...t, newPrice: t.unitPrice * ratio })) };
};
/**
* Works out one application of `bundle` against what's left in `pool`, without changing
* it. Returns { takes: [{ index, quantity, newPrice }], saving } or null when the
* bundle can't apply.
*/
function tryBundle(bundle, pool) {
const left = pool.map(p => p.remaining);
const kind = bundle.kind || 'exact';
const price = Number(bundle.price || 0);
let priced;
if (kind === 'exact') {
// Variant-specific items first, so an "any variant" item can't use up the units a
// specific one needs.
const items = [...bundle.items].filter(i => Number(i.quantity) > 0).sort((a, b) => (b.variantId ? 1 : 0) - (a.variantId ? 1 : 0));
const takes = [];
for (const item of items) {
const got = takeDearest(pool, left, Number(item.quantity), line => itemMatches(item, line));
if (!got) return null;
takes.push(...got);
}
priced = atSetPrice(takes, price);
} else if (kind === 'mix' || kind === 'free') {
const takes = takeDearest(pool, left, Number(bundle.setSize), line => qualifies(bundle, line));
if (!takes) return null;
if (kind === 'mix') {
priced = atSetPrice(takes, price);
} else {
// The cheapest freeCount units cost nothing; split takes so only those are free.
let free = Number(bundle.freeCount);
const out = [];
for (const t of [...takes].sort((a, b) => a.unitPrice - b.unitPrice)) {
const f = Math.min(free, t.quantity);
free -= f;
if (f > 0) out.push({ ...t, quantity: f, newPrice: 0 });
if (t.quantity - f > 0) out.push({ ...t, quantity: t.quantity - f, newPrice: t.unitPrice });
}
priced = { charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0), takes: out };
}
} else if (kind === 'group') {
const total = pool.reduce((s, p, i) => s + (qualifies(bundle, p.line) ? left[i] : 0), 0);
if (total < Number(bundle.setSize)) return null;
const takes = takeDearest(pool, left, total, line => qualifies(bundle, line));
const pct = Math.min(100, Math.max(0, price)) / 100;
priced = {
charged: takes.reduce((s, t) => s + t.quantity * t.unitPrice, 0),
takes: takes.map(t => ({ ...t, newPrice: bundle.groupPricing === 'percent' ? t.unitPrice * (1 - pct) : Math.min(t.unitPrice, price) })),
};
} else {
return null;
}
const after = priced.takes.reduce((s, t) => s + t.quantity * t.newPrice, 0);
return { takes: priced.takes, saving: priced.charged - after };
}
/**
* Applies bundles to coupon-style lines (see coupons.js buildCouponLines). Pure.
*
* @param {Array} bundles - [{ id, name, kind, price, setSize, freeCount, groupPricing,
* items: [{ eventOptionId, variantId?, quantity }] }]
* already filtered to the ones this registration may use
* @param {Array} lines - [{ eventOptionId, variantId, quantity, unitPrice, basePrice }]
* @returns {{ discount: number, applications: Array<{ bundleId, name, count, discount }>, lines: Array }}
* `lines` are the input lines with bundled units split out at their bundle
* price (flagged `bundled: true`), ready for the coupon calculation.
*/
function applyBundles(bundles, lines) {
const usable = (bundles || []).filter(isBundleUsable);
const pool = (lines || []).map(line => ({ line, remaining: Number(line.quantity || 0), bundled: [] }));
const byBundle = new Map();
// Each round applies the single best-saving bundle once; stops when nothing saves.
const maxRounds = pool.reduce((s, p) => s + p.remaining, 0);
for (let round = 0; round < maxRounds; round++) {
let best = null;
for (const bundle of usable) {
// A group discount covers every qualifying ticket in one go, so it applies once.
if (bundle.kind === 'group' && byBundle.has(bundle.id)) continue;
const attempt = tryBundle(bundle, pool);
if (attempt && attempt.saving > 0.005 && (!best || attempt.saving > best.attempt.saving + 0.0001)) best = { bundle, attempt };
}
if (!best) break;
const { bundle, attempt } = best;
for (const t of attempt.takes) {
pool[t.index].remaining -= t.quantity;
pool[t.index].bundled.push({ quantity: t.quantity, unitPrice: t.newPrice });
}
const entry = byBundle.get(bundle.id) || { bundleId: bundle.id, name: bundle.name, count: 0, discount: 0 };
entry.count += 1;
entry.discount += attempt.saving;
byBundle.set(bundle.id, entry);
}
const applications = [...byBundle.values()].map(a => ({ ...a, discount: round2(a.discount) }));
const outLines = [];
for (const p of pool) {
if (p.remaining > 0) outLines.push({ ...p.line, quantity: p.remaining });
for (const b of p.bundled) {
outLines.push({ ...p.line, quantity: b.quantity, unitPrice: b.unitPrice, basePrice: b.unitPrice, bundled: true });
}
}
return { discount: round2(applications.reduce((s, a) => s + a.discount, 0)), applications, lines: outLines };
}
const itemLabel = (i) => `${i.eventOption?.name || 'ticket'}${i.variant?.name ? ` (${i.variant.name})` : ''}`;
/** "5 × Adult + 3 × Child (Large)", or for the other kinds "Adult, Child". Needs names loaded. */
function describeBundleItems(bundle) {
const items = bundle?.items || [];
if ((bundle?.kind || 'exact') === 'exact') return items.map(i => `${i.quantity} × ${itemLabel(i)}`).join(' + ');
return items.map(itemLabel).join(', ');
}
/**
* The deal in one line, e.g. "2 × Adult + 3 × Child for R220", "Any 4 of Adult, Child
* for R250", "Buy 3, get 1 free: Adult, Child", "10+ tickets (Adult, Child): R70 each".
*/
function describeBundle(bundle) {
const kind = bundle?.kind || 'exact';
const items = describeBundleItems(bundle);
const price = Number(bundle?.price || 0);
if (kind === 'mix') return `Any ${bundle.setSize} of ${items} for ${fmtR(price)}`;
if (kind === 'free') return `Buy ${bundle.setSize - bundle.freeCount}, get ${bundle.freeCount} free: ${items}`;
if (kind === 'group') {
return `${bundle.setSize}+ tickets (${items}): ${bundle.groupPricing === 'percent' ? `${price}% off` : `${fmtR(price)} each`}`;
}
return `${items} for ${fmtR(price)}`;
}
// ─── Database helpers ─────────────────────────────────────────────────────────
const BUNDLE_INCLUDE = {
items: { include: { eventOption: { select: { id: true, name: true } }, variant: { select: { id: true, name: true } } } },
};
// A registration's bundle deals, for API responses and receipts.
const BUNDLE_APPS_INCLUDE = { select: { id: true, bundleId: true, count: true, discount: true, bundle: { select: { id: true, name: true } } } };
/**
* Bundles a registration on this event may use: the ones available now, plus any in
* `keepIds` (bundles the registration already has).
*/
async function loadBundlesFor(eventId, { keepIds = [], now = new Date() } = {}, db = prisma) {
if (!eventId) return [];
const bundles = await db.eventBundle.findMany({ where: { eventId }, include: BUNDLE_INCLUDE, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }] });
return bundles.filter(b => isBundleAvailable(b, now) || (keepIds.includes(b.id) && isBundleUsable(b)));
}
/** Replaces a registration's RegistrationBundle rows when the applications changed. */
async function saveBundleApplications(registrationId, existing, applications, db = prisma) {
const key = (list) => JSON.stringify([...list].map(a => [a.bundleId, a.count, round2(a.discount)]).sort());
if (key(existing || []) === key(applications)) return;
await db.registrationBundle.deleteMany({ where: { registrationId } });
if (applications.length > 0) {
await db.registrationBundle.createMany({
data: applications.map(a => ({ registrationId, bundleId: a.bundleId, count: a.count, discount: a.discount })),
});
}
}
module.exports = {
KINDS,
isBundleUsable,
isBundleAvailable,
applyBundles,
describeBundleItems,
describeBundle,
loadBundlesFor,
saveBundleApplications,
BUNDLE_INCLUDE,
BUNDLE_APPS_INCLUDE,
};
+152 -8
View File
@@ -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,
+43 -24
View File
@@ -15,9 +15,12 @@
* standard price, and only the part that beats the early-bird price is discounted.
* - Restricted codes (users, roles, email domain) need a known account, so guests
* can't use them; a match on any one restriction is enough.
* - Bundle deals (utils/bundles.js) come off first; the coupon is worked out on what's
* left. discountTotal = bundle discount + coupon discounts.
*/
const prisma = require('../config/db');
const { applyBundles, loadBundlesFor, saveBundleApplications } = require('./bundles');
function normalizeCode(code) {
return String(code || '').trim().toUpperCase();
@@ -166,9 +169,15 @@ const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true };
const REGISTRATION_INCLUDE = {
registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } },
bundleApplications: true,
user: { select: { id: true, role: true, email: true } },
};
/** Bundle deals a basket gets before any coupon (nothing saved). */
async function previewBundles(eventId, lines, db = prisma) {
return applyBundles(await loadBundlesFor(eventId, {}, db), lines);
}
async function findCouponByCode(code, db = prisma) {
const normalized = normalizeCode(code);
if (!normalized) return null;
@@ -187,14 +196,16 @@ async function countCouponUses(couponId, userId, db = prisma) {
/**
* Validates `code` against hypothetical lines (nothing saved) and returns the discount it
* would give. Throws a coupon error with a user-facing message when it can't be used.
* would give after the basket's bundle deals (returned as `bundles`). Throws a coupon
* error with a user-facing message when it can't be used.
*/
async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) {
const coupon = await findCouponByCode(code);
const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {};
const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses });
const bundles = await previewBundles(eventId, lines);
const check = validateCoupon(coupon, { eventId, user, lines: bundles.lines, deferRestrictions, ...uses });
if (!check.ok) throw couponError(check.message);
return { coupon, discount: computeCouponDiscount(coupon, lines) };
return { coupon, discount: computeCouponDiscount(coupon, bundles.lines), bundles };
}
/**
@@ -219,7 +230,7 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
: 'A code has already been applied to this registration.');
}
const lines = buildCouponLines(registration);
const { lines } = await registrationBundles(registration, tx);
const uses = await countCouponUses(coupon.id, registration.userId, tx);
const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses });
if (!check.ok) throw couponError(check.message);
@@ -228,31 +239,37 @@ async function applyCouponToRegistration({ registrationId, code, appliedById = n
const redemption = await tx.couponRedemption.create({
data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById },
});
await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } });
await recalculateRegistrationDiscount(registrationId, tx);
await tx.registration.update({ where: { id: registrationId }, data: { updatedAt: new Date() } });
return { coupon, discount, redemption };
});
}
/** A loaded registration's bundle deals (bundles it already has stay usable). */
async function registrationBundles(registration, db = prisma) {
const bundles = await loadBundlesFor(registration.eventId, { keepIds: (registration.bundleApplications || []).map(a => a.bundleId) }, db);
return applyBundles(bundles, buildCouponLines(registration));
}
/**
* Re-works active discounts after a registration's items change: percentage and comp
* codes are recalculated over the new items, fixed codes keep their amount (capped at
* what's still in scope). Pass `tx` to run inside the caller's transaction.
* Re-works a registration's discounts: bundle deals first, then the coupon on what's
* left. Percentage and comp codes are recalculated over the new items, fixed codes keep
* their amount (capped at what's still in scope). `keepCouponAmounts` keeps every
* coupon's amount (capped the same way): used when only prices changed, e.g. an
* early-bird tier expiring. Updates bundleDiscount and discountTotal and returns
* discountTotal. Pass `tx` to run inside the caller's transaction.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
async function recalculateRegistrationDiscount(registrationId, db = prisma, { keepCouponAmounts = false } = {}) {
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!registration) return 0;
if (registration.couponRedemptions.length === 0) {
if (registration.discountTotal !== 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
return 0;
}
const lines = buildCouponLines(registration);
let total = 0;
const bundles = await registrationBundles(registration, db);
await saveBundleApplications(registrationId, registration.bundleApplications, bundles.applications, db);
const lines = bundles.lines;
let total = bundles.discount;
for (const r of registration.couponRedemptions) {
let amount;
if (r.coupon.type === 'fixed') {
if (r.coupon.type === 'fixed' || keepCouponAmounts) {
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
amount = round2(Math.min(r.discountAmount, inScope));
} else {
@@ -264,21 +281,22 @@ async function recalculateRegistrationDiscount(registrationId, db = prisma) {
total += amount;
}
total = round2(total);
if (Math.abs(total - registration.discountTotal) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
if (Math.abs(total - registration.discountTotal) > 0.001 || Math.abs(bundles.discount - registration.bundleDiscount) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total, bundleDiscount: bundles.discount } });
}
return total;
}
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
/**
* Releases a registration's coupon uses so they can be claimed again (cancelled, or the
* code removed). Bundle deals stay.
*/
async function releaseRegistrationCoupons(registrationId, db = prisma) {
const released = await db.couponRedemption.updateMany({
where: { registrationId, releasedAt: null },
data: { releasedAt: new Date() },
});
if (released.count > 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
if (released.count > 0) await recalculateRegistrationDiscount(registrationId, db);
return released.count;
}
@@ -292,6 +310,7 @@ module.exports = {
findCouponByCode,
countCouponUses,
previewCoupon,
previewBundles,
applyCouponToRegistration,
recalculateRegistrationDiscount,
releaseRegistrationCoupons,
+12 -13
View File
@@ -2,7 +2,7 @@ const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
const { couponDiscountLine } = require('./pdfDocs');
const { discountLines } = require('./pdfDocs');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -90,7 +90,7 @@ async function loadRegistrationFull(registrationId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -137,7 +137,7 @@ async function loadPaymentFull(paymentId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } }, bundleApplications: { include: { bundle: { select: { name: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -149,8 +149,8 @@ async function loadPaymentFull(paymentId) {
// ─── Shared building blocks ───────────────────────────────────────────────────
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
function selectionsTable(registrationOptions, couponLine = null) {
/** Renders the selections summary table, with each discount (bundle, coupon) as its own row. */
function selectionsTable(registrationOptions, discounts = []) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
@@ -164,10 +164,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
if (couponLine) {
for (const line of discounts || []) {
rows.push(`<tr>
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${line.label}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(line.amount)}</td>
</tr>`);
}
@@ -181,11 +181,10 @@ function selectionsTable(registrationOptions, couponLine = null) {
</table>`;
}
/** Plain-text selections, one bullet per item plus the coupon discount. */
/** Plain-text selections, one bullet per item plus each discount. */
function selectionsText(reg) {
const lines = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`);
const coupon = couponDiscountLine(reg);
if (coupon) lines.push(` • ${coupon.label} — -${fmtAmount(coupon.amount)}`);
for (const d of discountLines(reg)) lines.push(` • ${d.label} — -${fmtAmount(d.amount)}`);
return lines.join('\n');
}
@@ -317,7 +316,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${selectionsTable(reg.registrationOptions, discountLines(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
@@ -358,7 +357,7 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${selectionsTable(reg.registrationOptions, discountLines(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
+30 -12
View File
@@ -95,16 +95,34 @@ function buildLineItems(registrationOptions) {
/**
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount comes from
* Registration.discountTotal, which is always present.
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount is
* Registration.discountTotal less the bundle part, both always present.
*/
function couponDiscountLine(registration) {
const amount = Number(registration?.discountTotal || 0);
const amount = Math.round((Number(registration?.discountTotal || 0) - Number(registration?.bundleDiscount || 0)) * 100) / 100;
if (!(amount > 0.001)) return null;
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
}
/**
* The registration's bundle-deal saving, labelled with the bundle names when
* `bundleApplications[].bundle.name` is loaded, e.g. "Bundle: Family deal ×2".
*/
function bundleDiscountLine(registration) {
const amount = Number(registration?.bundleDiscount || 0);
if (!(amount > 0.001)) return null;
const names = (registration.bundleApplications || [])
.map(a => a.bundle?.name ? `${a.bundle.name}${a.count > 1 ? ` ×${a.count}` : ''}` : null)
.filter(Boolean);
return { amount, label: names.length ? `Bundle: ${names.join(', ')}` : 'Bundle deal' };
}
/** Bundle saving then coupon discount, as display lines ({ label, amount }). */
function discountLines(registration) {
return [bundleDiscountLine(registration), couponDiscountLine(registration)].filter(Boolean);
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
const colDesc = x;
@@ -210,13 +228,13 @@ async function generateReceiptPdf(payment) {
if (rows.length > 0) {
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
}
const coupon = couponDiscountLine(reg);
if (coupon) {
for (const line of discountLines(reg)) {
y += 8;
doc.font('Helvetica').fontSize(10).fillColor('#059669')
.text(`${coupon.label}: -${fmtAmount(coupon.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
y += 14;
.text(`${line.label}: -${fmtAmount(line.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
y += 6;
}
if (discountLines(reg).length > 0) y += 8;
y += 20;
const boxW = 160, boxH = 32;
@@ -259,7 +277,7 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
const discount = Math.max(0, fullPriceTotal - chargedTotal);
const coupon = couponDiscountLine(registration);
const extraDiscounts = discountLines(registration);
const filename = `invoice-${invoiceNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
@@ -323,10 +341,10 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
y += (opts.size || 10) + 10;
};
if (discount > 0.01 || coupon) {
if (discount > 0.01 || extraDiscounts.length > 0) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
if (discount > 0.01) totalLine(extraDiscounts.length ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
for (const line of extraDiscounts) totalLine(line.label.toUpperCase(), `-${fmtAmount(line.amount)}`, { color: '#059669' });
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
y += 8;
}
@@ -369,4 +387,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber };
+8 -2
View File
@@ -25,7 +25,8 @@
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
* never touched.
* never touched. When a price changes, bundle deals are re-worked (they depend on the
* prices charged) and coupon amounts are kept.
*/
const prisma = require('../config/db');
@@ -269,7 +270,12 @@ async function refreshPricingForRegistration(registrationId) {
return false;
}));
return { changed: results.some(Boolean) };
const changed = results.some(Boolean);
if (changed) {
const { recalculateRegistrationDiscount } = require('./coupons');
await recalculateRegistrationDiscount(registrationId, prisma, { keepCouponAmounts: true });
}
return { changed };
}
/**
+2 -3
View File
@@ -47,11 +47,10 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const { couponDiscountLine } = require('./pdfDocs');
const coupon = couponDiscountLine(reg);
const { discountLines } = require('./pdfDocs');
const items = [
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
...discountLines(reg).map(d => `- ${d.label} — -${fmtAmount(d.amount)}`),
].join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
+194
View File
@@ -0,0 +1,194 @@
const { applyBundles, isBundleAvailable } = require('../src/utils/bundles');
const { computeCouponDiscount } = require('../src/utils/coupons');
const line = (eventOptionId, quantity, unitPrice, extra = {}) => ({ eventOptionId, variantId: null, quantity, unitPrice, basePrice: unitPrice, ...extra });
const family = { id: 'b1', name: 'Family deal', price: 500, items: [{ eventOptionId: 'adult', quantity: 5 }, { eventOptionId: 'child', quantity: 3 }] };
const charged = (lines) => lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
describe('applyBundles', () => {
test('5 adults + 3 children for R500 instead of R550', () => {
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50)]);
expect(r.discount).toBe(50);
expect(r.applications).toEqual([{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 }]);
expect(charged(r.lines)).toBeCloseTo(500, 6);
expect(r.lines.every(l => l.bundled)).toBe(true);
});
test('applies as many times as fits, leftovers at full price', () => {
const r = applyBundles([family], [line('adult', 11, 80), line('child', 7, 50)]);
expect(r.applications[0].count).toBe(2);
expect(r.discount).toBe(100);
// 1 adult and 1 child left over at full price.
expect(charged(r.lines)).toBeCloseTo(1000 + 80 + 50, 6);
expect(r.lines.filter(l => !l.bundled).map(l => [l.eventOptionId, l.quantity])).toEqual([['adult', 1], ['child', 1]]);
});
test('nothing applies when the items are not all there', () => {
const r = applyBundles([family], [line('adult', 5, 80), line('child', 2, 50)]);
expect(r.discount).toBe(0);
expect(r.applications).toEqual([]);
expect(charged(r.lines)).toBe(500);
});
test('best price wins: no discount when early-bird prices already beat the bundle', () => {
const r = applyBundles([family], [line('adult', 5, 60, { basePrice: 80 }), line('child', 3, 40, { basePrice: 50 })]);
expect(r.discount).toBe(0);
expect(charged(r.lines)).toBe(420);
});
test('the most expensive units go into the bundle first', () => {
// Two adults bought at early-bird R60, four at R80: the bundle should take the R80 ones.
const r = applyBundles([family], [line('adult', 2, 60), line('adult', 4, 80), line('child', 3, 50)]);
// 4×80 + 1×60 + 3×50 = 530 → R30 saving.
expect(r.discount).toBe(30);
expect(r.lines.find(l => !l.bundled)).toMatchObject({ eventOptionId: 'adult', quantity: 1, unitPrice: 60 });
});
test('when bundles compete, the bigger saving wins', () => {
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
const r = applyBundles([family, pair], [line('adult', 5, 80), line('child', 3, 50)]);
// Family saves 50 once; pairs save 10 each (two of them = 20), and would block the family deal.
expect(r.applications.map(a => a.bundleId)).toEqual(['b1']);
expect(r.discount).toBe(50);
});
test('different bundles can stack on different items', () => {
const pair = { id: 'b2', name: 'Pair', price: 150, items: [{ eventOptionId: 'adult', quantity: 2 }] };
const r = applyBundles([family, pair], [line('adult', 7, 80), line('child', 3, 50)]);
expect(r.applications).toEqual([
{ bundleId: 'b1', name: 'Family deal', count: 1, discount: 50 },
{ bundleId: 'b2', name: 'Pair', count: 1, discount: 10 },
]);
});
test('a variant-specific item only takes that variant; an any-variant item takes the rest', () => {
const shirts = { id: 'b3', name: 'Shirts', price: 300, items: [{ eventOptionId: 'shirt', variantId: 'L', quantity: 1 }, { eventOptionId: 'shirt', quantity: 2 }] };
const lines = [
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 150, basePrice: 150 },
{ eventOptionId: 'shirt', variantId: 'S', quantity: 2, unitPrice: 120, basePrice: 120 },
];
const r = applyBundles([shirts], lines);
expect(r.discount).toBe(90);
const noLarge = applyBundles([shirts], [{ ...lines[1], quantity: 3 }]);
expect(noLarge.discount).toBe(0);
});
test('a coupon after the bundle works out on the bundle price', () => {
const r = applyBundles([family], [line('adult', 5, 80), line('child', 3, 50), line('lunch', 2, 40)]);
const tenPercent = { type: 'percent', value: 10, stackEarlyBird: true, options: [] };
// (500 + 80) × 10% = 58
expect(computeCouponDiscount(tenPercent, r.lines)).toBe(58);
// Best-price-wins coupons also see the bundle price as the base, so it still gives 10%.
expect(computeCouponDiscount({ ...tenPercent, stackEarlyBird: false }, r.lines)).toBe(58);
const childOnly = { ...tenPercent, options: [{ eventOptionId: 'child' }] };
// The 3 children cost 150 of 550 → 136.36 of the R500 bundle → 13.64 off.
expect(computeCouponDiscount(childOnly, r.lines)).toBe(13.64);
});
test('a free bundle (R0) still works', () => {
const r = applyBundles([{ ...family, price: 0 }], [line('adult', 5, 80), line('child', 3, 50)]);
expect(r.discount).toBe(550);
expect(charged(r.lines)).toBe(0);
});
test('bundles with no items are ignored', () => {
expect(applyBundles([{ id: 'x', name: 'x', price: 1, items: [] }], [line('adult', 3, 80)]).discount).toBe(0);
});
});
describe('pick any (mix)', () => {
const anyFour = { id: 'm', name: 'Any 4', kind: 'mix', price: 250, setSize: 4, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('2 adults + 2 children or 4 adults both make a set', () => {
expect(applyBundles([anyFour], [line('adult', 2, 80), line('child', 2, 50)]).discount).toBe(10);
expect(applyBundles([anyFour], [line('adult', 4, 80)]).discount).toBe(70);
});
test('repeats, dearest tickets first, leftovers at full price', () => {
const r = applyBundles([anyFour], [line('adult', 5, 80), line('child', 4, 50)]);
// Set 1: 4 adults (320 → 250). Set 2: 1 adult + 3 children (230): no saving, so not applied.
expect(r.applications[0].count).toBe(1);
expect(r.discount).toBe(70);
expect(charged(r.lines)).toBeCloseTo(250 + 80 + 200, 6);
});
test('non-qualifying tickets are left alone', () => {
const r = applyBundles([anyFour], [line('adult', 2, 80), line('lunch', 5, 65)]);
expect(r.discount).toBe(0);
});
});
describe('buy X get Y free', () => {
const b3g1 = { id: 'f', name: 'Buy 3 get 1', kind: 'free', price: 0, setSize: 4, freeCount: 1, items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('the cheapest ticket in the set is free', () => {
const r = applyBundles([b3g1], [line('adult', 3, 80), line('child', 1, 50)]);
expect(r.discount).toBe(50);
expect(r.lines.find(l => l.unitPrice === 0)).toMatchObject({ eventOptionId: 'child', quantity: 1, bundled: true });
});
test('8 tickets make two sets', () => {
const r = applyBundles([b3g1], [line('adult', 8, 80)]);
expect(r.applications[0].count).toBe(2);
expect(r.discount).toBe(160);
});
test('three tickets get nothing', () => {
expect(applyBundles([b3g1], [line('adult', 3, 80)]).discount).toBe(0);
});
test('a free count that is not below the set size is ignored', () => {
expect(applyBundles([{ ...b3g1, freeCount: 4 }], [line('adult', 8, 80)]).discount).toBe(0);
});
});
describe('group discount', () => {
const tenPlus = { id: 'g', name: 'Groups', kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: [{ eventOptionId: 'adult' }, { eventOptionId: 'child' }] };
test('10+ qualifying tickets all get the group price, once, never dearer', () => {
const r = applyBundles([tenPlus], [line('adult', 8, 80), line('child', 4, 50)]);
// Adults 80 → 70 (8 × 10 = 80 saved); children already cheaper, unchanged.
expect(r.discount).toBe(80);
expect(r.applications).toEqual([{ bundleId: 'g', name: 'Groups', count: 1, discount: 80 }]);
expect(charged(r.lines)).toBeCloseTo(8 * 70 + 4 * 50, 6);
});
test('below the minimum: nothing', () => {
expect(applyBundles([tenPlus], [line('adult', 9, 80)]).discount).toBe(0);
});
test('percentage off everything in the group', () => {
const pct = { ...tenPlus, groupPricing: 'percent', price: 10 };
expect(applyBundles([pct], [line('adult', 10, 80), line('child', 2, 50)]).discount).toBe(90);
});
test('an exact bundle beats a group discount when it saves more', () => {
const r = applyBundles([family, tenPlus], [line('adult', 5, 80), line('child', 5, 50)]);
// Group: 5 × 10 = 50 saved. Family: 50 saved on 5A+3C, then group can't reach 10 again.
// Equal savings: the first found wins, either way the total is 50.
expect(r.discount).toBe(50);
});
});
describe('describeBundle', () => {
const { describeBundle } = require('../src/utils/bundles');
const names = [{ eventOptionId: 'a', quantity: 2, eventOption: { name: 'Adult' } }, { eventOptionId: 'c', quantity: 3, eventOption: { name: 'Child' } }];
test('one line per kind', () => {
expect(describeBundle({ kind: 'exact', price: 220, items: names })).toBe('2 × Adult + 3 × Child for R220');
expect(describeBundle({ kind: 'mix', price: 250, setSize: 4, items: names })).toBe('Any 4 of Adult, Child for R250');
expect(describeBundle({ kind: 'free', setSize: 4, freeCount: 1, items: names })).toBe('Buy 3, get 1 free: Adult, Child');
expect(describeBundle({ kind: 'group', price: 70, setSize: 10, groupPricing: 'per_ticket', items: names })).toBe('10+ tickets (Adult, Child): R70 each');
expect(describeBundle({ kind: 'group', price: 15, setSize: 10, groupPricing: 'percent', items: names })).toBe('10+ tickets (Adult, Child): 15% off');
});
});
describe('isBundleAvailable', () => {
const now = new Date('2026-06-01T10:00:00Z');
test('switched off, not started, ended and empty bundles are not available', () => {
expect(isBundleAvailable({ ...family, isActive: true }, now)).toBe(true);
expect(isBundleAvailable({ ...family, isActive: false }, now)).toBe(false);
expect(isBundleAvailable({ ...family, isActive: true, validFrom: '2026-06-02' }, now)).toBe(false);
expect(isBundleAvailable({ ...family, isActive: true, validUntil: '2026-05-31' }, now)).toBe(false);
expect(isBundleAvailable({ ...family, isActive: true, items: [] }, now)).toBe(false);
});
});
+154
View File
@@ -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);
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.26.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.26.0",
"version": "1.30.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.26.0",
"version": "1.30.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -8,6 +8,7 @@ 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",
@@ -23,6 +24,9 @@ const ACTIONS = [
"coupon_updated",
"coupon_disabled",
"coupon_applied_manual",
"bundle_created",
"bundle_updated",
"bundle_deleted",
"consent_override",
"consent_rerequested",
"medical_list_viewed",
@@ -52,6 +56,9 @@ const ACTION_LABELS: Record<string, string> = {
coupon_updated: "Coupon edited",
coupon_disabled: "Coupon turned off",
coupon_applied_manual: "Coupon applied/removed by staff",
bundle_created: "Bundle deal created",
bundle_updated: "Bundle deal edited",
bundle_deleted: "Bundle deal deleted",
consent_override: "Consent overridden at check-in",
consent_rerequested: "Consent re-requested",
medical_list_viewed: "Medical list viewed",
@@ -192,7 +199,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,13 +1,14 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
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, Event } 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"];
@@ -20,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");
@@ -65,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>
@@ -537,34 +535,73 @@ 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>
@@ -604,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 &amp; 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>
);
@@ -629,32 +920,53 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
const isClosed = data.event.cashupStatus === "closed";
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);
@@ -669,7 +981,14 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
};
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 } });
@@ -683,7 +1002,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
};
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 } });
@@ -710,60 +1029,41 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
};
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&apos;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>
@@ -789,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">
@@ -8,6 +8,7 @@ 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();
@@ -55,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>}
@@ -33,6 +33,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ href: "/dashboard/supervisor/bundles", label: "Bundle deals", description: "Tickets sold together for one price", icon: Gift },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
@@ -8,6 +8,7 @@ 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;
@@ -197,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 && (
@@ -2,7 +2,7 @@
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
@@ -14,6 +14,7 @@ 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";
@@ -85,7 +86,6 @@ export default function SiteSettingsPage() {
function SiteSettingsPageInner() {
const { token } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const { reload: reloadSettings } = useSiteSettings();
@@ -366,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 */}
@@ -10,6 +10,7 @@ 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;
@@ -259,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>
@@ -10,6 +10,7 @@ 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();
@@ -239,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>
@@ -5,13 +5,13 @@ 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.
@@ -33,7 +33,6 @@ type ScanTicket = {
};
export default function TicketScanningPage() {
const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null);
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
const [error, setError] = useDismissingState<string | null>(null);
@@ -330,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.
@@ -10,6 +10,7 @@ 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 }[] };
@@ -207,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 &amp; groups</h1>
<p className="text-sm text-gray-500">Put attendees into rooms, small groups, teams or buses</p>
</div>
@@ -7,12 +7,14 @@ import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { BackLink } from "@/components/shared/BackLink";
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";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -104,6 +106,7 @@ export default function AtTheDoorPage() {
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
const [doorCollectGender, setDoorCollectGender] = useState(false);
const [doorBundles, setDoorBundles] = useState<PublicBundle[]>([]);
const [doorNaming, setDoorNaming] = useState(false);
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
@@ -163,8 +166,10 @@ export default function AtTheDoorPage() {
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
setDoorBundles(ev.bundles || []);
} catch {
setEventOptions([]);
setDoorBundles([]);
}
})();
}, [eventId, token]);
@@ -394,7 +399,10 @@ export default function AtTheDoorPage() {
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<DoorOpen className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">At The Door</h1>
</div>
</div>
<select
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
@@ -506,6 +514,7 @@ export default function AtTheDoorPage() {
setCoupon={setDoorCoupon}
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
bundles={doorBundles}
names={doorAllowsNames && !pendingEditReg ? {
enabled: doorNaming, onEnabledChange: setDoorNaming,
entries: doorEntries, onEntriesChange: setDoorEntries,
@@ -1225,19 +1234,22 @@ type OptionsModalProps = {
setCoupon: (c: AppliedCoupon | null) => void;
couponForUserId: string | null;
existingCode: string | null;
bundles: PublicBundle[];
// 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;
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names }: OptionsModalProps) {
const basket = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
});
// Bundle deals apply to the whole registration, so this holds for edits too.
const bundleQuote = useBundleQuote({ eventId, basket, token, forUserId: couponForUserId, enabled: open });
if (!open) return null;
const safeOptions: EventOption[] = options || [];
@@ -1249,7 +1261,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
}, 0);
const belowPaid = isEdit && total < totalPaid;
const afterBundles = bundleQuote ? bundleQuote.total : total;
const belowPaid = isEdit && afterBundles < totalPaid;
const belowIssued = isEdit && Object.entries(minQuantities).some(
([key, minQty]) => minQty > 0 && (quantities[key] || 0) < minQty
);
@@ -1275,6 +1288,13 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
<BundleOffers bundles={bundles} />
<BundleBuilder
bundles={bundles}
options={safeOptions}
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
/>
{safeOptions.map((opt) => {
const hasVariants = (opt.variants || []).length > 0;
if (hasVariants) {
@@ -1335,7 +1355,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="p-4 border-t flex-shrink-0 bg-white">
{belowPaid && (
<div className="mb-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">
New total (R {total.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
New total (R {afterBundles.toFixed(2)}) is less than amount already paid (R {Number(totalPaid).toFixed(2)}). Please increase the selection.
</div>
)}
{belowIssued && (
@@ -1365,7 +1385,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{coupon && coupon.discount > 0 && !isEdit && (
<div className="text-xs text-gray-500"><span className="line-through">R {coupon.subtotal.toFixed(2)}</span> <span className="text-green-700">−R {coupon.discount.toFixed(2)}</span></div>
)}
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : total).toFixed(2)}</div>
<BundleSavings applications={bundleQuote?.bundles} className="text-xs" />
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : afterBundles).toFixed(2)}</div>
{coupon && isEdit && <div className="text-xs text-green-700">Code {coupon.code} is applied when you save</div>}
</div>
<div className="flex gap-2">
@@ -0,0 +1,557 @@
"use client";
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 { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
type ItemDraft = { eventOptionId: string; variantId: string | null; quantity: string };
type Draft = {
name: string;
description: string;
kind: BundleKind;
price: string;
setSize: string;
freeCount: string;
groupPricing: BundleGroupPricing;
items: ItemDraft[];
validFrom: string;
validUntil: string;
isActive: boolean;
};
const blankDraft = (options: OptionLite[]): Draft => ({
name: "", description: "", kind: "exact", price: "", setSize: "4", freeCount: "1", groupPricing: "per_ticket",
items: options.slice(0, 2).map(o => ({ eventOptionId: o.id, variantId: null, quantity: "1" })),
validFrom: "", validUntil: "", isActive: true,
});
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
const toLocalInput = (iso: string | null | undefined) => {
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const draftFromBundle = (b: EventBundle): Draft => ({
name: b.name, description: b.description || "", kind: b.kind || "exact",
price: b.kind === "free" ? "" : String(b.price),
setSize: b.setSize ? String(b.setSize) : "4", freeCount: b.freeCount ? String(b.freeCount) : "1",
groupPricing: b.groupPricing || "per_ticket",
items: b.items.map(i => ({ eventOptionId: i.eventOptionId, variantId: i.variantId || null, quantity: String(i.quantity) })),
validFrom: toLocalInput(b.validFrom), validUntil: toLocalInput(b.validUntil), isActive: b.isActive,
});
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
// fieldCls has no width, for fields sized by their row (inputCls is full width).
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const inputCls = `w-full ${fieldCls}`;
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
const KIND_INFO: Record<BundleKind, { label: string; hint: string; example: string }> = {
exact: { label: "Exact set", hint: "These tickets in these numbers", example: "2 adults + 3 kids for R220" },
mix: { label: "Pick any", hint: "Any mix of the chosen tickets", example: "Any 4 tickets for R250" },
free: { label: "Buy X get Y free", hint: "Cheapest ones in each set are free", example: "Buy 3, get 1 free" },
group: { label: "Group discount", hint: "A lower price from a group size up", example: "10+ tickets: R70 each" },
};
/** Standard price of one unit of an item (variant price, else the option's). */
function unitPrice(options: OptionLite[], item: { eventOptionId: string; variantId: string | null }) {
const o = options.find(x => x.id === item.eventOptionId);
if (!o) return 0;
const v = item.variantId ? (o.variants || []).find(x => x.id === item.variantId) : null;
return Number(v && v.price !== null && v.price !== undefined ? v.price : o.price) || 0;
}
function bundleStatus(b: EventBundle): { label: string; cls: string } {
const now = Date.now();
if (!b.isActive) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
if (b.validUntil && new Date(b.validUntil).getTime() < now) return { label: "Ended", cls: "bg-gray-100 text-gray-600" };
if (b.validFrom && new Date(b.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
return { label: "On sale", cls: "bg-green-100 text-green-800" };
}
export default function BundlesPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
const [showPast, setShowPast] = useState(false);
const [eventId, setEventId] = useState("");
const [bundles, setBundles] = useState<EventBundle[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
useEffect(() => {
if (!token || !canView) return;
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token })
.then(list => setAllEvents(list || []))
.catch(e => setError(errorMessage(e) || "Failed to load events"));
}, [token, canView, setError]);
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
const events = useMemo(() => {
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return showPast ? [...upcoming, ...past] : upcoming;
}, [allEvents, showPast]);
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
useEffect(() => {
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
}, [events, eventId]);
const event = events.find(e => e.id === eventId) || null;
const options = event?.eventOptions || [];
const load = useCallback(async () => {
if (!token || !eventId) { setBundles([]); return; }
setFetching(true);
try {
setBundles(await apiFetch<EventBundle[]>(`/api/bundles/event/${eventId}`, { authToken: token }) || []);
} catch (e) {
setError(errorMessage(e) || "Failed to load bundles");
} finally {
setFetching(false);
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const toggleActive = async (b: EventBundle) => {
try {
await apiFetch(`/api/bundles/${b.id}`, { method: "PUT", authToken: token!, body: { isActive: !b.isActive } });
setInfo(`${b.name} turned ${b.isActive ? "off" : "on"}.`);
load();
} catch (e) {
setError(errorMessage(e) || "Failed to update bundle");
}
};
const remove = async (b: EventBundle) => {
if (!window.confirm(`Delete "${b.name}"?`)) return;
try {
const res = await apiFetch<{ message: string }>(`/api/bundles/${b.id}`, { method: "DELETE", authToken: token! });
setInfo(res?.message || "Bundle deleted");
load();
} catch (e) {
setError(errorMessage(e) || "Failed to delete bundle");
}
};
const startNew = () => setEditing({ id: null, draft: blankDraft(options) });
const canAdd = !!event && options.length > 0;
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Gift className="w-5 h-5 text-brand-600" />
</div>
<div>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Bundle deals</h1>
<p className="text-sm text-gray-500">Tickets sold together for one price</p>
</div>
</div>
{canView && (
<button
onClick={startNew}
disabled={!canAdd}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50 w-full sm:w-auto"
>
<Plus className="w-4 h-4" /> New bundle
</button>
)}
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage bundle deals.</div>
)}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
<div className="w-full sm:w-auto">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
))}
</select>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
</div>
{event && options.length === 0 && (
<p className="text-sm text-gray-500 py-6 text-center">This event has no tickets yet. Add ticket options to the event first.</p>
)}
{event && options.length > 0 && !fetching && bundles.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-emerald-50 flex items-center justify-center mx-auto mb-3">
<Gift className="w-6 h-6 text-emerald-600" />
</div>
<p className="text-sm font-medium text-gray-900">No bundle deals for this event yet</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. &quot;Family deal: 2 adults + 3 kids for R220&quot;. Buyers get it automatically when their tickets match.</p>
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Create a bundle
</button>
</div>
)}
{bundles.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{bundles.map(b => (
<BundleCard
key={b.id}
bundle={b}
onEdit={() => setEditing({ id: b.id, draft: draftFromBundle(b) })}
onToggle={() => toggleActive(b)}
onDelete={() => remove(b)}
/>
))}
</div>
)}
<p className="text-xs text-gray-500 mt-4">
Bundles apply automatically, as many times as someone&apos;s tickets allow, and only when cheaper than the tickets at the prices
actually charged (early-bird and staff prices included). Coupon codes come off afterwards.
</p>
</div>
)}
{editing && event && (
<BundleModal
token={token!}
event={event}
bundleId={editing.id}
initial={editing.draft}
onClose={() => setEditing(null)}
onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }}
/>
)}
</div>
);
}
function BundleCard({ bundle: b, onEdit, onToggle, onDelete }: { bundle: EventBundle; onEdit: () => void; onToggle: () => void; onDelete: () => void }) {
const status = bundleStatus(b);
const kind = b.kind || "exact";
const dates = b.validFrom || b.validUntil
? `${b.validFrom ? fmtDate(b.validFrom) : "Now"} – ${b.validUntil ? fmtDate(b.validUntil) : "no end date"}`
: null;
return (
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${b.isActive ? "bg-white" : "bg-gray-50"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-gray-900 break-words">{b.name}</div>
{b.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{b.description}</div>}
</div>
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
</div>
<div className="text-xs font-medium text-brand-700">{KIND_INFO[kind].label}{kind === "exact" ? "" : ` · ${kind === "group" ? "qualifying" : "any of"}:`}</div>
<div className="flex flex-wrap gap-1.5">
{b.items.map(i => (
<span key={i.id || `${i.eventOptionId}-${i.variantId}`} className="inline-flex items-center gap-1 rounded-full bg-emerald-50 text-emerald-800 border border-emerald-100 px-2.5 py-1 text-xs">
{kind === "exact" && <span className="font-semibold">{i.quantity} ×</span>} {i.eventOption?.name || "Ticket"}{i.variant ? ` (${i.variant.name})` : ""}
</span>
))}
</div>
<div>
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
{kind === "exact" || kind === "mix" ? (
<>
<span className="text-xl font-semibold text-gray-900">{fmtRand(b.price)}</span>
<span className="text-sm text-gray-500">{kind === "mix" ? `for any ${b.setSize}` : "for the set"}</span>
{b.standardValue !== null && <span className="text-sm text-gray-400 line-through">{fmtRand(b.standardValue)}</span>}
</>
) : kind === "free" ? (
<span className="text-xl font-semibold text-gray-900">Buy {(b.setSize || 0) - (b.freeCount || 0)}, get {b.freeCount} free</span>
) : (
<>
<span className="text-xl font-semibold text-gray-900">{b.groupPricing === "percent" ? `${b.price}% off` : `${fmtRand(b.price)} each`}</span>
<span className="text-sm text-gray-500">from {b.setSize} tickets</span>
</>
)}
</div>
<div className={`text-xs font-medium ${b.savingText || kind === "free" || (kind === "group" && b.groupPricing === "percent") ? "text-green-700" : "text-amber-700"}`}>
{b.savingText || (kind === "free" || (kind === "group" && b.groupPricing === "percent") ? "" : "No saving at the standard prices")}
</div>
</div>
<div className="text-xs text-gray-600 space-y-0.5">
<div>
Used <strong>{b.uses || 0}×</strong> on {b.registrations || 0} registration{b.registrations === 1 ? "" : "s"} · {fmtRand(b.totalDiscount || 0)} given
</div>
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5" /> {dates}</div>}
</div>
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 ${b.isActive ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
<Power className="w-3.5 h-3.5" /> {b.isActive ? "Turn off" : "Turn on"}
</button>
<button onClick={onDelete} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border text-red-700 border-red-200 hover:bg-red-50 mt-2 sm:ml-auto">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
</div>
);
}
function BundleModal({ token, event, bundleId, initial, onClose, onSaved }: {
token: string;
event: EventLite;
bundleId: string | null;
initial: Draft;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [d, setD] = useState<Draft>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
const options = event.eventOptions || [];
const setItem = (i: number, patch: Partial<ItemDraft>) => set("items", d.items.map((it, j) => (j === i ? { ...it, ...patch } : it)));
const exact = d.kind === "exact";
const price = Number(d.price) || 0;
const size = Math.max(0, Math.floor(Number(d.setSize) || 0));
const free = Math.max(0, Math.floor(Number(d.freeCount) || 0));
const dearest = d.items.reduce((m, it) => Math.max(m, unitPrice(options, it)), 0);
// Exact: what the set costs separately. Pick any: the dearest possible set.
const standardValue = exact ? d.items.reduce((s, it) => s + unitPrice(options, it) * (Number(it.quantity) || 0), 0) : dearest * size;
const saving_ = standardValue - price;
const priceLabel = exact ? "Bundle price (R)" : d.kind === "mix" ? "Price for the set (R)" : d.groupPricing === "percent" ? "Percentage off (%)" : "Price per ticket (R)";
const save = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSaving(true);
try {
const body = {
name: d.name.trim(),
description: d.description.trim() || null,
kind: d.kind,
price: d.kind === "free" ? 0 : Number(d.price),
setSize: exact ? null : Number(d.setSize),
freeCount: d.kind === "free" ? Number(d.freeCount) : null,
groupPricing: d.kind === "group" ? d.groupPricing : null,
items: d.items.map(it => ({ eventOptionId: it.eventOptionId, variantId: it.variantId, quantity: exact ? Number(it.quantity) : 1 })),
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
isActive: d.isActive,
};
if (bundleId) {
await apiFetch(`/api/bundles/${bundleId}`, { method: "PUT", authToken: token, body });
onSaved(`${body.name} saved.`);
} else {
await apiFetch(`/api/bundles/event/${event.id}`, { method: "POST", authToken: token, body });
onSaved(`${body.name} created.`);
}
} catch (err) {
setError(errorMessage(err) || "Failed to save");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3 p-4 border-b">
<div className="min-w-0">
<h2 className="font-semibold">{bundleId ? "Edit bundle" : "New bundle deal"}</h2>
<p className="text-xs text-gray-500 truncate">{event.title}</p>
</div>
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto overflow-x-hidden overscroll-contain">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input className={inputCls} value={d.name} onChange={e => set("name", e.target.value)} placeholder="Family deal" required maxLength={80} />
<p className="text-xs text-gray-500 mt-1">Shown to buyers and on receipts.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description <span className="font-normal text-gray-400">(optional)</span></label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Bring the whole family" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Type of deal</label>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(KIND_INFO) as BundleKind[]).map(k => (
<button type="button" key={k} onClick={() => set("kind", k)} aria-pressed={d.kind === k}
className={`text-left rounded-lg border p-2.5 transition-colors ${d.kind === k ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
<div className="text-sm font-medium text-gray-900">{KIND_INFO[k].label}</div>
<div className="text-xs text-gray-500">{KIND_INFO[k].hint}</div>
<div className="text-[11px] text-gray-400 mt-0.5 hidden sm:block">e.g. {KIND_INFO[k].example}</div>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{exact ? "Tickets in the bundle" : "Qualifying tickets"}</label>
{!exact && <p className="text-xs text-gray-500 -mt-1 mb-2">Any of these count towards the deal.</p>}
<div className="space-y-2">
{d.items.map((it, i) => {
const opt = options.find(o => o.id === it.eventOptionId);
const hasVariants = (opt?.variants || []).length > 0;
return (
<div key={i} className="rounded-lg border bg-gray-50 p-2.5">
<div className="flex items-center gap-2">
{exact && (
<>
<input type="number" min="1" step="1" inputMode="numeric" className={`${fieldCls} w-16 shrink-0 px-2 bg-white text-center`} value={it.quantity} onChange={e => setItem(i, { quantity: e.target.value })} aria-label="Quantity" required />
<span className="text-sm text-gray-500 shrink-0">×</span>
</>
)}
<select className={`${fieldCls} min-w-0 flex-1 bg-white`} value={it.eventOptionId} onChange={e => setItem(i, { eventOptionId: e.target.value, variantId: null })} aria-label="Ticket">
{options.map(o => <option key={o.id} value={o.id}>{o.name} ({fmtRand(o.price)})</option>)}
</select>
<button type="button" onClick={() => set("items", d.items.filter((_, j) => j !== i))} disabled={d.items.length <= 1}
className="p-2 shrink-0 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 disabled:opacity-30 disabled:hover:bg-transparent" aria-label="Remove ticket">
<Trash2 className="w-4 h-4" />
</button>
</div>
{hasVariants && (
<select className={`${inputCls} mt-2 bg-white`} value={it.variantId || ""} onChange={e => setItem(i, { variantId: e.target.value || null })} aria-label="Variant">
<option value="">Any {opt?.name.toLowerCase()} variant</option>
{(opt?.variants || []).map(v => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
)}
</div>
);
})}
</div>
<button type="button" onClick={() => set("items", [...d.items, { eventOptionId: options[0]?.id || "", variantId: null, quantity: "1" }])}
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-brand-700 hover:underline">
<Plus className="w-4 h-4" /> Add a ticket type
</button>
</div>
{!exact && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.kind === "group" ? "Minimum tickets" : "Tickets per set"}</label>
<input type="number" min={d.kind === "free" ? 2 : 2} step="1" inputMode="numeric" className={inputCls} value={d.setSize} onChange={e => set("setSize", e.target.value)} required />
</div>
{d.kind === "free" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Free in each set</label>
<input type="number" min="1" step="1" inputMode="numeric" className={inputCls} value={d.freeCount} onChange={e => set("freeCount", e.target.value)} required />
</div>
)}
{d.kind === "group" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Group price</label>
<select className={inputCls} value={d.groupPricing} onChange={e => set("groupPricing", e.target.value as BundleGroupPricing)}>
<option value="per_ticket">Price per ticket</option>
<option value="percent">Percentage off</option>
</select>
</div>
)}
</div>
)}
{d.kind === "free" ? (
<div className="rounded-lg bg-emerald-50 p-3 text-sm text-emerald-900">
{size > free && free > 0
? <>Buy {size - free}, get {free} free. In every {size} qualifying tickets, the cheapest {free === 1 ? "one is" : `${free} are`} free.</>
: <span className="text-amber-700">The set needs more tickets than are free.</span>}
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{priceLabel}</label>
<input type="number" min="0" max={d.kind === "group" && d.groupPricing === "percent" ? 100 : undefined} step="0.01" inputMode="decimal" className={`${inputCls} sm:max-w-[12rem]`} value={d.price} onChange={e => set("price", e.target.value)} required />
{d.kind === "group" ? (
<p className="text-xs text-gray-500 mt-2">
{size >= 2 ? `From ${size} qualifying tickets, every one of them ` : "Every qualifying ticket "}
{d.groupPricing === "percent" ? `gets ${d.price || "…"}% off.` : `costs ${d.price === "" ? "…" : fmtRand(price)} (tickets already cheaper stay as they are).`}
</p>
) : (
<>
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-gray-50 p-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">{exact ? "Separately" : "Dearest set"}</div>
<div className="text-sm font-semibold text-gray-900">{fmtRand(standardValue)}</div>
</div>
<div className="rounded-lg bg-gray-50 p-2">
<div className="text-[11px] uppercase tracking-wide text-gray-500">Bundle</div>
<div className="text-sm font-semibold text-gray-900">{d.price === "" ? "—" : fmtRand(price)}</div>
</div>
<div className={`rounded-lg p-2 ${d.price !== "" && saving_ <= 0 ? "bg-amber-50" : "bg-emerald-50"}`}>
<div className="text-[11px] uppercase tracking-wide text-gray-500">Saving</div>
<div className={`text-sm font-semibold ${d.price !== "" && saving_ <= 0 ? "text-amber-700" : "text-emerald-700"}`}>{d.price === "" ? "—" : fmtRand(Math.max(0, saving_))}</div>
</div>
</div>
{d.price !== "" && saving_ <= 0 && <p className="text-xs text-amber-700 mt-1">The bundle isn&apos;t cheaper than the standard prices, so it won&apos;t apply unless prices change.</p>}
{!exact && <p className="text-xs text-gray-500 mt-1">The saving depends on which tickets are picked; it only applies when the set price is cheaper.</p>}
</>
)}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Available from <span className="font-normal text-gray-400">(optional)</span></label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Available until <span className="font-normal text-gray-400">(optional)</span></label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.isActive} onChange={e => set("isActive", e.target.checked)} />
<span>On sale <span className="block text-xs text-gray-500">Registrations that already have this bundle keep it if you turn it off later.</span></span>
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : bundleId ? "Save changes" : "Create bundle"}
</button>
</div>
</form>
</div>
);
}
@@ -10,6 +10,7 @@ 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";
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
@@ -134,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&apos;s consent before check-in</p>
</div>
@@ -5,9 +5,9 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
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 { TicketPercent, Plus, X, Search, Pencil, Power, Users, CalendarClock, ListChecks } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type CouponType = "percent" | "fixed" | "comp";
type Role = "user" | "staff" | "supervisor" | "admin";
@@ -16,6 +16,7 @@ type EventLite = {
id: string;
title: string;
startDate: string;
endDate?: string;
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
};
@@ -121,17 +122,19 @@ function bodyFromDraft(d: Draft) {
};
}
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtDate = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
function couponStatus(c: Coupon): { label: string; cls: string } {
type Status = { key: "active" | "scheduled" | "off" | "expired" | "used"; label: string; cls: string };
function couponStatus(c: Coupon): Status {
const now = Date.now();
if (!c.active) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
if (c.maxUses && c.uses >= c.maxUses) return { label: "Used up", cls: "bg-gray-100 text-gray-600" };
return { label: "Active", cls: "bg-green-100 text-green-800" };
if (!c.active) return { key: "off", label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { key: "expired", label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.maxUses && c.uses >= c.maxUses) return { key: "used", label: "Used up", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { key: "scheduled", label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
return { key: "active", label: "Active", cls: "bg-green-100 text-green-800" };
}
export default function CouponsPage() {
@@ -150,7 +153,7 @@ export default function CouponsPage() {
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [showOff, setShowOff] = useState(false);
const [showInactive, setShowInactive] = useState(false);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
@@ -174,10 +177,17 @@ export default function CouponsPage() {
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
// Codes that can still be used first; off, expired and used-up ones only when asked for.
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return coupons.filter(c => (showOff || c.active) && (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q)));
}, [coupons, query, showOff]);
const order: Record<Status["key"], number> = { active: 0, scheduled: 1, used: 2, expired: 3, off: 4 };
return coupons
.map(c => ({ c, status: couponStatus(c) }))
.filter(({ c, status }) => (showInactive || status.key === "active" || status.key === "scheduled")
&& (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q) || c.events.some(e => e.event.title.toLowerCase().includes(q))))
.sort((a, b) => order[a.status.key] - order[b.status.key]);
}, [coupons, query, showInactive]);
const hiddenCount = coupons.filter(c => !["active", "scheduled"].includes(couponStatus(c).key)).length;
const toggleActive = async (c: Coupon) => {
try {
@@ -198,23 +208,25 @@ export default function CouponsPage() {
}
};
const startNew = () => setEditing({ id: null, draft: blankDraft() });
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<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>
</div>
{canView && (
<button
onClick={() => setEditing({ id: null, draft: blankDraft() })}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700"
onClick={startNew}
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 w-full sm:w-auto"
>
<Plus className="w-4 h-4" /> New code
</button>
@@ -229,72 +241,46 @@ export default function CouponsPage() {
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="relative w-full sm:w-64">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative w-full sm:w-72">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Search codes" value={query} onChange={e => setQuery(e.target.value)} />
<input className={`${inputCls} pl-8`} placeholder="Search codes or events" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={showOff} onChange={e => setShowOff(e.target.checked)} /> Show codes that are off
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} />
Show codes that are off, expired or used up{hiddenCount > 0 ? ` (${hiddenCount})` : ""}
</label>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Events</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Valid</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map(c => {
const status = couponStatus(c);
return (
<TableRow key={c.id}>
<TableCell>
<div className="font-mono font-semibold text-gray-900">{c.code}</div>
{c.description && <div className="text-xs text-gray-500">{c.description}</div>}
{(c.restrictRoles.length > 0 || c.allowedUsers.length > 0 || c.restrictDomain) && (
<div className="text-xs text-amber-700 mt-0.5">
Restricted: {c.allowedUsers.length ? `${c.allowedUsers.length} person${c.allowedUsers.length === 1 ? "" : "s"}` : c.restrictRoles.length ? c.restrictRoles.join(", ") : `@${c.restrictDomain}`}
</div>
)}
</TableCell>
<TableCell>
<div>{c.summary}</div>
{c.options.length > 0 && <div className="text-xs text-gray-500">{c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</div>}
</TableCell>
<TableCell className="text-sm">{c.events.length ? c.events.map(e => e.event.title).join(", ") : <span className="text-gray-500">All events</span>}</TableCell>
<TableCell className="text-sm">
{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}
<div className="text-xs text-gray-500">{fmtRand(c.totalDiscount)} given</div>
</TableCell>
<TableCell className="text-xs text-gray-600">
{c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end"}` : "Always"}
</TableCell>
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
<TableCell className="text-right whitespace-nowrap">
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => openUses(c)}>Uses</button>
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}>Edit</button>
<button className={`px-2 py-1 text-xs rounded text-white ${c.active ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(c)}>
{c.active ? "Turn off" : "Turn on"}
</button>
</TableCell>
</TableRow>
);
})}
{!fetching && visible.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">No coupon codes yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
{!fetching && visible.length === 0 && (
<div className="text-center py-10">
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center mx-auto mb-3">
<TicketPercent className="w-6 h-6 text-brand-600" />
</div>
<p className="text-sm font-medium text-gray-900">{coupons.length === 0 ? "No coupon codes yet" : "No codes match"}</p>
<p className="text-xs text-gray-500 mt-1 max-w-sm mx-auto">e.g. &quot;STAFF50&quot;: 50% off for staff, or &quot;WELCOME20&quot;: R20 off for first-time visitors.</p>
{coupons.length === 0 && (
<button onClick={startNew} className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Create a code
</button>
)}
</div>
)}
{visible.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{visible.map(({ c, status }) => (
<CouponCard
key={c.id}
coupon={c}
status={status}
onUses={() => openUses(c)}
onEdit={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}
onToggle={() => toggleActive(c)}
/>
))}
</div>
)}
</div>
)}
@@ -310,47 +296,119 @@ export default function CouponsPage() {
)}
{viewing && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setViewing(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">Uses of <span className="font-mono">{viewing.coupon.code}</span></h2>
<button onClick={() => setViewing(null)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto">
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<Table>
<TableHeader>
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
</TableHeader>
<TableBody>
{viewing.redemptions.map(r => (
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
<TableCell>
<div className="text-sm">{r.user.name}</div>
<div className="text-xs text-gray-500">{r.user.email}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</TableCell>
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
<TableCell className="text-sm">
{fmtRand(r.discountAmount)}
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</TableCell>
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
<Sheet title={<>Uses of <span className="font-mono">{viewing.coupon.code}</span></>} onClose={() => setViewing(null)}>
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<ul className="divide-y">
{viewing.redemptions.map(r => (
<li key={r.id} className={`py-2.5 flex items-start justify-between gap-3 ${r.releasedAt ? "opacity-60" : ""}`}>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{r.user.name}</div>
<div className="text-xs text-gray-500 truncate">{r.user.email}</div>
<div className="text-xs text-gray-500">{r.registration.event.title} · {fmtDate(r.createdAt)}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</div>
<div className="text-right shrink-0">
<div className="text-sm font-semibold">{fmtRand(r.discountAmount)}</div>
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</div>
</li>
))}
</ul>
)}
</Sheet>
)}
</div>
);
}
/** A dialog that slides up full-width on phones and sits centred on bigger screens. */
function Sheet({ title, subtitle, onClose, children, footer, onSubmit }: {
title: React.ReactNode;
subtitle?: React.ReactNode;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
onSubmit?: (e: React.FormEvent) => void;
}) {
const Box = onSubmit ? "form" : "div";
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-end sm:items-center justify-center sm:p-4" onClick={onClose}>
<Box onSubmit={onSubmit} className="bg-white w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-xl shadow-xl flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between gap-3 p-4 border-b">
<div className="min-w-0">
<h2 className="font-semibold">{title}</h2>
{subtitle && <p className="text-xs text-gray-500 truncate">{subtitle}</p>}
</div>
<button type="button" onClick={onClose} aria-label="Close" className="p-1 -m-1"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto overflow-x-hidden overscroll-contain">{children}</div>
{footer && <div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 sm:gap-3 p-4 border-t">{footer}</div>}
</Box>
</div>
);
}
function CouponCard({ coupon: c, status, onUses, onEdit, onToggle }: { coupon: Coupon; status: Status; onUses: () => void; onEdit: () => void; onToggle: () => void }) {
const restricted = c.allowedUsers.length
? `${c.allowedUsers.length} ${c.allowedUsers.length === 1 ? "person" : "people"}`
: c.restrictRoles.length ? c.restrictRoles.map(r => (r === "user" ? "members" : r)).join(", ") : c.restrictDomain ? `@${c.restrictDomain}` : null;
const dates = c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end date"}` : null;
const limits = [
c.maxUses ? `${c.maxUses} uses in total` : null,
c.maxUsesPerUser ? `${c.maxUsesPerUser} per person` : null,
c.minQuantity ? `min ${c.minQuantity} tickets` : null,
].filter(Boolean).join(" · ");
const live = status.key === "active" || status.key === "scheduled";
return (
<div className={`border rounded-xl p-4 flex flex-col gap-3 ${live ? "bg-white" : "bg-gray-50"}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-mono font-semibold text-gray-900 break-all">{c.code}</div>
{c.description && <div className="text-xs text-gray-500 mt-0.5 break-words">{c.description}</div>}
</div>
<span className={`shrink-0 text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span>
</div>
<div className="flex items-baseline flex-wrap gap-x-2 gap-y-0.5">
<span className="text-xl font-semibold text-gray-900">{c.summary}</span>
{c.options.length > 0 && (
<span className="text-xs text-gray-500">on {c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{c.events.length === 0
? <span className="rounded-full bg-gray-100 text-gray-700 px-2.5 py-1 text-xs">All events</span>
: c.events.map(e => (
<span key={e.eventId} className="rounded-full bg-brand-50 text-brand-800 border border-brand-100 px-2.5 py-1 text-xs break-words">{e.event.title}</span>
))}
</div>
<div className="text-xs text-gray-600 space-y-0.5">
<div>Used <strong>{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}×</strong> · {fmtRand(c.totalDiscount)} given</div>
{restricted && <div className="flex items-center gap-1 text-amber-700"><Users className="w-3.5 h-3.5 shrink-0" /> Only for {restricted}</div>}
{dates && <div className="flex items-center gap-1"><CalendarClock className="w-3.5 h-3.5 shrink-0" /> {dates}</div>}
{limits && <div>{limits}</div>}
</div>
<div className="flex flex-wrap gap-2 pt-1 border-t border-gray-100 mt-auto">
<button onClick={onUses} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<ListChecks className="w-3.5 h-3.5" /> Uses
</button>
<button onClick={onEdit} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border hover:bg-gray-50 mt-2">
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
<button onClick={onToggle} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border mt-2 sm:ml-auto ${c.active ? "text-amber-700 border-amber-200 hover:bg-amber-50" : "text-green-700 border-green-200 hover:bg-green-50"}`}>
<Power className="w-3.5 h-3.5" /> {c.active ? "Turn off" : "Turn on"}
</button>
</div>
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
@@ -364,8 +422,22 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const [eventQuery, setEventQuery] = useState("");
const [showPast, setShowPast] = useState(false);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Upcoming events soonest first. Past events only when asked for, but ones already
// picked always stay in the list so they can be unticked.
const eventChoices = useMemo(() => {
const q = eventQuery.trim().toLowerCase();
const upcoming = events.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const past = events.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
return [...upcoming, ...past]
.filter(e => showPast || !isPast(e) || d.eventIds.includes(e.id))
.filter(e => !q || e.title.toLowerCase().includes(q));
}, [events, eventQuery, showPast, d.eventIds]);
const pastCount = events.filter(isPast).length;
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
@@ -414,184 +486,200 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
}
};
const label = "block text-sm font-medium text-gray-700 mb-1";
const optional = <span className="font-normal text-gray-400">(optional)</span>;
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
<option value="percent">Percentage</option>
<option value="fixed">Fixed amount (R)</option>
<option value="comp">Free (100%)</option>
</select>
</div>
{d.type !== "comp" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<div className="flex items-end">
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
{events.map(ev => (
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span>{ev.title}</span>
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
</label>
))}
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-4 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<Sheet
title={couponId ? "Edit code" : "New coupon code"}
onClose={onClose}
onSubmit={save}
footer={(
<>
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
</button>
</>
)}
>
<div className="space-y-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}>Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className={label}>Description {optional}</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
<p className="text-xs text-gray-500 mt-1">Only staff see this.</p>
</div>
</div>
</form>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Discount</label>
<div className="grid grid-cols-3 gap-2">
{([["percent", "Percentage", "e.g. 20% off"], ["fixed", "Amount off", "e.g. R50 off"], ["comp", "Free", "100% off"]] as [CouponType, string, string][]).map(([t, name, hint]) => (
<button type="button" key={t} onClick={() => set("type", t)} aria-pressed={d.type === t}
className={`text-left rounded-lg border p-2.5 transition-colors ${d.type === t ? "border-brand-500 bg-brand-50 ring-1 ring-brand-500" : "hover:bg-gray-50"}`}>
<div className="text-sm font-medium text-gray-900">{name}</div>
<div className="text-xs text-gray-500">{hint}</div>
</button>
))}
</div>
{d.type !== "comp" && (
<div className="mt-3 sm:max-w-[12rem]">
<label className={label}>{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" inputMode="decimal" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<label className="mt-3 flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
<div>
<label className={label}>Events</label>
<p className="text-xs text-gray-500 mb-2">
{d.eventIds.length === 0 ? "None picked: the code works for every event." : `${d.eventIds.length} picked. The code only works for ${d.eventIds.length === 1 ? "this event" : "these events"}.`}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Find an event" value={eventQuery} onChange={e => setEventQuery(e.target.value)} />
</div>
{pastCount > 0 && (
<label className="flex items-center gap-2 text-sm text-gray-700 shrink-0">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
</label>
)}
</div>
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
{eventChoices.map(ev => (
<label key={ev.id} className={`flex items-center gap-2 px-3 py-2 text-sm ${d.eventIds.includes(ev.id) ? "bg-brand-50/60" : ""}`}>
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span className="min-w-0 flex-1 truncate">{ev.title}</span>
<span className="text-xs text-gray-500 shrink-0">{fmtDate(ev.startDate)}{isPast(ev) ? " · past" : ""}</span>
</label>
))}
{eventChoices.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">{eventQuery ? "No events match." : "No upcoming events."}</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className={label}>Only these tickets / items {optional}</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3 bg-gray-50">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm py-0.5">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6 py-0.5">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={label}>Valid from {optional}</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className={label}>Valid until {optional}</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className={label}>Max total uses</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Max uses per person</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className={label}>Minimum tickets</label>
<input type="number" min="1" inputMode="numeric" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className={label}>Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-x-4 gap-y-2 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.active} onChange={e => set("active", e.target.checked)} />
<span>Active <span className="block text-xs text-gray-500">Discounts already given keep their amount if you turn it off later.</span></span>
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
</Sheet>
);
}
@@ -9,6 +9,7 @@ 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';
@@ -489,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>
@@ -8,6 +8,7 @@ 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
@@ -209,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 && (
@@ -12,6 +12,7 @@ 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 ────────────────────────────────────────────────────────────────
@@ -1792,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>
@@ -9,6 +9,7 @@ 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';
@@ -452,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
@@ -9,6 +9,7 @@ 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 };
@@ -67,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>
@@ -7,11 +7,13 @@ import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
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 type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -58,6 +60,7 @@ export default function ManualRegistrationPage() {
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [quantities, setQuantities] = useState<Record<string, number>>({});
const [bundles, setBundles] = useState<PublicBundle[]>([]);
// User search state
const [userQuery, setUserQuery] = useState("");
@@ -265,6 +268,16 @@ export default function ManualRegistrationPage() {
}
}, [selectedEventId, events]);
// The events list doesn't carry bundle deals; the single-event response does.
useEffect(() => {
if (!selectedEventId) { setBundles([]); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setBundles([]); });
return () => { cancelled = true; };
}, [selectedEventId, token]);
const totalDue = useMemo(() => {
return options.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
@@ -274,6 +287,13 @@ export default function ManualRegistrationPage() {
}, 0);
}, [options, quantities]);
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
}), [quantities]);
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
const submit = async () => {
if (!token) return;
setError(null);
@@ -348,9 +368,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 && (
@@ -524,6 +546,7 @@ export default function ManualRegistrationPage() {
<div className="mt-6 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">3) Select ticket options</div>
{options.length > 0 && <BundleOffers bundles={bundles} className="mb-3" />}
{options.length === 0 ? (
<div className="text-sm text-gray-500">Select an event to view options.</div>
) : (
@@ -587,6 +610,13 @@ export default function ManualRegistrationPage() {
})}
</div>
)}
<BundleBuilder
className="mt-3"
bundles={bundles}
options={options}
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
/>
</div>
{selectedEventAllowsNames && (
@@ -611,10 +641,7 @@ export default function ManualRegistrationPage() {
<div className="mt-6 max-w-md">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
basket={basket}
token={token}
forUserId={pickedUserId}
applied={coupon}
@@ -625,8 +652,9 @@ export default function ManualRegistrationPage() {
<div className="flex items-center justify-between mt-6">
<div className="text-sm">
Total due: <span className="font-semibold">R {(coupon ? coupon.total : totalDue).toFixed(2)}</span>
{coupon && coupon.discount > 0 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
<BundleSavings applications={bundleQuote?.bundles} />
Total due: <span className="font-semibold">R {payable.toFixed(2)}</span>
{payable < totalDue - 0.005 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
</div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
@@ -9,6 +9,7 @@ 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 = {
@@ -86,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>
@@ -30,6 +30,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ href: "/dashboard/supervisor/bundles", label: "Bundle deals", description: "Tickets sold together for one price", icon: Gift },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
@@ -10,6 +10,7 @@ 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.
@@ -499,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 && (
@@ -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>
);
@@ -10,6 +10,7 @@ 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;
@@ -100,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>
@@ -9,6 +9,7 @@ import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-
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 };
@@ -608,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 && (
@@ -6,6 +6,7 @@ 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();
@@ -65,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>
@@ -8,6 +8,7 @@ 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 };
@@ -139,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>}
+4 -2
View File
@@ -14,6 +14,7 @@ import { TransferTicketModal, canOfferTransfer } from "@/components/shared/Trans
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { BundleSavings } from "@/components/shared/BundleDeals";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
// Helper formatters
@@ -1096,9 +1097,10 @@ export default function UserDashboardPage() {
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
{Number(activeReg.discountTotal || 0) > 0 && (
<BundleSavings applications={activeReg.bundleApplications} className="mt-2" />
{Number(activeReg.discountTotal || 0) - Number(activeReg.bundleDiscount || 0) > 0.005 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal))}
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal) - Number(activeReg.bundleDiscount || 0))}
</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
@@ -9,6 +9,7 @@ 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;
@@ -108,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>}
@@ -9,6 +9,7 @@ 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 };
@@ -309,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 &amp; Security</h1>
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Profile &amp; Security</h1>
<p className="text-sm text-gray-500 mt-1">Manage your personal information and keep your account secure.</p>
</div>
@@ -6,6 +6,7 @@ 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();
@@ -50,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>}
+6 -1
View File
@@ -5,7 +5,8 @@ import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions";
import { ContactButton } from "@/components/events/ContactButton";
import { LocationMap } from "@/components/events/LocationMap";
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { soldOutLines } from "@/lib/waitlist";
import { SponsorSpot } from "@/components/events/SponsorSpot";
import { FunnelTracker } from "@/components/events/FunnelTracker";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
@@ -45,11 +46,14 @@ type Event = {
location?: string | null;
waitlistEnabled?: boolean;
allowSponsorships?: boolean;
bundles?: PublicBundle[];
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types";
// Tiered low-stock threshold: larger events use a smaller percentage.
// Math.round avoids Math.ceil inflating the threshold (e.g. ceil(1.5)=2 made
@@ -262,6 +266,7 @@ function TicketList({ event }: { event: Event }) {
}
return (
<div className="space-y-2">
<BundleOffers bundles={event.bundles} />
{options.map((opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
+6 -4
View File
@@ -18,6 +18,8 @@ function FormsContent() {
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId");
// Donation chosen on the register form — passed through so the success page can add it to the payment.
const donationParam = search.get("donation") ? `&donation=${encodeURIComponent(search.get("donation")!)}` : "";
// Set when a named attendee opens the link sent to them — show only their form.
const onlyAttendeeId = search.get("attendeeId");
@@ -119,7 +121,7 @@ function FormsContent() {
if (payload.length === 0) {
// Nothing left to submit – still go to success page to continue the flow
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))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
return;
}
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
@@ -129,7 +131,7 @@ function FormsContent() {
});
// On success, redirect to the success page
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))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
} catch (e) {
setError(errorMessage(e) || 'Failed to submit forms');
}
@@ -149,7 +151,7 @@ function FormsContent() {
try {
// Redirect to the success page
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))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
} catch (e) {
setError(errorMessage(e) || 'Failded to go to next step');
}
@@ -163,7 +165,7 @@ function FormsContent() {
try {
// Redirect to the success page
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))}`:''}`);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}${donationParam}`);
} catch (e) {
setError(errorMessage(e) || 'Failded to skip');
}
@@ -4,6 +4,8 @@ import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import type { PublicBundle } from "@/types";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
import { analyticsSessionId } from "@/lib/analytics";
@@ -49,6 +51,7 @@ export type RegisterEvent = {
requiresParentalConsent?: boolean;
collectGender?: boolean;
waitlistEnabled?: boolean;
bundles?: PublicBundle[];
};
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
@@ -254,6 +257,77 @@ function OptionCard({
);
}
// Online donations have the same R15 minimum as the donate page and the backend.
const MIN_DONATION = 15;
// Suggested donations: 10/15/20/25% of the ticket total rounded up to the next R10 (at least R20),
// topped up with fixed amounts when the percentages collapse on a small total.
function donationPresets(total: number): number[] {
const out: number[] = [];
for (const pct of [0.10, 0.15, 0.20, 0.25]) {
const v = Math.max(20, Math.ceil((total * pct) / 10) * 10);
if (!out.includes(v)) out.push(v);
}
for (const v of [50, 100, 200]) {
if (out.length >= 3) break;
if (!out.includes(v) && v > out[out.length - 1]) out.push(v);
}
return out;
}
type DonationChoice = { kind: "none" } | { kind: "preset"; index: number } | { kind: "custom" };
function DonationPicker({
presets,
choice,
onChoiceChange,
customAmount,
onCustomAmountChange,
}: {
presets: number[];
choice: DonationChoice;
onChoiceChange: (c: DonationChoice) => void;
customAmount: string;
onCustomAmountChange: (v: string) => void;
}) {
const btn = (active: boolean) =>
`px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${active ? "bg-brand-600 border-brand-600 text-white" : "bg-white text-gray-700 hover:bg-gray-50"}`;
return (
<div className="border rounded-lg p-4 bg-white shadow-sm">
<div className="font-medium text-sm">Donation amount</div>
<p className="text-xs text-gray-500 mt-0.5 mb-3">Would you like to add a donation to support this event? It&apos;s added to your payment.</p>
<div className="flex flex-wrap gap-2">
{presets.map((amt, i) => (
<button key={i} type="button" className={btn(choice.kind === "preset" && choice.index === i)} onClick={() => onChoiceChange({ kind: "preset", index: i })}>
R{amt}
</button>
))}
<button type="button" className={btn(choice.kind === "custom")} onClick={() => onChoiceChange({ kind: "custom" })}>
Custom
</button>
<button type="button" className={btn(choice.kind === "none")} onClick={() => onChoiceChange({ kind: "none" })}>
None
</button>
</div>
{choice.kind === "custom" && (
<div className="mt-3">
<input
type="number"
min={MIN_DONATION}
step="1"
inputMode="decimal"
autoFocus
placeholder={`Amount in Rand (min R${MIN_DONATION})`}
value={customAmount}
onChange={(e) => onCustomAmountChange(e.target.value)}
className="w-full sm:w-60 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
)}
</div>
);
}
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
const { token, user } = useAuth();
const eventId = baseEvent.id;
@@ -359,8 +433,21 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
}, [event]);
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
// The server prices the basket (staff price, tiers) when checking a code, so its total wins.
const payable = coupon ? coupon.total : total;
const bundleQuote = useBundleQuote({ eventId, basket, token, enabled: (event.bundles || []).length > 0 });
// The server prices the basket (staff price, tiers, bundle deals) when quoting or checking
// a code, so its total wins.
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : total;
// Optional donation on top of the tickets — paid in the same Yoco checkout and recorded
// as a separate donation payment. Only offered when there's something to pay online.
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
const [customDonation, setCustomDonation] = useState("");
const presets = useMemo(() => donationPresets(total), [total]);
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
const donation = !showDonation ? 0
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
: 0;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
@@ -386,6 +473,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
if (missing) { setError(missing); return; }
}
if (showDonation && donationChoice.kind === "custom" && donation > 0 && donation < MIN_DONATION) {
setError(`The minimum donation is R${MIN_DONATION}. Choose None to skip it.`);
return;
}
const donationParam = donation > 0 ? `&donation=${encodeURIComponent(donation.toFixed(2))}` : "";
// Claim link without logging in: the link identifies the person.
if (waitlistToken && !isLoggedIn) {
@@ -430,8 +522,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
const due = res.totalDueComputed ?? payable;
const next = hasForm
? `/forms?registrationId=${encodeURIComponent(res.id)}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`;
? `/forms?registrationId=${encodeURIComponent(res.id)}${donationParam}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}${donationParam}`;
if (res.couponError) {
// The code ran out between checking and registering — the registration is
// saved at full price, so say so before moving on.
@@ -544,6 +636,8 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
</div>
)}
{!eventSoldOut && <BundleOffers bundles={event.bundles} className="mb-3" />}
<div className="space-y-3">
{(event.eventOptions || []).map((opt) => (
<OptionCard
@@ -556,6 +650,28 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
))}
</div>
{!eventSoldOut && (
<BundleBuilder
className="mt-3"
bundles={event.bundles}
options={event.eventOptions || []}
qty={qtyFor}
onSetQuantity={(optionId, variantId, quantity) => handleQtyChange(variantId ? `${optionId}:${variantId}` : optionId, quantity)}
/>
)}
{showDonation && (
<div className="mt-3">
<DonationPicker
presets={presets}
choice={donationChoice}
onChoiceChange={setDonationChoice}
customAmount={customDonation}
onCustomAmountChange={setCustomDonation}
/>
</div>
)}
{(() => {
// Collect all upcoming early-bird tiers across all options
const now = new Date();
@@ -599,13 +715,17 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
<div className="mt-6 flex items-center justify-between">
<div>
{coupon && coupon.discount > 0 && (
<div className="text-sm text-gray-500">
<span className="line-through">R{coupon.subtotal.toFixed(2)}</span>
<span className="ml-2 text-green-700">−R{coupon.discount.toFixed(2)}</span>
</div>
{(bundleQuote || (coupon && coupon.discount > 0)) && (
<div className="text-sm text-gray-500 line-through">R{(coupon?.subtotal ?? bundleQuote?.subtotal ?? total).toFixed(2)}</div>
)}
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
<BundleSavings applications={bundleQuote?.bundles} />
{coupon && coupon.discount > 0 && (
<div className="text-sm text-green-700">Code {coupon.code} −R{coupon.discount.toFixed(2)}</div>
)}
{donation > 0 && (
<div className="text-sm text-gray-600">Donation +R{donation.toFixed(2)}</div>
)}
<div className="text-lg font-semibold">Total: R{(payable + donation).toFixed(2)}</div>
</div>
{eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
+16 -2
View File
@@ -21,6 +21,13 @@ function RegistrationSuccessContent() {
return isNaN(n) ? 0 : n;
}, [fallbackTotalParam]);
// Optional donation picked on the register form; added to the Yoco checkout as its own line.
const donationParam = search.get("donation");
const donation = React.useMemo(() => {
const n = donationParam ? Number(donationParam) : 0;
return isNaN(n) || n < 15 ? 0 : Math.round(n * 100) / 100;
}, [donationParam]);
const [reg, setReg] = React.useState<Registration | null>(null);
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [error, setError] = React.useState<string | null>(null);
@@ -81,7 +88,7 @@ function RegistrationSuccessContent() {
setError(null);
setPayLoading(true);
const { createFullPaymentCheckout } = await import('@/lib/api');
const res = await createFullPaymentCheckout(token, registrationId);
const res = await createFullPaymentCheckout(token, registrationId, donation);
if (res.priceUpdated) {
// Early-bird price changed since registration — surface the new total and
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
@@ -123,7 +130,14 @@ function RegistrationSuccessContent() {
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
</div>
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
<>
{donation > 0 && (
<p className="text-gray-700 mb-2">
Thank you for adding a donation of <strong>R{donation.toFixed(2)}</strong>. It&apos;s included when you pay now.
</p>
)}
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
</>
) : null}
<div className="flex flex-col sm:flex-row gap-3 mt-4">
+32 -8
View File
@@ -5,9 +5,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { apiFetch } from "@/lib/api";
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types";
import type { PublicBundle, Registration } from "@/types";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -143,6 +144,7 @@ export default function SelfServicePage() {
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
const [kioskBundles, setKioskBundles] = useState<PublicBundle[]>([]);
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
@@ -217,6 +219,22 @@ export default function SelfServicePage() {
}, 0);
}, [selectedEvent, quantities]);
// The events list doesn't carry bundle deals; the single-event response does.
useEffect(() => {
if (!selectedEventId) { setKioskBundles([]); return; }
let cancelled = false;
apiFetch<{ bundles?: PublicBundle[] }>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
.then(ev => { if (!cancelled) setKioskBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setKioskBundles([]); });
return () => { cancelled = true; };
}, [selectedEventId, supervisorToken]);
const kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
}), [quantities]);
const bundleQuote = useBundleQuote({ eventId: selectedEventId || "", basket: kioskBasket, token: supervisorToken, forUserId: matchedAccount?.id ?? null });
// Does the selected event have a required form?
const eventHasRequiredForm = useMemo(() => {
return !!(selectedEvent?.form?.isRequired && (selectedEvent.form.fields || []).some(f => f.type !== 'statement'));
@@ -939,6 +957,7 @@ export default function SelfServicePage() {
{(selectedEvent.eventOptions || []).length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</label>
<BundleOffers bundles={kioskBundles} className="mb-3" />
<div className="space-y-3">
{(selectedEvent.eventOptions || []).map((opt) => {
const hasVariants = (opt.variants || []).length > 0;
@@ -1014,6 +1033,13 @@ export default function SelfServicePage() {
);
})}
</div>
<BundleBuilder
className="mt-3"
bundles={kioskBundles}
options={selectedEvent.eventOptions || []}
qty={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
onSetQuantity={(o, v, n) => setQuantities(q => ({ ...q, [v ? `${o}::${v}` : o]: n }))}
/>
{(selectedEvent?.allowNamedAttendees || selectedEvent?.requiresParentalConsent) && (
<div className="mt-4">
<AttendeeNamesEditor
@@ -1034,10 +1060,7 @@ export default function SelfServicePage() {
<div className="mt-4">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
basket={kioskBasket}
token={supervisorToken}
forUserId={matchedAccount?.id ?? null}
applied={kioskCoupon}
@@ -1047,10 +1070,11 @@ export default function SelfServicePage() {
)}
{total > 0 && (
<div className="mt-3 text-right text-base font-semibold text-gray-800">
{kioskCoupon && kioskCoupon.discount > 0 && (
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon.subtotal)}</span>
<BundleSavings applications={bundleQuote?.bundles} className="flex flex-col items-end font-normal" />
{((kioskCoupon && kioskCoupon.discount > 0) || bundleQuote) && (
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon?.subtotal ?? bundleQuote?.subtotal ?? total)}</span>
)}
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : total)}
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : bundleQuote ? bundleQuote.total : total)}
</div>
)}
</div>
@@ -4,8 +4,9 @@ import React, { useState } from "react";
import { ListPlus, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import type { WaitlistLine } from "@/lib/waitlist";
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
export type { WaitlistLine };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
@@ -125,21 +126,3 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
function keyOf(l?: WaitlistLine) {
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
}
/** The sold-out lines of an event, for the waitlist button. */
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
const out: WaitlistLine[] = [];
for (const o of options || []) {
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
const variants = o.variants || [];
if (variants.length > 0) {
for (const v of variants) {
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
}
} else if (optOut) {
out.push({ eventOptionId: o.id, label: o.name });
}
}
return out;
}
@@ -42,7 +42,7 @@ export const REPORTS = [
{ key: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
{ key: "discounts", label: "Discounts (codes & bundles)", description: "Uses and value given away per code, bundle deal and event", category: "Financial", icon: TicketPercent, fields: 4 },
] as const satisfies ReadonlyArray<{
key: string;
label: string;
@@ -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>
+41 -28
View File
@@ -18,6 +18,7 @@ import type { ConsentRow, Event, EventCashup, EventFinancials, MedicalRow, Payme
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const WRITE_OFF_LABEL: Record<string, string> = { card_fees: "Card / online fees", bank_charges: "Bank charges", cash_shortfall: "Cash shortfall", other: "Other" };
function denomLabel(v: number): string {
return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`;
}
@@ -94,9 +95,12 @@ type AuditRow = EventCashup & {
_previousCloseAt?: string;
};
// GET /api/coupons/redemptions — active coupon uses, with a readable summary of the code.
// GET /api/coupons/redemptions — active coupon uses and bundle deals, with a readable summary.
// For a bundle, coupon.code is the bundle name and uses is how many times it applied.
type DiscountRow = {
id: string;
kind?: "coupon" | "bundle";
uses?: number;
discountAmount: number;
createdAt: string;
summary?: string;
@@ -143,7 +147,7 @@ type FunnelReport = {
};
// Simple MultiSelect control used in filters
export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
const { token, user } = useAuth();
const role = user?.role || "user";
const canView = role === "admin" || role === "supervisor";
@@ -410,12 +414,14 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// Discounts report: one row per code per event.
const discountSummary = useMemo(() => {
const byKey = new Map<string, { code: string; summary: string; event: string; uses: number; total: number }>();
const byKey = new Map<string, { code: string; isBundle: boolean; summary: string; event: string; uses: number; total: number }>();
for (const r of discountRows) {
const event = r.registration?.event?.title || "—";
const key = `${r.coupon?.code}__${event}`;
const row = byKey.get(key) || { code: r.coupon?.code || "?", summary: r.summary || "", event, uses: 0, total: 0 };
row.uses += 1;
const isBundle = r.kind === "bundle";
const code = isBundle ? `Bundle: ${r.coupon?.code || "?"}` : r.coupon?.code || "?";
const key = `${code}__${event}`;
const row = byKey.get(key) || { code, isBundle, summary: r.summary || "", event, uses: 0, total: 0 };
row.uses += r.uses ?? 1;
row.total += Number(r.discountAmount || 0);
byKey.set(key, row);
}
@@ -1150,16 +1156,16 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
}
if (report === "discounts") {
return {
title: 'Discounts (coupon codes)', subtitle, kind: 'table', orientation: 'portrait',
title: 'Discounts (codes and bundles)', subtitle, kind: 'table', orientation: 'portrait',
stats: [
{ label: 'Value given away', value: `R ${discountSummary.totalValue.toFixed(2)}`, tone: 'rose' },
{ label: 'Uses', value: String(discountSummary.totalUses), tone: 'violet' },
{ label: 'Codes used', value: String(discountSummary.codes), tone: 'blue' },
{ label: 'Codes & bundles used', value: String(discountSummary.codes), tone: 'blue' },
],
chart: { title: 'Value given away, by code', data: discountSummary.chart.map(c => ({ ...c, displayValue: `R ${c.value.toFixed(2)}` })) },
note: 'Counts codes on registrations that are not cancelled. Cancelling a registration frees its code.',
chart: { title: 'Value given away, by code or bundle', data: discountSummary.chart.map(c => ({ ...c, displayValue: `R ${c.value.toFixed(2)}` })) },
note: 'Counts codes and bundle deals on registrations that are not cancelled. Cancelling a registration frees its code. Uses for a bundle are how many times it applied.',
table: {
columns: ["Code", "Discount", "Event", "Uses", "Value (R)"],
columns: ["Code / bundle", "Discount", "Event", "Uses", "Value (R)"],
rows: [
...discountSummary.rows.map(r => [r.code, r.summary, r.event, r.uses, Number(r.total.toFixed(2))]),
["Total", "", "", discountSummary.totalUses, Number(discountSummary.totalValue.toFixed(2))],
@@ -1178,23 +1184,25 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m];
incomeByMethod[m] = (incomeByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0);
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), r?.actual != null ? Number(r.actual.toFixed(2)) : "not reconciled", r?.variance != null ? Number(r.variance.toFixed(2)) : ""]);
const amount = (v: number | null | undefined) => (v != null && Math.abs(v) >= 0.005 ? Number(v.toFixed(2)) : "");
rows.push([title, METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2)), Number((f.costsByMethod?.[m] || 0).toFixed(2)), Number((f.expectedCashByMethod?.[m] || 0).toFixed(2)), amount(r?.bank), amount(r?.onHand), amount(r?.pendingOutstanding), amount(r?.writeOffTotal), r?.unaccounted != null ? Number(r.unaccounted.toFixed(2)) : "not reconciled"]);
if (m === "cash") {
(r?.denominations || []).forEach((d) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", Number((d.value * d.count).toFixed(2)), ""]));
(r?.denominations || []).forEach((d) => rows.push([title, ` ${denomLabel(d.value)} × ${d.count}`, "", "", "", "", Number((d.value * d.count).toFixed(2)), "", "", ""]));
}
(r?.writeOffs || []).forEach((w) => rows.push([title, ` Write-off: ${WRITE_OFF_LABEL[w.category] || w.category}${w.reason ? ` (${w.reason})` : ""}`, "", "", "", "", "", "", Number(w.amount.toFixed(2)), ""]));
});
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", ""]);
rows.push([title, "Donations counted as profit", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2)), ""]);
if ((f.untaggedCostsTotal || 0) > 0) rows.push([title, "Untagged costs", "", Number(f.untaggedCostsTotal.toFixed(2)), "", "", "", "", "", ""]);
rows.push([title, "Donations counted as profit", "", "", "", "", "", "", "", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
});
return {
title: 'Cashup Report', subtitle, kind: 'table', orientation: 'landscape',
chart: { title: 'Income by method, across all selected events', data: METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: incomeByMethod[m] || 0, displayValue: `R ${(incomeByMethod[m] || 0).toFixed(2)}` })) },
table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected cash", "Actual", "Variance"], rows }
table: { columns: ["Event", "Method", "Income", "Costs from method", "Expected", "In bank", "On hand", "Pending", "Written off", "Unaccounted"], rows }
};
}
if (report === "financeReport") {
const rows: (string | number)[][] = [];
let sold = 0, paidByAll = 0, donationsProfit = 0, costsTotal = 0, netProfitTotal = 0;
let sold = 0, paidByAll = 0, donationsProfit = 0, costsTotal = 0, writeOffsTotal = 0, netProfitTotal = 0;
const paidByMethod: Record<string, number> = {};
Object.keys(financialsByEvent).forEach(evId => {
const f = financialsByEvent[evId];
@@ -1206,6 +1214,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
METHOD_KEYS.forEach(m => { paidByMethod[m] = (paidByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); });
donationsProfit += f.unallocatedDonationsTotal || 0;
costsTotal += f.totalCosts || 0;
writeOffsTotal += f.totalWriteOffs || 0;
netProfitTotal += f.netProfit || 0;
// "Sales" is ticket-price revenue for orders that reached 'paid' status — whether that
// order was funded directly or via an assigned donation, its full price is already
@@ -1215,16 +1224,17 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// as profit" — no need to repeat it here.
rows.push([title, "Sales", "What was sold", ""]);
(f.salesByOption || []).forEach((s) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))]));
if ((f.discountsTotal ?? 0) > 0) rows.push([title, "", "Less discount codes", -Number((f.discountsTotal ?? 0).toFixed(2))]);
if ((f.discountsTotal ?? 0) > 0) rows.push([title, "", "Less discounts (codes & bundles)", -Number((f.discountsTotal ?? 0).toFixed(2))]);
rows.push([title, "Paid by", "", ""]);
METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))]));
rows.push([title, "Cashup comparison", "", ""]);
METHOD_KEYS.forEach(m => {
const r = f.reconciled?.byMethod?.[m];
rows.push([title, "", `${METHOD_LABEL[m]} — expected / actual / variance`, `${money2(f.expectedCashByMethod?.[m])} / ${r?.actual != null ? money2(r.actual) : "—"} / ${r?.variance != null ? money2(r.variance) : "—"}`]);
rows.push([title, "", `${METHOD_LABEL[m]} — expected / actual / unaccounted`, `${money2(f.expectedCashByMethod?.[m])} / ${r?.actual != null ? money2(r.actual) : "—"} / ${r?.unaccounted != null ? money2(r.unaccounted) : "—"}`]);
});
rows.push([title, "Costs", "", ""]);
(f.costs || []).forEach((c) => rows.push([title, "", `${c.label}${c.paidFromMethod ? ` (via ${METHOD_LABEL[c.paidFromMethod]})` : ""}`, Number((-(c.total ?? c.amount)).toFixed(2))]));
METHOD_KEYS.forEach(m => (f.reconciled?.byMethod?.[m]?.writeOffs || []).forEach((w) => rows.push([title, "", `${WRITE_OFF_LABEL[w.category] || w.category} (${METHOD_LABEL[m]})${w.reason ? ` — ${w.reason}` : ""}`, -Number(w.amount.toFixed(2))])));
rows.push([title, "", "Donations counted as profit", Number((f.unallocatedDonationsTotal || 0).toFixed(2))]);
rows.push([title, "", "Net profit", Number((f.netProfit || 0).toFixed(2))]);
});
@@ -1235,6 +1245,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
{ label: 'Paid by (all methods)', value: `R ${paidByAll.toFixed(2)}`, tone: 'blue' },
{ label: 'Donations counted as profit', value: `R ${donationsProfit.toFixed(2)}`, tone: 'rose' },
{ label: 'Total costs', value: `R ${costsTotal.toFixed(2)}`, tone: 'amber' },
{ label: 'Bank & card charges', value: `R ${writeOffsTotal.toFixed(2)}`, tone: 'amber' },
{ label: 'Net profit', value: `R ${netProfitTotal.toFixed(2)}`, tone: 'violet' },
],
chart: { title: 'Paid by, across all selected events', data: METHOD_KEYS.map(m => ({ label: METHOD_LABEL[m], value: paidByMethod[m] || 0, displayValue: `R ${(paidByMethod[m] || 0).toFixed(2)}` })) },
@@ -1247,23 +1258,25 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const f = financialsByEvent[evId];
acc.revenue += f?.effectiveTotalRevenue || 0;
acc.costs += f?.totalCosts || 0;
acc.charges += f?.totalWriteOffs || 0;
acc.profit += f?.netProfit || 0;
return acc;
}, { revenue: 0, costs: 0, profit: 0 });
}, { revenue: 0, costs: 0, charges: 0, profit: 0 });
const rows: (string | number)[][] = evIds.map(evId => {
const f = financialsByEvent[evId];
const ev = filteredEvents.find(e => e.id === evId);
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))];
return [ev?.title || evId, Number((f?.effectiveTotalRevenue || 0).toFixed(2)), Number((f?.totalCosts || 0).toFixed(2)), Number((f?.totalWriteOffs || 0).toFixed(2)), Number((f?.netProfit || 0).toFixed(2))];
});
return {
title: 'Profit Report', subtitle, kind: 'table', orientation: 'portrait',
stats: [
{ label: 'Revenue', value: `R ${totals.revenue.toFixed(2)}`, tone: 'green' },
{ label: 'Costs', value: `R ${totals.costs.toFixed(2)}`, tone: 'amber' },
{ label: 'Bank & card charges', value: `R ${totals.charges.toFixed(2)}`, tone: 'amber' },
{ label: 'Net profit', value: `R ${totals.profit.toFixed(2)}`, tone: 'violet' },
],
chart: evIds.length > 1 ? { title: 'Net profit by event', data: evIds.map(evId => ({ label: filteredEvents.find(e => e.id === evId)?.title || evId, value: financialsByEvent[evId]?.netProfit || 0, displayValue: `R ${(financialsByEvent[evId]?.netProfit || 0).toFixed(2)}` })) } : undefined,
table: { columns: ["Event", "Revenue", "Costs", "Net profit"], rows }
table: { columns: ["Event", "Revenue", "Costs", "Bank & card charges", "Net profit"], rows }
};
}
if (report === "cashupAudit") {
@@ -1455,7 +1468,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
report={report} setReport={(r: ReportKey) => { setReport(r); setReady(false); }}
onViewReport={() => { setPopupOpen(true); onView(); }}
busy={busy}
onBack={onBack}
backHref={backHref}
/>
{popupOpen && (
@@ -1935,23 +1948,23 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
{report === "discounts" && (
<div>
{discountSummary.rows.length === 0 ? (
<div className="text-sm text-gray-500">No discount codes used for the selected events and dates.</div>
<div className="text-sm text-gray-500">No discount codes or bundle deals used for the selected events and dates.</div>
) : (
<>
<StatTileRow>
<StatTile icon={TicketPercent} label="Value given away" value={`R ${discountSummary.totalValue.toFixed(2)}`} tone="rose" />
<StatTile icon={ShoppingCart} label="Uses" value={String(discountSummary.totalUses)} tone="violet" />
<StatTile icon={Gift} label="Codes used" value={String(discountSummary.codes)} tone="blue" />
<StatTile icon={Gift} label="Codes & bundles used" value={String(discountSummary.codes)} tone="blue" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Value given away, by code</div>
<div className="text-xs text-gray-500 mb-3">Value given away, by code or bundle</div>
<HorizontalBarChart data={discountSummary.chart} valueFormatter={v => `R ${v.toFixed(2)}`} />
</div>
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[640px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Code</th>
<th className="py-2 px-3">Code / bundle</th>
<th className="py-2 px-3">Discount</th>
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3 text-right">Uses</th>
@@ -1961,7 +1974,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<tbody>
{discountSummary.rows.map(row => (
<tr key={`${row.code}-${row.event}`} className="border-t border-gray-100">
<td className="py-2 px-3 font-mono">{row.code}</td>
<td className={`py-2 px-3 ${row.isBundle ? "" : "font-mono"}`}>{row.code}</td>
<td className="py-2 px-3">{row.summary}</td>
<td className="py-2 px-3">{row.event}</td>
<td className="py-2 px-3 text-right">{row.uses}</td>
@@ -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>
);
}
@@ -0,0 +1,283 @@
"use client";
import React, { useEffect, useState } from "react";
import { Gift } from "lucide-react";
import { apiFetch } from "@/lib/api";
import type { BundleItem, BundleQuote, PublicBundle } from "@/types";
import type { BasketItem } from "@/components/shared/CouponCodeField";
const fmt = (n: number) => `R${n.toFixed(2)}`;
/**
* The bundle deals a basket gets, from POST /api/bundles/quote (the server prices the
* basket the same way registration will). Null while there's nothing to show. Pass
* `enabled={false}` when the event has no bundles to skip the request.
*
* Staff pricing on someone's behalf pass `forUserId` (the attendee), or `null` for a
* walk-in with no account yet.
*/
export function useBundleQuote({ eventId, basket, token, forUserId, enabled = true }: {
eventId: string;
basket: BasketItem[];
token?: string | null;
forUserId?: string | null;
enabled?: boolean;
}): BundleQuote | null {
const [quote, setQuote] = useState<BundleQuote | null>(null);
const items = basket.filter(b => b.quantity > 0);
const key = JSON.stringify(items);
useEffect(() => {
const options = JSON.parse(key) as BasketItem[];
if (!enabled || !eventId || options.length === 0) { setQuote(null); return; }
let cancelled = false;
const timer = setTimeout(async () => {
try {
const body: Record<string, unknown> = { eventId, options };
if (forUserId !== undefined) body.forUserId = forUserId;
const res = await apiFetch<BundleQuote>("/api/bundles/quote", { method: "POST", authToken: token || undefined, body });
if (!cancelled) setQuote(res.bundleDiscount > 0 ? res : null);
} catch {
if (!cancelled) setQuote(null);
}
}, 300);
return () => { cancelled = true; clearTimeout(timer); };
}, [eventId, key, token, forUserId, enabled]);
return quote;
}
/** The event's deals, so buyers know what to pick. Applied automatically. */
export function BundleOffers({ bundles, className = "" }: { bundles?: PublicBundle[] | null; className?: string }) {
if (!bundles || bundles.length === 0) return null;
return (
<div className={`rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900 ${className}`}>
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4" /> Bundle deals</div>
<ul className="mt-1.5 space-y-1">
{bundles.map(b => (
<li key={b.id}>
<span className="font-medium">{b.name}:</span> {b.headline}
{b.savingText && <span className="text-emerald-700"> ({b.savingText})</span>}
{b.description && <span className="block text-xs text-emerald-800/80">{b.description}</span>}
</li>
))}
</ul>
<p className="mt-1.5 text-xs text-emerald-800/80">Applied automatically when your tickets match.</p>
</div>
);
}
/** "Family deal ×2 −R100.00" lines for a quote or a saved registration's bundles. */
export function BundleSavings({ applications, className = "" }: {
applications?: { bundleId: string; name?: string; count: number; discount: number; bundle?: { name: string } | null }[] | null;
className?: string;
}) {
const rows = (applications || []).filter(a => a.discount > 0);
if (rows.length === 0) return null;
return (
<div className={`text-sm text-emerald-700 ${className}`}>
{rows.map(a => (
<div key={a.bundleId} className="flex items-center gap-1.5">
<Gift className="h-3.5 w-3.5 shrink-0" />
<span>{a.name || a.bundle?.name || "Bundle deal"}{a.count > 1 ? ` ×${a.count}` : ""}</span>
<span className="font-medium">−{fmt(a.discount)}</span>
</div>
))}
</div>
);
}
// Just what the builder reads, so pages with their own option types can pass them in.
type BuilderOption = {
id: string;
name: string;
stockLimit?: number | null;
availableCount?: number | null;
variants?: { id: string; name: string; stockLimit?: number | null; availableCount?: number | null }[] | null;
};
type Qty = (optionId: string, variantId: string | null) => number;
type Change = { optionId: string; variantId: string | null; quantity: number };
const leftOf = (stockLimit?: number | null, availableCount?: number | null) => ((stockLimit ?? 0) > 0 && availableCount !== undefined && availableCount !== null ? availableCount : Infinity);
/** Tickets of this item already in the basket. An "any variant" item counts every variant. */
function itemHave(item: BundleItem, opt: BuilderOption | undefined, qty: Qty) {
if (!opt) return 0;
if (item.variantId) return qty(opt.id, item.variantId);
const variants = opt.variants || [];
return variants.length > 0 ? variants.reduce((s, v) => s + qty(opt.id, v.id), 0) : qty(opt.id, null);
}
/** Every basket line (option, or option + variant) that counts towards a bundle, without repeats. */
function qualifyingKeys(bundle: PublicBundle, options: BuilderOption[]) {
const keys: { opt: BuilderOption; variantId: string | null }[] = [];
const seen = new Set<string>();
for (const item of bundle.items) {
const opt = options.find(o => o.id === item.eventOptionId);
if (!opt) continue;
const variants = opt.variants || [];
const ids = variants.length === 0 ? [null] : item.variantId ? [item.variantId] : variants.map(v => v.id);
for (const variantId of ids) {
const k = `${opt.id}::${variantId || ""}`;
if (!seen.has(k)) { seen.add(k); keys.push({ opt, variantId }); }
}
}
return keys;
}
/** Places left for one basket line, or Infinity. */
function roomFor(opt: BuilderOption, variantId: string | null, qty: Qty) {
const variant = variantId ? (opt.variants || []).find(v => v.id === variantId) : null;
return Math.min(leftOf(opt.stockLimit, opt.availableCount), variant ? leftOf(variant.stockLimit, variant.availableCount) : Infinity) - qty(opt.id, variantId);
}
/**
* Exact bundles: quantities that make up `sets` full sets, or null when there aren't
* enough tickets left. Only ever raises quantities. An "any variant" item tops up the
* variant already picked most (or the first one with places left).
*/
function exactChanges(bundle: PublicBundle, options: BuilderOption[], qty: Qty, sets: number): Change[] | null {
const changes: Change[] = [];
for (const item of bundle.items) {
const opt = options.find(o => o.id === item.eventOptionId);
if (!opt) return null;
const short = sets * item.quantity - itemHave(item, opt, qty);
if (short <= 0) continue;
let variantId = item.variantId || null;
if (!variantId && (opt.variants || []).length > 0) {
const pick = (opt.variants || []).filter(v => roomFor(opt, v.id, qty) >= short).sort((a, b) => qty(opt.id, b.id) - qty(opt.id, a.id))[0];
if (!pick) return null;
variantId = pick.id;
}
if (roomFor(opt, variantId, qty) < short) return null;
changes.push({ optionId: opt.id, variantId, quantity: qty(opt.id, variantId) + short });
}
return changes;
}
/** Pick-any and group bundles: add `count` tickets to the qualifying line picked most. */
function topUpChange(bundle: PublicBundle, options: BuilderOption[], qty: Qty, count: number): Change[] | null {
if (count <= 0) return [];
const pick = qualifyingKeys(bundle, options)
.filter(k => roomFor(k.opt, k.variantId, qty) >= count)
.sort((a, b) => qty(b.opt.id, b.variantId) - qty(a.opt.id, a.variantId))[0];
if (!pick) return null;
return [{ optionId: pick.opt.id, variantId: pick.variantId, quantity: qty(pick.opt.id, pick.variantId) + count }];
}
const orList = (summary: string) => summary.replace(/, ([^,]*)$/, " or $1");
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
type Row = {
bundle: PublicBundle;
// Something from the bundle is in the basket.
touched: boolean;
// Full sets in the basket (for a group discount: 1 once it applies).
sets: number;
// A set (or the group) has been started but isn't complete.
partial: boolean;
// What's missing, in words.
missing: string;
// Quantities to complete it (partial) or add another set (complete); null = no room.
next: Change[] | null;
// false for a group discount once it applies: it has no "another one".
canAddMore: boolean;
};
function rowFor(bundle: PublicBundle, options: BuilderOption[], qty: Qty): Row {
const kind = bundle.kind || "exact";
if (kind === "exact") {
const have = bundle.items.map(item => {
const opt = options.find(o => o.id === item.eventOptionId);
return { item, opt, n: itemHave(item, opt, qty) };
});
const sets = Math.min(...have.map(h => Math.floor(h.n / h.item.quantity)));
// Tickets beyond the full sets mean the next set has been started.
const partial = have.some(h => h.n > sets * h.item.quantity);
const missing = have
.filter(h => h.n < (sets + 1) * h.item.quantity)
.map(h => {
const v = h.item.variantId ? (h.opt?.variants || []).find(x => x.id === h.item.variantId) : null;
return `${(sets + 1) * h.item.quantity - h.n} × ${h.opt?.name || "ticket"}${v ? ` (${v.name})` : ""}`;
})
.join(" and ");
return { bundle, touched: have.some(h => h.n > 0), sets, partial, missing, next: exactChanges(bundle, options, qty, sets + 1), canAddMore: true };
}
const size = Number(bundle.setSize) || 1;
const have = qualifyingKeys(bundle, options).reduce((s, k) => s + qty(k.opt.id, k.variantId), 0);
const names = orList(bundle.summary);
if (kind === "group") {
const short = Math.max(0, size - have);
return {
bundle, touched: have > 0, sets: short === 0 ? 1 : 0, partial: short > 0,
missing: `${plural(short, "more ticket")} (${names})`,
next: topUpChange(bundle, options, qty, short), canAddMore: false,
};
}
const sets = Math.floor(have / size);
const short = have % size === 0 ? size : size - (have % size);
return {
bundle, touched: have > 0, sets, partial: have % size > 0,
missing: `${plural(short, "more ticket")} (${names})`,
next: topUpChange(bundle, options, qty, short), canAddMore: true,
};
}
/**
* On the register form, at the door, manual registration and the kiosk: each bundle the
* basket has started on, with what's missing and a "Complete" button, then "+" to add
* another full set. Buttons set the ticket quantities through `onSetQuantity`.
*/
export function BundleBuilder({ bundles, options, qty, onSetQuantity, className = "" }: {
bundles?: PublicBundle[] | null;
options: BuilderOption[];
qty: Qty;
onSetQuantity: (optionId: string, variantId: string | null, quantity: number) => void;
className?: string;
}) {
const rows = (bundles || []).map(b => rowFor(b, options, qty)).filter(r => r.touched);
if (rows.length === 0) return null;
const apply = (changes: Change[]) => changes.forEach(c => onSetQuantity(c.optionId, c.variantId, c.quantity));
return (
<div className={`space-y-2 ${className}`}>
{rows.map(({ bundle, sets, partial, missing, next, canAddMore }) => {
const isGroup = bundle.kind === "group";
const done = isGroup ? `✓ Group price on all your ${orList(bundle.summary)} tickets` : `✓ ${sets > 1 ? `${sets} bundles` : "Bundle"} in your basket`;
return (
<div key={bundle.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 font-semibold"><Gift className="h-4 w-4 shrink-0" /> {bundle.name}</div>
<div className="text-xs text-emerald-800">
{bundle.headline}{bundle.savingText ? ` (${bundle.savingText})` : ""}
</div>
{partial ? (
<div className="text-xs text-emerald-800 mt-0.5">
Add {missing} to {isGroup ? "get the group price" : sets > 0 ? "complete another bundle" : "complete it"}.
</div>
) : (
<div className="text-xs font-medium text-emerald-700 mt-0.5">{done}</div>
)}
{!next && (partial || canAddMore) && <div className="text-xs text-amber-700 mt-0.5">Not enough tickets left for {partial ? "this bundle" : "another one"}.</div>}
</div>
{partial ? (
<button type="button" onClick={() => next && apply(next)} disabled={!next}
className="shrink-0 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50">
{isGroup ? "Complete group" : "Complete bundle"}
</button>
) : canAddMore ? (
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs font-medium">×{sets}</span>
<button type="button" onClick={() => next && apply(next)} disabled={!next} aria-label={`Add another ${bundle.name}`} title="Add another bundle"
className="h-7 w-7 rounded border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-100 disabled:opacity-50">
+
</button>
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -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&apos;t in the bank yet. Add the report or POP reference.</li>
<li><strong>Write-offs</strong>: money that&apos;s gone, such as card fees, bank charges or a cash shortfall. Write-offs come off net profit and show as &quot;Bank &amp; card charges&quot; in the reports.</li>
</ul>
<p>Each method shows what&apos;s still <strong>unaccounted</strong>. You can close with an unaccounted amount, but you&apos;ll be warned and it comes off profit.</p>
<div className="space-y-4">
<GuideItem icon={ClipboardCheck} title="Cash accountability" tone="amber">
Cash&apos;s &quot;actual&quot; figure isn&apos;t typed directly — it&apos;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&apos;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>
),
},
+6
View File
@@ -22,6 +22,7 @@ import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
import { supervisorFormsHelpContent } from "./supervisor-forms";
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
import { supervisorBundlesHelpContent } from "./supervisor-bundles";
import { supervisorConsentHelpContent } from "./supervisor-consent";
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
@@ -182,6 +183,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/coupons",
content: supervisorCouponsHelpContent,
},
{
slug: "supervisor-bundles",
matches: pathname => pathname === "/dashboard/supervisor/bundles",
content: supervisorBundlesHelpContent,
},
{
slug: "supervisor-forms",
matches: pathname => pathname === "/dashboard/supervisor/forms" || pathname === "/dashboard/admin/forms",
@@ -0,0 +1,76 @@
import React from "react";
import { Gift, Plus, Layers, BadgePercent, CalendarClock, TicketPercent, Repeat, BarChart2, Lock, Shuffle, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/bundles. */
export const supervisorBundlesHelpContent: HelpContent = {
title: "Bundle deals",
subtitle: "Tickets sold together at a better price.",
tabs: [
{
key: "create",
label: "Creating bundles",
icon: Plus,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Gift} title="Exact set" tone="brand">
These tickets in these numbers for one price, e.g. 2 × Adult + 3 × Child for R220.
</GuideItem>
<GuideItem icon={Shuffle} title="Pick any" tone="violet">
Any mix of the qualifying tickets, e.g. any 4 of Adult or Child for R250: 2 + 2 and 4 + 0 both count.
</GuideItem>
<GuideItem icon={TicketPercent} title="Buy X get Y free" tone="rose">
In every set of qualifying tickets the cheapest are free, e.g. buy 3, get 1 free (4 per set, 1 free).
</GuideItem>
<GuideItem icon={Users} title="Group discount" tone="blue">
From a group size up, every qualifying ticket gets a lower price or a percentage off, e.g. 10+ tickets: R70 each. Tickets already cheaper than the group price stay as they are.
</GuideItem>
<p className="text-xs text-gray-500">For options with variants (sizes, room types) choose one variant or &quot;Any variant&quot;.</p>
<GuideItem icon={BadgePercent} title="The saving" tone="green">
The form shows what the tickets cost separately at the standard price and how much the bundle saves. Buyers see the deal on the event page and when they register.
</GuideItem>
<GuideItem icon={CalendarClock} title="Dates" tone="amber">
Optional &quot;Available from&quot; and &quot;Available until&quot; dates, e.g. for a launch-week deal. &quot;Turn off&quot; stops it straight away.
</GuideItem>
</div>
</div>
),
},
{
key: "how",
label: "How bundles work",
icon: Layers,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={Repeat} title="Automatic, as many times as fits" tone="brand">
No code needed. Exact, pick-any and buy-X-get-Y-free deals apply as many times as the tickets allow (10 adults + 6 children gets a 5 + 3 deal twice); anything left over is charged as usual. A group discount applies once, to every qualifying ticket. This works the same online, for manual registrations, at the door and on the kiosk, where a &quot;Complete bundle&quot; button tops up the tickets.
</GuideItem>
<GuideItem icon={BadgePercent} title="Best price wins" tone="green">
A bundle only applies when it&apos;s cheaper than the tickets at the prices actually charged, early-bird and staff prices included, so it never makes anything dearer. If an event has several bundles, the one that saves the most is used first.
</GuideItem>
<GuideItem icon={TicketPercent} title="Coupon codes" tone="violet">
A code still works with a bundle: the bundle comes off first, then the code is worked out on what&apos;s left.
</GuideItem>
<GuideItem icon={Lock} title="Changes later" tone="amber">
Registrations that already have a bundle keep it, even after it&apos;s turned off or its dates pass. It&apos;s worked out again only when the registration&apos;s own tickets change. A bundle that has been used can&apos;t be deleted; deleting it turns it off instead.
</GuideItem>
</div>
</div>
),
},
{
key: "tracking",
label: "Tracking",
icon: BarChart2,
content: (
<div className="space-y-4">
<p>Each bundle shows how many times it&apos;s been used, on how many registrations, and the total saving given. The saving shows as its own line on invoices, receipts, emails and the payment page.</p>
<p>The <strong>Discounts</strong> report (Reports → Financial) lists bundles next to coupon codes, and the Finance report shows sales after discounts.</p>
</div>
),
},
],
};
+2 -1
View File
@@ -203,12 +203,13 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<CheckoutResponse> {
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
method: "POST",
authToken: token,
body: {
registrationId,
...(donationAmount && donationAmount > 0 ? { donationAmount } : {}),
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
+22
View File
@@ -0,0 +1,22 @@
// Pure helpers shared by server and client components. Must not be "use client",
// or server pages calling these get a client reference instead of the function.
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
/** The sold-out lines of an event, for the waitlist button. */
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
const out: WaitlistLine[] = [];
for (const o of options || []) {
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
const variants = o.variants || [];
if (variants.length > 0) {
for (const v of variants) {
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
}
} else if (optOut) {
out.push({ eventOptionId: o.id, label: o.name });
}
}
return out;
}
+107 -4
View File
@@ -141,6 +141,8 @@ export type Event = {
eventOptions?: EventOption[];
attachments?: EventAttachment[];
form?: EventForm | null;
// Bundle deals on sale now (single-event responses only).
bundles?: PublicBundle[];
createdAt?: string;
updatedAt?: string;
cashupStatus?: "open" | "closed";
@@ -150,6 +152,54 @@ export type Event = {
reopenedById?: string | null;
};
export type BundleItem = { id?: string; eventOptionId: string; variantId?: string | null; quantity: number; eventOption?: { id: string; name: string; price?: number } | null; variant?: { id: string; name: string } | null };
// exact: these tickets in these numbers for a set price. mix: any setSize of the
// qualifying tickets for a set price. free: any setSize, the cheapest freeCount free.
// group: setSize or more qualifying tickets at price each (or price% off).
export type BundleKind = "exact" | "mix" | "free" | "group";
export type BundleGroupPricing = "per_ticket" | "percent";
// A bundle deal as shown to buyers on the event page.
export type PublicBundle = {
id: string;
name: string;
description?: string | null;
kind: BundleKind;
price: number;
setSize?: number | null;
freeCount?: number | null;
groupPricing?: BundleGroupPricing | null;
validUntil?: string | null;
// The tickets: "5 × Adult + 3 × Child" (exact) or "Adult, Child" (the others).
summary: string;
// The deal in one line, e.g. "Any 4 of Adult, Child for R250".
headline: string;
// What an exact set (or the dearest pick-any set) costs at standard prices.
standardValue: number | null;
// e.g. "save R60", "save up to R10 a ticket"; null when the headline says it.
savingText: string | null;
items: BundleItem[];
};
// A bundle deal as supervisors manage it.
export type EventBundle = PublicBundle & {
eventId: string;
isActive: boolean;
validFrom?: string | null;
order: number;
available: boolean;
uses?: number;
registrations?: number;
totalDiscount?: number;
createdAt?: string;
};
// One bundle applied to a basket or registration.
export type BundleApplication = { id?: string; bundleId: string; name?: string; count: number; discount: number; bundle?: { id: string; name: string } | null };
export type BundleQuote = { subtotal: number; bundleDiscount: number; bundles: BundleApplication[]; total: number };
export type EventCostType = "once_off" | "per_item";
export type CashupMethod = "cash" | "card" | "eft" | "other";
@@ -183,7 +233,36 @@ export type EventCashupLine = {
actualAmount?: number | null;
variance?: number | null;
notes?: string | null;
bankAmount?: number | null;
onHandAmount?: number | null;
pendingAmount?: number | null;
pendingReference?: string | null;
writeOffTotal?: number;
denominations?: EventCashupDenomination[];
writeOffs?: CashupWriteOff[];
};
export type CashupWriteOffCategory = "card_fees" | "bank_charges" | "cash_shortfall" | "other";
export type CashupWriteOff = {
id?: string;
category: CashupWriteOffCategory;
amount: number;
reason?: string | null;
createdAt?: string;
// Written off after the event was closed (from pending money that never arrived).
afterClose?: boolean;
};
export type PendingResolution = {
id: string;
lineId: string;
outcome: "received" | "written_off";
amount: number;
category?: CashupWriteOffCategory | null;
notes?: string | null;
performedBy?: { id: string; name: string } | null;
createdAt: string;
};
export type CashupAction = "closed" | "quick_closed" | "reopened";
@@ -204,12 +283,25 @@ export type EventCashup = {
event?: Pick<Event, "id" | "title">;
};
// One method of the latest close, with pending money settled since then folded in.
export type ReconciledByMethod = {
lineId: string | null;
expected: number;
actual: number | null;
variance: number | null;
notes?: string | null;
denominations: EventCashupDenomination[];
bank: number | null;
onHand: number | null;
pending: number | null;
pendingReference: string | null;
pendingReceived: number;
pendingOutstanding: number;
pendingResolutions: PendingResolution[];
writeOffs: CashupWriteOff[];
writeOffTotal: number;
// expected − actual − write-offs; null when nothing was entered for the method.
unaccounted: number | null;
};
export type ReconciledSnapshot = {
@@ -225,9 +317,12 @@ export type ReconciledSnapshot = {
export type CashupDraft = {
lines?: {
method: string;
actualAmount?: string | number;
notes?: string;
denominations?: { value: number; count: number }[];
bankAmount?: string | number | null;
onHandAmount?: string | number | null;
pendingAmount?: string | number | null;
pendingReference?: string | null;
writeOffs?: { category: CashupWriteOffCategory; amount: string | number | null; reason?: string | null }[];
notes?: string | null;
}[];
};
@@ -250,8 +345,13 @@ export type EventFinancials = {
reconciled: ReconciledSnapshot | null;
effectiveGrossIncomeByMethod: Record<CashupMethod, number>;
effectiveTotalRevenue: number;
writeOffsByMethod: Record<CashupMethod, number>;
writeOffsByCategory: Record<CashupWriteOffCategory, number>;
// Card fees, bank charges and shortfalls written off; comes off net profit.
totalWriteOffs: number;
pendingOutstandingTotal: number;
netProfit: number;
// Coupon discounts given on the event's registrations.
// Bundle and coupon discounts given on the event's registrations.
discountsTotal?: number;
unallocatedDonations: Payment[];
unallocatedDonationsTotal: number;
@@ -299,7 +399,10 @@ export type Registration = {
userId: string;
eventId: string;
status: RegistrationStatus;
// All discounts: bundle deals (bundleDiscount) plus the coupon.
discountTotal?: number;
bundleDiscount?: number;
bundleApplications?: BundleApplication[];
ticketDelivery?: "payer" | "each";
createdAt: string;
updatedAt?: string;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.26.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.26.0",
"version": "1.30.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.26.0",
"version": "1.30.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",