Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c066e67d9 | ||
|
|
0ffbb13a1f | ||
|
|
e9d1b1fb24 | ||
|
|
98173c1380 | ||
|
|
6297613d0d | ||
|
|
5efb4cd6dd | ||
|
|
e40803cbbc | ||
|
|
fc393a90c1 | ||
|
|
c1211db8cf | ||
|
|
fb42d6ac19 | ||
|
|
2fc9e08f89 | ||
|
|
78bcd7cc7c | ||
|
|
37731a4696 |
+138
@@ -7,6 +7,144 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.50.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Audit log:** search by person (name or email), target (type or `#id`) or IP, a Role filter,
|
||||
**Clear filters**, and "21–40 of 312" under the list. The Action filter now lists every action
|
||||
that can be logged (it was missing 35 — campuses, kids check-in, volunteers, series, season
|
||||
passes, webhooks, bulk actions, Hub updates, certificates and more), sorted by name.
|
||||
- **Data requests:** search by name or email, and closed requests a page (50) at a time — the list
|
||||
used to stop silently at 200.
|
||||
- **Webhook delivery log:** filter by status (failed, retrying, succeeded) and **Load older** —
|
||||
it used to show only the latest 50.
|
||||
- API filters: `/api/registrations?unpaid=1&userId=`, `/api/payments?userId=&from=&donations=1`,
|
||||
`/api/privacy/requests?search=&page=&limit=`, `/api/outbound-webhooks/:id/deliveries?before=`,
|
||||
`/api/admin/audit-log?search=&role=`. Shared `components/shared/Pager.tsx`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Audit log: the **To** date left out that day's entries (it meant midnight at the start of the
|
||||
day). Both dates are now whole days in the site's time zone.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Payments** (supervisor) no longer downloads every payment and registration ever made when it
|
||||
opens: it loads the latest payments, today's (for the totals), donations with what they were
|
||||
applied to, and only registrations still owing. Refunds load the chosen person's own payments and
|
||||
registrations when they're picked. Faster as the years add up; the page works the same.
|
||||
|
||||
## [1.50.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Accessibility pass (WCAG 2.2 AA)** on the public pages, sign-in/sign-up, registration,
|
||||
payment, My Events and the self-service kiosk:
|
||||
- **Keyboard:** a "Skip to main content" link as the first Tab stop on every page; a clear focus
|
||||
ring (in the brand colour) on everything you can tab to; popups (Contact us, Join the waitlist,
|
||||
Sponsor a spot, Transfer ticket, the registration popup and send dialogs on My Events, the kiosk's
|
||||
Change Event and Confirm account changes) are announced as dialogs, take focus when they open,
|
||||
keep Tab inside, close with Escape and hand focus back. The kiosk's "Create an account" switch
|
||||
and its password eye can now be used with the keyboard.
|
||||
- **Screen readers:** every form field on those pages has a label tied to it (sign-in, sign-up,
|
||||
forgot/reset password, activation, profile, privacy centre, payments filters, donate, forms,
|
||||
feedback, consent, waitlist, sponsorship, transfer, kiosk and its custom questions); ticket
|
||||
quantity buttons say what they do ("Add one Adult", "Remove one Adult") and the number is
|
||||
announced; option buttons say which one is chosen; error messages are announced when they
|
||||
appear.
|
||||
- **Colour:** light grey text darkened to meet 4.5:1, and links inside sentences are underlined
|
||||
so they don't rely on colour alone. The page honours "reduce motion" on the device.
|
||||
- **Branding:** Site settings → Branding (and the setup wizard) warn when the primary colour is
|
||||
too light for white text and links (under 4.5:1) and offer a darker shade of it in one click.
|
||||
- Automated accessibility checks: `e2e/accessibility.spec.ts` runs axe-core (WCAG 2.0–2.2 A/AA
|
||||
rules) on those pages, plus keyboard tests (skip link, popup focus and Escape, ticket quantities,
|
||||
the kiosk switch). New dev dependency `@axe-core/playwright`.
|
||||
- `components/shared/ModalOverlay.tsx` for the app's own popups, and `components/layout/SkipLink.tsx`.
|
||||
|
||||
### Changed
|
||||
|
||||
- e2e tests click ticket quantities by name (`/^Add one /`) instead of "+".
|
||||
|
||||
## [1.49.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Saved filters** on Manage registrations, Users, Events and Reports: set the filters, then
|
||||
**Saved filters → Save** under a name; click one to apply it; **★** makes it the default, applied
|
||||
when the page opens (but not over a link that already says what to show, such as the
|
||||
dashboard's "Waiting for you"). Reports remember the report, events, dates, campus and the
|
||||
past/inactive/closed options. Per person, stored on the server (up to 30 per page), removed when
|
||||
an account is anonymised.
|
||||
- `/api/saved-filters` (list, save, rename/default, delete — your own only). Migration
|
||||
`20261028180000_saved_filters`.
|
||||
|
||||
## [1.49.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Bulk actions on registrations** (Admin → Manage registrations): tick registrations, or "Select
|
||||
all shown" after filtering, and act on them at once —
|
||||
- **Send a message** by email or WhatsApp, as an event update to the person who registered
|
||||
(placeholders as on Email attendees);
|
||||
- **Mark paid** — records what each still owes, by cash, EFT, card or other; paid registrations get
|
||||
their tickets by email, as with a single payment;
|
||||
- **Move to another ticket** (one event) — the same lines, issued tickets and QR codes move with
|
||||
them, repriced at the new ticket's current price, so the difference shows as owing or overpaid;
|
||||
places on the new ticket are checked first;
|
||||
- **Put in a room or group** (one event) — their named people, with the group's capacity and
|
||||
gender rules;
|
||||
- **Export to Excel** (recorded in the access log, like other exports);
|
||||
- **Cancel** — coupon uses released and the waitlist offered the places; no refund (record refunds
|
||||
on the Payments page).
|
||||
Each runs the same code as doing it one at a time (closed events, payment caps, coupons,
|
||||
waitlist, privacy rules on messages), with a result per registration. Up to 500 at a time.
|
||||
- `POST /api/registrations/bulk` (campus-checked for every registration; payments and ticket moves
|
||||
refused in licence read-only mode); `registrationIds` filter on the email and WhatsApp attendee
|
||||
endpoints. Audit action `registrations_bulk` (migration `20261028170000_registrations_bulk`).
|
||||
|
||||
## [1.49.0] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Today on the dashboard** (staff, supervisors, admins): what's on today — each event, or its
|
||||
session today — with people checked in so far against places, children in now at kids church,
|
||||
and volunteers here against rostered (and how many short). Supervisors and admins also see money
|
||||
in today and over the last 7 days. Refreshes every 30 seconds; follows campus scoping.
|
||||
- **Waiting for you** (supervisors, admins): what needs someone, each linking to where it's dealt
|
||||
with — unpaid registrations for events in the next two weeks, young people waiting for parental
|
||||
consent, waitlists, cover requests, serving roles in the next week short of people, and for
|
||||
admins past events to cash up, provider payments to reconcile, open privacy requests and failed
|
||||
webhook deliveries.
|
||||
- **Command palette — Ctrl K (⌘K)** anywhere in the dashboard, or "Search or jump to…" on the
|
||||
dashboards: pages, settings tabs and reports for the person's role, actions ("New event", "Scan
|
||||
tickets", "Record a payment"…), and search — events for everyone; payments by amount ("R450") for
|
||||
supervisors; people and registrations (by reference) for admins. Keyboard: ↑/↓, Enter, Esc.
|
||||
- Deep links: `/dashboard/supervisor/events?edit=<id>` and `?new=1`; `/dashboard/admin/registrations?q=&status=&event=`;
|
||||
`/dashboard/admin/users?q=`.
|
||||
- `GET /api/dashboard/today`, `GET /api/dashboard/search?q=`.
|
||||
|
||||
## [1.48.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Four demo seeds**, to match the church a demo is for — `npm run demo:small`, `demo:events`
|
||||
(the original demo), `demo:ministry` and `demo:multicampus` from the repo root (`demo:list`
|
||||
describes them; in `backend/` it's `npm run set-demo -- --profile=<name>`). New in the seeds:
|
||||
multi-session courses with attendance and certificates, a "pick your days" conference, kids church
|
||||
with families, children, rooms and check-ins (including some checked in right now), volunteer
|
||||
rosters with QR check-ins and a cover request, an event series with followers and a season pass,
|
||||
and three campuses with campus-limited staff. Campuses are switched on only for `multicampus`.
|
||||
Series announcements and volunteer reminders are marked as sent, so the jobs don't email demo
|
||||
people. See `backend/DEMO_DATA.md`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`npm run set-demo` failed since 1.43.0** ("Event_createdById_fkey"): clearing the `Campus` table
|
||||
with TRUNCATE … CASCADE also emptied `User` (it points at Campus for the home campus), deleting the
|
||||
admin it keeps. Tables that `User` points at are now emptied with DELETE after the admin's links
|
||||
to them are cleared.
|
||||
|
||||
## [1.48.1] - 2026-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
+42
-12
@@ -35,10 +35,11 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
19. [Volunteer rosters](#volunteer-rosters)
|
||||
20. [Accounts & security](#accounts--security)
|
||||
21. [Privacy (POPIA)](#privacy-popia)
|
||||
22. [Help system](#help-system)
|
||||
23. [Background jobs](#background-jobs)
|
||||
24. [Operations](#operations)
|
||||
25. [Page map](#page-map)
|
||||
22. [Accessibility](#accessibility)
|
||||
23. [Help system](#help-system)
|
||||
24. [Background jobs](#background-jobs)
|
||||
25. [Operations](#operations)
|
||||
26. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
@@ -414,9 +415,18 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Supervisor tools
|
||||
|
||||
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
|
||||
tickets sold, each vs. the previous month), revenue trend chart, top events,
|
||||
and recent scans. Tiles link to the matching report.
|
||||
- **Dashboard** — **Today** (each event or session on today: checked in so far
|
||||
against places, children in now, volunteers here; money in today and this
|
||||
week) and **Waiting for you** (unpaid registrations for the next two weeks,
|
||||
parental consent outstanding, waitlists, cover requests, roster gaps; for
|
||||
admins also cashups due, payments to reconcile, privacy requests, failed
|
||||
webhooks — each links to where it's dealt with); then KPIs (active events,
|
||||
revenue, donations, registrations, tickets sold, each vs. the previous month),
|
||||
revenue trend chart, top events, and recent scans. Staff see Today too.
|
||||
- **Command palette (Ctrl K / ⌘K)** — anywhere in the dashboard: pages, settings
|
||||
and reports for the role, actions ("New event", "Scan tickets"…), and search:
|
||||
events; payments by amount ("R450") for supervisors; people and registrations
|
||||
for admins. Follows campus scoping.
|
||||
- **Manage events** — the 9-step event wizard, ticket options, variants,
|
||||
sections, form and attachments.
|
||||
- **At the door** — search by name/phone/ticket ID, walk-in registration,
|
||||
@@ -467,12 +477,20 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
[Privacy](#privacy-popia)).
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
form answers and payments (with who recorded each one). **Bulk actions** on
|
||||
ticked registrations (or all shown): message (email/WhatsApp), mark paid
|
||||
(records the balance by method), move to another ticket (same tickets and QR
|
||||
codes, repriced), put in a room or group, export to Excel, cancel — each
|
||||
through the same rules as one at a time, with a result per registration.
|
||||
- **Saved filters** — on Manage registrations, Users, Events and Reports: name the
|
||||
current filters, apply one with a click, star one as the default for the page
|
||||
(not applied over a deep link). Per person, on the server.
|
||||
- **Cashup** — see below.
|
||||
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
|
||||
refunds, donation assign/unassign, manual registrations, staff
|
||||
cancellations, event create/update/delete, settings changes, and coupon codes
|
||||
(created, edited, turned off, applied or removed by staff).
|
||||
(created, edited, turned off, applied or removed by staff), and every later feature's admin
|
||||
actions. Search by person, target or IP; filter by action, role and dates; 50 a page.
|
||||
- **Site settings** — see below.
|
||||
|
||||
---
|
||||
@@ -529,7 +547,7 @@ applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Campuses | Off by default. Turn on, then add, order, archive or delete campuses (name, web address, address, contact, colour, logo, default event location, daily-summary recipients) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Branding | Primary/secondary/accent colours (with a readability warning and a darker shade offered when the primary is too light), logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
|
||||
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
|
||||
@@ -757,7 +775,7 @@ contact page shows the Information Officer.
|
||||
and the people affected were told, and drafts of both notifications.
|
||||
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`, searchable, closed ones paged): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
admins, reminded daily when due within a week. Completing a deletion anonymises the
|
||||
person: name, contact details, the people they registered, form answers, consent
|
||||
@@ -768,6 +786,18 @@ log.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
Built to WCAG 2.2 AA on the public pages, sign-in, registration, payment, My Events and the
|
||||
self-service kiosk (1.50.0): "Skip to main content" link, visible keyboard focus, labelled fields,
|
||||
named buttons (ticket quantities say "Add one …"), errors announced as they appear, popups that
|
||||
trap focus and close with Escape, readable contrast with underlined in-text links, and "reduce
|
||||
motion" respected. Branding warns when the primary colour is too light for white text and
|
||||
suggests a darker shade. axe-core checks and keyboard tests in the e2e suite
|
||||
(`e2e/accessibility.spec.ts`) keep it that way.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
@@ -787,7 +817,7 @@ Site settings → **Webhooks** (admins): send what happens to other systems as i
|
||||
check-in with session, person, event); headers `X-CrossEvents-Event`, `X-CrossEvents-Delivery` and
|
||||
`X-CrossEvents-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">` with the endpoint's secret.
|
||||
- **Delivery log** per endpoint (30 days): status, tries, response code or error, the full message,
|
||||
**Resend**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
|
||||
**Resend**; filter by status and **Load older**. Retries after 1 min, 5 min, 30 min, 2 h, 12 h, then failed.
|
||||
- Every path that registers, pays, cancels, refunds or checks in is covered (Prisma client
|
||||
extension); rolled-back changes send nothing. Kids check-in and volunteer tickets are never sent.
|
||||
- https and public addresses only (DNS checked at each send, redirects not followed). Each endpoint
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ in-process settings cache TTL) without a server restart.
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. The Branding tab warns when it is under 4.5:1 against white (too light for white text and links) and offers a darker shade (1.50.0). |
|
||||
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
|
||||
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
|
||||
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
|
||||
|
||||
@@ -12,6 +12,55 @@ Write for church admins, not developers: no environment variables, endpoints or
|
||||
developer log is CHANGELOG.md. A tagged version without a section here shows "No details given".
|
||||
-->
|
||||
|
||||
## 1.50.1
|
||||
|
||||
- **Finding things in long lists.** The audit log can be searched by person, what was changed or
|
||||
IP address, filtered by role, and its action list now includes every kind of action. Data
|
||||
requests can be searched and come a page at a time, and the webhook delivery log can show only
|
||||
failed deliveries and load older ones.
|
||||
- **Fix:** the audit log's "To" date now includes that day.
|
||||
- **Faster Payments page** for churches with a few years of payments behind them.
|
||||
|
||||
## 1.50.0
|
||||
|
||||
- **Easier for everyone to use.** The public pages, registration, payment, My Events and the
|
||||
self-service kiosk now work properly with a keyboard and with screen readers: a "Skip to main
|
||||
content" link, a clear outline showing where you are, popups that can be closed with Escape, and
|
||||
every form field, button and error message read out correctly. Faint grey text is darker and
|
||||
links in sentences are underlined, so they're easier to read.
|
||||
- **Branding check.** If the main colour you pick in Site settings → Branding is too light for
|
||||
white text to be readable, you'll see a warning and can switch to a darker shade with one click.
|
||||
|
||||
## 1.49.2
|
||||
|
||||
- **Saved filters.** On Manage registrations, Users, Events and Reports, save the filters you use
|
||||
under a name and get back to them with one click. Star one and it's ready every time you open the
|
||||
page. They're yours and follow you to any device.
|
||||
|
||||
## 1.49.1
|
||||
|
||||
- **Do many at once.** On Manage registrations, tick the people you want (or select everyone
|
||||
shown after filtering) and send them a message, mark them paid, move them to another ticket, put
|
||||
them in a room or group, export them to Excel, or cancel them.
|
||||
- Moving people to another ticket keeps their tickets and QR codes; the price follows the new
|
||||
ticket, so any difference shows as owing.
|
||||
- You see what happened to each one, and anything that couldn't be done says why.
|
||||
|
||||
## 1.49.0
|
||||
|
||||
- **Today, at a glance.** The dashboard now starts with what's on today: each event with how many
|
||||
people are checked in, children in at kids church, and volunteers here — plus money in today and
|
||||
this week.
|
||||
- **Waiting for you.** One list of what needs someone — unpaid registrations, consent forms still
|
||||
outstanding, waitlists, volunteers asking for cover, events to cash up — each a click away.
|
||||
- **Ctrl K to find anything.** Press Ctrl K anywhere in the dashboard to jump to a page or setting,
|
||||
start "New event", or find an event, a payment by amount ("R450") or a person.
|
||||
|
||||
## 1.48.2
|
||||
|
||||
- No changes for churches in this version — it adds demo data for showing Cross Events to other
|
||||
churches.
|
||||
|
||||
## 1.48.1
|
||||
|
||||
- **Privacy notice brought up to date** with volunteer rosters, event series, season passes and
|
||||
|
||||
@@ -243,7 +243,7 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
| PUT | `/api/registrations/:id/forms/draft` | optional | Save form draft |
|
||||
| POST | `/api/registrations/:id/forms/responses` | optional | Submit form responses |
|
||||
| PUT | `/api/registrations/:id/forms/responses` | supervisor+ | Replace form responses |
|
||||
| GET | `/api/registrations` | staff+ | All registrations |
|
||||
| GET | `/api/registrations?page=&limit=&unpaid=1&userId=` | staff+ | All registrations (a plain array; paged `{ data, total, page, limit, pages }` when `page` or `limit` is given). `unpaid=1`: still owing (pending, confirmed, partially paid); `userId`: one person's |
|
||||
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
|
||||
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
|
||||
@@ -570,7 +570,7 @@ The data subject's tools, unsubscribe links and the admin data-requests inbox (`
|
||||
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
|
||||
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
|
||||
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all&search=&page=&limit=` | admin | Data requests (not self-service downloads), 50 a page (`total`, `pages`), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended). `search` matches name or email |
|
||||
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
|
||||
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
|
||||
|
||||
@@ -643,7 +643,7 @@ Options and variants also accept an optional `staffPrice`, charged automatically
|
||||
| GET | `/api/payments/mypayments` | user+ | Own payments |
|
||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
||||
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
|
||||
| GET | `/api/payments` | supervisor+ | All payments |
|
||||
| GET | `/api/payments?page=&limit=&userId=&from=&donations=1` | supervisor+ | Payments, newest first, paged (`limit` up to 200). `userId`: one person's; `from`: created on/after (ISO); `donations=1`: donations and the legs applying them |
|
||||
| GET | `/api/payments/event/:eventId` | staff+ | Payments for an event |
|
||||
| PUT | `/api/payments/assign-donation` | supervisor+ | Assign a donation payment to a registration |
|
||||
| POST | `/api/payments/refund` | supervisor+ | Create refund record |
|
||||
@@ -933,6 +933,38 @@ Allergies, medical and custody notes are stored encrypted. Staff routes are camp
|
||||
Since 1.46.0 board rooms also carry `rostered: [{ userId, name, checkedIn }]` (volunteers on a serving
|
||||
role linked to the room); checked-in ones are counted in `leaders`.
|
||||
|
||||
## Saved filters
|
||||
|
||||
Per person, on list pages `registrations`, `users`, `events`, `reports` (staff+; your own only — someone
|
||||
else's id answers 404).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/saved-filters?page=` | Mine for the page, default first |
|
||||
| POST | `/api/saved-filters` | `{ page, name, params, isDefault? }` — params: strings, numbers, booleans, lists of strings (≤ 4 KB); same name replaces; ≤ 30 per page |
|
||||
| PUT | `/api/saved-filters/:id` | `{ name?, isDefault? }` (one default per page) |
|
||||
| DELETE | `/api/saved-filters/:id` | Delete |
|
||||
|
||||
## Bulk actions on registrations
|
||||
|
||||
`POST /api/registrations/bulk` (supervisor+): `{ action, registrationIds: [...] (≤ 500), ...params }` →
|
||||
`{ action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }`. Every id is campus-checked.
|
||||
|
||||
| action | params | What happens |
|
||||
|--------|--------|--------------|
|
||||
| `markPaid` | `method`: cash/eft/card/other, `paidAt?` | A payment for what's still owed (as `POST /api/payments`); refused in licence read-only mode |
|
||||
| `cancel` | — | As cancelling one registration (coupons released, waitlist offered); no refund |
|
||||
| `moveOption` | `fromOptionId`, `toOptionId` (same event) | The lines, tickets and attendees move to the new ticket, repriced; status recalculated; places checked; refused in read-only mode |
|
||||
| `allocate` | `setId`, `groupId` | Each named person placed, as `PUT /api/allocations/sets/:id/assignments` |
|
||||
| `message` | `channel`: email/whatsapp, `subject` (email), `text` | Per event, as `POST /api/events/:id/email-attendees` / `whatsapp-attendees` with `filter.registrationIds`, kind "update" |
|
||||
|
||||
## Dashboard and command palette
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/dashboard/today` | staff+ | `{ events: [{ id, title, startsAt, endsAt, sessionLabel, places, checkedIn, kidsIn, volunteers }], money: { today, todayCount, week } \| null, pending: [{ key, label, count, href, tone }] }` — money and pending for supervisors/admins; campus-scoped |
|
||||
| GET | `/api/dashboard/search?q=` | staff+ | `{ results: [{ type: event\|payment\|person\|registration, id, title, subtitle, href }] }` — payments by amount for supervisors, people and registrations for admins |
|
||||
|
||||
## Event series
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -973,7 +1005,7 @@ where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anythi
|
||||
| DELETE | `/api/outbound-webhooks/:id` | Endpoint and its deliveries |
|
||||
| POST | `/api/outbound-webhooks/:id/rotate-secret` | `{ secret }` |
|
||||
| POST | `/api/outbound-webhooks/:id/test` | `{ status, statusCode, error }` |
|
||||
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=` | Delivery log |
|
||||
| GET | `/api/outbound-webhooks/:id/deliveries?status=&limit=&before=` | Delivery log, newest first; `before` (a `createdAt`) loads older ones |
|
||||
| POST | `/api/outbound-webhooks/deliveries/:id/resend` | Send again now |
|
||||
|
||||
## Season passes
|
||||
|
||||
+21
-8
@@ -1,10 +1,19 @@
|
||||
# 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.
|
||||
The demo seeds replace everything in the database with a realistic set of demo data, for
|
||||
showing the platform to other churches. Pick the one that looks like the church you're
|
||||
showing it to:
|
||||
|
||||
| Command (repo root) | For | What's in it |
|
||||
|---|---|---|
|
||||
| `npm run demo:small` | A small church, one congregation | 3 upcoming events (a sold-out breakfast with a waitlist, a family day, free carols), 4 past events with check-ins and cashups. No campuses, kids check-in, rosters, courses or series. |
|
||||
| `npm run demo:events` | Events and conferences (the original demo) | 6 upcoming events (coupons, variants, early birds, a youth camp with consent and cabins, bundles, donations) and 10 past events with every kind of cashup and funnel analytics. |
|
||||
| `npm run demo:ministry` | One campus with weekly ministry | The Alpha Course running now (sessions, attendance, late joining, certificates), a finished Marriage Course with certificates, a 3-day conference sold by day, **Kids Church** with families, children, rooms and check-ins, **volunteer rosters**, a **Men's Breakfast series** with followers and a **season pass**, plus a family day, women's conference and carols. |
|
||||
| `npm run demo:multicampus` | A church with several campuses | **Campuses** Central, North and South (switched on), events per campus and church-wide, home campuses, staff limited to a campus (Anri Botha → North, Daniel Pillay → South), the breakfast series moving between campuses, kids church and rosters at two campuses, and all the events-demo events spread over the campuses. |
|
||||
|
||||
`npm run demo:list` prints the same list. Kids church always has a session **today** with
|
||||
children checked in right now, so the kids desk and room screens have something live to
|
||||
show on any day.
|
||||
|
||||
> **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.
|
||||
@@ -14,11 +23,15 @@ check-in and cashup screen has something to show.
|
||||
## 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)
|
||||
npm run demo:ministry # (repo root) shows the database it will wipe and asks you to type "demo"
|
||||
npm run demo:ministry -- --yes # no prompt (for scripts)
|
||||
# or in backend/:
|
||||
npm run set-demo -- --profile=ministry --yes # without --profile it seeds "events"
|
||||
```
|
||||
|
||||
The campuses setting is switched on for `multicampus` and off for the others (site settings
|
||||
are otherwise kept). **Restart the backend afterwards** so it picks the setting up.
|
||||
|
||||
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`:
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ 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).
|
||||
`npm run set-demo -- --profile=<small|events|ministry|multicampus>` wipes the database (keeping site settings and the admin account) and stages demo data for that kind of church; from the repo root it's `npm run demo:<name>` (`npm run demo:list` describes them). See [DEMO_DATA.md](DEMO_DATA.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.50.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.50.1",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.50.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'registrations_bulk';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SavedFilter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"page" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"params" JSONB NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SavedFilter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SavedFilter_userId_page_idx" ON "SavedFilter"("userId", "page");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SavedFilter_userId_page_name_key" ON "SavedFilter"("userId", "page", "name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SavedFilter" ADD CONSTRAINT "SavedFilter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -164,6 +164,7 @@ model User {
|
||||
volunteersCheckedIn VolunteerSlot[] @relation("VolunteerSlotCheckedInBy")
|
||||
seriesFollows SeriesFollow[]
|
||||
seasonPassPurchases SeasonPassPurchase[]
|
||||
savedFilters SavedFilter[]
|
||||
swapsAsked VolunteerSwap[] @relation("VolunteerSwapFrom")
|
||||
swapsTaken VolunteerSwap[] @relation("VolunteerSwapTo")
|
||||
|
||||
@@ -818,6 +819,7 @@ enum AdminAuditAction {
|
||||
series_deleted
|
||||
season_pass_changed
|
||||
webhook_endpoint_changed
|
||||
registrations_bulk
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -2296,3 +2298,20 @@ model WebhookDelivery {
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// A person's saved filters on a list page (1.49.2): `page` is the list (registrations, users,
|
||||
/// events, reports), `params` the filter values. At most one default per person and page.
|
||||
model SavedFilter {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
page String
|
||||
name String
|
||||
params Json
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, page, name])
|
||||
@@index([userId, page])
|
||||
}
|
||||
|
||||
+598
-19
@@ -2,9 +2,19 @@
|
||||
* set-demo.js
|
||||
*
|
||||
* Replaces the database contents with demo data for showing the system to other
|
||||
* churches: six upcoming events with registrations, payments, tickets, attendee forms,
|
||||
* coupons, a waitlist, camp cabins, sponsorships, costs and funnel analytics, plus ten
|
||||
* completed events from the past year with door sales, check-ins, feedback and cashups.
|
||||
* churches. Four profiles, for different kinds of church (see PROFILES below, or
|
||||
* `npm run demo:list`):
|
||||
*
|
||||
* small One congregation, the essentials: a few events, payments, check-ins, cashups.
|
||||
* events Events and conferences (the original demo, the default): six upcoming events
|
||||
* with coupons, a waitlist, camp cabins, sponsorships, bundles, costs and funnel
|
||||
* analytics, and ten completed events with door sales, feedback and cashups.
|
||||
* ministry One campus with weekly ministry: a multi-session course (attendance and
|
||||
* certificates), a "pick your days" conference, kids church with kids check-in,
|
||||
* volunteer rosters, and a Men's Breakfast series with followers and a season pass.
|
||||
* multicampus Three campuses: events per campus and church-wide, home campuses,
|
||||
* campus-limited staff, a series across campuses, kids check-in and rosters at two
|
||||
* campuses.
|
||||
*
|
||||
* Everything is removed first EXCEPT the site settings (AppSetting) and the admin
|
||||
* account (DEMO_ADMIN_EMAIL, default joshua@crosscode.co.za). Dates are relative to
|
||||
@@ -13,10 +23,14 @@
|
||||
* No emails or WhatsApp messages are sent. Demo people use @example.com addresses and
|
||||
* have no phone numbers, so later reminders can't reach a real person.
|
||||
*
|
||||
* Usage:
|
||||
* cd backend
|
||||
* npm run set-demo (asks for confirmation)
|
||||
* npm run set-demo -- --yes (no prompt)
|
||||
* Campuses are switched on (site setting) for "multicampus" and off for the others; restart the
|
||||
* backend afterwards so it picks the setting up.
|
||||
*
|
||||
* Usage (from the repo root):
|
||||
* npm run demo:small | demo:events | demo:ministry | demo:multicampus (asks for confirmation)
|
||||
* npm run demo:list (what each one has)
|
||||
* or in backend/:
|
||||
* npm run set-demo -- --profile=ministry --yes (no prompt)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -38,6 +52,8 @@ const prisma = createPrismaClient();
|
||||
const ADMIN_EMAIL = (process.env.DEMO_ADMIN_EMAIL || 'joshua@crosscode.co.za').trim().toLowerCase();
|
||||
const DEMO_PASSWORD = 'Demo@1234';
|
||||
const SKIP_PROMPT = process.argv.includes('--yes') || process.argv.includes('-y');
|
||||
const PROFILE_NAME = (process.argv.find(a => a.startsWith('--profile=')) || '--profile=events').split('=')[1].trim().toLowerCase();
|
||||
const LIST_ONLY = process.argv.includes('--list');
|
||||
|
||||
// Not truncated: migrations, site settings, users (all but the admin are deleted
|
||||
// separately) and the admin's own account-security rows.
|
||||
@@ -161,13 +177,16 @@ const add = (model, row) => {
|
||||
};
|
||||
|
||||
const INSERT_ORDER = [
|
||||
'user', 'event', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
|
||||
'campus', 'user', 'eventSeries', 'event', 'eventSession', 'eventOption', 'optionVariant', 'earlyBirdTier', 'section', 'sectionOption',
|
||||
'eventForm', 'eventFormField', 'coupon', 'couponEvent', 'eventBundle', 'eventBundleItem',
|
||||
'seasonPass', 'seasonPassItem', 'seasonPassPurchase',
|
||||
'registration', 'registrationOption', 'registrationOptionTranche', 'attendee', 'payment', 'ticket',
|
||||
'ticketUsage', 'couponRedemption', 'registrationBundle',
|
||||
'formResponse', 'formAnswer', 'consentRequest', 'waitlistEntry', 'allocationSet',
|
||||
'allocationGroup', 'allocationAssignment', 'eventCost', 'feedbackForm', 'feedbackField',
|
||||
'feedbackInvite', 'feedbackResponse', 'feedbackAnswer', 'analyticsEvent', 'analyticsDaily',
|
||||
'certificate', 'seriesFollow', 'family', 'familyMember', 'child', 'authorisedAdult',
|
||||
'kidsPickupCode', 'kidsCheckIn', 'kidsRoomDuty', 'servingRole', 'volunteerSlot', 'volunteerSwap',
|
||||
];
|
||||
|
||||
// Extra facts about demo people that aren't columns (kept out of the inserted rows).
|
||||
@@ -314,6 +333,8 @@ function addRegistration(event, user, lines, { createdAt, pay = 'full', method =
|
||||
quantity: l.quantity,
|
||||
priceSnapshot: price,
|
||||
appliedTierId: tier ? tier.id : null,
|
||||
// "Pick sessions" events: a per-session line.
|
||||
sessionId: l.sessionId || null,
|
||||
});
|
||||
add('registrationOptionTranche', {
|
||||
id: uuidv4(),
|
||||
@@ -823,8 +844,11 @@ const IMPROVEMENTS = [
|
||||
* donations { card, cash, amounts }, showRate, feedback (what people enjoyed), cashup.
|
||||
*/
|
||||
function buildPastEvent(people, spec) {
|
||||
// Each profile has its own selection of past events (`always`: built by a profile's extras).
|
||||
if (!spec.always && profile.past && !profile.past.includes(spec.title)) return null;
|
||||
const { start } = spec;
|
||||
const event = addEvent({
|
||||
...(spec.seriesId ? { seriesId: spec.seriesId, seriesAnnouncedAt: start } : {}),
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
startDate: start,
|
||||
@@ -1189,6 +1213,524 @@ async function closePastEvents() {
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Profiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
const UPCOMING = {
|
||||
mensBreakfast: buildMensBreakfast,
|
||||
familyFunDay: buildFamilyFunDay,
|
||||
womensConference: buildWomensConference,
|
||||
youthCamp: buildYouthCamp,
|
||||
carols: buildCarols,
|
||||
marriageWeekend: buildMarriageWeekend,
|
||||
};
|
||||
|
||||
/**
|
||||
* upcoming: which of the six upcoming events (UPCOMING); past: titles of past events to build
|
||||
* (null = all ten); extras: the newer features (series, courses, conference, kids, volunteers);
|
||||
* campuses: three campuses, switched on.
|
||||
*/
|
||||
const PROFILES = {
|
||||
small: {
|
||||
title: 'Small church — one congregation, the essentials',
|
||||
about: [
|
||||
'Three upcoming events: a breakfast (sold out, with a waitlist), a family fun day and free carols.',
|
||||
'Four past events with check-ins and cashups (one left open to cash up live).',
|
||||
'No campuses, kids check-in, volunteer rosters, courses or series.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: ['mensBreakfast', 'familyFunDay', 'carols'],
|
||||
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Easter Sunrise Service & Breakfast', 'Worship Night'],
|
||||
extras: [],
|
||||
},
|
||||
events: {
|
||||
title: 'Events & conferences — the full events demo (default)',
|
||||
about: [
|
||||
'Six upcoming events: breakfast with a waitlist, family day, women\'s conference (variants, early birds, coupons),',
|
||||
'youth camp (parental consent, cabins, medical forms), free carols with donations, marriage weekend (bundles).',
|
||||
'Ten past events with door sales, refunds, feedback and every kind of cashup; funnel analytics.',
|
||||
'One campus; no kids check-in, rosters, courses or series.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: Object.keys(UPCOMING),
|
||||
past: null,
|
||||
extras: [],
|
||||
},
|
||||
ministry: {
|
||||
title: 'Weekly ministry — one campus, courses, kids church and volunteers',
|
||||
about: [
|
||||
'Alpha Course running now (7 weekly sessions, attendance per session, late joining, certificates) and a finished',
|
||||
'Marriage Course with certificates issued; a 3-day leadership conference sold by day ("pick sessions").',
|
||||
'Kids Church every Sunday with kids check-in: families, children (some allergies), rooms with ratios, past check-ins.',
|
||||
'Volunteer rosters for kids church and the family day (sign-ups, QR check-ins, an open cover request).',
|
||||
'A Men\'s Breakfast series (two past, three coming) with followers and a season pass; family day, women\'s conference, carols.',
|
||||
],
|
||||
campuses: false,
|
||||
upcoming: ['familyFunDay', 'womensConference', 'carols'],
|
||||
past: ['Church Family Picnic', 'Christmas Eve Candlelight Service', 'Kids Holiday Club', 'Youth Winter Camp', 'Worship Night'],
|
||||
extras: ['series', 'courses', 'conference', 'kids', 'volunteers'],
|
||||
},
|
||||
multicampus: {
|
||||
title: 'Multi-campus church — three campuses',
|
||||
about: [
|
||||
'Campuses Central, North and South (switched on), events per campus and church-wide, home campuses for most people.',
|
||||
'Staff limited to a campus: Anri Botha (North) and Daniel Pillay (South).',
|
||||
'The Men\'s Breakfast series moves between the campuses, with followers and a season pass.',
|
||||
'Kids church with kids check-in and volunteer rosters at Central and North.',
|
||||
'All six upcoming and ten past events of the events demo, spread over the campuses.',
|
||||
],
|
||||
campuses: true,
|
||||
upcoming: ['familyFunDay', 'womensConference', 'youthCamp', 'carols', 'marriageWeekend'],
|
||||
past: null,
|
||||
extras: ['series', 'kids', 'volunteers'],
|
||||
},
|
||||
};
|
||||
|
||||
let profile = PROFILES[PROFILE_NAME];
|
||||
|
||||
const hex = (bytes = 16) => crypto.randomBytes(bytes).toString('hex');
|
||||
let campuses = [];
|
||||
|
||||
// ─── Campuses (multicampus) ──────────────────────────────────────────────────
|
||||
|
||||
function buildCampuses() {
|
||||
const campus = (name, slug, address, colour, defaultLocation, order) => add('campus', {
|
||||
id: uuidv4(), name, slug, address, contactName: 'Lerato Khumalo', contactEmail: `${slug}@example.com`,
|
||||
colour, defaultLocation, order, createdAt: addTime(NOW, -300 * DAY), updatedAt: NOW,
|
||||
});
|
||||
campuses = [
|
||||
campus('Central', 'central', '12 Church Street, Hatfield, Pretoria', '#2563eb', 'Main auditorium', 0),
|
||||
campus('North', 'north', '45 Zambezi Drive, Montana', '#16a34a', 'North campus hall', 1),
|
||||
campus('South', 'south', '8 Lenchen Avenue, Centurion', '#ea580c', 'South campus hall', 2),
|
||||
];
|
||||
for (const u of rows.user) if (u.role === 'user' && chance(0.85)) u.homeCampusId = pick(campuses).id;
|
||||
}
|
||||
|
||||
/** Spreads the events over the campuses; the big ones stay church-wide. */
|
||||
function assignCampuses() {
|
||||
const byTitle = {
|
||||
'Family Fun Day': 1, 'Carols by Candlelight': 2, 'Youth Winter Camp': null, 'Christmas Eve Candlelight Service': 0,
|
||||
'Rooted Women’s Conference': null, 'Ignite Summer Camp': null, 'Marriage Enrichment Weekend': null,
|
||||
};
|
||||
let i = 0;
|
||||
for (const event of rows.event) {
|
||||
if (event.campusId !== undefined && event.campusId !== null) continue; // set by its builder
|
||||
if (event.seriesId || event.kidsCheckIn) continue;
|
||||
const idx = event.title in byTitle ? byTitle[event.title] : (i++ % campuses.length);
|
||||
event.campusId = idx === null ? null : campuses[idx].id;
|
||||
if (idx !== null && event.location === 'Church hall') event.location = campuses[idx].defaultLocation;
|
||||
}
|
||||
}
|
||||
|
||||
/** Staff limited to one campus (a many-to-many, so it's set after the rows are in). */
|
||||
async function scopeStaff(tx) {
|
||||
const byName = (first) => staffTeam.find(s => s.name.startsWith(first));
|
||||
for (const [first, campusIdx] of [['Anri', 1], ['Daniel', 2]]) {
|
||||
await tx.user.update({ where: { id: byName(first).id }, data: { scopedCampuses: { connect: [{ id: campuses[campusIdx].id }] } } });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Event series and a season pass ──────────────────────────────────────────
|
||||
|
||||
function buildBreakfast(people, { start, title, sold }) {
|
||||
const event = addEvent({
|
||||
title,
|
||||
description: 'A full breakfast, a guest speaker and an honest conversation about faith, work and family. Bring a friend.',
|
||||
startDate: start,
|
||||
endDate: addTime(start, 2.5 * HOUR),
|
||||
registrationDeadline: addTime(start, -2 * DAY),
|
||||
price: 150,
|
||||
picture: IMG('photo-1414235077428-338989a2e8c0'),
|
||||
location: 'Church hall',
|
||||
contactName: 'Sipho Dlamini',
|
||||
});
|
||||
const ticket = addOption(event, { name: 'Breakfast ticket', price: 150, stockLimit: 60 });
|
||||
addCost(event, 'Breakfast catering', 'per_item', 85, ticket);
|
||||
for (const user of shuffle(people.men).slice(0, sold)) {
|
||||
addRegistration(event, user, [line(ticket, 1)], { createdAt: pastDate(20, 1), ...payPlan({ card: 0.7, eft: 0.1, none: 0.2 }) });
|
||||
}
|
||||
return { event, ticket };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Men's Breakfast as a series: two past breakfasts (cashed up), this month's (sold out) and two
|
||||
* more, 18 followers, and a season pass for the next two with six bought. On the multi-campus demo
|
||||
* each breakfast is at a different campus.
|
||||
*/
|
||||
function buildSeries(people) {
|
||||
const series = add('eventSeries', {
|
||||
id: uuidv4(), name: "Men's Breakfast", slug: 'mens-breakfast',
|
||||
description: 'Once a month on a Saturday morning: a full breakfast, a guest speaker and an honest conversation about faith, work and family. Follow the series to hear when the next one is out.',
|
||||
picture: IMG('photo-1414235077428-338989a2e8c0'), createdAt: addTime(NOW, -200 * DAY), updatedAt: NOW,
|
||||
});
|
||||
const campusAt = (i) => (profile.campuses ? campuses[i % campuses.length] : null);
|
||||
const pastSpec = (title, daysAgo, speaker) => ({
|
||||
always: true, seriesId: series.id, title, description: `Breakfast with ${speaker}.`,
|
||||
start: lastWeekday(daysAgo, 6, 7, 30), hours: 2.5, picture: 'photo-1414235077428-338989a2e8c0', location: 'Church hall', pool: 'men',
|
||||
options: [{ name: 'Breakfast ticket', price: 150 }],
|
||||
costs: o => [['Breakfast catering', 'per_item', 85, o[0]]],
|
||||
online: int(34, 42), onlinePay: { card: 80, eft: 15, none: 5 }, door: int(3, 8), doorPay: { cash: 70, card: 30 },
|
||||
lines: o => [line(o[0], chance(0.2) ? 2 : 1)],
|
||||
showRate: 0.9,
|
||||
cashup: { close: 'quick', daysAfter: 3, notes: 'Quick closed — card and cash banked.' },
|
||||
});
|
||||
const past = [
|
||||
buildPastEvent(people, pastSpec("Men's Breakfast: Fathers & Sons", 63, 'Pastor Ernest Molefe')),
|
||||
buildPastEvent(people, pastSpec("Men's Breakfast: Faith at Work", 35, 'Thabo Nkosi')),
|
||||
];
|
||||
const current = buildMensBreakfast(people);
|
||||
const next = buildBreakfast(people, { start: nextWeekday(36, 6, 7, 30), title: "Men's Breakfast: Leading at Home", sold: 22 });
|
||||
const later = buildBreakfast(people, { start: nextWeekday(64, 6, 7, 30), title: "Men's Breakfast: Money & Generosity", sold: 9 });
|
||||
[...past, current, next.event, later.event].forEach((event, i) => {
|
||||
event.seriesId = series.id;
|
||||
// Followers were told when each came out (so the series job doesn't email demo people).
|
||||
event.seriesAnnouncedAt = event.goLiveAt < NOW ? event.goLiveAt : NOW;
|
||||
const campus = campusAt(i);
|
||||
if (campus) { event.campusId = campus.id; event.location = campus.defaultLocation; }
|
||||
});
|
||||
for (const user of shuffle(people.men).slice(0, 18)) add('seriesFollow', { id: uuidv4(), seriesId: series.id, userId: user.id, createdAt: pastDate(150, 3) });
|
||||
|
||||
// Season pass: the next two breakfasts for R260 (R300 separately), R130 per event.
|
||||
const pass = add('seasonPass', {
|
||||
id: uuidv4(), seriesId: series.id, name: 'Next two breakfasts', description: 'Both of the next breakfasts for one price.',
|
||||
price: 260, isActive: true, saleEndsAt: null, createdAt: addTime(NOW, -20 * DAY), updatedAt: NOW,
|
||||
});
|
||||
[next, later].forEach((b, order) => add('seasonPassItem', { id: uuidv4(), passId: pass.id, eventId: b.event.id, eventOptionId: b.ticket.id, variantId: null, order }));
|
||||
for (const user of shuffle(people.men).slice(0, 6)) {
|
||||
const at = pastDate(15, 1);
|
||||
const purchase = add('seasonPassPurchase', { id: uuidv4(), passId: pass.id, userId: user.id, quantity: 1, total: 260, status: 'paid', checkoutId: null, createdAt: at, updatedAt: at });
|
||||
[next, later].forEach((b, i) => {
|
||||
const reg = add('registration', { id: uuidv4(), userId: user.id, eventId: b.event.id, status: 'paid', discountTotal: 0, bundleDiscount: 0, ticketDelivery: 'payer', analyticsSessionId: null, seasonPassPurchaseId: purchase.id, createdAt: at });
|
||||
const ro = add('registrationOption', { id: uuidv4(), registrationId: reg.id, eventOptionId: b.ticket.id, variantId: null, quantity: 1, priceSnapshot: 130, appliedTierId: null, sessionId: null });
|
||||
add('registrationOptionTranche', { id: uuidv4(), registrationOptionId: ro.id, quantity: 1, priceSnapshot: 130, appliedTierId: null, createdAt: at });
|
||||
add('payment', {
|
||||
id: uuidv4(), amount: 130, method: 'card', userId: user.id, recordedById: null, registrationId: reg.id, eventId: b.event.id,
|
||||
status: 'completed', externalId: i === 0 ? `demo_${uuidv4().replace(/-/g, '')}` : null, seasonPassPurchaseId: purchase.id, createdAt: after(at, 2, 10),
|
||||
});
|
||||
add('ticket', { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: ro.id, userId: user.id, eventId: b.event.id, quantity: 1, emailSent: true, createdAt: at });
|
||||
});
|
||||
}
|
||||
return [current, next.event, later.event];
|
||||
}
|
||||
|
||||
// ─── Courses and a pick-your-days conference (ministry) ──────────────────────
|
||||
|
||||
function addSessions(event, first, count, { everyDays = 7, minutes = 120, labels = [], capacity = null } = {}) {
|
||||
return Array.from({ length: count }, (_, k) => {
|
||||
const startsAt = addTime(first, k * everyDays * DAY);
|
||||
return add('eventSession', {
|
||||
id: uuidv4(), eventId: event.id, label: labels[k] || null, startsAt, endsAt: addTime(startsAt, minutes * MINUTE),
|
||||
order: k, capacity, location: null, createdAt: event.createdAt, updatedAt: NOW,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Check-ins per session that has ended, for the event's tickets. */
|
||||
function attendSessions(event, sessions, showRate) {
|
||||
const attended = new Map();
|
||||
for (const s of sessions.filter(x => x.endsAt < NOW)) {
|
||||
for (const t of rows.ticket.filter(x => x.eventId === event.id)) {
|
||||
if (!chance(showRate)) continue;
|
||||
add('ticketUsage', { id: uuidv4(), ticketId: t.id, scannedById: pick(staffTeam).id, quantityRedeemed: t.quantity, sessionId: s.id, scannedAt: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 15 * MINUTE)) });
|
||||
attended.set(t.id, (attended.get(t.id) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return attended;
|
||||
}
|
||||
|
||||
const CERTIFICATE = (min) => ({
|
||||
template: 'classic', title: 'Certificate of Completion', signatureImage: '', sendOnIssue: false,
|
||||
body: 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
|
||||
signatureName: 'Pastor Ernest Molefe', signatureRole: 'Senior Pastor', rule: { type: 'count', value: min },
|
||||
});
|
||||
|
||||
function buildCourses(people) {
|
||||
// Alpha: seven Wednesdays, three already held; late joining until week 4 at a pro-rata price.
|
||||
const first = lastWeekday(15, 3, 18, 30);
|
||||
const alpha = addEvent({
|
||||
title: 'Alpha Course',
|
||||
description: 'Seven Wednesday evenings exploring the big questions of life and faith — each with dinner, a short talk and honest conversation in small groups. Everyone is welcome; no question is off limits.',
|
||||
startDate: first, endDate: addTime(first, (6 * 7) * DAY + 2 * HOUR), price: 250,
|
||||
picture: IMG('photo-1529070538774-1843cb3265df'), location: 'Church hall',
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', lateJoinEnabled: true, latePricing: 'prorata', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
|
||||
goLiveAt: addTime(first, -35 * DAY), createdAt: addTime(first, -40 * DAY),
|
||||
});
|
||||
const alphaSessions = addSessions(alpha, first, 7, { labels: ['Is there more to life than this?', 'Who is Jesus?', 'Why did Jesus die?', 'How can I have faith?', 'Why and how do I pray?', 'How can I resist evil?', 'What about the church?'] });
|
||||
alpha.lateJoinUntilSessionId = alphaSessions[3].id;
|
||||
const alphaTicket = addOption(alpha, { name: 'Course (dinner included)', price: 250, stockLimit: 40 });
|
||||
for (const user of shuffle(people.everyone).slice(0, 30)) {
|
||||
addRegistration(alpha, user, [line(alphaTicket, 1)], { createdAt: daytimeBetween(addTime(first, -30 * DAY), addTime(first, -DAY)), ...payPlan({ card: 0.7, eft: 0.2, none: 0.1 }) });
|
||||
}
|
||||
attendSessions(alpha, alphaSessions, 0.85);
|
||||
|
||||
// Marriage Course: finished six weeks ago; certificates issued to couples who came to 5 of 6.
|
||||
const mFirst = lastWeekday(80, 4, 19);
|
||||
const marriage = addEvent({
|
||||
title: 'The Marriage Course',
|
||||
description: 'Six Thursday evenings for couples: a candle-lit dinner for two, a short talk and time to talk together — no group sharing.',
|
||||
startDate: mFirst, endDate: addTime(mFirst, 35 * DAY + 2 * HOUR), price: 500,
|
||||
picture: IMG('photo-1529634806980-85c3dd6d34ac'), location: 'Church hall',
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day',
|
||||
certificatesEnabled: true, certificateConfig: CERTIFICATE(5),
|
||||
goLiveAt: addTime(mFirst, -40 * DAY), createdAt: addTime(mFirst, -45 * DAY),
|
||||
});
|
||||
const mSessions = addSessions(marriage, mFirst, 6, { labels: ['Building strong foundations', 'The art of communication', 'Resolving conflict', 'The power of forgiveness', 'Parents and in-laws', 'Love in action'] });
|
||||
const couple = addOption(marriage, { name: 'Couple', price: 500, stockLimit: 16 });
|
||||
for (const user of shuffle(people.everyone).slice(0, 14)) {
|
||||
addRegistration(marriage, user, [line(couple, 1)], { createdAt: daytimeBetween(addTime(mFirst, -35 * DAY), addTime(mFirst, -2 * DAY)), payBy: mFirst, ...payPlan({ card: 0.75, eft: 0.25 }) });
|
||||
}
|
||||
const attended = attendSessions(marriage, mSessions, 0.88);
|
||||
const issuedAt = addTime(mSessions[5].endsAt, DAY);
|
||||
for (const t of rows.ticket.filter(x => x.eventId === marriage.id)) {
|
||||
const n = attended.get(t.id) || 0;
|
||||
if (n < 5) continue;
|
||||
const owner = rows.user.find(u => u.id === t.userId);
|
||||
add('certificate', { id: uuidv4(), eventId: marriage.id, ticketId: t.id, userId: owner.id, name: owner.name, code: hex(4).toUpperCase(), sessionsAttended: n, totalSessions: 6, manual: false, issuedAt, emailedAt: issuedAt });
|
||||
}
|
||||
return [alpha];
|
||||
}
|
||||
|
||||
function buildLeadershipConference(people) {
|
||||
const day1 = nextWeekday(40, 4, 8, 30);
|
||||
const event = addEvent({
|
||||
title: 'Lead Well Leadership Conference',
|
||||
description: 'Three days for everyone who leads — small groups, ministries, teams at work. Come to all three, or pick the days you can make.',
|
||||
startDate: day1, endDate: addTime(day1, 2 * DAY + 8 * HOUR), price: 650,
|
||||
picture: IMG('photo-1540575467063-178a50c2df87'), location: 'Main auditorium',
|
||||
scheduleType: 'sessions', attendanceMode: 'pick', checkInWindow: 'whole_day',
|
||||
});
|
||||
const days = addSessions(event, day1, 3, { everyDays: 1, minutes: 8 * 60, labels: ['Day 1 — Vision', 'Day 2 — Teams', 'Day 3 — Practice'], capacity: 150 });
|
||||
const full = addOption(event, { name: 'Full conference pass', price: 650, sessionMode: 'all' });
|
||||
addTier(full, { deadline: addTime(day1, -14 * DAY), price: 550, stockLimit: 40 });
|
||||
const dayPass = addOption(event, { name: 'Day pass', price: 280, sessionMode: 'pick' });
|
||||
const buyers = shuffle(people.everyone);
|
||||
for (const user of buyers.splice(0, 34)) {
|
||||
addRegistration(event, user, [line(full, chance(0.2) ? 2 : 1)], { createdAt: pastDate(30, 1), ...payPlan({ card: 0.8, eft: 0.12, none: 0.08 }) });
|
||||
}
|
||||
for (const user of buyers.splice(0, 26)) {
|
||||
const picked = shuffle(days).slice(0, chance(0.4) ? 2 : 1);
|
||||
addRegistration(event, user, picked.map(s => line(dayPass, 1, { sessionId: s.id })), { createdAt: pastDate(25, 1), ...payPlan({ card: 0.8, none: 0.2 }) });
|
||||
}
|
||||
return [event];
|
||||
}
|
||||
|
||||
// ─── Kids church with kids check-in ──────────────────────────────────────────
|
||||
|
||||
const ROOMS = [['Nursery', 0, 2, 3, 12], ['Little Ones', 3, 5, 6, 24], ['Explorers', 6, 9, 8, 30], ['Pre-teens', 10, 12, 10, 24]];
|
||||
const inFamily = new Set();
|
||||
const kidsChurches = [];
|
||||
|
||||
function ageAt(dob, at) {
|
||||
let age = at.getFullYear() - dob.getFullYear();
|
||||
if (at.getMonth() < dob.getMonth() || (at.getMonth() === dob.getMonth() && at.getDate() < dob.getDate())) age--;
|
||||
return Math.max(0, age);
|
||||
}
|
||||
|
||||
/** A pickup code that's free on this occasion (4 characters, no look-alikes). */
|
||||
function kidsCode(taken) {
|
||||
const { CODE_ALPHABET, CODE_LENGTH } = require('../src/utils/kids');
|
||||
for (;;) {
|
||||
let c = '';
|
||||
for (let i = 0; i < CODE_LENGTH; i++) c += CODE_ALPHABET[int(0, CODE_ALPHABET.length - 1)];
|
||||
if (!taken.has(c)) { taken.add(c); return c; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sunday kids church: a hidden event with a session every Sunday (four held, eight to come), the
|
||||
* kids ticket and rooms, 16 families with their children and authorised adults, and check-ins
|
||||
* and check-outs (with pickup codes, tickets and room duty) for the Sundays already held.
|
||||
*/
|
||||
function buildKidsChurch(people, campus = null) {
|
||||
const { encrypt } = require('../src/utils/encryption');
|
||||
const first = lastWeekday(28, 0, 9, 0);
|
||||
const event = addEvent({
|
||||
title: campus ? `Kids Church — ${campus.name}` : 'Kids Church',
|
||||
description: 'Sunday kids church during the 09:00 service, for babies to 12-year-olds. Check in at the kids desk: each family gets a pickup code, and only someone with the code can collect.',
|
||||
startDate: first, endDate: addTime(first, 77 * DAY + 90 * MINUTE), price: 0, isHidden: true,
|
||||
scheduleType: 'sessions', attendanceMode: 'whole', checkInWindow: 'whole_day', kidsCheckIn: true,
|
||||
location: campus ? `${campus.name} kids wing` : 'Kids wing', campusId: campus ? campus.id : null,
|
||||
goLiveAt: addTime(first, -30 * DAY), createdAt: addTime(first, -32 * DAY),
|
||||
});
|
||||
const sessions = addSessions(event, first, 12, { minutes: 90 });
|
||||
// A session today, so the desk and room screens have children in right now whatever day the
|
||||
// demo is shown (on a Sunday it's that Sunday's session).
|
||||
let live = sessions.find(s => s.startsAt.toDateString() === NOW.toDateString() && s.endsAt > NOW);
|
||||
if (!live) {
|
||||
const startsAt = new Date(NOW); startsAt.setMinutes(0, 0, 0); startsAt.setHours(Math.max(8, startsAt.getHours() - 1));
|
||||
live = add('eventSession', { id: uuidv4(), eventId: event.id, label: 'Midweek Kids Club', startsAt, endsAt: addTime(startsAt, 3 * HOUR), order: 0, capacity: null, location: null, createdAt: event.createdAt, updatedAt: NOW });
|
||||
sessions.push(live);
|
||||
sessions.sort((a, b) => a.startsAt - b.startsAt).forEach((s, order) => { s.order = order; });
|
||||
}
|
||||
const option = addOption(event, { name: 'Kids check-in', price: 0 });
|
||||
event.kidsOptionId = option.id;
|
||||
const set = add('allocationSet', { id: uuidv4(), eventId: event.id, name: 'Kids rooms', showOnTicket: false, order: 0 });
|
||||
event.kidsRoomSetId = set.id;
|
||||
const rooms = ROOMS.map(([name, minAge, maxAge, ratio, capacity], order) => add('allocationGroup', { id: uuidv4(), setId: set.id, name, minAge, maxAge, ratio, capacity, order, gender: null, leaderUserId: null }));
|
||||
const roomFor = age => rooms.find(r => age >= r.minAge && age <= r.maxAge) || rooms[rooms.length - 1];
|
||||
|
||||
const parents = shuffle(people.everyone).filter(u => !inFamily.has(u.id)).slice(0, 16);
|
||||
const families = parents.map(parent => {
|
||||
inFamily.add(parent.id);
|
||||
const { last } = userMeta.get(parent.id);
|
||||
const createdAt = pastDate(200, 40);
|
||||
const family = add('family', { id: uuidv4(), name: `The ${last} family`, qrToken: hex(), createdAt, updatedAt: NOW });
|
||||
add('familyMember', { id: uuidv4(), familyId: family.id, userId: parent.id, createdAt });
|
||||
if (chance(0.5)) {
|
||||
add('authorisedAdult', { id: uuidv4(), familyId: family.id, name: `${pick(chance(0.5) ? FEMALE_FIRST : MALE_FIRST)} ${last}`, phone: null, relationship: pick(['Grandmother', 'Grandfather', 'Aunt', 'Uncle']), createdAt });
|
||||
}
|
||||
const children = Array.from({ length: int(1, 3) }, () => {
|
||||
const girl = chance(0.5);
|
||||
const dob = new Date(NOW);
|
||||
dob.setFullYear(dob.getFullYear() - int(1, 12), int(0, 11), int(1, 28));
|
||||
dob.setHours(0, 0, 0, 0);
|
||||
return add('child', {
|
||||
id: uuidv4(), familyId: family.id, firstName: pick(girl ? GIRL_FIRST : BOY_FIRST), lastName: last, dateOfBirth: dob, gender: girl ? 'female' : 'male',
|
||||
allergies: chance(0.15) ? encrypt(pick(['Peanuts', 'Tree nuts', 'Dairy', 'Eggs', 'Bee stings'])) : null,
|
||||
medicalNotes: chance(0.06) ? encrypt('Asthma — inhaler in the nappy bag') : null,
|
||||
custodyNotes: chance(0.04) ? encrypt('Only the mother or grandmother may collect') : null,
|
||||
lastCheckInAt: null, createdAt, updatedAt: NOW,
|
||||
});
|
||||
});
|
||||
return { parent, family, children };
|
||||
});
|
||||
|
||||
const held = sessions.filter(s => s.endsAt < NOW);
|
||||
const codes = new Map([...held, live].map(s => [s.id, new Set()]));
|
||||
let liveFamilies = 0;
|
||||
for (const f of families) {
|
||||
const attending = held.filter(() => chance(0.75));
|
||||
// About half the families are in right now (checked in, not yet collected).
|
||||
const inNow = live.startsAt <= NOW && liveFamilies < 8 && chance(0.6);
|
||||
if (inNow) { attending.push(live); liveFamilies++; }
|
||||
if (!attending.length) continue;
|
||||
// The desk's first check-in gives the parent a free kids registration: a named place and ticket per child.
|
||||
const { regLines } = addRegistration(event, f.parent, [line(option, f.children.length, {
|
||||
attendees: f.children.map(c => ({ name: `${c.firstName} ${c.lastName}`, dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: f.parent.name, parentEmail: f.parent.email })),
|
||||
})], { createdAt: addTime(attending[0].startsAt, -25 * MINUTE) });
|
||||
const attendees = regLines[0].attendees;
|
||||
for (const s of attending) {
|
||||
const code = add('kidsPickupCode', { id: uuidv4(), eventId: event.id, occasion: s.id, code: kidsCode(codes.get(s.id)), familyId: f.family.id, createdAt: addTime(s.startsAt, -int(10, 20) * MINUTE) });
|
||||
const isLive = s === live;
|
||||
f.children.forEach((c, i) => {
|
||||
if (!chance(0.92)) return;
|
||||
const at = isLive
|
||||
? within(addTime(NOW, -50 * MINUTE), addTime(NOW, -5 * MINUTE))
|
||||
: within(addTime(s.startsAt, -20 * MINUTE), addTime(s.startsAt, 10 * MINUTE));
|
||||
const ticket = rows.ticket.find(t => t.attendeeId === attendees[i].id);
|
||||
const desk = pick(staffTeam);
|
||||
add('kidsCheckIn', {
|
||||
id: uuidv4(), eventId: event.id, occasion: s.id, sessionId: s.id, childId: c.id, childName: `${c.firstName} ${c.lastName}`,
|
||||
roomId: roomFor(ageAt(c.dateOfBirth, s.startsAt)).id, pickupCodeId: code.id, attendeeId: attendees[i].id, ticketId: ticket.id,
|
||||
checkedInAt: at, checkedInById: desk.id,
|
||||
checkedOutAt: isLive ? null : within(addTime(s.endsAt, -5 * MINUTE), addTime(s.endsAt, 15 * MINUTE)),
|
||||
checkedOutById: isLive ? null : pick(staffTeam).id,
|
||||
collectedBy: isLive ? null : chance(0.85) ? f.parent.name : 'Authorised adult', offline: false,
|
||||
});
|
||||
add('ticketUsage', { id: uuidv4(), ticketId: ticket.id, scannedById: desk.id, scannedAt: at, sessionId: s.id, quantityRedeemed: 1 });
|
||||
if (!c.lastCheckInAt || at > c.lastCheckInAt) c.lastCheckInAt = at;
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const s of [...held, live]) {
|
||||
const isLive = s === live;
|
||||
if (isLive && live.startsAt > NOW) continue;
|
||||
for (const room of rooms) {
|
||||
for (const leader of shuffle(staffTeam).slice(0, int(1, 2))) {
|
||||
add('kidsRoomDuty', {
|
||||
id: uuidv4(), roomId: room.id, occasion: s.id, userId: leader.id, fromRoster: false,
|
||||
startedAt: isLive ? addTime(NOW, -55 * MINUTE) : addTime(s.startsAt, -15 * MINUTE),
|
||||
endedAt: isLive ? null : addTime(s.endsAt, 10 * MINUTE),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
kidsChurches.push({ event, sessions, rooms });
|
||||
return [event];
|
||||
}
|
||||
|
||||
// ─── Volunteer rosters ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serving roles with people on them. `roles`: [name, needed, room?]. For a held session or event
|
||||
* the places are filled and most checked in (QR); later ones are partly signed up. Reminders are
|
||||
* marked as sent, so the reminder job doesn't email demo people.
|
||||
*/
|
||||
function addRoster(people, event, { session = null, roles, rooms = [] }) {
|
||||
const start = session ? session.startsAt : event.startDate;
|
||||
const held = (session ? session.endsAt : event.endDate) < NOW;
|
||||
const daysAway = (start - NOW) / DAY;
|
||||
const pool = shuffle(people.everyone);
|
||||
const slots = [];
|
||||
roles.forEach(([name, needed, roomName], order) => {
|
||||
const role = add('servingRole', {
|
||||
id: uuidv4(), eventId: event.id, sessionId: session ? session.id : null, name, description: null, needed,
|
||||
startsAt: null, endsAt: null, kidsRoomId: roomName ? rooms.find(r => r.name === roomName)?.id || null : null,
|
||||
order, createdAt: addTime(NOW, -25 * DAY), updatedAt: NOW,
|
||||
});
|
||||
const filled = held ? needed : daysAway < 10 ? int(Math.max(0, needed - 1), needed) : int(0, Math.max(0, needed - 1));
|
||||
for (const user of pool.splice(0, filled)) {
|
||||
const checked = held && chance(0.9);
|
||||
// Most scan the QR code themselves; now and then a staff member ticks them in.
|
||||
const method = checked ? (chance(0.85) ? 'qr' : 'manual') : null;
|
||||
slots.push(add('volunteerSlot', {
|
||||
id: uuidv4(), roleId: role.id, userId: user.id, assignedById: chance(0.3) ? staffTeam[0].id : null,
|
||||
checkedInAt: checked ? within(addTime(start, -40 * MINUTE), addTime(start, -5 * MINUTE)) : null,
|
||||
checkedInById: method === 'qr' ? user.id : method ? pick(staffTeam).id : null, checkInMethod: method,
|
||||
remindedAt: held ? addTime(start, -DAY) : NOW, registrationId: null, createdAt: pastDate(20, 1),
|
||||
}));
|
||||
}
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
function buildVolunteers(people, built) {
|
||||
for (const { event, sessions, rooms } of kidsChurches) {
|
||||
event.volunteerCheckInToken = hex();
|
||||
const kidsRoles = [['Welcome desk', 3], ['Nursery leader', 2, 'Nursery'], ['Little Ones leader', 2, 'Little Ones'], ['Explorers leader', 2, 'Explorers'], ['Pre-teens leader', 1, 'Pre-teens']];
|
||||
const lastHeld = sessions.filter(s => s.endsAt < NOW).pop();
|
||||
const coming = sessions.filter(s => s.startsAt > NOW).slice(0, 2);
|
||||
if (lastHeld) addRoster(people, event, { session: lastHeld, roles: kidsRoles, rooms });
|
||||
coming.forEach((session, i) => {
|
||||
const slots = addRoster(people, event, { session, roles: kidsRoles, rooms });
|
||||
// Someone can't make the next Sunday and has asked for cover.
|
||||
if (i === 0 && slots.length) {
|
||||
const slot = slots[0];
|
||||
add('volunteerSwap', { id: uuidv4(), slotId: slot.id, roleId: slot.roleId, fromUserId: slot.userId, toUserId: null, note: 'Away for a family wedding — can anyone take this?', status: 'open', createdAt: pastDate(2) });
|
||||
}
|
||||
});
|
||||
}
|
||||
const dayEvent = built.find(e => e.title === 'Family Fun Day');
|
||||
if (dayEvent) {
|
||||
dayEvent.volunteerCheckInToken = hex();
|
||||
addRoster(people, dayEvent, { roles: [['Parking marshals', 4], ['Welcome & info table', 3], ['First aid', 1], ['Coffee bar', 3], ['Jumping castle supervisors', 2]] });
|
||||
}
|
||||
}
|
||||
|
||||
/** The extras of the chosen profile, in order; returns the upcoming events they add. */
|
||||
function buildExtras(people, built) {
|
||||
const out = [];
|
||||
const has = (x) => profile.extras.includes(x);
|
||||
if (has('series')) out.push(...buildSeries(people));
|
||||
if (has('courses')) out.push(...buildCourses(people));
|
||||
if (has('conference')) out.push(...buildLeadershipConference(people));
|
||||
if (has('kids')) {
|
||||
if (profile.campuses) for (const c of campuses.slice(0, 2)) out.push(...buildKidsChurch(people, c));
|
||||
else out.push(...buildKidsChurch(people));
|
||||
}
|
||||
if (has('volunteers')) buildVolunteers(people, built.concat(out));
|
||||
return out;
|
||||
}
|
||||
|
||||
function listProfiles() {
|
||||
console.log('\nDemo profiles — npm run demo:<name> (from the repo root):\n');
|
||||
for (const [name, p] of Object.entries(PROFILES)) {
|
||||
console.log(` ${name.padEnd(12)} ${p.title}`);
|
||||
for (const line of p.about) console.log(` ${''.padEnd(12)} ${line}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Funnel analytics ────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCES = { direct: 30, whatsapp: 35, facebook: 18, instagram: 10, email: 7 };
|
||||
@@ -1198,7 +1740,8 @@ function buildAnalytics() {
|
||||
const { sessionsFrom, rollupRows, RAW_DAYS } = require('../src/utils/analytics');
|
||||
const rawCutoff = addTime(NOW, -RAW_DAYS * DAY);
|
||||
|
||||
for (const event of rows.event) {
|
||||
// Hidden events (kids church) aren't shown publicly, so nobody "visits" them.
|
||||
for (const event of rows.event.filter(e => !e.isHidden)) {
|
||||
const raw = [];
|
||||
const session = (stages, at) => {
|
||||
const sessionId = uuidv4();
|
||||
@@ -1242,10 +1785,25 @@ function buildAnalytics() {
|
||||
|
||||
async function clearData(tx) {
|
||||
const tables = await tx.$queryRaw`SELECT tablename FROM pg_tables WHERE schemaname = current_schema()`;
|
||||
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t));
|
||||
// Tables the User table points at (e.g. Campus via homeCampusId): TRUNCATE ... CASCADE on them
|
||||
// would truncate User too — and with it the admin we keep — so their rows are deleted instead,
|
||||
// after the admin's links to them are cleared.
|
||||
const userRefs = await tx.$queryRaw`
|
||||
SELECT DISTINCT ref.relname AS table, att.attname AS column
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class src ON src.oid = c.conrelid
|
||||
JOIN pg_class ref ON ref.oid = c.confrelid
|
||||
JOIN pg_attribute att ON att.attrelid = c.conrelid AND att.attnum = ANY (c.conkey)
|
||||
WHERE c.contype = 'f' AND src.relname = 'User' AND ref.relname <> 'User'`;
|
||||
const deleteInstead = new Set(userRefs.map(r => r.table));
|
||||
const toClear = tables.map(t => t.tablename).filter(t => !KEEP_TABLES.has(t) && !deleteInstead.has(t));
|
||||
if (toClear.length) {
|
||||
await tx.$executeRawUnsafe(`TRUNCATE TABLE ${toClear.map(t => `"${t}"`).join(', ')} CASCADE`);
|
||||
}
|
||||
if (userRefs.length) {
|
||||
await tx.$executeRawUnsafe(`UPDATE "User" SET ${[...new Set(userRefs.map(r => `"${r.column}" = NULL`))].join(', ')}`);
|
||||
for (const table of deleteInstead) await tx.$executeRawUnsafe(`DELETE FROM "${table}"`);
|
||||
}
|
||||
// Their password resets, 2FA codes and trusted devices go with them (onDelete: Cascade).
|
||||
const { count } = await tx.user.deleteMany({ where: { id: { not: admin.id } } });
|
||||
await tx.securityEvent.deleteMany({ where: { OR: [{ userId: null }, { userId: { not: admin.id } }] } });
|
||||
@@ -1269,6 +1827,7 @@ function describeDatabase() {
|
||||
}
|
||||
|
||||
async function confirm(admin) {
|
||||
console.log(`\nDemo profile "${PROFILE_NAME}": ${profile.title}`);
|
||||
if (SKIP_PROMPT) return true;
|
||||
console.log(`\nThis will DELETE all events, registrations, payments, tickets, coupons and users`);
|
||||
console.log(`in ${describeDatabase()}, keeping only the site settings and ${admin.email}.`);
|
||||
@@ -1279,6 +1838,13 @@ async function confirm(admin) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (LIST_ONLY) { listProfiles(); return; }
|
||||
if (!profile) {
|
||||
console.error(`Unknown demo profile "${PROFILE_NAME}". Choose one of: ${Object.keys(PROFILES).join(', ')}.`);
|
||||
listProfiles();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
admin = await prisma.user.findFirst({ where: { email: { equals: ADMIN_EMAIL, mode: 'insensitive' } } });
|
||||
if (!admin) {
|
||||
console.error(`Admin account ${ADMIN_EMAIL} was not found — nothing was changed.`);
|
||||
@@ -1295,15 +1861,11 @@ async function main() {
|
||||
|
||||
passwordHash = await bcrypt.hash(DEMO_PASSWORD, 10);
|
||||
const people = buildPeople();
|
||||
const events = [
|
||||
buildMensBreakfast(people),
|
||||
buildFamilyFunDay(people),
|
||||
buildWomensConference(people),
|
||||
buildYouthCamp(people),
|
||||
buildCarols(people),
|
||||
buildMarriageWeekend(people),
|
||||
];
|
||||
if (profile.campuses) buildCampuses();
|
||||
const events = profile.upcoming.map(key => UPCOMING[key](people));
|
||||
events.push(...buildExtras(people, events));
|
||||
buildPastEvents(people);
|
||||
if (profile.campuses) assignCampuses();
|
||||
buildAnalytics();
|
||||
|
||||
const cleared = await prisma.$transaction(async (tx) => {
|
||||
@@ -1311,6 +1873,13 @@ async function main() {
|
||||
for (const model of INSERT_ORDER) {
|
||||
if (rows[model] && rows[model].length) await tx[model].createMany({ data: rows[model] });
|
||||
}
|
||||
if (profile.campuses) await scopeStaff(tx);
|
||||
// Campuses on for the multi-campus demo, off for the others (site settings are kept).
|
||||
await tx.appSetting.upsert({
|
||||
where: { key: 'campuses_enabled' },
|
||||
update: { value: profile.campuses ? 'true' : 'false' },
|
||||
create: { key: 'campuses_enabled', value: profile.campuses ? 'true' : 'false' },
|
||||
});
|
||||
return result;
|
||||
}, { maxWait: 10 * 1000, timeout: 5 * 60 * 1000 });
|
||||
clearScheduledEmails();
|
||||
@@ -1348,7 +1917,17 @@ async function main() {
|
||||
].filter(Boolean).join(', ');
|
||||
console.log(` ${event.startDate.toDateString().padEnd(16)} ${event.title.padEnd(34)} ${String(regs).padStart(3)} registrations, ${String(scans).padStart(3)} check-ins — ${CASHUP_LABELS[cashup.close]}${notes ? ` (${notes})` : ''}`);
|
||||
}
|
||||
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named campers, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
|
||||
console.log(`\nCreated ${count('user')} users, ${count('payment')} payments, ${count('ticket')} tickets, ${count('ticketUsage')} check-ins, ${count('attendee')} named attendees, ${count('feedbackResponse')} feedback responses, ${count('analyticsEvent') + count('analyticsDaily')} funnel rows.`);
|
||||
const extras = [
|
||||
count('campus') && `${count('campus')} campuses (on), 2 campus-limited staff`,
|
||||
count('eventSeries') && `${count('eventSeries')} series with ${count('seriesFollow')} followers, ${count('seasonPassPurchase')} season passes sold`,
|
||||
count('eventSession') && `${count('eventSession')} sessions`,
|
||||
count('certificate') && `${count('certificate')} certificates`,
|
||||
count('family') && `${count('family')} families, ${count('child')} children, ${count('kidsCheckIn')} kids check-ins`,
|
||||
count('servingRole') && `${count('servingRole')} serving roles, ${count('volunteerSlot')} volunteers rostered, ${count('volunteerSwap')} cover request`,
|
||||
].filter(Boolean);
|
||||
if (extras.length) console.log(`Also: ${extras.join('; ')}.`);
|
||||
console.log(`Campuses are ${profile.campuses ? 'ON' : 'off'} — restart the backend so it picks the setting up.`);
|
||||
console.log(`\nKept: site settings and ${admin.email}.`);
|
||||
console.log(`Demo staff logins (password ${DEMO_PASSWORD}):`);
|
||||
for (const s of staffTeam) console.log(` ${s.role.padEnd(10)} ${s.email}`);
|
||||
|
||||
@@ -6,8 +6,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
// @access Admin
|
||||
const listAuditLog = async (req, res) => {
|
||||
try {
|
||||
const { page, limit, actorId, action, from, to } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
|
||||
const { page, limit, actorId, action, role, from, to, search } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, role, from, to, search });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
|
||||
@@ -1602,6 +1602,9 @@ const emailEventAttendees = async (req, res) => {
|
||||
// Prefer server-side filtering when possible
|
||||
where.userId = { in: attendeeIds };
|
||||
}
|
||||
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
|
||||
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
|
||||
if (registrationIds.length > 0) where.id = { in: registrationIds };
|
||||
|
||||
// Name/email substring filter (kept for backward compatibility)
|
||||
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
|
||||
@@ -1870,6 +1873,9 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
if (attendeeIds.length > 0) {
|
||||
where.userId = { in: attendeeIds };
|
||||
}
|
||||
// Bulk actions (1.49.1): exactly the registrations ticked on the registrations list.
|
||||
const registrationIds = Array.isArray(filter?.registrationIds) ? filter.registrationIds.filter((x) => typeof x === 'string' && x.trim()).slice(0, 2000) : [];
|
||||
if (registrationIds.length > 0) where.id = { in: registrationIds };
|
||||
|
||||
const nameQ = (filter && filter.name) ? String(filter.name).trim() : '';
|
||||
const phoneQ = (filter && filter.phone) ? String(filter.phone).trim() : '';
|
||||
|
||||
@@ -154,6 +154,9 @@ const test = async (req, res) => {
|
||||
const deliveries = async (req, res) => {
|
||||
try {
|
||||
const where = { endpointId: req.params.id, ...(req.query.status ? { status: String(req.query.status) } : {}) };
|
||||
// "Load older": the next page is everything before the last one shown.
|
||||
const before = req.query.before ? new Date(String(req.query.before)) : null;
|
||||
if (before && !Number.isNaN(before.getTime())) where.createdAt = { lt: before };
|
||||
const rows = await prisma.webhookDelivery.findMany({
|
||||
where, orderBy: { createdAt: 'desc' }, take: Math.min(200, parseInt(req.query.limit, 10) || 50),
|
||||
select: { id: true, eventId: true, type: true, status: true, attempts: true, nextAttemptAt: true, lastStatusCode: true, lastError: true, deliveredAt: true, createdAt: true, payload: true },
|
||||
|
||||
@@ -354,7 +354,15 @@ const getPayments = async (req, res) => {
|
||||
// Campus-scoped supervisors: payments for events they can see (through the payment's event or
|
||||
// its registration's), so unlinked donations aren't listed for them.
|
||||
const scopeW = require('../utils/campusScope').eventWhere(await require('../utils/campusScope').scopeFor(req));
|
||||
const where = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
const scopeWhere = Object.keys(scopeW).length ? { OR: [{ event: scopeW }, { registration: { event: scopeW } }] } : {};
|
||||
// Narrowing filters: ?userId= (one person's), ?from= (on or after, e.g. today's start),
|
||||
// ?donations=1 (donations and the legs that apply them to registrations).
|
||||
const filters = [];
|
||||
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
|
||||
const from = req.query.from ? new Date(String(req.query.from)) : null;
|
||||
if (from && !Number.isNaN(from.getTime())) filters.push({ createdAt: { gte: from } });
|
||||
if (['1', 'true'].includes(String(req.query.donations || ''))) filters.push({ OR: [{ isDonation: true }, { originalPaymentId: { not: null } }] });
|
||||
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
|
||||
const [payments, total] = await prisma.$transaction([
|
||||
prisma.payment.findMany({ where, include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
||||
prisma.payment.count({ where })
|
||||
|
||||
@@ -227,12 +227,18 @@ const listRequests = async (req, res) => {
|
||||
const where = { selfService: false };
|
||||
if (status === 'open') where.status = 'open';
|
||||
else if (status === 'closed') where.status = { not: 'open' };
|
||||
const rows = await prisma.dataRequest.findMany({
|
||||
const q = String(req.query.search || '').trim().slice(0, 100);
|
||||
if (q) where.OR = [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }];
|
||||
// Closed requests pile up over the years, so they come a page at a time (newest first).
|
||||
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const [rows, total] = await Promise.all([prisma.dataRequest.findMany({
|
||||
where,
|
||||
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
|
||||
take: 200,
|
||||
take: limit,
|
||||
skip: (page - 1) * limit,
|
||||
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
|
||||
});
|
||||
}), prisma.dataRequest.count({ where })]);
|
||||
const requests = await Promise.all(rows.map(async r => ({
|
||||
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
|
||||
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
|
||||
@@ -240,8 +246,8 @@ const listRequests = async (req, res) => {
|
||||
overdue: r.status === 'open' && r.dueAt < new Date(),
|
||||
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
|
||||
})));
|
||||
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount });
|
||||
const openCount = status === 'open' && !q ? total : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
|
||||
res.json({ requests, openCount, total, page, limit, pages: Math.max(1, Math.ceil(total / limit)) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
|
||||
@@ -569,7 +569,13 @@ const getRegistrations = async (req, res) => {
|
||||
// array of every registration) working unchanged; callers that pass either param get back
|
||||
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
|
||||
// Campus-scoped staff: registrations for events they can see.
|
||||
const where = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
const scopeWhere = await require('../utils/campusScope').listWhere(req, 'event');
|
||||
// Narrowing filters, so callers needn't download every registration ever made:
|
||||
// ?unpaid=1 — still owing (not paid or cancelled); ?userId= — one person's.
|
||||
const filters = [];
|
||||
if (['1', 'true'].includes(String(req.query.unpaid || ''))) filters.push({ status: { in: ['pending', 'confirmed', 'partial_paid'] } });
|
||||
if (req.query.userId) filters.push({ userId: String(req.query.userId) });
|
||||
const where = filters.length ? { AND: [scopeWhere, ...filters] } : scopeWhere;
|
||||
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
||||
const registrations = await prisma.registration.findMany({ where, include });
|
||||
return res.json(attachComputedTotalsToList(registrations));
|
||||
|
||||
+18
-1
@@ -188,6 +188,8 @@ app.use('/api/volunteers', require('./routes/volunteerRoutes'));
|
||||
app.use('/api/series', require('./routes/seriesRoutes'));
|
||||
app.use('/api/season-passes', require('./routes/seasonPassRoutes'));
|
||||
app.use('/api/outbound-webhooks', require('./routes/outboundWebhookRoutes'));
|
||||
app.use('/api/dashboard', require('./routes/dashboardRoutes'));
|
||||
app.use('/api/saved-filters', require('./routes/savedFilterRoutes'));
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/registrations', registrationRoutes);
|
||||
@@ -681,6 +683,8 @@ app.get('/docs', async (req, res) => {
|
||||
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
|
||||
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
|
||||
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
|
||||
{ method:'POST', path:'/api/registrations/bulk', auth:'supervisor+', desc:'Bulk actions on ticked registrations (up to 500), each run through the same code as doing it one at a time: action "markPaid" { method: cash|eft|card|other, paidAt? } records the balance; "cancel"; "moveOption" { fromOptionId, toOptionId } (same event); "allocate" { setId, groupId } places their named people; "message" { channel: email|whatsapp, subject?, text } sends an event update. Answers { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }. Campus-checked for every id; payments and ticket changes refused in licence read-only mode. Audited as registrations_bulk',
|
||||
request:{ body:{ action:'markPaid', registrationIds:['reg-uuid-1','reg-uuid-2'], method:'cash' } } },
|
||||
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
|
||||
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
|
||||
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
|
||||
@@ -700,7 +704,7 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Impersonation', base: '/api/admin', endpoints: [
|
||||
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
|
||||
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, role, from, to, search)' },
|
||||
{ method:'GET', path:'/api/admin/jobs', auth:'admin', desc:'Background jobs: queue mode, timezone, and each job schedule, next run and last 10 runs' },
|
||||
{ method:'POST', path:'/api/admin/jobs/:name/run', auth:'admin', desc:'Run a background job now (queued for a worker); audit-logged' },
|
||||
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
|
||||
@@ -1012,6 +1016,19 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/series/:id/compare', auth:'supervisor+', desc:'Year on year: registrations, tickets, income (as on the dashboard), checked in (average per session on multi-session events) and attendance rate per event in the series, oldest first, with the % change from the one before. Campus-scoped staff: their campuses\x27 events only' },
|
||||
]},
|
||||
|
||||
{ title: 'Saved filters', base: '/api/saved-filters', endpoints: [
|
||||
{ method:'GET', path:'/api/saved-filters?page=', auth:'staff+', desc:'My saved filters for a list page (registrations, users, events, reports), default first' },
|
||||
{ method:'POST', path:'/api/saved-filters', auth:'staff+', desc:'Save (or replace by name) a filter: { page, name, params, isDefault? }. Up to 30 per page; making one the default clears the old default',
|
||||
request:{ body:{ page:'registrations', name:'Camp — unpaid', params:{ event:'ev-uuid', status:'pending' }, isDefault:false } } },
|
||||
{ method:'PUT', path:'/api/saved-filters/:id', auth:'staff+', desc:'Rename it, or make it (or stop it being) my default — my own only' },
|
||||
{ method:'DELETE', path:'/api/saved-filters/:id', auth:'staff+', desc:'Delete one of my saved filters' },
|
||||
]},
|
||||
|
||||
{ title: 'Dashboard and command palette', base: '/api/dashboard', endpoints: [
|
||||
{ method:'GET', path:'/api/dashboard/today', auth:'staff+', desc:'Events on today (or with a session today): places, people checked in today, children in now, volunteers rostered/checked in. Supervisors and admins also get money in (today, last 7 days) and "waiting" items: unpaid registrations for the next two weeks, parental consent, waitlist, cover requests, roster gaps, and for admins cashups due, provider payments to reconcile, privacy requests, failed webhooks. Campus-scoped' },
|
||||
{ method:'GET', path:'/api/dashboard/search?q=', auth:'staff+', desc:'Command palette search: events; supervisors also payments by amount ("R450"); admins also people and registrations by reference. { results: [{ type, id, title, subtitle, href }] }' },
|
||||
]},
|
||||
|
||||
{ title: 'Outbound webhooks', base: '/api/outbound-webhooks', endpoints: [
|
||||
{ method:'GET', path:'/api/outbound-webhooks', auth:'admin', desc:'Endpoints (with last-week delivery counts per status) and the event types: registration.created, registration.paid, registration.cancelled, payment.received, payment.refunded, ticket.checked_in, user.created, event.published' },
|
||||
{ method:'POST', path:'/api/outbound-webhooks', auth:'admin', desc:'Add an endpoint (https, public address). The answer holds the signing secret — shown only now. Also added to the POPIA operator register. Audited as webhook_endpoint_changed',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { scopeList } = require('../utils/campusScope');
|
||||
const dashboard = require('../utils/dashboard');
|
||||
|
||||
// Both follow the person's campus scope inside utils/dashboard.js.
|
||||
|
||||
// @desc Today's events and how they're going, money in, and what's waiting (1.49.0)
|
||||
// @route GET /api/dashboard/today
|
||||
// @access Private/Staff (money and waiting items for supervisors and admins)
|
||||
router.get('/today', protect, staff, scopeList, async (req, res) => {
|
||||
try { res.json(await dashboard.today(req)); } catch (e) { res.status(400).json({ message: e.message }); }
|
||||
});
|
||||
|
||||
// @desc Command palette search (?q=): events; supervisors also payments by amount; admins people and registrations
|
||||
// @route GET /api/dashboard/search
|
||||
// @access Private/Staff
|
||||
router.get('/search', protect, staff, scopeList, async (req, res) => {
|
||||
try { res.json({ results: await dashboard.search(req, req.query.q) }); } catch (e) { res.status(400).json({ message: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -17,7 +17,7 @@ const {
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
const { scopeEvent, scopeList } = require('../utils/campusScope');
|
||||
const { scopeEvent, scopeList, scopeBody } = require('../utils/campusScope');
|
||||
const { notOnSeasonPass } = require('../utils/seasonPass');
|
||||
|
||||
// Campus-scoped staff (utils/campusScope.js). Routes open to the registration's owner as well
|
||||
@@ -46,5 +46,9 @@ router.get('/', protect, staff, scopeList, getRegistrations);
|
||||
router.put('/:id', protect, staff, scopeEvent({ resolve: 'registration' }), updateRegistrationStatus);
|
||||
router.get('/event/:eventId', protect, staff, scopeEvent(), getRegistrationsByEvent);
|
||||
router.post('/manual', protect, supervisor, scopeEvent({ from: req => req.body?.eventId }), createManualRegistration);
|
||||
// Bulk actions (1.49.1): every ticked registration must be one this person may manage.
|
||||
router.post('/bulk', protect, supervisor, scopeBody([['registrationIds', 'registration']], { orphan: 'deny' }), async (req, res) => {
|
||||
try { res.json(await require('../utils/bulkRegistrations').runBulk(req, req.body || {})); } catch (e) { res.status(e.statusCode || 400).json({ message: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { protect, staff } = require('../middleware/authMiddleware');
|
||||
const { campusIndependent } = require('../utils/campusScope');
|
||||
const filters = require('../utils/savedFilters');
|
||||
|
||||
const OWN = campusIndependent('a person\'s own saved filters (the lists they filter are campus-checked themselves)');
|
||||
const fail = (res, e) => res.status(e.statusCode || 400).json({ message: e.message });
|
||||
|
||||
// Saved filters on list pages (1.49.2) — always the signed-in person's own.
|
||||
router.get('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.list(req.user.id, req.query.page)); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.post('/', protect, staff, OWN, async (req, res) => {
|
||||
try { res.status(201).json(await filters.save(req.user.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.put('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { res.json(await filters.update(req.user.id, req.params.id, req.body || {})); } catch (e) { fail(res, e); }
|
||||
});
|
||||
router.delete('/:id', protect, staff, OWN, async (req, res) => {
|
||||
try { await filters.remove(req.user.id, req.params.id); res.json({ ok: true }); } catch (e) { fail(res, e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -21,16 +21,44 @@ async function logAdminAction({ actorId, actorRole, action, targetType, targetId
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// A date (YYYY-MM-DD) means midnight in the server's time zone (APP_TIMEZONE), not UTC.
|
||||
const startOf = (v) => new Date(DATE_ONLY.test(v) ? `${v}T00:00:00` : v);
|
||||
|
||||
// The Prisma filter for the audit-log page. Dates (YYYY-MM-DD) are whole days, so `to`
|
||||
// includes that day. `search` matches the person (name or email), the target type or id,
|
||||
// or the IP.
|
||||
function auditLogWhere({ actorId, action, role, from, to, search } = {}) {
|
||||
const where = {};
|
||||
if (actorId) where.actorId = actorId;
|
||||
if (action) where.action = action;
|
||||
if (role) where.actorRole = role;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
if (from) where.createdAt.gte = startOf(from);
|
||||
if (to) {
|
||||
if (DATE_ONLY.test(to)) where.createdAt.lt = new Date(startOf(to).getTime() + DAY_MS);
|
||||
else where.createdAt.lte = new Date(to);
|
||||
}
|
||||
}
|
||||
const q = String(search || '').trim().slice(0, 100);
|
||||
if (q) {
|
||||
const contains = { contains: q, mode: 'insensitive' };
|
||||
where.OR = [
|
||||
{ actor: { name: contains } },
|
||||
{ actor: { email: contains } },
|
||||
{ targetType: contains },
|
||||
{ targetId: { startsWith: q.replace(/^#/, '') } },
|
||||
{ ip: { startsWith: q } },
|
||||
];
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, ...filters } = {}) {
|
||||
const where = auditLogWhere(filters);
|
||||
|
||||
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
|
||||
@@ -49,4 +77,4 @@ async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, t
|
||||
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
|
||||
}
|
||||
|
||||
module.exports = { logAdminAction, getAdminAuditLog };
|
||||
module.exports = { logAdminAction, getAdminAuditLog, auditLogWhere };
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Bulk actions on registrations (1.49.1): the registrations list lets a supervisor or admin tick
|
||||
* many and act on them at once — mark paid, cancel, move to another ticket, put in a room or
|
||||
* group, or send a message.
|
||||
*
|
||||
* Each action runs the same code as doing it one at a time (the controllers' own handlers, called
|
||||
* in-process), so every rule still applies: closed events, payment caps and overpayments becoming
|
||||
* donations, tickets and emails on payment, coupons released and the waitlist offered on
|
||||
* cancelling, pricing on a ticket change, room capacity and gender, and the privacy rules and
|
||||
* footers on messages. Results come back per registration; one failing doesn't stop the rest.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const MAX = 500;
|
||||
const ACTIONS = ['markPaid', 'cancel', 'moveOption', 'allocate', 'message'];
|
||||
const METHODS = ['cash', 'eft', 'card', 'other'];
|
||||
|
||||
function bulkError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Calls an Express handler in-process; resolves to { ok, status, body }. */
|
||||
function call(handler, req) {
|
||||
return new Promise((resolve) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
|
||||
send(body) { resolve({ ok: this.statusCode < 400, status: this.statusCode, body }); return this; },
|
||||
};
|
||||
Promise.resolve(handler({ query: {}, params: {}, body: {}, headers: {}, ip: req.ip, ...req }, res))
|
||||
.catch(e => resolve({ ok: false, status: 500, body: { message: e?.message || String(e) } }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Who is asking, with their request's headers and socket (so audit entries keep the real IP). */
|
||||
const from = req => ({ user: req.user, headers: req.headers || {}, socket: req.socket });
|
||||
|
||||
const messageOf = r => r.body?.message || (r.ok ? 'Done' : `Failed (${r.status})`);
|
||||
|
||||
async function markPaid(req, regs, { method, paidAt }) {
|
||||
if (!METHODS.includes(method)) throw bulkError('Choose how it was paid (cash, EFT, card or other)');
|
||||
const { createPayment } = require('../controllers/paymentController');
|
||||
const { refreshPricingForRegistration, computeRegistrationTotalDue } = require('./pricing');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled — not paid' }); continue; }
|
||||
try { await refreshPricingForRegistration(r.id); } catch { /* the payment code refreshes too */ }
|
||||
const fresh = await prisma.registration.findUnique({ where: { id: r.id }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } }, payments: true } });
|
||||
const owed = Math.round((computeRegistrationTotalDue(fresh, new Date()) - fresh.payments.reduce((s, p) => s + p.amount, 0)) * 100) / 100;
|
||||
if (owed <= 0) { out.push({ id: r.id, ok: true, skipped: true, message: 'Already paid' }); continue; }
|
||||
const res = await call(createPayment, { ...from(req), body: { amount: owed, method, registrationId: r.id, userId: r.userId, ...(paidAt ? { paidAt } : {}) } });
|
||||
out.push({ id: r.id, ok: res.ok, message: res.ok ? `R${owed.toFixed(2)} recorded` : messageOf(res) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function cancel(req, regs) {
|
||||
const { cancelRegistration } = require('../controllers/registrationController');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
if (r.status === 'cancelled') { out.push({ id: r.id, ok: true, skipped: true, message: 'Already cancelled' }); continue; }
|
||||
const res = await call(cancelRegistration, { ...from(req), params: { id: r.id } });
|
||||
out.push({ id: r.id, ok: res.ok, message: res.ok ? 'Cancelled' : messageOf(res) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves every place on `fromOptionId` to `toOptionId`, keeping the same lines, tickets (and their
|
||||
* QR codes) and named people — the editing screen can't take issued tickets away, but a move
|
||||
* doesn't need to. Each moved line is repriced at the new ticket's current price (early birds and
|
||||
* staff prices as for a new registration); the status follows what's now paid against owed, so
|
||||
* moving up shows the difference owing and moving down shows the overpayment. Places on the new
|
||||
* ticket are checked first.
|
||||
*/
|
||||
async function moveOption(req, regs, { fromOptionId, toOptionId }) {
|
||||
if (!fromOptionId || !toOptionId || fromOptionId === toOptionId) throw bulkError('Choose the ticket to move from and the one to move to');
|
||||
const options = await prisma.eventOption.findMany({ where: { id: { in: [fromOptionId, toOptionId] } }, include: { earlyBirdTiers: true, event: { select: { id: true, cashupStatus: true, kidsOptionId: true, volunteerOptionId: true } } } });
|
||||
if (options.length !== 2 || options[0].eventId !== options[1].eventId) throw bulkError('Both tickets must belong to the same event');
|
||||
const target = options.find(o => o.id === toOptionId);
|
||||
const event = target.event;
|
||||
if (event.cashupStatus === 'closed') throw bulkError('This event is closed. Reopen it (admin only) before making changes.');
|
||||
if ([event.kidsOptionId, event.volunteerOptionId].includes(toOptionId)) throw bulkError('The kids and volunteer tickets are handed out by staff, not moved to');
|
||||
const { resolveOptionPrice } = require('./pricing');
|
||||
const { remainingFor } = require('./stock');
|
||||
const { updateRegistrationStatus } = require('../payments/webhook');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
if (r.eventId !== event.id) { out.push({ id: r.id, ok: false, message: 'Not for this event' }); continue; }
|
||||
if (r.status === 'cancelled') { out.push({ id: r.id, ok: false, message: 'Cancelled' }); continue; }
|
||||
if (r.seasonPassPurchaseId) { out.push({ id: r.id, ok: false, message: 'Part of a season pass — tickets can\'t be changed' }); continue; }
|
||||
const lines = await prisma.registrationOption.findMany({ where: { registrationId: r.id, eventOptionId: fromOptionId, sessionId: null }, select: { id: true, quantity: true } });
|
||||
if (!lines.length) { out.push({ id: r.id, ok: true, skipped: true, message: 'No such ticket to move' }); continue; }
|
||||
const qty = lines.reduce((s, l) => s + l.quantity, 0);
|
||||
const left = await remainingFor(target, null);
|
||||
if (left !== null && left < qty) { out.push({ id: r.id, ok: false, message: `Only ${left} place${left === 1 ? '' : 's'} left on ${target.name}` }); continue; }
|
||||
try {
|
||||
const owner = await prisma.user.findUnique({ where: { id: r.userId }, select: { role: true } });
|
||||
const { price, tierId } = await resolveOptionPrice(target, qty, owner);
|
||||
await prisma.$transaction(async tx => {
|
||||
for (const line of lines) {
|
||||
await tx.registrationOption.update({ where: { id: line.id }, data: { eventOptionId: toOptionId, variantId: null, priceSnapshot: price, appliedTierId: tierId || null } });
|
||||
await tx.registrationOptionTranche.deleteMany({ where: { registrationOptionId: line.id } });
|
||||
await tx.registrationOptionTranche.create({ data: { registrationOptionId: line.id, quantity: line.quantity, priceSnapshot: price, appliedTierId: tierId || null } });
|
||||
await tx.attendee.updateMany({ where: { registrationOptionId: line.id }, data: { eventOptionId: toOptionId, variantId: null } });
|
||||
}
|
||||
});
|
||||
const updated = await updateRegistrationStatus(r.id);
|
||||
const diff = Math.round(((updated.totalDue || 0) - (updated.totalPaid || 0)) * 100) / 100;
|
||||
out.push({ id: r.id, ok: true, message: diff > 0 ? `Moved — R${diff.toFixed(2)} now owing` : diff < 0 ? `Moved — R${(-diff).toFixed(2)} overpaid` : 'Moved' });
|
||||
} catch (e) {
|
||||
out.push({ id: r.id, ok: false, message: e?.message || 'Couldn\'t move' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function allocate(req, regs, { setId, groupId }) {
|
||||
if (!setId || !groupId) throw bulkError('Choose the group to put them in');
|
||||
const { assign } = require('../controllers/allocationController');
|
||||
const out = [];
|
||||
for (const r of regs) {
|
||||
const people = await prisma.attendee.findMany({ where: { registrationId: r.id }, select: { id: true, name: true } });
|
||||
if (!people.length) { out.push({ id: r.id, ok: false, message: 'No named people on this registration to place' }); continue; }
|
||||
const fails = [];
|
||||
for (const a of people) {
|
||||
const res = await call(assign, { ...from(req), params: { id: setId }, body: { attendeeId: a.id, groupId } });
|
||||
if (!res.ok) fails.push(`${a.name}: ${messageOf(res)}`);
|
||||
}
|
||||
out.push({ id: r.id, ok: fails.length === 0, message: fails.length ? fails.join('; ') : `${people.length} placed` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function message(req, regs, { channel, subject, text }) {
|
||||
if (!['email', 'whatsapp'].includes(channel)) throw bulkError('Choose email or WhatsApp');
|
||||
if (!String(text || '').trim()) throw bulkError('Write the message');
|
||||
if (channel === 'email' && !String(subject || '').trim()) throw bulkError('Give the email a subject');
|
||||
const { emailEventAttendees, whatsappEventAttendees } = require('../controllers/eventController');
|
||||
const byEvent = new Map();
|
||||
for (const r of regs) byEvent.set(r.eventId, [...(byEvent.get(r.eventId) || []), r.id]);
|
||||
const out = [];
|
||||
for (const [eventId, ids] of byEvent) {
|
||||
// An event update to the people ticked (they registered, so it goes even to those who opted out of news).
|
||||
const body = channel === 'email'
|
||||
? { subject, text, kind: 'update', filter: { registrationIds: ids } }
|
||||
: { message: text, kind: 'update', filter: { registrationIds: ids } };
|
||||
const res = await call(channel === 'email' ? emailEventAttendees : whatsappEventAttendees, { ...from(req), params: { id: eventId }, body });
|
||||
for (const id of ids) out.push({ id, ok: res.ok, message: res.ok ? `Sent with ${res.body?.sent ?? res.body?.matched ?? ids.length} for this event` : messageOf(res) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one bulk action. Returns { action, done, skipped, failed, results: [{ id, ok, skipped?, message }] }.
|
||||
*/
|
||||
async function runBulk(req, { action, registrationIds, ...params }) {
|
||||
if (!ACTIONS.includes(action)) throw bulkError('Unknown bulk action');
|
||||
const ids = [...new Set((Array.isArray(registrationIds) ? registrationIds : []).map(String))];
|
||||
if (!ids.length) throw bulkError('Tick at least one registration');
|
||||
if (ids.length > MAX) throw bulkError(`At most ${MAX} at a time`);
|
||||
// Read-only licence: no new payments or ticket changes (cancelling and messaging still work).
|
||||
if (['markPaid', 'moveOption'].includes(action)) {
|
||||
const { licenceStatus } = require('../hub/licence');
|
||||
const status = await licenceStatus().catch(() => null);
|
||||
if (status?.readOnly) throw bulkError('This site is read-only (licence), so payments and ticket changes can\'t be recorded.', 403);
|
||||
}
|
||||
const regs = await prisma.registration.findMany({ where: { id: { in: ids } }, include: { registrationOptions: { select: { eventOptionId: true, variantId: true, quantity: true, sessionId: true } } } });
|
||||
const missing = ids.filter(id => !regs.some(r => r.id === id)).map(id => ({ id, ok: false, message: 'Not found' }));
|
||||
const run = { markPaid, cancel, moveOption, allocate, message }[action];
|
||||
const results = [...missing, ...(await run(req, regs, params))];
|
||||
const { logAdminAction } = require('./adminAudit');
|
||||
const { getClientIp } = require('./requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'registrations_bulk', targetType: 'Registration',
|
||||
metadata: { bulk: action, registrations: ids.length, done: results.filter(r => r.ok && !r.skipped).length, failed: results.filter(r => !r.ok).length },
|
||||
ip: getClientIp({ headers: {}, ...req }),
|
||||
});
|
||||
return {
|
||||
action,
|
||||
done: results.filter(r => r.ok && !r.skipped).length,
|
||||
skipped: results.filter(r => r.skipped).length,
|
||||
failed: results.filter(r => !r.ok).length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { runBulk, ACTIONS, MAX };
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* The home dashboard for staff, supervisors and admins (1.49.0): what's on today, money in, and
|
||||
* what's waiting for someone — plus the search behind the Ctrl+K command palette.
|
||||
*
|
||||
* Everything follows the person's campus scope (utils/campusScope.js): a campus-limited
|
||||
* supervisor sees their campuses' events and church-wide ones. Items only some roles can act on
|
||||
* (cashups, provider payments, privacy requests, webhooks) are shown to those roles only.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { scopeFor, eventWhere } = require('./campusScope');
|
||||
|
||||
const DAY = 864e5;
|
||||
// The same "real money" as the dashboards: everything except legs that apply an earlier donation.
|
||||
const EXCLUDE_DONATION_LEGS = { NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] } };
|
||||
|
||||
function dayBounds(now = new Date()) {
|
||||
const start = new Date(now); start.setHours(0, 0, 0, 0);
|
||||
return { start, end: new Date(start.getTime() + DAY - 1) };
|
||||
}
|
||||
|
||||
const isAdmin = req => req.user?.role === 'admin';
|
||||
const isSupervisor = req => ['admin', 'supervisor'].includes(req.user?.role);
|
||||
|
||||
/** Events on today (single-date events running today, or a session today), with how it's going. */
|
||||
async function todaysEvents(scopeWhere, now = new Date()) {
|
||||
const { start, end } = dayBounds(now);
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
AND: [scopeWhere, { isActive: true }, {
|
||||
OR: [
|
||||
{ scheduleType: 'single', startDate: { lte: end }, endDate: { gte: start } },
|
||||
{ scheduleType: 'sessions', sessions: { some: { startsAt: { gte: start, lte: end }, cancelledAt: null } } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
orderBy: { startDate: 'asc' },
|
||||
select: {
|
||||
id: true, title: true, startDate: true, endDate: true, location: true, scheduleType: true, kidsCheckIn: true, isHidden: true,
|
||||
campus: { select: { id: true, name: true, slug: true, colour: true } },
|
||||
sessions: { where: { startsAt: { gte: start, lte: end }, cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } },
|
||||
},
|
||||
});
|
||||
return Promise.all(events.map(async e => {
|
||||
const session = e.sessions[0] || null;
|
||||
const [places, scans, kidsIn, roles] = await Promise.all([
|
||||
prisma.ticket.aggregate({ where: { eventId: e.id, registrationOption: { registration: { status: { not: 'cancelled' } } } }, _sum: { quantity: true } }),
|
||||
prisma.ticketUsage.aggregate({ where: { ticket: { eventId: e.id }, scannedAt: { gte: start, lte: end } }, _sum: { quantityRedeemed: true } }),
|
||||
e.kidsCheckIn ? prisma.kidsCheckIn.count({ where: { eventId: e.id, checkedOutAt: null, checkedInAt: { gte: start, lte: end } } }) : Promise.resolve(null),
|
||||
prisma.servingRole.findMany({
|
||||
where: { eventId: e.id, OR: [{ sessionId: null }, ...(session ? [{ sessionId: session.id }] : [])] },
|
||||
select: { needed: true, slots: { select: { checkedInAt: true } } },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
id: e.id, title: e.title, location: e.location, campus: e.campus, isHidden: e.isHidden, kidsCheckIn: e.kidsCheckIn,
|
||||
startsAt: session ? session.startsAt : e.startDate, endsAt: session ? session.endsAt : e.endDate,
|
||||
sessionLabel: session ? session.label : null,
|
||||
places: places._sum.quantity || 0,
|
||||
checkedIn: scans._sum.quantityRedeemed || 0,
|
||||
kidsIn,
|
||||
volunteers: roles.length ? {
|
||||
needed: roles.reduce((s, r) => s + r.needed, 0),
|
||||
rostered: roles.reduce((s, r) => s + r.slots.length, 0),
|
||||
checkedIn: roles.reduce((s, r) => s + r.slots.filter(x => x.checkedInAt).length, 0),
|
||||
} : null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/** Money in today and over the last 7 days (the person's events, or everything for the unscoped). */
|
||||
async function moneyIn(scope, now = new Date()) {
|
||||
const { start } = dayBounds(now);
|
||||
const weekStart = new Date(start.getTime() - 6 * DAY);
|
||||
const where = from => ({ ...EXCLUDE_DONATION_LEGS, createdAt: { gte: from }, ...(scope ? { event: eventWhere(scope) } : {}) });
|
||||
const [today, week] = await Promise.all([
|
||||
prisma.payment.aggregate({ where: where(start), _sum: { amount: true }, _count: { _all: true } }),
|
||||
prisma.payment.aggregate({ where: where(weekStart), _sum: { amount: true } }),
|
||||
]);
|
||||
return { today: today._sum.amount || 0, todayCount: today._count._all, week: week._sum.amount || 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* What's waiting for someone: each { key, label, count, href, tone }, only those with a count.
|
||||
* `href` takes the person to where it's dealt with.
|
||||
*/
|
||||
async function pendingItems(req, scope, now = new Date()) {
|
||||
const upcoming = { startDate: { gte: now } };
|
||||
const scoped = (extra = {}) => ({ AND: [eventWhere(scope), extra] });
|
||||
const soon = new Date(now.getTime() + 14 * DAY);
|
||||
const week = new Date(now.getTime() + 7 * DAY);
|
||||
const items = [];
|
||||
const push = async (key, fn) => {
|
||||
try {
|
||||
const item = await fn();
|
||||
if (item && item.count > 0) items.push({ key, ...item });
|
||||
} catch (e) {
|
||||
console.warn(`[dashboard] ${key}:`, e?.message || e);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
push('unpaid', async () => ({
|
||||
label: 'Unpaid or part-paid registrations for events in the next two weeks',
|
||||
count: await prisma.registration.count({ where: { status: { in: ['pending', 'partial_paid'] }, event: scoped({ startDate: { gte: now, lte: soon }, isActive: true }) } }),
|
||||
href: isAdmin(req) ? '/dashboard/admin/registrations?status=pending' : '/dashboard/supervisor/reports?report=regStatus',
|
||||
tone: 'amber',
|
||||
})),
|
||||
push('consent', async () => {
|
||||
const pending = await prisma.consentRequest.findMany({ where: { status: 'pending', event: scoped(upcoming) }, select: { attendeeId: true }, distinct: ['attendeeId'] });
|
||||
const given = new Set((await prisma.consentRequest.findMany({ where: { status: 'given', attendeeId: { in: pending.map(p => p.attendeeId) } }, select: { attendeeId: true } })).map(g => g.attendeeId));
|
||||
return { label: 'Young people still waiting for parental consent', count: pending.filter(p => !given.has(p.attendeeId)).length, href: '/dashboard/supervisor/consent', tone: 'rose' };
|
||||
}),
|
||||
push('waitlist', async () => ({
|
||||
label: 'People on a waitlist',
|
||||
count: await prisma.waitlistEntry.count({ where: { status: 'waiting', event: scoped(upcoming) } }),
|
||||
href: '/dashboard/supervisor/waitlist',
|
||||
tone: 'blue',
|
||||
})),
|
||||
push('cover', async () => ({
|
||||
label: 'Volunteers asking for someone to cover their shift',
|
||||
count: await prisma.volunteerSwap.count({ where: { status: 'open', role: { event: scoped({ endDate: { gte: now } }) } } }),
|
||||
href: '/dashboard/supervisor/volunteers',
|
||||
tone: 'violet',
|
||||
})),
|
||||
push('roster', async () => {
|
||||
const roles = await prisma.servingRole.findMany({
|
||||
where: {
|
||||
event: scoped({ isActive: true }),
|
||||
OR: [{ session: { startsAt: { gte: now, lte: week } } }, { sessionId: null, event: { startDate: { gte: now, lte: week } } }],
|
||||
},
|
||||
select: { needed: true, _count: { select: { slots: true } } },
|
||||
});
|
||||
return { label: 'Serving roles in the next week that still need people', count: roles.filter(r => r._count.slots < r.needed).length, href: '/dashboard/supervisor/volunteers', tone: 'violet' };
|
||||
}),
|
||||
isAdmin(req) && push('cashup', async () => ({
|
||||
label: 'Past events still to cash up',
|
||||
count: await prisma.event.count({ where: { cashupStatus: 'open', endDate: { lt: new Date(now.getTime() - DAY), gte: new Date(now.getTime() - 120 * DAY) }, payments: { some: {} } } }),
|
||||
href: '/dashboard/admin/cashup',
|
||||
tone: 'green',
|
||||
})),
|
||||
(isAdmin(req) || (isSupervisor(req) && !scope)) && push('reconcile', async () => ({
|
||||
label: 'Online payments that didn\'t match a registration (reconcile them)',
|
||||
count: await prisma.providerTransaction.count({ where: { reconciled: false, ignored: false } }),
|
||||
href: '/dashboard/supervisor/payments',
|
||||
tone: 'amber',
|
||||
})),
|
||||
isAdmin(req) && push('privacy', async () => ({
|
||||
label: 'Privacy requests to answer',
|
||||
count: await prisma.dataRequest.count({ where: { status: 'open' } }),
|
||||
href: '/dashboard/admin/data-requests',
|
||||
tone: 'rose',
|
||||
})),
|
||||
isAdmin(req) && push('webhooks', async () => ({
|
||||
label: 'Webhook deliveries that failed this week',
|
||||
count: await prisma.webhookDelivery.count({ where: { status: 'failed', createdAt: { gte: new Date(now.getTime() - 7 * DAY) } } }),
|
||||
href: '/dashboard/admin/settings?tab=webhooks',
|
||||
tone: 'rose',
|
||||
})),
|
||||
].filter(Boolean));
|
||||
const order = ['privacy', 'consent', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks'];
|
||||
return items.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key));
|
||||
}
|
||||
|
||||
async function today(req, now = new Date()) {
|
||||
const scope = await scopeFor(req);
|
||||
const [events, money, pending] = await Promise.all([
|
||||
todaysEvents(eventWhere(scope), now),
|
||||
isSupervisor(req) ? moneyIn(scope, now) : Promise.resolve(null),
|
||||
isSupervisor(req) ? pendingItems(req, scope, now) : Promise.resolve([]),
|
||||
]);
|
||||
return { events, money, pending };
|
||||
}
|
||||
|
||||
// ─── Command palette search ──────────────────────────────────────────────────
|
||||
|
||||
/** "R450", "450", "r 450.50" → 450.5; anything else → null. Pure. */
|
||||
function amountOf(q) {
|
||||
const m = /^r?\s*(\d{1,7}(?:[.,]\d{1,2})?)$/i.exec(String(q || '').trim());
|
||||
return m ? parseFloat(m[1].replace(',', '.')) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Results for the command palette: events (everyone with dashboard access), and for supervisors
|
||||
* payments by amount ("R450"); for admins also people and registrations. Each { type, id, title,
|
||||
* subtitle, href }. Up to 5 of each kind.
|
||||
*/
|
||||
async function search(req, rawQuery, now = new Date()) {
|
||||
const q = String(rawQuery || '').trim().slice(0, 80);
|
||||
if (q.length < 2) return [];
|
||||
const scope = await scopeFor(req);
|
||||
const contains = { contains: q, mode: 'insensitive' };
|
||||
const out = [];
|
||||
const supervisor = isSupervisor(req);
|
||||
const fmtDate = d => new Date(d).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: { AND: [eventWhere(scope), { title: contains }] },
|
||||
orderBy: { startDate: 'desc' }, take: 5,
|
||||
select: { id: true, title: true, startDate: true, isActive: true, campus: { select: { name: true } } },
|
||||
});
|
||||
for (const e of events) {
|
||||
out.push({
|
||||
type: 'event', id: e.id, title: e.title,
|
||||
subtitle: [fmtDate(e.startDate), e.campus?.name, e.isActive ? null : 'inactive'].filter(Boolean).join(' · '),
|
||||
href: supervisor ? `/dashboard/supervisor/events?edit=${e.id}` : `/events/${e.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const amount = amountOf(q);
|
||||
if (supervisor && amount !== null) {
|
||||
const payments = await prisma.payment.findMany({
|
||||
where: { amount: { in: [amount, -amount] }, createdAt: { gte: new Date(now.getTime() - 365 * DAY) }, ...(scope ? { event: eventWhere(scope) } : {}) },
|
||||
orderBy: { createdAt: 'desc' }, take: 5,
|
||||
select: { id: true, amount: true, method: true, createdAt: true, registrationId: true, user: { select: { name: true } }, event: { select: { title: true } } },
|
||||
});
|
||||
for (const p of payments) {
|
||||
out.push({
|
||||
type: 'payment', id: p.id, title: `R${p.amount.toFixed(2)} — ${p.user?.name || 'Unknown'}`,
|
||||
subtitle: [p.method, p.event?.title, fmtDate(p.createdAt)].filter(Boolean).join(' · '),
|
||||
href: isAdmin(req) && p.registrationId ? `/dashboard/admin/registrations?q=${p.registrationId}` : '/dashboard/supervisor/payments',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin(req)) {
|
||||
const people = await prisma.user.findMany({
|
||||
where: { isActive: true, OR: [{ name: contains }, { email: contains }, { phoneNumber: { contains: q.replace(/\s+/g, '') } }] },
|
||||
orderBy: { name: 'asc' }, take: 5,
|
||||
select: { id: true, name: true, email: true, role: true },
|
||||
});
|
||||
for (const u of people) {
|
||||
out.push({ type: 'person', id: u.id, title: u.name, subtitle: [u.email, u.role !== 'user' ? u.role : null].filter(Boolean).join(' · '), href: `/dashboard/admin/users?q=${encodeURIComponent(u.email || u.name)}` });
|
||||
}
|
||||
// A registration by its reference (the start of its id), as on emails and invoices.
|
||||
if (/^[0-9a-f-]{6,36}$/i.test(q)) {
|
||||
const regs = await prisma.registration.findMany({
|
||||
where: { id: { startsWith: q.toLowerCase() } }, take: 5,
|
||||
select: { id: true, status: true, user: { select: { name: true } }, event: { select: { title: true } } },
|
||||
});
|
||||
for (const r of regs) {
|
||||
out.push({ type: 'registration', id: r.id, title: `Registration ${r.id.slice(0, 8)} — ${r.user?.name || ''}`.trim(), subtitle: [r.event?.title, r.status].filter(Boolean).join(' · '), href: `/dashboard/admin/registrations?q=${r.id}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { today, todaysEvents, moneyIn, pendingItems, search, amountOf, dayBounds };
|
||||
@@ -467,6 +467,8 @@ async function anonymiseUser(userId, db = prisma) {
|
||||
await tx.seriesFollow.deleteMany({ where: { userId } });
|
||||
// Webhook deliveries about them (kept 30 days for the delivery log).
|
||||
await tx.webhookDelivery.deleteMany({ where: { userId } });
|
||||
// Their saved list filters (can hold search text, such as names).
|
||||
await tx.savedFilter.deleteMany({ where: { userId } });
|
||||
|
||||
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length, familyDeleted, shiftsGivenUp };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Saved filters (1.49.2): a person names the filters they use on a list page and applies them
|
||||
* with one click; one per page can be their default (applied when the page opens). Stored per
|
||||
* person on the server, so they follow them to any device. Only their own are ever read or changed.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// The list pages that have saved filters, and who can open them.
|
||||
const PAGES = { registrations: 'admin', users: 'admin', events: 'supervisor', reports: 'supervisor' };
|
||||
const MAX_PER_PAGE = 30;
|
||||
const MAX_PARAMS_BYTES = 4000;
|
||||
|
||||
function filterError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function cleanPage(page) {
|
||||
const p = String(page || '');
|
||||
if (!PAGES[p]) throw filterError('Unknown list page');
|
||||
return p;
|
||||
}
|
||||
|
||||
function cleanName(name) {
|
||||
const n = String(name || '').trim().replace(/\s+/g, ' ');
|
||||
if (!n) throw filterError('Give the filter a name');
|
||||
return n.slice(0, 60);
|
||||
}
|
||||
|
||||
/** The filter values: a plain object of strings, numbers, booleans and lists of strings. Pure. */
|
||||
function cleanParams(params) {
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) throw filterError('Nothing to save');
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(k)) continue;
|
||||
if (typeof v === 'string') out[k] = v.slice(0, 500);
|
||||
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
|
||||
else if (typeof v === 'boolean') out[k] = v;
|
||||
else if (Array.isArray(v)) out[k] = v.filter(x => typeof x === 'string').slice(0, 200).map(x => x.slice(0, 100));
|
||||
}
|
||||
if (JSON.stringify(out).length > MAX_PARAMS_BYTES) throw filterError('Too much to save in one filter');
|
||||
return out;
|
||||
}
|
||||
|
||||
const view = f => ({ id: f.id, page: f.page, name: f.name, params: f.params, isDefault: f.isDefault, updatedAt: f.updatedAt });
|
||||
|
||||
async function list(userId, page) {
|
||||
const rows = await prisma.savedFilter.findMany({ where: { userId, page: cleanPage(page) }, orderBy: [{ isDefault: 'desc' }, { name: 'asc' }] });
|
||||
return rows.map(view);
|
||||
}
|
||||
|
||||
/** Saves (or replaces, by name) a filter. Making it the default clears the old default. */
|
||||
async function save(userId, { page, name, params, isDefault = false }) {
|
||||
const p = cleanPage(page);
|
||||
const n = cleanName(name);
|
||||
const data = cleanParams(params);
|
||||
const existing = await prisma.savedFilter.findUnique({ where: { userId_page_name: { userId, page: p, name: n } } });
|
||||
if (!existing && (await prisma.savedFilter.count({ where: { userId, page: p } })) >= MAX_PER_PAGE) {
|
||||
throw filterError(`You can keep up to ${MAX_PER_PAGE} filters on a page — delete one first`);
|
||||
}
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault) await tx.savedFilter.updateMany({ where: { userId, page: p, isDefault: true }, data: { isDefault: false } });
|
||||
const row = existing
|
||||
? await tx.savedFilter.update({ where: { id: existing.id }, data: { params: data, ...(isDefault ? { isDefault: true } : {}) } })
|
||||
: await tx.savedFilter.create({ data: { userId, page: p, name: n, params: data, isDefault: !!isDefault } });
|
||||
return view(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function own(userId, id) {
|
||||
const row = await prisma.savedFilter.findUnique({ where: { id: String(id) } });
|
||||
if (!row || row.userId !== userId) throw filterError('Saved filter not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Rename, or make (or stop being) the default. */
|
||||
async function update(userId, id, { name, isDefault }) {
|
||||
const row = await own(userId, id);
|
||||
return prisma.$transaction(async tx => {
|
||||
if (isDefault === true) await tx.savedFilter.updateMany({ where: { userId, page: row.page, isDefault: true, NOT: { id: row.id } }, data: { isDefault: false } });
|
||||
try {
|
||||
return view(await tx.savedFilter.update({
|
||||
where: { id: row.id },
|
||||
data: { ...(name !== undefined ? { name: cleanName(name) } : {}), ...(isDefault !== undefined ? { isDefault: isDefault === true } : {}) },
|
||||
}));
|
||||
} catch (e) {
|
||||
if (e?.code === 'P2002') throw filterError('You already have a filter with that name on this page');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(userId, id) {
|
||||
const row = await own(userId, id);
|
||||
await prisma.savedFilter.delete({ where: { id: row.id } });
|
||||
}
|
||||
|
||||
module.exports = { PAGES, MAX_PER_PAGE, cleanParams, cleanName, cleanPage, list, save, update, remove };
|
||||
@@ -0,0 +1,40 @@
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
const { auditLogWhere } = require('../src/utils/adminAudit');
|
||||
|
||||
describe('auditLogWhere', () => {
|
||||
test('no filters → everything', () => {
|
||||
expect(auditLogWhere({})).toEqual({});
|
||||
});
|
||||
|
||||
test('a "to" date includes that whole day', () => {
|
||||
const where = auditLogWhere({ from: '2026-10-01', to: '2026-10-28' });
|
||||
expect(where.createdAt.gte).toEqual(new Date('2026-10-01T00:00:00'));
|
||||
expect(where.createdAt.lt).toEqual(new Date('2026-10-29T00:00:00'));
|
||||
expect(where.createdAt.lte).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a full timestamp is used as given', () => {
|
||||
const where = auditLogWhere({ to: '2026-10-28T10:00:00.000Z' });
|
||||
expect(where.createdAt.lte).toEqual(new Date('2026-10-28T10:00:00.000Z'));
|
||||
});
|
||||
|
||||
test('action and role filter exactly', () => {
|
||||
expect(auditLogWhere({ action: 'refund_created', role: 'supervisor' })).toEqual({ action: 'refund_created', actorRole: 'supervisor' });
|
||||
});
|
||||
|
||||
test('search looks at the person, the target and the IP', () => {
|
||||
const where = auditLogWhere({ search: ' #ab12 ' });
|
||||
expect(where.OR).toEqual([
|
||||
{ actor: { name: { contains: '#ab12', mode: 'insensitive' } } },
|
||||
{ actor: { email: { contains: '#ab12', mode: 'insensitive' } } },
|
||||
{ targetType: { contains: '#ab12', mode: 'insensitive' } },
|
||||
{ targetId: { startsWith: 'ab12' } },
|
||||
{ ip: { startsWith: '#ab12' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a blank search is ignored', () => {
|
||||
expect(auditLogWhere({ search: ' ' }).OR).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
const mockDb = {
|
||||
registration: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||
eventOption: { findMany: jest.fn() },
|
||||
attendee: { findMany: jest.fn(), updateMany: jest.fn() },
|
||||
registrationOption: { findMany: jest.fn(), update: jest.fn() },
|
||||
registrationOptionTranche: { deleteMany: jest.fn(), create: jest.fn() },
|
||||
user: { findUnique: jest.fn().mockResolvedValue({ role: "user" }) },
|
||||
$transaction: jest.fn(fn => fn(mockDb)),
|
||||
};
|
||||
const mockLeft = jest.fn().mockResolvedValue(null);
|
||||
jest.mock("../src/utils/stock", () => ({ remainingFor: (...a) => mockLeft(...a) }));
|
||||
jest.mock("../src/utils/pricing", () => ({ resolveOptionPrice: jest.fn().mockResolvedValue({ price: 150, tierId: null }) }));
|
||||
const mockStatus = jest.fn();
|
||||
jest.mock("../src/payments/webhook", () => ({ updateRegistrationStatus: (...a) => mockStatus(...a) }));
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/adminAudit', () => ({ logAdminAction: jest.fn() }));
|
||||
const mockLicence = jest.fn().mockResolvedValue({ readOnly: false });
|
||||
jest.mock('../src/hub/licence', () => ({ licenceStatus: (...a) => mockLicence(...a) }));
|
||||
const mockCancel = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
jest.mock('../src/controllers/registrationController', () => ({
|
||||
cancelRegistration: (req, res) => mockCancel(req, res),
|
||||
updateRegistrationOptions: (req, res) => mockUpdate(req, res),
|
||||
}));
|
||||
const mockAssign = jest.fn();
|
||||
jest.mock('../src/controllers/allocationController', () => ({ assign: (req, res) => mockAssign(req, res) }));
|
||||
const mockEmail = jest.fn();
|
||||
jest.mock('../src/controllers/eventController', () => ({ emailEventAttendees: (req, res) => mockEmail(req, res), whatsappEventAttendees: jest.fn() }));
|
||||
|
||||
const bulk = require('../src/utils/bulkRegistrations');
|
||||
const { isBlocked } = require('../src/hub/readOnly');
|
||||
|
||||
const req = { user: { id: 'sup', role: 'supervisor' } };
|
||||
const reg = (id, extra = {}) => ({ id, userId: `u-${id}`, eventId: 'ev1', status: 'pending', seasonPassPurchaseId: null, registrationOptions: [{ eventOptionId: 'adult', variantId: null, quantity: 1, sessionId: null }], ...extra });
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('the input is checked before anything runs', async () => {
|
||||
await expect(bulk.runBulk(req, { action: 'explode', registrationIds: ['a'] })).rejects.toThrow('Unknown');
|
||||
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: [] })).rejects.toThrow('Tick at least one');
|
||||
await expect(bulk.runBulk(req, { action: 'cancel', registrationIds: Array.from({ length: 501 }, (_, i) => `r${i}`) })).rejects.toThrow('At most 500');
|
||||
});
|
||||
|
||||
test('cancelling runs the normal cancel for each; already-cancelled ones are skipped; a refusal is reported', async () => {
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { status: 'cancelled' }), reg('c')]);
|
||||
mockCancel.mockImplementation((r, res) => (r.params.id === 'c' ? res.status(403).json({ message: 'has payments' }) : res.json({ message: 'Registration cancelled' })));
|
||||
const out = await bulk.runBulk(req, { action: 'cancel', registrationIds: ['a', 'b', 'c', 'gone'] });
|
||||
expect(out).toMatchObject({ done: 1, skipped: 1, failed: 2 });
|
||||
expect(out.results.find(r => r.id === 'c')).toMatchObject({ ok: false, message: 'has payments' });
|
||||
expect(out.results.find(r => r.id === 'gone')).toMatchObject({ ok: false, message: 'Not found' });
|
||||
expect(mockCancel).toHaveBeenCalledTimes(2);
|
||||
expect(mockCancel.mock.calls[0][0].user).toBe(req.user);
|
||||
});
|
||||
|
||||
test('moving keeps the same lines and tickets, repriced; checks places; not other events or season passes', async () => {
|
||||
mockDb.eventOption.findMany.mockResolvedValue([
|
||||
{ id: 'adult', eventId: 'ev1', name: 'Adult', event: { id: 'ev1', cashupStatus: 'open' } },
|
||||
{ id: 'vip', eventId: 'ev1', name: 'VIP', event: { id: 'ev1', cashupStatus: 'open' } },
|
||||
]);
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b', { eventId: 'ev2' }), reg('c', { seasonPassPurchaseId: 'p1' }), reg('d')]);
|
||||
mockDb.registrationOption.findMany.mockResolvedValue([{ id: 'line1', quantity: 1 }]);
|
||||
mockLeft.mockResolvedValueOnce(null).mockResolvedValueOnce(0);
|
||||
mockStatus.mockResolvedValue({ totalDue: 150, totalPaid: 100 });
|
||||
const out = await bulk.runBulk(req, { action: 'moveOption', registrationIds: ['a', 'b', 'c', 'd'], fromOptionId: 'adult', toOptionId: 'vip' });
|
||||
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: true, message: 'Moved — R50.00 now owing' });
|
||||
expect(mockDb.registrationOption.update).toHaveBeenCalledWith({ where: { id: 'line1' }, data: { eventOptionId: 'vip', variantId: null, priceSnapshot: 150, appliedTierId: null } });
|
||||
expect(mockDb.attendee.updateMany).toHaveBeenCalledWith({ where: { registrationOptionId: 'line1' }, data: { eventOptionId: 'vip', variantId: null } });
|
||||
expect(out.results.find(r => r.id === 'b').message).toBe('Not for this event');
|
||||
expect(out.results.find(r => r.id === 'c').message).toMatch(/season pass/);
|
||||
expect(out.results.find(r => r.id === 'd')).toMatchObject({ ok: false, message: 'Only 0 places left on VIP' });
|
||||
});
|
||||
|
||||
test('payments and ticket changes are refused in licence read-only mode; cancelling is not', async () => {
|
||||
mockLicence.mockResolvedValueOnce({ readOnly: true });
|
||||
await expect(bulk.runBulk(req, { action: 'markPaid', registrationIds: ['a'], method: 'cash' })).rejects.toMatchObject({ statusCode: 403 });
|
||||
expect(isBlocked('POST', '/api/registrations/bulk')).toBe(false);
|
||||
});
|
||||
|
||||
test('allocating places each named person; a registration without names says so', async () => {
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b')]);
|
||||
mockDb.attendee.findMany.mockImplementation(({ where }) => Promise.resolve(where.registrationId === 'a' ? [{ id: 'p1', name: 'Amy' }, { id: 'p2', name: 'Ben' }] : []));
|
||||
mockAssign.mockImplementation((r, res) => (r.body.attendeeId === 'p2' ? res.status(400).json({ message: 'Cabin 3 is full' }) : res.json({})));
|
||||
const out = await bulk.runBulk(req, { action: 'allocate', registrationIds: ['a', 'b'], setId: 's1', groupId: 'g1' });
|
||||
expect(out.results.find(r => r.id === 'a')).toMatchObject({ ok: false, message: 'Ben: Cabin 3 is full' });
|
||||
expect(out.results.find(r => r.id === 'b').message).toMatch(/No named people/);
|
||||
});
|
||||
|
||||
test('messages go per event as an event update to exactly the ticked registrations', async () => {
|
||||
mockDb.registration.findMany.mockResolvedValue([reg('a'), reg('b'), reg('c', { eventId: 'ev2' })]);
|
||||
mockEmail.mockImplementation((r, res) => res.json({ sent: r.body.filter.registrationIds.length }));
|
||||
const out = await bulk.runBulk(req, { action: 'message', registrationIds: ['a', 'b', 'c'], channel: 'email', subject: 'Bus times', text: 'The bus leaves at 7:30.' });
|
||||
expect(out.done).toBe(3);
|
||||
expect(mockEmail.mock.calls.map(c => [c[0].params.id, c[0].body.filter.registrationIds, c[0].body.kind])).toEqual([['ev1', ['a', 'b'], 'update'], ['ev2', ['c'], 'update']]);
|
||||
await expect(bulk.runBulk(req, { action: 'message', registrationIds: ['a'], channel: 'email', text: 'x' })).rejects.toThrow('subject');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
const count = jest.fn().mockResolvedValue(2);
|
||||
const mockDb = {
|
||||
registration: { count, findMany: jest.fn().mockResolvedValue([]) },
|
||||
consentRequest: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
waitlistEntry: { count },
|
||||
volunteerSwap: { count },
|
||||
servingRole: { findMany: jest.fn().mockResolvedValue([{ needed: 3, _count: { slots: 1 } }, { needed: 2, _count: { slots: 2 } }]) },
|
||||
event: { count, findMany: jest.fn().mockResolvedValue([]) },
|
||||
providerTransaction: { count },
|
||||
dataRequest: { count },
|
||||
webhookDelivery: { count },
|
||||
payment: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
user: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const mockScope = jest.fn();
|
||||
jest.mock('../src/utils/campusScope', () => ({
|
||||
scopeFor: (...a) => mockScope(...a),
|
||||
eventWhere: scope => (scope ? { OR: [{ campusId: { in: scope.campusIds } }, { campusId: null }] } : {}),
|
||||
}));
|
||||
|
||||
const dashboard = require('../src/utils/dashboard');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('an amount typed in the palette', () => {
|
||||
expect(dashboard.amountOf('R450')).toBe(450);
|
||||
expect(dashboard.amountOf('r 450,50')).toBe(450.5);
|
||||
expect(dashboard.amountOf('450.5')).toBe(450.5);
|
||||
expect(dashboard.amountOf('Camp 2026')).toBeNull();
|
||||
expect(dashboard.amountOf('')).toBeNull();
|
||||
});
|
||||
|
||||
describe('what is waiting', () => {
|
||||
test('admins see everything they can act on', async () => {
|
||||
const items = await dashboard.pendingItems({ user: { role: 'admin' } }, null);
|
||||
expect(items.map(i => i.key)).toEqual(['privacy', 'unpaid', 'cover', 'roster', 'waitlist', 'reconcile', 'cashup', 'webhooks']);
|
||||
// Only roles still short of people count.
|
||||
expect(items.find(i => i.key === 'roster').count).toBe(1);
|
||||
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/admin/registrations?status=pending');
|
||||
});
|
||||
|
||||
test('a campus-limited supervisor: no cashups, provider payments, privacy or webhooks; only their events', async () => {
|
||||
const scope = { campusIds: ['north'] };
|
||||
const items = await dashboard.pendingItems({ user: { role: 'supervisor' } }, scope);
|
||||
expect(items.map(i => i.key)).toEqual(['unpaid', 'cover', 'roster', 'waitlist']);
|
||||
expect(items.find(i => i.key === 'unpaid').href).toBe('/dashboard/supervisor/reports?report=regStatus');
|
||||
const where = mockDb.registration.count.mock.calls[0][0].where;
|
||||
expect(where.event.AND[0]).toEqual({ OR: [{ campusId: { in: ['north'] } }, { campusId: null }] });
|
||||
});
|
||||
|
||||
test('items with nothing waiting are left out', async () => {
|
||||
count.mockResolvedValue(0);
|
||||
mockDb.servingRole.findMany.mockResolvedValueOnce([]);
|
||||
expect(await dashboard.pendingItems({ user: { role: 'admin' } }, null)).toEqual([]);
|
||||
count.mockResolvedValue(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('palette search', () => {
|
||||
test('too short a query finds nothing', async () => {
|
||||
expect(await dashboard.search({ user: { role: 'admin' } }, 'a')).toEqual([]);
|
||||
});
|
||||
|
||||
test('staff get events only, linked to the public page', async () => {
|
||||
mockScope.mockResolvedValue(null);
|
||||
mockDb.event.findMany.mockResolvedValueOnce([{ id: 'e1', title: 'Camp 2026', startDate: new Date('2026-03-01'), isActive: true, campus: null }]);
|
||||
const out = await dashboard.search({ user: { role: 'staff' } }, 'camp');
|
||||
expect(out).toEqual([expect.objectContaining({ type: 'event', href: '/events/e1' })]);
|
||||
expect(mockDb.user.findMany).not.toHaveBeenCalled();
|
||||
expect(mockDb.payment.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('supervisors find payments by amount; admins also people', async () => {
|
||||
mockScope.mockResolvedValue(null);
|
||||
mockDb.payment.findMany.mockResolvedValueOnce([{ id: 'p1', amount: 450, method: 'card', createdAt: new Date(), registrationId: 'r1', user: { name: 'Thandi' }, event: { title: 'Camp' } }]);
|
||||
const sup = await dashboard.search({ user: { role: 'supervisor' } }, 'R450');
|
||||
expect(sup.find(r => r.type === 'payment')).toMatchObject({ title: 'R450.00 — Thandi', href: '/dashboard/supervisor/payments' });
|
||||
expect(mockDb.payment.findMany.mock.calls[0][0].where.amount).toEqual({ in: [450, -450] });
|
||||
expect(mockDb.user.findMany).not.toHaveBeenCalled();
|
||||
|
||||
mockDb.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Thandi Test', email: 't@test.local', role: 'user' }]);
|
||||
const adm = await dashboard.search({ user: { role: 'admin' } }, 'thandi');
|
||||
expect(adm.find(r => r.type === 'person')).toMatchObject({ href: '/dashboard/admin/users?q=t%40test.local' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
const mockDb = {
|
||||
savedFilter: { findMany: jest.fn(), findUnique: jest.fn(), count: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn(), delete: jest.fn() },
|
||||
$transaction: jest.fn(fn => fn(mockDb)),
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
const filters = require('../src/utils/savedFilters');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test('only simple filter values are kept', () => {
|
||||
expect(filters.cleanParams({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b', 3], n: 2, nested: { x: 1 }, 'bad key': 'x', fn: null })).toEqual({ q: 'Thandi', status: 'pending', past: true, events: ['a', 'b'], n: 2 });
|
||||
expect(() => filters.cleanParams(null)).toThrow('Nothing to save');
|
||||
expect(() => filters.cleanParams({ q: 'x'.repeat(500), a: 'x'.repeat(500), b: 'x'.repeat(500), c: 'x'.repeat(500), d: 'x'.repeat(500), e: 'x'.repeat(500), f: 'x'.repeat(500), g: 'x'.repeat(500), h: 'x'.repeat(500) })).toThrow('Too much');
|
||||
});
|
||||
|
||||
test('pages and names are checked', async () => {
|
||||
await expect(filters.list('u1', 'secrets')).rejects.toThrow('Unknown list page');
|
||||
expect(() => filters.cleanName(' ')).toThrow('name');
|
||||
expect(filters.cleanName(' Camp — unpaid ')).toBe('Camp — unpaid');
|
||||
});
|
||||
|
||||
test('saving a default clears the old default; saving the same name replaces it', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f1' });
|
||||
mockDb.savedFilter.update.mockImplementation(async ({ data }) => ({ id: 'f1', page: 'registrations', name: 'Unpaid', ...data }));
|
||||
await filters.save('u1', { page: 'registrations', name: 'Unpaid', params: { status: 'pending' }, isDefault: true });
|
||||
expect(mockDb.savedFilter.updateMany).toHaveBeenCalledWith({ where: { userId: 'u1', page: 'registrations', isDefault: true }, data: { isDefault: false } });
|
||||
expect(mockDb.savedFilter.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('at most 30 per page', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue(null);
|
||||
mockDb.savedFilter.count.mockResolvedValue(30);
|
||||
await expect(filters.save('u1', { page: 'events', name: 'One more', params: { q: 'x' } })).rejects.toThrow('up to 30');
|
||||
});
|
||||
|
||||
test('someone else\'s saved filter is "not found"', async () => {
|
||||
mockDb.savedFilter.findUnique.mockResolvedValue({ id: 'f9', userId: 'someone-else', page: 'users' });
|
||||
await expect(filters.remove('u1', 'f9')).rejects.toMatchObject({ statusCode: 404 });
|
||||
await expect(filters.update('u1', 'f9', { name: 'Mine now' })).rejects.toMatchObject({ statusCode: 404 });
|
||||
expect(mockDb.savedFilter.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
+88
-5
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.48.1** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-28, after **1.50.1** (and Hub 1.5.0). The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -14,6 +14,12 @@ encrypted settings unreadable or break existing installs.
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.50.1 | Long lists: audit-log search/role filter/all actions and inclusive To date, data-request search and paging, webhook-delivery status filter and Load older, supervisor Payments loads only what it needs (`unpaid`, `userId`, `from`, `donations` filters). `components/shared/Pager.tsx`; e2e `e2e/long-lists.spec.ts` |
|
||||
| 1.50.0 | **Accessibility pass** (§11, WCAG 2.2 AA) on public pages, auth, registration, payment, My Events and the kiosk: skip link, focus ring, labelled fields, named quantity buttons, announced errors, `ModalOverlay` popups (focus trap, Escape), contrast and underlined in-text links, reduce-motion; Branding warns about a too-light primary colour. axe + keyboard e2e in `e2e/accessibility.spec.ts` |
|
||||
| 1.49.2 | **Saved filters** (§9 part 3) on registrations, users, events and reports (`/api/saved-filters`, `components/shared/SavedFilters.tsx`). Migration `20261028180000_saved_filters` |
|
||||
| 1.49.1 | **Bulk actions** on Manage registrations (§9 part 2): message, mark paid, move ticket, allocate, export, cancel (`POST /api/registrations/bulk`, `utils/bulkRegistrations.js`). Migration `20261028170000_registrations_bulk` |
|
||||
| 1.49.0 | **Dashboard and command palette** (§9 part 1): Today and Waiting for you on the staff/supervisor/admin dashboards (`GET /api/dashboard/today`), Ctrl+K palette (`GET /api/dashboard/search`, pages in `lib/commandPages.ts`), deep links on events/registrations/users |
|
||||
| 1.48.2 | Four demo seeds (`npm run demo:small|events|ministry|multicampus`, `demo:list`); fixes `set-demo`, broken since 1.43.0 by TRUNCATE CASCADE through `User.homeCampusId` |
|
||||
| 1.48.1 | Built-in privacy notice updated (rosters, series, season passes, webhooks, acceptance); editor shows built-in changes to sites with their own version; activation asks for agreement; never-accepted users asked once after login |
|
||||
| 1.48.0 | **Outbound webhooks** (§8): Site settings → Webhooks, 8 event types, signed deliveries with retries and a delivery log, operator register entry per endpoint. Migration `20261028160000_webhooks` |
|
||||
| 1.47.4 | Fix: an update request for a version that has since been overtaken shows as such, with **Ask for … instead** (and the banner again). Hub 1.5.0 moves the open request on (`changed`); with an older Hub `requestUpdate` withdraws and asks again |
|
||||
@@ -54,8 +60,8 @@ encrypted settings unreadable or break existing installs.
|
||||
Hub 1.1.0 is deployed with update requests rolled out, and `scripts/install.sh` has been tried on
|
||||
a test server (both done 2026-10-28).
|
||||
|
||||
1. **1.49.0 — Admin dashboard, command palette (Ctrl+K), bulk actions** (§9), then 1.50.0 Afrikaans
|
||||
and 1.51.0 accessibility.
|
||||
1. **1.51.0 — TypeScript backend, OpenAPI and `/api/v2`** (§14), then **2.0.0 — AI helpers** (§15).
|
||||
Languages (Afrikaans, §10) are removed from scope (2026-10-28).
|
||||
|
||||
## Working on this repo
|
||||
|
||||
@@ -361,6 +367,85 @@ a test server (both done 2026-10-28).
|
||||
- Frontend: `components/admin/WebhooksTab.tsx`. Tests: `tests/webhooks.test.js`, e2e
|
||||
`webhooks.spec.ts` (runs a receiver on localhost:5199; e2e backend has `WEBHOOKS_ALLOW_PRIVATE`).
|
||||
|
||||
## Demo data (1.48.2)
|
||||
|
||||
- `backend/scripts/set-demo.js` builds everything in memory and writes it with `createMany` in one
|
||||
transaction (so no webhooks fire). `PROFILES` says which of the six upcoming events, which past
|
||||
events (by title) and which extras (`series`, `courses`, `conference`, `kids`, `volunteers`) each
|
||||
profile gets, and whether campuses are on. **A new feature that's worth demoing** gets a builder
|
||||
and an extra; add its tables to `INSERT_ORDER` (parents first).
|
||||
- Keep jobs quiet: set anything a job would act on as already done (`seriesAnnouncedAt`,
|
||||
`remindedAt`, certificates issued). Demo people use @example.com and no phone numbers.
|
||||
- `clearData` truncates every table except settings and users, but deletes (not truncates) tables
|
||||
that `User` points at — TRUNCATE CASCADE would empty `User` with them.
|
||||
- Tried by seeding all four into a throwaway database and calling the API (events, series page and
|
||||
report, season pass, kids board, roster, attendance registers, certificate check, cashups, campus
|
||||
scoping).
|
||||
|
||||
## Where the dashboard and palette live (1.49.0)
|
||||
|
||||
- `backend/src/utils/dashboard.js` — `todaysEvents`, `moneyIn`, `pendingItems` (each item counted
|
||||
separately, failures logged and skipped; role-only items for admins), `search` (+ `amountOf`).
|
||||
Everything goes through `eventWhere(scopeFor(req))`. Routes: `routes/dashboardRoutes.js`.
|
||||
**A new "something needs doing" count** goes in `pendingItems` (with its link and the `order`).
|
||||
- Frontend: `components/dashboard/TodayPanel.tsx`, `components/shared/CommandPalette.tsx` (mounted in
|
||||
`app/dashboard/layout.tsx`; `CommandPaletteButton` on the dashboards), `lib/commandPages.ts` —
|
||||
**a new page or setting tab should be added there** with its roles.
|
||||
- Deep links read `window.location.search` once on load (no Suspense needed).
|
||||
- Tests: `tests/dashboard.test.js`, e2e `dashboard.spec.ts`.
|
||||
|
||||
## Where bulk actions live (1.49.1)
|
||||
|
||||
- `backend/src/utils/bulkRegistrations.js` — `runBulk` checks the input (≤ 500, licence read-only for
|
||||
payments and moves) and runs one action per registration, collecting a result for each. Most
|
||||
actions **call the existing Express handlers in-process** (`call(handler, req)`, with the original
|
||||
request's user, headers and socket so audits keep the IP): `createPayment`, `cancelRegistration`,
|
||||
`allocationController.assign`, `emailEventAttendees` / `whatsappEventAttendees` (new
|
||||
`filter.registrationIds`). **Change a rule in those handlers and bulk follows.**
|
||||
- Moving tickets is its own code (`moveOption`): the edit handler refuses to remove issued tickets,
|
||||
so the lines are re-pointed in place (tickets and QR codes kept), repriced with `resolveOptionPrice`,
|
||||
attendees moved, then `updateRegistrationStatus`.
|
||||
- Frontend: `components/admin/RegistrationBulkBar.tsx` on `app/dashboard/admin/registrations`. Export
|
||||
goes through `/api/reports/excel` (access-logged). Tests: `tests/bulkRegistrations.test.js`, e2e
|
||||
`bulk.spec.ts`.
|
||||
|
||||
## Saved filters (1.49.2)
|
||||
|
||||
- `backend/src/utils/savedFilters.js` (`PAGES` is the list of pages with saved filters; values are
|
||||
cleaned to simple types) + `routes/savedFilterRoutes.js`. Removed in `anonymiseUser`.
|
||||
- `components/shared/SavedFilters.tsx` takes the page's current filter values and an `onApply`;
|
||||
`skipDefault` when the URL already says what to show. **Adding it to another list page:** add the
|
||||
page to `PAGES` (and the component's `page` type), then pass the values in and apply them out.
|
||||
- e2e `saved-filters.spec.ts` (deletes what it saves).
|
||||
|
||||
## Accessibility (1.50.0)
|
||||
|
||||
- **Popups:** use `components/shared/ModalOverlay.tsx` (`labelledBy` = the heading's id, `onClose`,
|
||||
`closeOnBackdrop`) instead of a bare `fixed inset-0` div — it gives the dialog role, focus trap,
|
||||
Escape and focus return. Radix `ui/dialog` is fine too.
|
||||
- **Fields:** tie every visible `<label>` to its control (`htmlFor`/`id`, or wrap the control); in
|
||||
lists use an id with the row key, or `aria-label`. Icon-only buttons need `aria-label`.
|
||||
- **Text colour:** `text-gray-500` is the lightest grey for text on white (`gray-400` fails 4.5:1).
|
||||
Links inside a `<p>`/`<label>` are underlined by `globals.css`.
|
||||
- **Tests:** `e2e/accessibility.spec.ts` — add new public/member pages to it. It checks with axe's
|
||||
WCAG 2.0–2.2 A/AA tags; the app has no dark mode, so only the light theme is checked.
|
||||
- **Not covered yet:** the staff, supervisor and admin screens (scanner, at-the-door, settings …)
|
||||
weren't in §11's scope; many of their labels still aren't tied to fields and they use hand-made
|
||||
popups. Do them when those pages are next worked on.
|
||||
|
||||
## Long lists (1.50.1)
|
||||
|
||||
- A list that grows for ever must page on the server (`page`/`limit`, `total`, `pages`) or load
|
||||
older items with a `before` cursor, and offer a search. `components/shared/Pager.tsx` renders
|
||||
"21–40 of 312 · Prev · Next". Already paged: users, registrations (Manage registrations), events,
|
||||
payments, audit log, consent/access logs, data requests, My payments.
|
||||
- **Audit-log actions:** a new `AdminAuditAction` needs a label in `ACTION_LABELS`
|
||||
(`app/dashboard/admin/audit-log/page.tsx`) — the filter is built from that list.
|
||||
- **Still loading everything (fine for now, watch as installs grow):** user pickers that call
|
||||
`fetchAllUsers` (Payments, Email/WhatsApp attendees, Forms, Manual) — a server-side search
|
||||
picker would replace them; event pickers using `/api/events/all?includePast=true`; the coupons
|
||||
list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
@@ -371,8 +456,6 @@ a test server (both done 2026-10-28).
|
||||
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
|
||||
- There's no log of individual messages sent, so "Download my data" can't include "messages
|
||||
sent to you" yet.
|
||||
- The audit-log page's action filter list was already missing newer actions before 1.38.0
|
||||
(only the three privacy ones were added).
|
||||
|
||||
- At the door, per-session tickets are booked on new registrations only; editing an
|
||||
existing registration leaves its booked sessions as they are (the backend refuses
|
||||
|
||||
+18
-7
@@ -35,8 +35,11 @@ up by one. 1.45.0 **kids check-in** shipped (fully digital: pickup codes, sticke
|
||||
ratios, offline desk). 1.46.0 **volunteer rosters** shipped (roles per event or session, sign-ups and
|
||||
assigning, swaps on accept, QR self check-in, kids-room roles on duty, optional volunteer ticket).
|
||||
Event series and cloning ships as 1.47.0–1.47.3 (2026-10-28); 1.47.0 **copy an event** and 1.47.1 **event series** (series page, Next in this series, follow) 1.47.2 **year-on-year
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. **Next: 1.49.0 Admin dashboard, command
|
||||
palette and bulk actions.**
|
||||
report** and 1.47.3 **season passes** shipped — §6 is done. 1.48.0 **outbound webhooks** shipped. 1.49.0 **dashboard (Today, Waiting for you) and Ctrl+K palette**
|
||||
shipped; 1.49.1 **bulk actions** and 1.49.2 **saved filters** shipped — §9 is done. Languages
|
||||
(Afrikaans) are removed from scope (2026-10-28), so everything after moves up by one. 1.50.0 **accessibility
|
||||
pass** shipped. 1.50.1 **pagination and search on long lists** shipped. **Next: 1.51.0 TypeScript
|
||||
backend / `/api/v2`.**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -58,7 +61,7 @@ palette and bulk actions.**
|
||||
7. ~~[Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)~~ — removed from scope
|
||||
8. [Outbound webhooks](#8-outbound-webhooks)
|
||||
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
|
||||
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
|
||||
10. ~~[Languages (Afrikaans first)](#10-languages-afrikaans-first)~~ — removed from scope
|
||||
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Cross Events Hub)](#13-central-management-platform-cross-events-hub)
|
||||
@@ -517,6 +520,9 @@ register; deliveries kept 30 days.)
|
||||
|
||||
## 9. Admin dashboard, command palette, bulk actions
|
||||
|
||||
Three releases (2026-10-28): **1.49.0** dashboard (Today, Waiting for you) and the Ctrl+K command
|
||||
palette ✅; **1.49.1** bulk actions ✅; **1.49.2** saved filters ✅.
|
||||
|
||||
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
|
||||
far, money in (today/week), pending items (unreconciled payments, consent
|
||||
outstanding, waitlist offers, data requests), alerts.
|
||||
@@ -530,6 +536,8 @@ register; deliveries kept 30 days.)
|
||||
|
||||
## 10. Languages (Afrikaans first)
|
||||
|
||||
> **Removed from scope (2026-10-28).** Kept here for reference only; not planned for 2.0.
|
||||
|
||||
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
|
||||
- User language preference; emails, WhatsApp messages and PDFs sent in the
|
||||
user's language, with per-language templates.
|
||||
@@ -546,6 +554,9 @@ register; deliveries kept 30 days.)
|
||||
fields.
|
||||
- Automated `axe` checks in the Playwright suite so it doesn't regress.
|
||||
|
||||
**Shipped in 1.50.0** for the pages above. The app has no dark mode, so only the light theme is
|
||||
checked. Staff/supervisor/admin screens are left for when they're next worked on (see HANDOVER).
|
||||
|
||||
---
|
||||
|
||||
## 12. Multi-campus (one install per church)
|
||||
@@ -936,8 +947,7 @@ each confirmed action in the audit log.
|
||||
### More helpers
|
||||
|
||||
Smaller one-button additions as they come up: suggest an event's price tiers from past
|
||||
events, write a post-event thank-you, draft certificate wording, translate a message into
|
||||
Afrikaans (ties in with §10), flag unusual payments for the cashup.
|
||||
events, write a post-event thank-you, draft certificate wording, flag unusual payments for the cashup.
|
||||
|
||||
### Self-hosted AI (long term)
|
||||
|
||||
@@ -968,8 +978,8 @@ income justify the hardware:
|
||||
| 7 | 1.43.0 ✅ – 1.44.0 ✅ | **Multi-campus**: 1.43.0 campuses (settings, events, public filter and landing pages, home campus, filters, messages, summaries); 1.44.0 campus-scoped staff | Kids rooms and volunteer roles are per campus, so campuses come first; scoping is split out because it's security-heavy |
|
||||
| 8 | 1.45.0 ✅ – 1.46.0 ✅ | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.47.0 ✅ – 1.47.3 ✅, 1.48.0 ✅ | Event series & cloning (1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass), Outbound webhooks (Wallet passes removed from scope) | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 10 | 1.49.0 ✅ – 1.49.2 ✅, 1.50.0 ✅, 1.50.1 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists (Afrikaans removed from scope) | Polish before the big release |
|
||||
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -1020,6 +1030,7 @@ feature.
|
||||
| — | Multi-campus split | Two releases: **1.43.0 campuses**, **1.44.0 campus-scoped staff**; everything after moves up by one. Scoped staff can view and scan "All campuses" events, only admins and unscoped supervisors edit them. "Everyone at campus X" = home campus X, plus (tick box) registrants of campus X's events in the last 12 months. Campus landing pages are in 1.43.0 (2026-10-27) |
|
||||
| — | Updates | Sites show **Request update**, never the deploy command: the request goes to the Hub, which tells Joshua; he updates the server. 1.42.0 / Hub 1.1.0; Multi-campus and everything after moves up by one (2026-10-27) |
|
||||
| — | Wallet passes | **Removed from scope** (2026-10-28); everything after volunteer rosters moves up by one |
|
||||
| — | Languages (Afrikaans) | **Removed from scope** (2026-10-28); the accessibility pass becomes 1.50.0 and the TypeScript backend / `/api/v2` 1.51.0 |
|
||||
| — | Kids check-in (1.45.0) | One release. **Fully digital** — no label printers: pickup code on the parent's phone, a "write on the sticker" panel for blank stickers. Staff desk only (no kiosk). Anyone with the pickup code can collect; families list authorised adults without accounts. Child profiles deleted after 6 months without a check-in (2026-10-28) |
|
||||
| — | Volunteer rosters (1.46.0) | Anyone with an account can volunteer. Roles are per event (or per session) — rosters cover one event/session and never repeat. A swap needs no approval: it happens once someone accepts, and the event's notify recipients are emailed. Volunteers check in by scanning a QR code or are checked in by staff. The free volunteer ticket is off by default and chosen per event. A role linked to a kids room counts its checked-in volunteers as leaders on duty for the room's ratio (2026-10-28) |
|
||||
| — | Event series and cloning (1.47.x) | One release per part: 1.47.0 clone, 1.47.1 series, 1.47.2 year-on-year reports, 1.47.3 season pass. A clone opens the New event popup filled in from the source, so everything is set there before saving. Of the newer features a clone copies only the campus (not kids check-in or volunteer roles); coupons are never copied. A series can span several campuses. Following a series is **News & announcements**; copies keep the source's certificate and reminder settings. Season pass: one checkout registers for every event on it, the supervisor picks the events, the price is split in proportion to normal prices (2026-10-28) |
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect, seed, loginAs } from "./fixtures";
|
||||
|
||||
// WCAG 2.2 AA checks (axe-core) on the pages visitors and members use, so the
|
||||
// accessibility pass (1.50.0) doesn't regress.
|
||||
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
||||
|
||||
async function check(page: Page, label: string) {
|
||||
// Let animations and late-loading data settle so colours are measured as shown.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(300);
|
||||
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||||
const summary = results.violations.map((v) => ({
|
||||
id: v.id,
|
||||
help: v.help,
|
||||
nodes: v.nodes.map((n) => `${n.target.join(" ")} — ${n.failureSummary?.split("\n").slice(0, 2).join(" ")}`),
|
||||
}));
|
||||
expect.soft(summary, `${label}: accessibility violations`).toEqual([]);
|
||||
}
|
||||
|
||||
async function visit(page: Page, url: string, label: string, ready?: () => Promise<void>) {
|
||||
await page.goto(url);
|
||||
if (ready) await ready();
|
||||
await check(page, label);
|
||||
}
|
||||
|
||||
test.describe("accessibility (WCAG 2.2 AA)", () => {
|
||||
test("public pages", async ({ page }) => {
|
||||
await visit(page, "/", "home");
|
||||
await visit(page, "/events", "events", () => expect(page.getByText("E2E Free Evening")).toBeVisible());
|
||||
await visit(page, `/events/${seed().events.course}`, "event page", () =>
|
||||
expect(page.getByRole("heading", { name: "E2E Alpha Course" })).toBeVisible(),
|
||||
);
|
||||
await visit(page, `/events/${seed().events.dropIn}`, "drop-in event page");
|
||||
await visit(page, "/contact", "contact");
|
||||
await visit(page, "/legal/privacy", "privacy notice");
|
||||
await visit(page, "/legal/terms", "terms");
|
||||
});
|
||||
|
||||
test("sign in, sign up and forgot password, with errors", async ({ page }) => {
|
||||
await visit(page, "/login", "login");
|
||||
await page.getByLabel("Email or phone").fill(seed().users.user.email);
|
||||
await page.getByLabel("Password", { exact: true }).fill("not-the-password");
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await check(page, "login with an error");
|
||||
await visit(page, "/register", "sign up");
|
||||
await visit(page, "/forgot-password", "forgot password");
|
||||
});
|
||||
|
||||
test("registration form", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await visit(page, `/register/${seed().events.paid}`, "registration", async () => {
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
await expect(page.getByText("Total: R150.00")).toBeVisible();
|
||||
});
|
||||
await visit(page, `/register/${seed().events.dropIn}`, "registration (pick sessions)");
|
||||
});
|
||||
|
||||
test("My Events and account pages", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await visit(page, "/dashboard/user", "My Events");
|
||||
await visit(page, "/dashboard/user/profile", "profile");
|
||||
await visit(page, "/dashboard/user/privacy", "privacy centre");
|
||||
await visit(page, "/dashboard/user/payments", "payments");
|
||||
await visit(page, "/dashboard/user/kids", "kids");
|
||||
await visit(page, "/dashboard/user/serving", "serving");
|
||||
});
|
||||
|
||||
test("self-service kiosk", async ({ page }) => {
|
||||
await visit(page, "/self-service", "kiosk sign-in");
|
||||
await page.getByPlaceholder("supervisor@example.com").fill(seed().users.supervisor.email);
|
||||
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
|
||||
await check(page, "kiosk event picker");
|
||||
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
|
||||
await page.locator("select").selectOption(value!);
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
|
||||
await check(page, "kiosk form");
|
||||
|
||||
// "Create an account" is a switch you can reach and flip with the keyboard.
|
||||
const create = page.getByRole("switch", { name: /Create an account/ });
|
||||
await create.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(create).toHaveAttribute("aria-checked", "true");
|
||||
await expect(page.getByLabel("Choose a password")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("keyboard", () => {
|
||||
test("the first Tab reaches “Skip to main content”, which moves focus to the page content", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
await expect(page.getByText("E2E Free Evening")).toBeVisible();
|
||||
await page.keyboard.press("Tab");
|
||||
const skip = page.getByRole("link", { name: "Skip to main content" });
|
||||
await expect(skip).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("main").first()).toBeFocused();
|
||||
});
|
||||
|
||||
test("a popup keeps focus inside, closes with Escape and hands focus back", async ({ page }) => {
|
||||
await page.goto("/self-service");
|
||||
await page.getByLabel("Email").fill(seed().users.supervisor.email);
|
||||
await page.getByLabel("Password", { exact: true }).fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
|
||||
await page.getByLabel("Event").selectOption(value!);
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
|
||||
const opener = page.getByRole("button", { name: "Change Event" });
|
||||
await opener.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const dialog = page.getByRole("dialog", { name: "Change Event" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByLabel("Password", { exact: true })).toBeFocused();
|
||||
// Tab doesn't leave the popup.
|
||||
for (let i = 0; i < 4; i++) await page.keyboard.press("Tab");
|
||||
expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(opener).toBeFocused();
|
||||
});
|
||||
|
||||
test("ticket quantities can be changed with the keyboard and are announced by name", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.paid}`);
|
||||
const add = page.getByRole("button", { name: /^Add one / }).first();
|
||||
await add.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("Total: R150.00")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Remove one / }).first()).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect, loginAs, api } from "./fixtures";
|
||||
|
||||
// Bulk actions on the registrations list (1.49.1): tick several, mark them paid, move them to
|
||||
// another ticket, cancel — each through the same rules as one at a time.
|
||||
type Ev = { id: string; eventOptions?: { id: string; name: string; isMainTicket?: boolean }[] };
|
||||
type Reg = { id: string; status: string; registrationOptions?: { eventOptionId: string; quantity: number }[]; payments?: { amount: number; method: string }[] };
|
||||
|
||||
test.describe.serial("bulk actions on registrations", () => {
|
||||
let event: Ev;
|
||||
let main: string;
|
||||
let vip: string;
|
||||
const regs: string[] = [];
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage();
|
||||
const start = new Date(Date.now() + 20 * 864e5);
|
||||
event = await api<Ev>(page, "admin", "POST", "/api/events", {
|
||||
title: "E2E Bulk Night", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3 * 3600e3).toISOString(),
|
||||
price: 100, location: "Church hall",
|
||||
});
|
||||
const full = await api<Ev>(page, "admin", "GET", `/api/events/${event.id}`);
|
||||
main = full.eventOptions!.find(o => o.isMainTicket)!.id;
|
||||
vip = (await api<{ id: string }>(page, "admin", "POST", `/api/events/${event.id}/options`, { name: "VIP", price: 150, isMainTicket: true })).id;
|
||||
for (const role of ["staff", "supervisor", "user"] as const) {
|
||||
regs.push((await api<{ id: string }>(page, role, "POST", "/api/registrations", { eventId: event.id, options: [{ eventOptionId: main, quantity: 1 }] })).id);
|
||||
}
|
||||
await page.close();
|
||||
});
|
||||
|
||||
const openList = async (page: import("@playwright/test").Page) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto(`/dashboard/admin/registrations?event=${event.id}`);
|
||||
await expect(page.getByText("3 of", { exact: false })).toBeVisible();
|
||||
};
|
||||
|
||||
test("tick all and mark them paid in cash", async ({ page }) => {
|
||||
await openList(page);
|
||||
await page.getByLabel("Select all shown").check();
|
||||
const bar = page.getByTestId("bulk-bar");
|
||||
await expect(bar).toContainText("3 selected");
|
||||
await bar.getByLabel("Bulk action").selectOption("markPaid");
|
||||
await bar.getByLabel("Paid by").selectOption("cash");
|
||||
page.once("dialog", d => d.accept());
|
||||
await bar.getByRole("button", { name: /Mark paid/ }).click();
|
||||
await expect(page.getByTestId("bulk-result")).toContainText("3 done");
|
||||
|
||||
for (const id of regs) {
|
||||
const r = await api<Reg>(page, "admin", "GET", `/api/registrations/${id}`);
|
||||
expect(r.status).toBe("paid");
|
||||
expect(r.payments?.map(p => [p.amount, p.method])).toEqual([[100, "cash"]]);
|
||||
}
|
||||
});
|
||||
|
||||
test("move two to the VIP ticket; cancel the third", async ({ page }) => {
|
||||
await openList(page);
|
||||
const rows = page.locator("li").filter({ has: page.getByRole("checkbox", { name: /^Select / }) });
|
||||
await rows.nth(0).getByRole("checkbox").check();
|
||||
await rows.nth(1).getByRole("checkbox").check();
|
||||
const bar = page.getByTestId("bulk-bar");
|
||||
await bar.getByLabel("Bulk action").selectOption("moveOption");
|
||||
await bar.getByLabel("Move from").selectOption(main);
|
||||
await bar.getByLabel("Move to").selectOption(vip);
|
||||
page.once("dialog", d => d.accept());
|
||||
await bar.getByRole("button", { name: /Move to another ticket/ }).click();
|
||||
await expect(page.getByTestId("bulk-result")).toContainText("2 done");
|
||||
|
||||
const moved = await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)));
|
||||
const onVip = moved.filter(r => r.registrationOptions?.some(o => o.eventOptionId === vip));
|
||||
expect(onVip).toHaveLength(2);
|
||||
// R150 now, R100 paid: they owe the difference.
|
||||
expect(onVip.every(r => r.status === "partial_paid")).toBe(true);
|
||||
|
||||
await bar.getByRole("button", { name: "Clear selection" }).click();
|
||||
const left = moved.find(r => !r.registrationOptions?.some(o => o.eventOptionId === vip))!;
|
||||
await page.getByRole("checkbox", { name: /^Select / }).nth(2).check();
|
||||
const selectedId = (await api<Reg>(page, "admin", "GET", `/api/registrations/${left.id}`)).id;
|
||||
expect(selectedId).toBe(left.id);
|
||||
const bar2 = page.getByTestId("bulk-bar");
|
||||
await bar2.getByLabel("Bulk action").selectOption("cancel");
|
||||
page.once("dialog", d => d.accept());
|
||||
await bar2.getByRole("button", { name: /Cancel — 1/ }).click();
|
||||
await expect(page.getByTestId("bulk-result")).toContainText("1 done");
|
||||
const cancelled = (await Promise.all(regs.map(id => api<Reg>(page, "admin", "GET", `/api/registrations/${id}`)))).filter(r => r.status === "cancelled");
|
||||
expect(cancelled).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect, loginAs } from "./fixtures";
|
||||
|
||||
// Dashboard "Today" and "Waiting for you", and the Ctrl+K command palette (1.49.0).
|
||||
test.describe("dashboard and command palette", () => {
|
||||
test("the admin dashboard shows what's on today and what's waiting", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin");
|
||||
const today = page.getByTestId("today-panel");
|
||||
await expect(today).toBeVisible();
|
||||
// Seeded on today: the hidden kids church and serving Sunday.
|
||||
await expect(today.getByTestId("today-events")).toContainText("E2E Serving Sunday");
|
||||
await expect(today.getByText("Waiting for you")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Ctrl+K finds an event and opens it", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor");
|
||||
await expect(page.getByTestId("today-panel")).toBeVisible();
|
||||
await page.keyboard.press("Control+k");
|
||||
const dialog = page.getByRole("dialog", { name: "Go to or search" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("combobox", { name: "Search" }).fill("Paid Conference");
|
||||
await expect(dialog.getByRole("option", { name: /E2E Paid Conference/ })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/dashboard\/supervisor\/events/);
|
||||
await expect(page.getByRole("heading", { name: "Edit Event" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Event title")).toHaveValue("E2E Paid Conference");
|
||||
});
|
||||
|
||||
test("the palette's actions and pages follow the role", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor");
|
||||
await page.getByRole("button", { name: /Search or jump to/ }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Go to or search" });
|
||||
await dialog.getByRole("combobox", { name: "Search" }).fill("new event");
|
||||
await dialog.getByRole("option", { name: "New event" }).click();
|
||||
await expect(page.getByRole("heading", { name: "New Event" })).toBeVisible();
|
||||
|
||||
// Staff don't get admin pages.
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/staff");
|
||||
await page.keyboard.press("Control+k");
|
||||
const staffDialog = page.getByRole("dialog", { name: "Go to or search" });
|
||||
await staffDialog.getByRole("combobox", { name: "Search" }).fill("users");
|
||||
await expect(staffDialog.getByRole("option", { name: "Users" })).toHaveCount(0);
|
||||
await staffDialog.getByRole("combobox", { name: "Search" }).fill("scan");
|
||||
await expect(staffDialog.getByRole("option", { name: "Scan tickets" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
// 1.50.1: long lists load a page (or just what's needed) at a time, with search and filters.
|
||||
test.describe("long lists", () => {
|
||||
test("Payments: today, recent and a person's payments for a refund", async ({ page }) => {
|
||||
// A donation recorded today for the seeded user (on an event no other test totals up).
|
||||
await api(page, "supervisor", "POST", "/api/payments", {
|
||||
amount: 77, method: "cash", isDonation: true, eventId: seed().events.conference, userId: seed().users.user.id,
|
||||
});
|
||||
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/payments");
|
||||
await expect(page.getByText("R 77.00").first()).toBeVisible();
|
||||
|
||||
// Refund: picking the person loads their payments.
|
||||
await page.locator("label", { hasText: "Refund" }).first().click();
|
||||
await page.getByPlaceholder("Search by name, email or phone…").last().fill(seed().users.user.name);
|
||||
await page.getByRole("button", { name: new RegExp(seed().users.user.name) }).first().click();
|
||||
await expect(page.locator("option", { hasText: "R 77.00" })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Payments API: narrowing filters", async ({ page }) => {
|
||||
const userId = seed().users.user.id;
|
||||
const mine = await api<{ data: { userId: string }[] }>(page, "supervisor", "GET", `/api/payments?userId=${userId}&limit=200`);
|
||||
expect(mine.data.length).toBeGreaterThan(0);
|
||||
expect(mine.data.every((p) => p.userId === userId)).toBe(true);
|
||||
|
||||
const unpaid = await api<{ status: string }[]>(page, "supervisor", "GET", "/api/registrations?unpaid=1");
|
||||
expect(unpaid.every((r) => ["pending", "confirmed", "partial_paid"].includes(r.status))).toBe(true);
|
||||
});
|
||||
|
||||
test("Audit log: search and the full list of actions", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/audit-log");
|
||||
const action = page.getByLabel("Action");
|
||||
await expect(action.locator("option", { hasText: "Bulk action on registrations" })).toHaveCount(1);
|
||||
await expect(action.locator("option", { hasText: "Webhook added, changed or deleted" })).toHaveCount(1);
|
||||
|
||||
await page.getByLabel("Search").fill("nobody-matches-this-xyz");
|
||||
await expect(page.getByText("No matching audit entries.")).toBeVisible();
|
||||
await expect(page.getByText(/0 actions match the filters/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear filters" }).click();
|
||||
await expect(page.getByLabel("Search")).toHaveValue("");
|
||||
});
|
||||
|
||||
test("Data requests: search", async ({ page }) => {
|
||||
const res = await api<{ requests: unknown[]; total: number }>(page, "admin", "GET", "/api/privacy/requests?status=closed&search=nobody-matches-this-xyz");
|
||||
expect(res.requests).toEqual([]);
|
||||
expect(res.total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ test.describe("pick sessions", () => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await expect(picker.getByText("Full")).toBeVisible();
|
||||
@@ -50,7 +50,7 @@ test.describe("pick sessions", () => {
|
||||
test("join the waitlist for a full class", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto(`/register/${seed().events.dropIn}`);
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await picker.getByRole("button", { name: "Join waitlist" }).click();
|
||||
@@ -80,7 +80,7 @@ test.describe("pick sessions", () => {
|
||||
await page.getByRole("button", { name: "Next — Choose Items" }).click();
|
||||
|
||||
await expect(page.getByText("R 50.00 per session")).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
const picker = page.getByRole("group", { name: "Choose sessions" });
|
||||
await picker.getByRole("checkbox").nth(1).check();
|
||||
await expect(page.getByText("Total: R 50.00")).toBeVisible();
|
||||
|
||||
@@ -10,7 +10,7 @@ test.describe("registration → payment → ticket → check-in → cashup", ()
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.free}`);
|
||||
await expect(page.getByText("E2E Free Evening").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
@@ -21,7 +21,7 @@ test.describe("registration → payment → ticket → check-in → cashup", ()
|
||||
test("a paid event: pending until paid, then a ticket that checks in once", async ({ page }) => {
|
||||
await loginAs(page, "user");
|
||||
await page.goto(`/register/${seed().events.paid}`);
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
await expect(page.getByText("Total: R150.00")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await page.waitForURL(/\/registration\/success/);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test, expect, loginAs } from "./fixtures";
|
||||
|
||||
// Saved filters on list pages (1.49.2): save the current filters, make them the default (applied
|
||||
// when the page opens), and delete them again.
|
||||
test("an admin saves a registrations filter as the default, and deletes it", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/registrations");
|
||||
const status = page.locator("select").filter({ has: page.locator("option", { hasText: "All statuses" }) });
|
||||
await status.selectOption("cancelled");
|
||||
|
||||
const saved = page.getByTestId("saved-filters");
|
||||
await saved.getByRole("button", { name: /Saved filters/ }).click();
|
||||
await saved.getByLabel("Save the current filters as").fill("Cancelled ones");
|
||||
await saved.getByLabel("Use when this page opens").check();
|
||||
await saved.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await expect(saved.getByRole("button", { name: "Cancelled ones" }).first()).toBeVisible();
|
||||
|
||||
// The default is applied when the page opens…
|
||||
await page.reload();
|
||||
await expect(status).toHaveValue("cancelled");
|
||||
await expect(saved.getByRole("button", { name: /Cancelled ones/ }).first()).toBeVisible();
|
||||
|
||||
// …but not over a link that says what to show.
|
||||
await page.goto("/dashboard/admin/registrations?status=paid");
|
||||
await expect(status).toHaveValue("paid");
|
||||
|
||||
await saved.getByRole("button", { name: /Saved filters/ }).click();
|
||||
page.once("dialog", d => d.accept());
|
||||
await saved.getByRole("button", { name: "Delete Cancelled ones" }).click();
|
||||
await expect(saved.getByText("No saved filters yet")).toBeVisible();
|
||||
await page.goto("/dashboard/admin/registrations");
|
||||
await expect(status).toHaveValue("");
|
||||
});
|
||||
@@ -88,7 +88,7 @@ test.describe("supervisor tools", () => {
|
||||
await page.getByPlaceholder("john@example.com").fill("walkin@e2e.test");
|
||||
await page.getByPlaceholder("+27 82 000 0000").fill("0820000099");
|
||||
await page.getByPlaceholder("John Smith").fill("Walk In Visitor");
|
||||
await page.getByRole("button", { name: "+" }).first().click();
|
||||
await page.getByRole("button", { name: /^Add one / }).first().click();
|
||||
await page.getByRole("button", { name: "Register", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "You're Registered!" })).toBeVisible();
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.50.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.50.1",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.1",
|
||||
"version": "1.50.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -45,14 +45,14 @@ export default function ForgotPasswordPage() {
|
||||
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
<label htmlFor="forgot-email" className="block text-sm font-medium mb-1">Email</label>
|
||||
<input id="forgot-email" type="email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
</div>
|
||||
<button disabled={loading || !email} className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60">
|
||||
{loading ? "Sending..." : "Send reset link"}
|
||||
</button>
|
||||
</form>
|
||||
{status && <p className="text-sm text-gray-700 mt-4">{status}</p>}
|
||||
{status && <p role="status" className="text-sm text-gray-700 mt-4">{status}</p>}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -57,13 +57,13 @@ function ActivateAccountContent() {
|
||||
{!token && <p className="text-red-600 mb-4">Missing or invalid activation link.</p>}
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
<label htmlFor="activate-password" className="block text-sm font-medium mb-1">Password</label>
|
||||
<input id="activate-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
<p className="text-xs text-gray-500 mt-1">At least 8 characters.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Confirm Password</label>
|
||||
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
<label htmlFor="activate-confirm-password" className="block text-sm font-medium mb-1">Confirm Password</label>
|
||||
<input id="activate-confirm-password" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
</div>
|
||||
{password && confirm && password !== confirm && (
|
||||
<p className="text-xs text-red-600">Passwords do not match.</p>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default async function VerifyCertificatePage({ params }: { params: Promis
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
|
||||
<p className="text-xs text-gray-500 mt-6 font-mono">{decodeURIComponent(code).toUpperCase()}</p>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -151,8 +151,8 @@ export default function ConsentPage() {
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-800">Type your full name to sign</label>
|
||||
<input className={inputCls} value={signedName} onChange={e => setSignedName(e.target.value)} aria-label="Your full name" />
|
||||
<label htmlFor="consent-type-your-full-name" className="block text-sm font-medium text-gray-800">Type your full name to sign</label>
|
||||
<input id="consent-type-your-full-name" className={inputCls} value={signedName} onChange={e => setSignedName(e.target.value)} aria-label="Your full name" />
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={isGuardian} onChange={e => setIsGuardian(e.target.checked)} />
|
||||
<span>I am the parent or legal guardian of {info.attendeeName}.</span>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function ContactPage() {
|
||||
</div>
|
||||
|
||||
{!loading && !hasAny && (
|
||||
<p className="text-center text-sm text-gray-400">Contact details haven't been set up yet.</p>
|
||||
<p className="text-center text-sm text-gray-500">Contact details haven't been set up yet.</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
|
||||
@@ -10,50 +10,6 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
const ACTIONS = [
|
||||
"refund_created",
|
||||
"donation_assigned",
|
||||
"donation_unassigned",
|
||||
"registration_created_manual",
|
||||
"registration_cancelled",
|
||||
"event_created",
|
||||
"event_updated",
|
||||
"event_deleted",
|
||||
"settings_updated",
|
||||
"coupon_created",
|
||||
"coupon_updated",
|
||||
"coupon_disabled",
|
||||
"coupon_applied_manual",
|
||||
"bundle_created",
|
||||
"bundle_updated",
|
||||
"bundle_deleted",
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
"scan_lock_acquired",
|
||||
"scan_lock_released",
|
||||
"scan_lock_force_released",
|
||||
"offline_sync",
|
||||
"ticket_transferred",
|
||||
"impersonation_started",
|
||||
"impersonation_ended",
|
||||
"yoco_config_updated",
|
||||
"yoco_webhook_registered",
|
||||
"yoco_webhook_deleted",
|
||||
"data_request_completed",
|
||||
"data_request_rejected",
|
||||
"user_anonymised",
|
||||
"privacy_notice_published",
|
||||
"operator_created",
|
||||
"operator_updated",
|
||||
"operator_deleted",
|
||||
"retention_policies_updated",
|
||||
"retention_run",
|
||||
"breach_recorded",
|
||||
"breach_updated",
|
||||
"hub_connected",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
refund_created: "Refund created",
|
||||
donation_assigned: "Donation assigned",
|
||||
@@ -121,8 +77,24 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
series_updated: "Event series changed",
|
||||
series_deleted: "Event series deleted",
|
||||
season_pass_changed: "Season pass created, changed or deleted",
|
||||
registrations_bulk: "Bulk action on registrations",
|
||||
attendance_marked: "Attendance marked",
|
||||
attendance_unmarked: "Attendance unmarked",
|
||||
certificate_issued: "Certificate issued",
|
||||
certificate_revoked: "Certificate revoked",
|
||||
event_sessions_changed: "Event sessions changed",
|
||||
job_run_manual: "Background job run by hand",
|
||||
payfast_config_updated: "PayFast settings changed",
|
||||
payment_providers_updated: "Payment providers changed",
|
||||
webhook_endpoint_changed: "Webhook added, changed or deleted",
|
||||
};
|
||||
|
||||
// Every action that can be logged (keep in step with AdminAuditAction in schema.prisma),
|
||||
// sorted by label for the filter.
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).sort((x, y) => x[1].localeCompare(y[1]));
|
||||
|
||||
const ROLES = ["admin", "supervisor", "staff"] as const;
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
@@ -211,16 +183,32 @@ export default function AdminAuditLogPage() {
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const [actionFilter, setActionFilter] = useState<string>("");
|
||||
const [roleFilter, setRoleFilter] = useState<string>("");
|
||||
const [searchInput, setSearchInput] = useState<string>("");
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [fromFilter, setFromFilter] = useState<string>("");
|
||||
const [toFilter, setToFilter] = useState<string>("");
|
||||
|
||||
const buildQuery = useCallback((p: number) => {
|
||||
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
|
||||
if (actionFilter) qs.set("action", actionFilter);
|
||||
if (roleFilter) qs.set("role", roleFilter);
|
||||
if (search) qs.set("search", search);
|
||||
if (fromFilter) qs.set("from", fromFilter);
|
||||
if (toFilter) qs.set("to", toFilter);
|
||||
return `/api/admin/audit-log?${qs.toString()}`;
|
||||
}, [actionFilter, fromFilter, toFilter]);
|
||||
}, [actionFilter, roleFilter, search, fromFilter, toFilter]);
|
||||
|
||||
// Search as you type, once typing pauses.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(searchInput.trim()), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
const filtered = !!(actionFilter || roleFilter || search || fromFilter || toFilter);
|
||||
const clearFilters = () => {
|
||||
setActionFilter(""); setRoleFilter(""); setSearchInput(""); setSearch(""); setFromFilter(""); setToFilter("");
|
||||
};
|
||||
|
||||
const load = useCallback(async (p = 1) => {
|
||||
if (!token) return;
|
||||
@@ -250,7 +238,7 @@ export default function AdminAuditLogPage() {
|
||||
<div>
|
||||
<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>
|
||||
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} {filtered ? "match the filters" : "recorded"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -261,25 +249,48 @@ export default function AdminAuditLogPage() {
|
||||
)}
|
||||
{isAdmin && token && <ImpersonationSessions token={token} />}
|
||||
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{error && <div role="alert" className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div className="flex-1 min-w-[14rem]">
|
||||
<label htmlFor="audit-search" className="block text-xs text-gray-600 mb-1">Search</label>
|
||||
<input
|
||||
id="audit-search"
|
||||
type="search"
|
||||
className="w-full border rounded px-2 py-1.5 text-sm"
|
||||
placeholder="Person, target (e.g. Event or #id) or IP"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Action</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
|
||||
<label htmlFor="audit-action" className="block text-xs text-gray-600 mb-1">Action</label>
|
||||
<select id="audit-action" className="border rounded px-2 py-1.5 text-sm max-w-[16rem]" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
|
||||
<option value="">All actions</option>
|
||||
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
|
||||
{ACTION_OPTIONS.map(([a, label]) => <option key={a} value={a}>{label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
|
||||
<label htmlFor="audit-role" className="block text-xs text-gray-600 mb-1">Role</label>
|
||||
<select id="audit-role" className="border rounded px-2 py-1.5 text-sm" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
|
||||
<option value="">Any role</option>
|
||||
{ROLES.map(r => <option key={r} value={r} className="capitalize">{r[0].toUpperCase() + r.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
|
||||
<label htmlFor="audit-from" className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input id="audit-from" type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="audit-to" className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input id="audit-to" type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} min={fromFilter || undefined} onChange={e => setToFilter(e.target.value)} />
|
||||
</div>
|
||||
{filtered && (
|
||||
<button className="text-sm px-2 py-1.5 rounded text-brand-700 hover:bg-brand-50" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
|
||||
{fetching ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
@@ -306,9 +317,9 @@ export default function AdminAuditLogPage() {
|
||||
<div className="text-xs text-gray-500">{e.actor.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Deleted user</span>
|
||||
<span className="text-gray-500 italic">Deleted user</span>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
|
||||
<div className="text-xs text-gray-500 capitalize">{e.actorRole}</div>
|
||||
</TableCell>
|
||||
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
|
||||
@@ -322,7 +333,7 @@ export default function AdminAuditLogPage() {
|
||||
)}
|
||||
{fetching && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-gray-400">Loading…</TableCell>
|
||||
<TableCell colSpan={5} className="text-gray-500">Loading…</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
@@ -330,7 +341,7 @@ export default function AdminAuditLogPage() {
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
||||
<span className="text-gray-500">{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} · page {page} of {totalPages}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Pager } from "@/components/shared/Pager";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Inbox, AlertTriangle } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -135,18 +136,33 @@ export default function DataRequestsPage() {
|
||||
const [view, setView] = useState<"open" | "closed">("open");
|
||||
const [requests, setRequests] = useState<DataRequest[] | null>(null);
|
||||
const [openCount, setOpenCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const pageSize = 50;
|
||||
|
||||
// Search once typing pauses; a new search or tab starts at page 1.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(searchInput.trim()), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
useEffect(() => { setPage(1); }, [view, search]);
|
||||
const [msg, setMsg] = useDismissingState<Msg>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ requests: DataRequest[]; openCount: number }>(`/api/privacy/requests?status=${view}`, { authToken: token });
|
||||
const qs = new URLSearchParams({ status: view, page: String(page), limit: String(pageSize) });
|
||||
if (search) qs.set("search", search);
|
||||
const res = await apiFetch<{ requests: DataRequest[]; openCount: number; total: number }>(`/api/privacy/requests?${qs.toString()}`, { authToken: token });
|
||||
setRequests(res.requests);
|
||||
setOpenCount(res.openCount);
|
||||
setTotal(res.total ?? res.requests.length);
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load requests." });
|
||||
}
|
||||
}, [token, view, setMsg]);
|
||||
}, [token, view, page, search, setMsg]);
|
||||
useEffect(() => { setRequests(null); load(); }, [load]);
|
||||
|
||||
return (
|
||||
@@ -163,20 +179,28 @@ export default function DataRequestsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{(["open", "closed"] as const).map(v => (
|
||||
<button key={v} onClick={() => setView(v)} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
|
||||
<button key={v} onClick={() => setView(v)} aria-pressed={view === v} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
|
||||
{v === "open" ? `Open${openCount ? ` (${openCount})` : ""}` : "Closed"}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Search by name or email"
|
||||
placeholder="Search by name or email"
|
||||
className="ml-auto border rounded-lg px-3 py-1.5 text-sm w-full sm:w-64"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`mb-4 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
|
||||
{requests === null ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
) : requests.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">{view === "open" ? "No open requests." : "No closed requests yet."}</p>
|
||||
<p className="text-sm text-gray-500">{search ? "No requests match your search." : view === "open" ? "No open requests." : "No closed requests yet."}</p>
|
||||
) : view === "open" ? (
|
||||
<ul className="space-y-3">
|
||||
{requests.map(r => <OpenRequest key={r.id} request={r} token={token!} onDone={m => { setMsg(m); load(); }} />)}
|
||||
@@ -189,13 +213,14 @@ export default function DataRequestsPage() {
|
||||
<span className="font-medium text-gray-900">{TYPE_LABEL[r.type]}</span>
|
||||
<span className="text-gray-700">— {r.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
|
||||
<span className="ml-auto text-xs text-gray-500">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
|
||||
</div>
|
||||
{r.response && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.response}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Pager page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats } from "@/types";
|
||||
import { CampusFilter } from "@/components/shared/CampusFilter";
|
||||
import { TodayPanel } from "@/components/dashboard/TodayPanel";
|
||||
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -124,11 +126,19 @@ export default function AdminDashboardPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||
</div>
|
||||
<CommandPaletteButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TodayPanel supervisor />
|
||||
|
||||
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mb-3" id="dashboard-campus" mine />
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need admin access to use these tools.
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ClipboardList } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, FormResponse, Registration } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RegistrationBulkBar } from "@/components/admin/RegistrationBulkBar";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -54,6 +56,20 @@ export default function AdminRegistrationsPage() {
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [includeInactiveEvents, setIncludeInactiveEvents] = useState(false);
|
||||
|
||||
// Deep links (the dashboard's "Waiting for you" list and the Ctrl+K palette): ?q=, ?status=, ?event=.
|
||||
// A search by reference may be for a past event's registration, so past events are included then.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const q = params.get("q");
|
||||
if (q) { setQuery(q); setIncludePastEvents(true); }
|
||||
if (params.get("status")) setStatusFilter(params.get("status") || "");
|
||||
if (params.get("event")) setEventFilter(params.get("event") || "");
|
||||
}, []);
|
||||
|
||||
// Ticked for a bulk action (1.49.1).
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const toggleSelected = (id: string) => setSelected(prev => { const s = new Set(prev); if (s.has(id)) s.delete(id); else s.add(id); return s; });
|
||||
|
||||
// Expanded rows + form responses cache
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
|
||||
@@ -163,6 +179,10 @@ export default function AdminRegistrationsPage() {
|
||||
});
|
||||
}, [registrations, query, eventFilter, statusFilter, eventIds]);
|
||||
|
||||
// Only ticked registrations still shown by the filters are acted on.
|
||||
const selectedRegs = useMemo(() => filtered.filter(r => selected.has(r.id)), [filtered, selected]);
|
||||
const allShownSelected = filtered.length > 0 && filtered.every(r => selected.has(r.id));
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
if (s === "paid") return "text-green-700 bg-green-50";
|
||||
if (s === "confirmed") return "text-blue-700 bg-blue-50";
|
||||
@@ -273,11 +293,30 @@ export default function AdminRegistrationsPage() {
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
|
||||
{loadingRegs ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
<SavedFilters
|
||||
page="registrations"
|
||||
value={{ q: query, event: eventFilter, status: statusFilter, past: includePastEvents, inactive: includeInactiveEvents }}
|
||||
onApply={p => {
|
||||
setQuery(String(p.q || "")); setEventFilter(String(p.event || "")); setStatusFilter(String(p.status || ""));
|
||||
setIncludePastEvents(p.past === true); setIncludeInactiveEvents(p.inactive === true);
|
||||
}}
|
||||
skipDefault={typeof window !== "undefined" && /[?&](q|status|event)=/.test(window.location.search)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
|
||||
</div>
|
||||
|
||||
{token && selectedRegs.length > 0 && (
|
||||
<RegistrationBulkBar selected={selectedRegs} events={events} token={token} onClear={() => setSelected(new Set())} onDone={loadRegistrations} />
|
||||
)}
|
||||
|
||||
<div className="border rounded-xl bg-white shadow-sm">
|
||||
{filtered.length > 0 && (
|
||||
<label className="flex items-center gap-2 px-3 py-2 border-b text-xs text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" aria-label="Select all shown" checked={allShownSelected} onChange={e => setSelected(e.target.checked ? new Set(filtered.map(r => r.id)) : new Set())} />
|
||||
Select all {filtered.length} shown
|
||||
</label>
|
||||
)}
|
||||
<ul className="divide-y text-sm">
|
||||
{filtered.map((r) => {
|
||||
const totalDue = totalDueFor(r);
|
||||
@@ -291,6 +330,10 @@ export default function AdminRegistrationsPage() {
|
||||
<li key={r.id} className="hover:bg-gray-50">
|
||||
<div className="p-3 cursor-pointer" onClick={() => toggleExpand(r)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<input
|
||||
type="checkbox" className="mt-1 shrink-0" aria-label={`Select ${r.user?.name || r.userId}`}
|
||||
checked={selected.has(r.id)} onClick={e => e.stopPropagation()} onChange={() => toggleSelected(r.id)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">{r.user?.name || r.userId}</span>
|
||||
|
||||
@@ -728,6 +728,7 @@ function SiteSettingsPageInner() {
|
||||
<div className="lg:col-span-2 lg:sticky lg:top-4">
|
||||
<BrandingPreviewPanel
|
||||
primaryColor={primaryColor}
|
||||
onUsePrimary={setPrimaryColor}
|
||||
secondaryColor={secondaryColor}
|
||||
accentColor={accentColor}
|
||||
logoSrc={currentLogoSrc}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useCampuses } from "@/hooks/useCampuses";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -58,6 +59,12 @@ export default function AdminUsersPage() {
|
||||
const [roleFilter, setRoleFilter] = useState<Role | "">("");
|
||||
const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">("");
|
||||
|
||||
// ?q= (the Ctrl+K palette links here with a person's email).
|
||||
useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search).get("q");
|
||||
if (q) { setQuery(q); setDebouncedQuery(q); }
|
||||
}, []);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 350);
|
||||
@@ -353,6 +360,17 @@ export default function AdminUsersPage() {
|
||||
<option value="false">Inactive only</option>
|
||||
</select>
|
||||
</div>
|
||||
<SavedFilters
|
||||
page="users" className="self-end"
|
||||
value={{ q: query, role: roleFilter, active: activeFilter }}
|
||||
onApply={p => {
|
||||
const q = String(p.q || "");
|
||||
setQuery(q); setDebouncedQuery(q);
|
||||
setRoleFilter(String(p.role || "") as Role | "");
|
||||
setActiveFilter(String(p.active || "") as "" | "true" | "false");
|
||||
}}
|
||||
skipDefault={typeof window !== "undefined" && /[?&]q=/.test(window.location.search)}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Per page</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
|
||||
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
|
||||
import { LicenceBanner } from "@/components/shared/LicenceBanner";
|
||||
import { ProviderNotices } from "@/components/shared/ProviderNotices";
|
||||
import { CommandPalette } from "@/components/shared/CommandPalette";
|
||||
|
||||
// The sidebar shows only on these exact routes — each role's dashboard root,
|
||||
// plus Profile & Security and Site Settings (matching the redesign
|
||||
@@ -92,6 +93,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<LicenceBanner />
|
||||
<ProviderNotices />
|
||||
<PrivacyPrompts />
|
||||
<CommandPalette />
|
||||
<MobileSidebar />
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<Sidebar />
|
||||
@@ -110,6 +112,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
<LicenceBanner />
|
||||
<ProviderNotices />
|
||||
<PrivacyPrompts />
|
||||
<CommandPalette />
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||
import { CampusScopeNote } from "@/components/shared/CampusFilter";
|
||||
import { TodayPanel } from "@/components/dashboard/TodayPanel";
|
||||
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
@@ -76,11 +78,18 @@ export default function StaffDashboardPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's today's scanning activity.</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's today's scanning activity.</p>
|
||||
</div>
|
||||
<CommandPaletteButton />
|
||||
</div>
|
||||
<CampusScopeNote className="mt-2" />
|
||||
</div>
|
||||
|
||||
<TodayPanel supervisor={false} />
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need staff, supervisor, or admin access to use staff tools.
|
||||
|
||||
@@ -1314,9 +1314,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => stepper(key, -1)} disabled={(quantities[key] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
|
||||
<button onClick={() => stepper(key, -1)} aria-label={`Remove one ${opt.name} (${v.name})`} disabled={(quantities[key] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
|
||||
<div className="w-6 text-center text-base font-semibold">{quantities[key] || 0}</div>
|
||||
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||||
<button onClick={() => stepper(key, 1)} aria-label={`Add one ${opt.name} (${v.name})`} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||||
</div>
|
||||
</div>
|
||||
{picker(key, opt)}
|
||||
@@ -1338,9 +1338,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
</div>
|
||||
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
|
||||
<div className="flex gap-3 items-center mt-2">
|
||||
<button onClick={() => stepper(opt.id, -1)} disabled={(quantities[opt.id] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
|
||||
<button onClick={() => stepper(opt.id, -1)} aria-label={`Remove one ${opt.name}`} disabled={(quantities[opt.id] || 0) <= minQty} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold disabled:opacity-40">–</button>
|
||||
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
|
||||
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||||
<button onClick={() => stepper(opt.id, 1)} aria-label={`Add one ${opt.name}`} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
|
||||
</div>
|
||||
{picker(opt.id, opt)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useMyCampuses } from "@/hooks/useCampuses";
|
||||
import { CampusBadge, CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import { Calendar, Copy } from "lucide-react";
|
||||
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
@@ -2113,6 +2114,19 @@ export default function ManageEventsPage() {
|
||||
};
|
||||
const closeModal = () => { setModalMode(null); setEditingEvent(null); setCopyFrom(null); };
|
||||
|
||||
// Deep links (the Ctrl+K palette): ?edit=<eventId> opens that event, ?new=1 a new one.
|
||||
const deepLinked = useRef(false);
|
||||
useEffect(() => {
|
||||
if (deepLinked.current || !token) return;
|
||||
deepLinked.current = true;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const edit = params.get("edit");
|
||||
if (edit) openEdit({ id: edit } as Event);
|
||||
else if (params.get("new") === "1") openCreate();
|
||||
if (edit || params.get("new")) window.history.replaceState(null, "", window.location.pathname);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
|
||||
@@ -2146,6 +2160,14 @@ export default function ManageEventsPage() {
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Show closed
|
||||
</label>
|
||||
<SavedFilters
|
||||
page="events"
|
||||
value={{ q: search, campus, past: includePast, inactive: showInactive, closed: showClosed }}
|
||||
onApply={p => {
|
||||
setSearch(String(p.q || "")); setCampus(String(p.campus || ""));
|
||||
setIncludePast(p.past === true); setShowInactive(p.inactive === true); setShowClosed(p.closed === true);
|
||||
}}
|
||||
/>
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
|
||||
import { CampusFilter, CampusScopeNote } from "@/components/shared/CampusFilter";
|
||||
import { TodayPanel } from "@/components/dashboard/TodayPanel";
|
||||
import { CommandPaletteButton } from "@/components/shared/CommandPalette";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -120,12 +122,20 @@ export default function SupervisorDashboardPage() {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mt-3" id="dashboard-campus" mine />
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Here's what's happening with your events today.</p>
|
||||
</div>
|
||||
<CommandPaletteButton />
|
||||
</div>
|
||||
<CampusScopeNote className="mt-2" />
|
||||
</div>
|
||||
|
||||
<TodayPanel supervisor />
|
||||
|
||||
<CampusFilter value={campus} onChange={setCampus} everyLabel="All campuses (church-wide total)" className="mb-3" id="dashboard-campus" mine />
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need supervisor or admin access to use these tools.
|
||||
|
||||
@@ -53,6 +53,19 @@ type ProviderTx = {
|
||||
note?: string | null;
|
||||
};
|
||||
|
||||
// What each registration still owes. The backend attaches a tranche-aware totalDueComputed
|
||||
// (exact even when a line spans multiple early-bird prices); otherwise estimate it here.
|
||||
function computeOutstanding(list: Registration[]): Outstanding {
|
||||
const now = new Date();
|
||||
const map: Outstanding = {};
|
||||
for (const r of list) {
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function RegistrationOptions({ regs, regOutstanding }: {
|
||||
regs: Registration[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
@@ -161,7 +174,11 @@ function PaymentsContent() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
// Not every payment ever made: the latest few for "Recent payments", today's for the totals,
|
||||
// and donations with the legs that apply them (for assigning and the assigned list).
|
||||
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
|
||||
const [todayPayments, setTodayPayments] = useState<Payment[]>([]);
|
||||
const [donationPayments, setDonationPayments] = useState<Payment[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
@@ -173,8 +190,15 @@ function PaymentsContent() {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingList(true);
|
||||
const list = await fetchAllPayments(token);
|
||||
setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||||
const [recent, today, donations] = await Promise.all([
|
||||
apiFetch<{ data: Payment[] }>("/api/payments?page=1&limit=40", { authToken: token }),
|
||||
fetchAllPayments(token, { from: start.toISOString() }),
|
||||
fetchAllPayments(token, { donations: "1" }),
|
||||
]);
|
||||
setRecentPayments(recent?.data || []);
|
||||
setTodayPayments(today);
|
||||
setDonationPayments(donations);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load payments");
|
||||
} finally {
|
||||
@@ -184,25 +208,16 @@ function PaymentsContent() {
|
||||
|
||||
useEffect(() => { loadPayments(); }, [loadPayments]);
|
||||
|
||||
// Load registrations for dropdowns.
|
||||
// GET /api/registrations already embeds each registration's `payments`, so outstanding
|
||||
// balances are computed from that in one pass — no per-registration follow-up requests.
|
||||
// Registrations still owing (not paid or cancelled), for the dropdowns. GET /api/registrations
|
||||
// embeds each registration's `payments`, so balances come from that in one pass. Refunds load
|
||||
// the chosen person's own registrations (including paid ones) when needed.
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations?unpaid=1", { authToken: token });
|
||||
const list = Array.isArray(regs) ? regs : [];
|
||||
const now = new Date();
|
||||
const map: Outstanding = {};
|
||||
for (const r of list) {
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
setRegOutstanding(map);
|
||||
setRegOutstanding(computeOutstanding(list));
|
||||
// Sort by createdAt desc
|
||||
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
setRegistrations(sorted);
|
||||
@@ -420,11 +435,11 @@ function PaymentsContent() {
|
||||
const todayTotals = useMemo(() => {
|
||||
const start = new Date(); start.setHours(0,0,0,0);
|
||||
// Exclude donation-application legs — that money was already counted once, as the donation.
|
||||
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
|
||||
const today = todayPayments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
|
||||
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
|
||||
const donations = today.filter(p => p.isDonation).length;
|
||||
return { revenue, donations, count: today.length };
|
||||
}, [payments]);
|
||||
}, [todayPayments]);
|
||||
|
||||
const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment");
|
||||
|
||||
@@ -741,10 +756,7 @@ function PaymentsContent() {
|
||||
{mode === 'refund' && (
|
||||
<>
|
||||
<RefundSection
|
||||
payments={payments}
|
||||
allUsers={allUsers}
|
||||
regsForUser={(uid:string)=> registrations.filter((r)=> String(r.user?.id||r.userId)===String(uid))}
|
||||
regOutstanding={regOutstanding}
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
|
||||
/>
|
||||
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
|
||||
@@ -754,7 +766,7 @@ function PaymentsContent() {
|
||||
{mode === 'donation' && (
|
||||
<>
|
||||
<DonationAssignSection
|
||||
payments={payments}
|
||||
payments={donationPayments}
|
||||
allUsers={allUsers}
|
||||
registrations={registrations}
|
||||
regOutstanding={regOutstanding}
|
||||
@@ -762,7 +774,7 @@ function PaymentsContent() {
|
||||
/>
|
||||
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
|
||||
<AssignedDonationsList
|
||||
payments={payments}
|
||||
payments={donationPayments}
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
|
||||
/>
|
||||
</>
|
||||
@@ -815,7 +827,7 @@ function PaymentsContent() {
|
||||
{loadingList && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
|
||||
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
|
||||
{recentPayments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
|
||||
const amt = p.amount || 0;
|
||||
const isRefund = amt < 0;
|
||||
return (
|
||||
@@ -834,7 +846,7 @@ function PaymentsContent() {
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{payments.length === 0 && <li className="text-gray-500">No payments yet.</li>}
|
||||
{recentPayments.length === 0 && <li className="text-gray-500">No payments yet.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -864,14 +876,13 @@ function PaymentsContent() {
|
||||
}
|
||||
|
||||
type RefundSectionProps = {
|
||||
payments: Payment[];
|
||||
allUsers: User[];
|
||||
regsForUser: (userId: string) => Registration[];
|
||||
regOutstanding: Outstanding;
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
|
||||
// The chosen person's own payments and registrations (paid ones too) are loaded when they're
|
||||
// picked, rather than every payment and registration up front.
|
||||
function RefundSection({ allUsers, onDone }: RefundSectionProps) {
|
||||
const { token } = useAuth();
|
||||
const [userId, setUserId] = useState<string>("");
|
||||
const [target, setTarget] = useState<'payment'|'registration'>('payment');
|
||||
@@ -883,10 +894,28 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const paymentsForUser = useMemo(() => {
|
||||
if (!userId) return [];
|
||||
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
|
||||
}, [payments, userId]);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [regs, setRegs] = useState<Registration[]>([]);
|
||||
const [loadingUser, setLoadingUser] = useState(false);
|
||||
useEffect(() => {
|
||||
setPayments([]); setRegs([]);
|
||||
if (!userId || !token) return;
|
||||
let cancelled = false;
|
||||
setLoadingUser(true);
|
||||
Promise.all([
|
||||
fetchAllPayments(token, { userId }),
|
||||
apiFetch<Registration[]>(`/api/registrations?userId=${encodeURIComponent(userId)}`, { authToken: token }),
|
||||
]).then(([p, r]) => {
|
||||
if (cancelled) return;
|
||||
setPayments(p);
|
||||
setRegs((Array.isArray(r) ? r : []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
}).catch((e) => { if (!cancelled) setErr(errorMessage(e) || "Couldn't load this person's payments"); })
|
||||
.finally(() => { if (!cancelled) setLoadingUser(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [userId, token]);
|
||||
const regOutstanding = useMemo(() => computeOutstanding(regs), [regs]);
|
||||
|
||||
const paymentsForUser = useMemo(() => payments.filter(p => (p.amount || 0) > 0), [payments]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset dependent fields on changes
|
||||
@@ -907,7 +936,6 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
|
||||
}
|
||||
}, [paymentId, target, payments]);
|
||||
|
||||
const regs = useMemo(() => regsForUser(userId), [regsForUser, userId]);
|
||||
|
||||
const submitRefund = async () => {
|
||||
if (!token) return;
|
||||
@@ -948,6 +976,7 @@ function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs text-gray-600">User</label>
|
||||
<UserSearchField allUsers={allUsers} value={userId} onChange={uid => setUserId(uid)} />
|
||||
{loadingUser && <div className="text-xs text-gray-500" role="status">Loading their payments…</div>}
|
||||
<label className="text-xs text-gray-600 mt-2">Refund target</label>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<label className="flex items-center gap-2"><input type="radio" name="refundTarget" checked={target==='payment'} onChange={()=>setTarget('payment')} /> Payment</label>
|
||||
|
||||
@@ -83,13 +83,14 @@ export default function DonatePage() {
|
||||
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
|
||||
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
|
||||
<label htmlFor="donate-event" className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select id="donate-event" className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
|
||||
</select>
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
|
||||
<label htmlFor="donate-amount" className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
|
||||
<input
|
||||
id="donate-amount"
|
||||
type="number"
|
||||
min="15"
|
||||
step="1"
|
||||
|
||||
@@ -207,17 +207,17 @@ function FormsContent() {
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
|
||||
) : f.type === 'yes_no' ? (
|
||||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
|
||||
<select aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
) : f.type === 'date' ? (
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
<input type="date" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
) : f.type === 'numeric' ? (
|
||||
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
<input type="number" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
) : (
|
||||
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
<input type="text" aria-label={f.label} className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
)}
|
||||
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
|
||||
</>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BundleSavings } from "@/components/shared/BundleDeals";
|
||||
import { MyCertificates } from "@/components/events/MyCertificates";
|
||||
import { isPickOption } from "@/lib/sessions";
|
||||
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
|
||||
// Helper formatters
|
||||
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
||||
@@ -952,10 +953,10 @@ export default function UserDashboardPage() {
|
||||
)}
|
||||
|
||||
{activeReg && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<ModalOverlay labelledBy="registration-title" closeOnBackdrop className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClose={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<h3 id="registration-title" className="text-lg font-semibold flex items-center gap-2">
|
||||
<span>{activeReg.event?.title || activeReg.eventId}</span>
|
||||
<EventStatusBadge event={activeReg.event} />
|
||||
</h3>
|
||||
@@ -1057,14 +1058,14 @@ export default function UserDashboardPage() {
|
||||
className="px-3 py-1 text-sm rounded border border-brand-600 text-brand-700 hover:bg-brand-50 disabled:opacity-50"
|
||||
>{codeBusy ? 'Applying…' : 'Apply'}</button>
|
||||
</div>
|
||||
{codeError && <p className="text-xs text-red-600 mt-1">{codeError}</p>}
|
||||
{codeError && <p role="alert" className="text-xs text-red-600 mt-1">{codeError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="border rounded p-3 space-y-2 bg-gray-50">
|
||||
{editError && <p className="text-xs text-red-600">{editError}</p>}
|
||||
{editError && <p role="alert" className="text-xs text-red-600">{editError}</p>}
|
||||
{editLoading ? (
|
||||
<div className="text-sm text-gray-600">Loading options…</div>
|
||||
) : (
|
||||
@@ -1073,7 +1074,7 @@ export default function UserDashboardPage() {
|
||||
{editEventOptions.map((eo) => {
|
||||
const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0;
|
||||
|
||||
const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
|
||||
const renderQtyInput = (key: string, label: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
|
||||
const qty = editQuantities[key] || 0;
|
||||
const minQty = editMinQty[key] || 0;
|
||||
const baseQty = editBaseQty[key] || 0;
|
||||
@@ -1082,8 +1083,8 @@ export default function UserDashboardPage() {
|
||||
: undefined;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-600">Qty</label>
|
||||
<input type="number" min={minQty} max={maxQty} value={qty}
|
||||
<label htmlFor={`qty-${key}`} className="text-xs text-gray-600">Qty</label>
|
||||
<input id={`qty-${key}`} aria-label={`${label} quantity`} type="number" min={minQty} max={maxQty} value={qty}
|
||||
onChange={e => {
|
||||
let v = Math.max(minQty, parseInt(e.target.value || '0', 10) || 0);
|
||||
if (maxQty !== undefined) v = Math.min(v, maxQty);
|
||||
@@ -1113,7 +1114,7 @@ export default function UserDashboardPage() {
|
||||
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
|
||||
)}
|
||||
</div>
|
||||
{renderQtyInput(key, unitPrice, v.stockLimit, v.availableCount)}
|
||||
{renderQtyInput(key, `${eo.name} (${v.name})`, unitPrice, v.stockLimit, v.availableCount)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -1134,7 +1135,7 @@ export default function UserDashboardPage() {
|
||||
<div className="text-gray-500 text-[11px]">{minQty} already issued</div>
|
||||
)}
|
||||
</div>
|
||||
{renderQtyInput(key, unitPrice, eo.stockLimit, eo.availableCount)}
|
||||
{renderQtyInput(key, eo.name, unitPrice, eo.stockLimit, eo.availableCount)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1243,16 +1244,18 @@ export default function UserDashboardPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
|
||||
{channelPicker && (
|
||||
<div
|
||||
<ModalOverlay
|
||||
labelledBy="channel-title"
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
|
||||
onClick={() => setChannelPicker(null)}
|
||||
onClose={() => setChannelPicker(null)}
|
||||
closeOnBackdrop
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5">
|
||||
<h2 id="channel-title" className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
@@ -1266,26 +1269,28 @@ export default function UserDashboardPage() {
|
||||
>WhatsApp</button>
|
||||
)}
|
||||
</div>
|
||||
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
|
||||
<button className="mt-3 text-xs text-gray-600 hover:underline px-1 py-1" onClick={() => setChannelPicker(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
|
||||
{dialog.open && (
|
||||
<div
|
||||
<ModalOverlay
|
||||
labelledBy="send-dialog-title"
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={() => { if (!dialog.loading) setDialog({ open: false, message: "", loading: false }); }}
|
||||
onClose={() => { if (!dialog.loading) setDialog({ open: false, message: "", loading: false }); }}
|
||||
closeOnBackdrop
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" aria-live="polite">
|
||||
{dialog.loading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
|
||||
<div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
|
||||
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-hidden="true" />
|
||||
<div id="send-dialog-title" className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
|
||||
<h2 id="send-dialog-title" className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</h2>
|
||||
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
@@ -1296,7 +1301,7 @@ export default function UserDashboardPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,8 +123,9 @@ export default function UserPaymentsPage() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<label htmlFor="pay-from" className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input
|
||||
id="pay-from"
|
||||
type="date"
|
||||
className="border rounded px-2 py-1.5 text-sm"
|
||||
value={startDate}
|
||||
@@ -132,8 +133,9 @@ export default function UserPaymentsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<label htmlFor="pay-to" className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input
|
||||
id="pay-to"
|
||||
type="date"
|
||||
className="border rounded px-2 py-1.5 text-sm"
|
||||
value={endDate}
|
||||
@@ -141,8 +143,8 @@ export default function UserPaymentsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
|
||||
<label htmlFor="pay-method" className="block text-xs text-gray-600 mb-1">Method</label>
|
||||
<select id="pay-method" className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
|
||||
<option value="">All methods</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="card">Card</option>
|
||||
@@ -152,8 +154,8 @@ export default function UserPaymentsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as "" | "payment" | "refund")}>
|
||||
<label htmlFor="pay-type" className="block text-xs text-gray-600 mb-1">Type</label>
|
||||
<select id="pay-type" className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as "" | "payment" | "refund")}>
|
||||
<option value="">Payments & refunds</option>
|
||||
<option value="payment">Payments only</option>
|
||||
<option value="refund">Refunds only</option>
|
||||
@@ -205,7 +207,7 @@ export default function UserPaymentsPage() {
|
||||
{payments.length === 0 && !fetching && (
|
||||
<li className="text-gray-500">No payments found.</li>
|
||||
)}
|
||||
{fetching && <li className="text-gray-400">Loading…</li>}
|
||||
{fetching && <li className="text-gray-500">Loading…</li>}
|
||||
</ul>
|
||||
|
||||
{totalPages > 1 && (
|
||||
@@ -226,7 +228,7 @@ export default function UserPaymentsPage() {
|
||||
}, [])
|
||||
.map((p, i) =>
|
||||
p === "…" ? (
|
||||
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400">…</span>
|
||||
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-500">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
|
||||
@@ -220,7 +220,7 @@ export default function PrivacyCentrePage() {
|
||||
onChange={v => setNews("whatsapp", v)}
|
||||
/>
|
||||
</div>
|
||||
) : !loadError && <p className="text-sm text-gray-400">Loading…</p>}
|
||||
) : !loadError && <p className="text-sm text-gray-500">Loading…</p>}
|
||||
{newsMsg && <Alert msg={newsMsg} />}
|
||||
{info && info.history.length > 0 && (
|
||||
<details className="mt-4">
|
||||
@@ -264,7 +264,7 @@ export default function PrivacyCentrePage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-800">{TYPE_LABEL[r.type]}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
|
||||
<span className="text-xs text-gray-400 ml-auto">{new Date(r.createdAt).toLocaleDateString()}</span>
|
||||
<span className="text-xs text-gray-500 ml-auto">{new Date(r.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{r.details && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.details}</p>}
|
||||
{r.response && <p className="text-gray-500 mt-1 text-xs whitespace-pre-line">Our answer: {r.response}</p>}
|
||||
@@ -317,12 +317,12 @@ export default function PrivacyCentrePage() {
|
||||
<strong>You won't be able to log in again.</strong> Download your data first if you want a copy.
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reason (optional)</label>
|
||||
<textarea className={inputCls} rows={2} maxLength={1000} value={deleteReason} onChange={e => setDeleteReason(e.target.value)} />
|
||||
<label htmlFor="privacy-reason" className="block text-sm font-medium text-gray-700 mb-1">Reason (optional)</label>
|
||||
<textarea id="privacy-reason" className={inputCls} rows={2} maxLength={1000} value={deleteReason} onChange={e => setDeleteReason(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={deletePassword} onChange={e => setDeletePassword(e.target.value)} required autoComplete="current-password" />
|
||||
<label htmlFor="privacy-confirm-with-your-password" className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input id="privacy-confirm-with-your-password" type="password" className={inputCls} value={deletePassword} onChange={e => setDeletePassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => { setDeleteStep("idle"); setDeletePassword(""); setDeleteReason(""); setDeleteMsg(null); }} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
|
||||
@@ -328,16 +328,16 @@ export default function UserProfilePage() {
|
||||
<SectionHeader icon={User} title="Personal information" />
|
||||
<form onSubmit={saveProfile} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
|
||||
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
|
||||
<label htmlFor="profile-full-name" className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
|
||||
<input id="profile-full-name" className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||
<input type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
<label htmlFor="profile-email-address" className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
|
||||
<input id="profile-email-address" type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
|
||||
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
|
||||
<label htmlFor="profile-phone-number" className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
|
||||
<input id="profile-phone-number" type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
|
||||
</div>
|
||||
|
||||
{campusInfo.enabled && campusInfo.campuses.length > 0 && (
|
||||
@@ -353,8 +353,8 @@ export default function UserProfilePage() {
|
||||
|
||||
{hasValidPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
|
||||
<select className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
|
||||
<label htmlFor="profile-notification-preference" className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
|
||||
<select id="profile-notification-preference" className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
|
||||
<option value="email">Email only</option>
|
||||
<option value="whatsapp">WhatsApp only</option>
|
||||
<option value="both">Email & WhatsApp</option>
|
||||
@@ -374,7 +374,7 @@ export default function UserProfilePage() {
|
||||
<section className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<SectionHeader icon={Clock} title="Account activity" />
|
||||
{loadingActivity && activity.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
) : activity.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No recent activity recorded yet.</p>
|
||||
) : (
|
||||
@@ -389,7 +389,7 @@ export default function UserProfilePage() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-gray-800">{meta.label(ev.device)}</div>
|
||||
<div className="text-xs text-gray-400">{new Date(ev.createdAt).toLocaleString()}</div>
|
||||
<div className="text-xs text-gray-500">{new Date(ev.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -405,16 +405,16 @@ export default function UserProfilePage() {
|
||||
<SectionHeader icon={Lock} title="Change password" />
|
||||
<form onSubmit={changePassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
|
||||
<input type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
|
||||
<label htmlFor="profile-current-password" className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
|
||||
<input id="profile-current-password" type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
|
||||
<input type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
|
||||
<label htmlFor="profile-new-password" className="block text-sm font-medium text-gray-700 mb-1">New password</label>
|
||||
<input id="profile-new-password" type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
|
||||
<input type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
|
||||
<label htmlFor="profile-confirm-new-password" className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
|
||||
<input id="profile-confirm-new-password" type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
|
||||
</div>
|
||||
<button type="submit" disabled={savingPw} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{savingPw ? "Changing…" : "Change password"}
|
||||
@@ -433,8 +433,9 @@ export default function UserProfilePage() {
|
||||
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. {tfEnabled ? "Enter it to switch your codes to this channel." : "Enter it to finish turning on two-factor authentication."}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
<label htmlFor="profile-verification-code" className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
<input
|
||||
id="profile-verification-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
@@ -480,8 +481,8 @@ export default function UserProfilePage() {
|
||||
) : (
|
||||
<form onSubmit={disableTwoFactor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
<label htmlFor="profile-confirm-with-your-password" className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input id="profile-confirm-with-your-password" type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
|
||||
@@ -502,8 +503,8 @@ export default function UserProfilePage() {
|
||||
: "Add a second step to logging in: after your password, we send a 6-digit code that you need to enter."}
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
|
||||
<select className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
|
||||
<label htmlFor="profile-send-my-codes-by" className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
|
||||
<select id="profile-send-my-codes-by" className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
|
||||
<option value="email">Email</option>
|
||||
{canWhatsAppCodes && <option value="whatsapp">WhatsApp</option>}
|
||||
</select>
|
||||
@@ -512,8 +513,8 @@ export default function UserProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
<label htmlFor="profile-confirm-with-your-password-2" className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
|
||||
<input id="profile-confirm-with-your-password-2" type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{tfEnabled && (
|
||||
@@ -587,10 +588,11 @@ export default function UserProfilePage() {
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="profile-confirm-with-your-password-3" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm with your password
|
||||
</label>
|
||||
<input
|
||||
id="profile-confirm-with-your-password-3"
|
||||
type="password"
|
||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
value={closePassword}
|
||||
|
||||
@@ -64,8 +64,9 @@ export default function ResetPasswordPage() {
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Current password</label>
|
||||
<label htmlFor="newpw-current-password" className="block text-sm font-medium mb-1">Current password</label>
|
||||
<input
|
||||
id="newpw-current-password"
|
||||
type="password"
|
||||
value={current}
|
||||
onChange={e => setCurrent(e.target.value)}
|
||||
@@ -76,8 +77,9 @@ export default function ResetPasswordPage() {
|
||||
<p className="text-xs text-gray-500 mt-1">For your security, please confirm your current password.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">New password</label>
|
||||
<label htmlFor="newpw-new-password" className="block text-sm font-medium mb-1">New password</label>
|
||||
<input
|
||||
id="newpw-new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
@@ -87,8 +89,9 @@ export default function ResetPasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Confirm new password</label>
|
||||
<label htmlFor="newpw-confirm-new-password" className="block text-sm font-medium mb-1">Confirm new password</label>
|
||||
<input
|
||||
id="newpw-confirm-new-password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
|
||||
@@ -296,7 +296,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<Sparkles className="w-6 h-6 text-gray-300 mx-auto mb-1.5" />
|
||||
<p className="text-sm text-gray-400">Free entry — no tickets required.</p>
|
||||
<p className="text-sm text-gray-500">Free entry — no tickets required.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -367,7 +367,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
{activeTiers.length > 0 ? (
|
||||
<>
|
||||
<span className="text-brand-700 font-medium">R{displayPrice.toFixed(2)}</span>
|
||||
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
|
||||
<span className="text-xs text-gray-500 line-through">R{opt.price.toFixed(2)}</span>
|
||||
<span className="text-xs text-brand-600">Early bird</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function FeedbackPage() {
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} value={answers[f.id] || ""} onChange={v => set(f.id, v)} />
|
||||
) : f.type === "yes_no" ? (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="flex gap-4 text-sm" role="radiogroup" aria-label={f.label}>
|
||||
{["yes", "no"].map(v => (
|
||||
<label key={v} className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" name={f.id} checked={answers[f.id] === v} onChange={() => set(f.id, v)} /> {v === "yes" ? "Yes" : "No"}
|
||||
@@ -107,18 +107,18 @@ export default function FeedbackPage() {
|
||||
))}
|
||||
</div>
|
||||
) : f.type === "numeric" ? (
|
||||
<input type="number" className={`${inputCls} sm:w-40`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
|
||||
<input type="number" aria-label={f.label} className={`${inputCls} sm:w-40`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
|
||||
) : f.type === "date" ? (
|
||||
<input type="date" className={`${inputCls} sm:w-48`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
|
||||
<input type="date" aria-label={f.label} className={`${inputCls} sm:w-48`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
|
||||
) : (
|
||||
<textarea className={inputCls} rows={3} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} maxLength={4000} />
|
||||
<textarea aria-label={f.label} className={inputCls} rows={3} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} maxLength={4000} />
|
||||
)}
|
||||
{f.helpText && <p className="text-xs text-gray-500 mt-1">{f.helpText}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{error && <div role="alert" className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<button type="submit" disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Sending…" : "Send feedback"}
|
||||
</button>
|
||||
|
||||
@@ -257,17 +257,17 @@ function FormsContent() {
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
|
||||
) : f.type === 'yes_no' ? (
|
||||
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
|
||||
<select aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
) : f.type === 'date' ? (
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
<input type="date" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
) : f.type === 'numeric' ? (
|
||||
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
<input type="number" aria-label={f.label} className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
) : (
|
||||
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
<input type="text" aria-label={f.label} className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
|
||||
)}
|
||||
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
|
||||
</>
|
||||
|
||||
@@ -72,3 +72,41 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/*
|
||||
* Accessibility (WCAG 2.2 AA, 1.50.0): a clear focus ring for keyboard users on every
|
||||
* focusable element (mouse clicks don't show it), in the brand colour with a white halo
|
||||
* so it shows on brand-coloured buttons too. Utilities such as `outline-none` still win.
|
||||
*/
|
||||
@layer base {
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--brand-600);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* People who ask their device for less motion get no smooth scrolling or animations. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Links inside a sentence are underlined, so they don't rely on colour alone (WCAG 1.4.1).
|
||||
A link styled as a button inside a paragraph can opt out with `no-underline`. */
|
||||
@layer base {
|
||||
p a,
|
||||
label a {
|
||||
text-decoration-line: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SetupGuard } from "@/components/shared/SetupGuard";
|
||||
import HelpFab from "@/components/shared/HelpFab";
|
||||
import { CookieConsent } from "@/components/shared/CookieConsent";
|
||||
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
|
||||
import { SkipLink } from "@/components/layout/SkipLink";
|
||||
import { resolveToApiOrigin } from "@/lib/api";
|
||||
import { getServerSettings } from "@/lib/serverSettings";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
@@ -61,6 +62,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
{cssVars && (
|
||||
<style id="brand-theme" dangerouslySetInnerHTML={{ __html: `:root{${cssVars}}` }} />
|
||||
)}
|
||||
<SkipLink />
|
||||
<SiteSettingsProvider initialSettings={settings}>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
|
||||
@@ -56,7 +56,7 @@ function PrivacyNotice() {
|
||||
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
|
||||
<h1 className="text-4xl font-bold text-brand-700 mb-2">Privacy Policy</h1>
|
||||
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
|
||||
{!notice && !error && <p className="text-sm text-gray-400">Loading…</p>}
|
||||
{!notice && !error && <p className="text-sm text-gray-500">Loading…</p>}
|
||||
{notice && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
|
||||
@@ -129,29 +129,33 @@ function StockBadge({ stockLimit, availableCount }: { stockLimit?: number; avail
|
||||
}
|
||||
|
||||
function QtyControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
max,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
max?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5" role="group" aria-label={`${label} quantity`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove one ${label}`}
|
||||
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||
onClick={() => onChange(Math.max(0, value - 1))}
|
||||
disabled={disabled || value <= 0}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm font-medium">{value}</span>
|
||||
<span className="w-6 text-center text-sm font-medium" aria-live="polite">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Add one ${label}`}
|
||||
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
|
||||
onClick={() => onChange(value + 1)}
|
||||
disabled={disabled || (max !== undefined && max > 0 && value >= max)}
|
||||
@@ -245,6 +249,7 @@ function OptionCard({
|
||||
)}
|
||||
</div>
|
||||
<QtyControl
|
||||
label={`${opt.name} (${variant.name})`}
|
||||
value={qty}
|
||||
onChange={(v) => onQtyChange(key, v)}
|
||||
disabled={!!(isSoldOut || variantSoldOut)}
|
||||
@@ -292,6 +297,7 @@ function OptionCard({
|
||||
</div>
|
||||
</div>
|
||||
<QtyControl
|
||||
label={opt.name}
|
||||
value={qty}
|
||||
onChange={(v) => onQtyChange(key, v)}
|
||||
disabled={!!(isSoldOut)}
|
||||
|
||||
@@ -48,13 +48,13 @@ function ResetPasswordContent() {
|
||||
{!token && <p className="text-red-600 mb-4">Missing or invalid token.</p>}
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">New Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
<label htmlFor="reset-new-password" className="block text-sm font-medium mb-1">New Password</label>
|
||||
<input id="reset-new-password" type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
<p className="text-xs text-gray-500 mt-1">At least 8 characters.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Confirm Password</label>
|
||||
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
<label htmlFor="reset-confirm-password" className="block text-sm font-medium mb-1">Confirm Password</label>
|
||||
<input id="reset-confirm-password" type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
|
||||
</div>
|
||||
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full font-medium transition-colors">
|
||||
{loading ? "Saving..." : "Set new password"}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, PublicBundle, Registration } from "@/types";
|
||||
import { choosesSessions, isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -116,8 +117,7 @@ function PasswordToggleButton({ shown, onToggle }: { shown: boolean; onToggle: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
tabIndex={-1}
|
||||
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-400 hover:text-gray-600"
|
||||
className="absolute inset-y-0 right-0 flex items-center px-4 text-gray-500 hover:text-gray-700"
|
||||
aria-label={shown ? "Hide password" : "Show password"}
|
||||
>
|
||||
{shown ? (
|
||||
@@ -693,7 +693,7 @@ export default function SelfServicePage() {
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<main className="min-h-screen bg-gray-50 flex flex-col">
|
||||
{/* Change Event button */}
|
||||
{screen === "form" && (
|
||||
<div className="fixed top-3 right-3 z-50">
|
||||
@@ -708,15 +708,16 @@ export default function SelfServicePage() {
|
||||
|
||||
{/* ── Change Event Modal ─────────────────────────────────────── */}
|
||||
{showChangeModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<ModalOverlay labelledBy="kiosk-change-title" onClose={() => setShowChangeModal(false)}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-1">Change Event</h2>
|
||||
<h2 id="kiosk-change-title" className="text-xl font-bold text-gray-800 mb-1">Change Event</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">Re-enter your password to change the event.</p>
|
||||
<form onSubmit={handleChangeEvent} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<label htmlFor="kiosk-password" className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="kiosk-password"
|
||||
type={showChangePassword ? "text" : "password"}
|
||||
value={changePassword}
|
||||
onChange={(e) => setChangePassword(e.target.value)}
|
||||
@@ -728,7 +729,7 @@ export default function SelfServicePage() {
|
||||
<PasswordToggleButton shown={showChangePassword} onToggle={() => setShowChangePassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
{changeError && <p className="text-red-600 text-sm">{changeError}</p>}
|
||||
{changeError && <p role="alert" className="text-red-600 text-sm">{changeError}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -747,7 +748,7 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
|
||||
{/* ── Setup Screen ───────────────────────────────────────────── */}
|
||||
@@ -762,8 +763,9 @@ export default function SelfServicePage() {
|
||||
{!isLoggedIn && (
|
||||
<form onSubmit={handleSetupLogin} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<label htmlFor="kiosk-email" className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
id="kiosk-email"
|
||||
type="email"
|
||||
value={setupEmail}
|
||||
onChange={(e) => setSetupEmail(e.target.value)}
|
||||
@@ -774,9 +776,10 @@ export default function SelfServicePage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<label htmlFor="kiosk-password-2" className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="kiosk-password-2"
|
||||
type={showSetupPassword ? "text" : "password"}
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
@@ -790,8 +793,9 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
{setupChallenge && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
<label htmlFor="kiosk-verification-code" className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
|
||||
<input
|
||||
id="kiosk-verification-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
@@ -816,7 +820,7 @@ export default function SelfServicePage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
|
||||
{setupError && <p role="alert" className="text-red-600 text-sm">{setupError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupLoading}
|
||||
@@ -836,8 +840,9 @@ export default function SelfServicePage() {
|
||||
<p className="text-gray-500 text-center py-4">Loading events…</p>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<label htmlFor="kiosk-event" className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select
|
||||
id="kiosk-event"
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
@@ -885,12 +890,13 @@ export default function SelfServicePage() {
|
||||
|
||||
<form onSubmit={handleRegisterSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="kiosk-email-address" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email Address
|
||||
{!visitorPhone.trim() && <span className="text-red-500"> *</span>}
|
||||
{visitorPhone.trim() && <span className="text-gray-400 font-normal"> (optional if phone provided)</span>}
|
||||
{visitorPhone.trim() && <span className="text-gray-500 font-normal"> (optional if phone provided)</span>}
|
||||
</label>
|
||||
<input
|
||||
id="kiosk-email-address"
|
||||
type="email"
|
||||
value={visitorEmail}
|
||||
onChange={(e) => {
|
||||
@@ -906,12 +912,13 @@ export default function SelfServicePage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor="kiosk-cell-number" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cell Number
|
||||
{!visitorEmail.trim() && <span className="text-red-500"> *</span>}
|
||||
{visitorEmail.trim() && <span className="text-gray-400 font-normal"> (optional if email provided)</span>}
|
||||
{visitorEmail.trim() && <span className="text-gray-500 font-normal"> (optional if email provided)</span>}
|
||||
</label>
|
||||
<input
|
||||
id="kiosk-cell-number"
|
||||
type="tel"
|
||||
value={visitorPhone}
|
||||
onChange={(e) => {
|
||||
@@ -943,8 +950,9 @@ export default function SelfServicePage() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
<label htmlFor="kiosk-full-name" className="block text-sm font-medium text-gray-700 mb-1">Full Name <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
id="kiosk-full-name"
|
||||
type="text"
|
||||
value={visitorName}
|
||||
onChange={(e) => setVisitorName(e.target.value)}
|
||||
@@ -957,13 +965,14 @@ export default function SelfServicePage() {
|
||||
{/* Preference selector — only shown when both channels are available */}
|
||||
{visitorEmail.trim() && visitorPhone.trim() && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Send tickets via</label>
|
||||
<div className="flex rounded-xl border border-gray-200 overflow-hidden text-sm font-medium">
|
||||
<p id="kiosk-send-via" className="block text-sm font-medium text-gray-700 mb-2">Send tickets via</p>
|
||||
<div role="group" aria-labelledby="kiosk-send-via" className="flex rounded-xl border border-gray-200 overflow-hidden text-sm font-medium">
|
||||
{(["email", "whatsapp", "both"] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setNotificationPref(p)}
|
||||
aria-pressed={notificationPref === p}
|
||||
className={`flex-1 py-3 transition-colors ${
|
||||
notificationPref === p
|
||||
? p === "whatsapp" ? "bg-green-600 text-white"
|
||||
@@ -982,7 +991,7 @@ export default function SelfServicePage() {
|
||||
{/* Ticket options */}
|
||||
{(selectedEvent.eventOptions || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</label>
|
||||
<h2 className="block text-sm font-medium text-gray-700 mb-3">Ticket Options</h2>
|
||||
<BundleOffers bundles={kioskBundles} className="mb-3" />
|
||||
<div className="space-y-3">
|
||||
{(selectedEvent.eventOptions || []).map((opt) => {
|
||||
@@ -1012,14 +1021,16 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [key]: Math.max(0, (q[key] || 0) - 1) }))}
|
||||
aria-label={`Remove one ${opt.name} (${v.name})`}
|
||||
className="w-10 h-10 rounded-full border-2 border-gray-300 text-xl font-bold text-gray-600 hover:bg-gray-100 flex items-center justify-center transition"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-lg font-semibold">{quantities[key] || 0}</span>
|
||||
<span className="w-8 text-center text-lg font-semibold" aria-live="polite">{quantities[key] || 0}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [key]: (q[key] || 0) + 1 }))}
|
||||
aria-label={`Add one ${opt.name} (${v.name})`}
|
||||
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
|
||||
>
|
||||
+
|
||||
@@ -1046,14 +1057,16 @@ export default function SelfServicePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: Math.max(0, (q[opt.id] || 0) - 1) }))}
|
||||
aria-label={`Remove one ${opt.name}`}
|
||||
className="w-10 h-10 rounded-full border-2 border-gray-300 text-xl font-bold text-gray-600 hover:bg-gray-100 flex items-center justify-center transition"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-lg font-semibold">{quantities[opt.id] || 0}</span>
|
||||
<span className="w-8 text-center text-lg font-semibold" aria-live="polite">{quantities[opt.id] || 0}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: (q[opt.id] || 0) + 1 }))}
|
||||
aria-label={`Add one ${opt.name}`}
|
||||
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
|
||||
>
|
||||
+
|
||||
@@ -1119,23 +1132,30 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-xl px-4 py-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<div
|
||||
onClick={() => setCreateAccount((v) => !v)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-300"}`}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={createAccount}
|
||||
onClick={() => setCreateAccount((v) => !v)}
|
||||
className="flex items-center gap-3 cursor-pointer text-left w-full"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative shrink-0 w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-500"}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${createAccount ? "translate-x-6" : ""}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">Create an account</p>
|
||||
<p className="text-sm text-gray-500">Save your details for future events</p>
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-medium text-gray-800">Create an account</span>
|
||||
<span className="block text-sm text-gray-500">Save your details for future events</span>
|
||||
</span>
|
||||
</button>
|
||||
{createAccount && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
|
||||
<label htmlFor="kiosk-choose-a-password" className="block text-sm font-medium text-gray-700 mb-1">Choose a password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="kiosk-choose-a-password"
|
||||
type={showVisitorPassword ? "text" : "password"}
|
||||
value={visitorPassword}
|
||||
onChange={(e) => setVisitorPassword(e.target.value)}
|
||||
@@ -1151,7 +1171,7 @@ export default function SelfServicePage() {
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
|
||||
<div role="alert" className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
@@ -1170,9 +1190,9 @@ export default function SelfServicePage() {
|
||||
|
||||
{/* ── Confirm Account Changes Modal ──────────────────────────── */}
|
||||
{showUpdateConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<ModalOverlay labelledBy="kiosk-confirm-title" onClose={() => setShowUpdateConfirm(false)}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 w-full max-w-sm mx-4">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
|
||||
<h2 id="kiosk-confirm-title" className="text-xl font-bold text-gray-800 mb-1">Confirm account changes</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
These details differ from what's on file for this account. Confirm to update them.
|
||||
</p>
|
||||
@@ -1180,7 +1200,7 @@ export default function SelfServicePage() {
|
||||
{pendingChanges.map((c) => (
|
||||
<div key={c.field} className="border border-gray-200 rounded-lg px-3 py-2">
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{c.field}</p>
|
||||
<p className="text-sm text-gray-400 line-through">{c.from}</p>
|
||||
<p className="text-sm text-gray-500 line-through">{c.from}</p>
|
||||
<p className="text-sm text-green-700 font-medium">{c.to}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -1202,7 +1222,7 @@ export default function SelfServicePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
|
||||
{/* ── Event Form Screen ──────────────────────────────────────── */}
|
||||
@@ -1241,7 +1261,7 @@ export default function SelfServicePage() {
|
||||
<form onSubmit={handleFormSubmit} className="space-y-5">
|
||||
{answerableFields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label htmlFor={`kiosk-field-${field.id}`} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{field.label}
|
||||
{field.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
@@ -1251,7 +1271,7 @@ export default function SelfServicePage() {
|
||||
{isExtraFieldType(field.type) ? (
|
||||
<ExtraFieldInput field={field} value={currentTicketAnswers[field.id] || ""} onChange={v => setCurrentTicketAnswers(a => ({ ...a, [field.id]: v }))} />
|
||||
) : field.type === "yes_no" ? (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-4" role="radiogroup" aria-label={field.label}>
|
||||
{["Yes", "No"].map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -1269,6 +1289,7 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
) : field.type === "numeric" ? (
|
||||
<input
|
||||
id={`kiosk-field-${field.id}`}
|
||||
type="number"
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
@@ -1277,6 +1298,7 @@ export default function SelfServicePage() {
|
||||
/>
|
||||
) : field.type === "date" ? (
|
||||
<input
|
||||
id={`kiosk-field-${field.id}`}
|
||||
type="date"
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
@@ -1285,6 +1307,7 @@ export default function SelfServicePage() {
|
||||
/>
|
||||
) : field.type === "paragraph" ? (
|
||||
<textarea
|
||||
id={`kiosk-field-${field.id}`}
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
required={field.isRequired}
|
||||
@@ -1293,6 +1316,7 @@ export default function SelfServicePage() {
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`kiosk-field-${field.id}`}
|
||||
type="text"
|
||||
value={currentTicketAnswers[field.id] || ""}
|
||||
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
|
||||
@@ -1304,7 +1328,7 @@ export default function SelfServicePage() {
|
||||
))}
|
||||
|
||||
{formSubmitError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
|
||||
<div role="alert" className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 text-red-700 text-sm">
|
||||
{formSubmitError}
|
||||
</div>
|
||||
)}
|
||||
@@ -1382,6 +1406,6 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -426,6 +426,7 @@ export default function SetupPage() {
|
||||
<div className="lg:col-span-2">
|
||||
<BrandingPreviewPanel
|
||||
primaryColor={primaryColor}
|
||||
onUsePrimary={setPrimaryColor}
|
||||
secondaryColor={secondaryColor}
|
||||
accentColor={accentColor}
|
||||
logoSrc={logoPreview}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Monitor, Smartphone, RefreshCw } from "lucide-react";
|
||||
import { buildBrandScale, buildSecondaryTokens, buildAccentTokens, contrastForeground, isValidHex } from "@/lib/colorScale";
|
||||
import { accessiblePrimary, buildBrandScale, buildSecondaryTokens, buildAccentTokens, contrastForeground, contrastRatio, isValidHex, MIN_TEXT_CONTRAST } from "@/lib/colorScale";
|
||||
|
||||
type View = "website" | "email";
|
||||
type Viewport = "desktop" | "mobile";
|
||||
@@ -15,9 +15,11 @@ type Viewport = "desktop" | "mobile";
|
||||
* page's own theme while the admin is still experimenting with colors.
|
||||
*/
|
||||
export function BrandingPreviewPanel({
|
||||
primaryColor, secondaryColor, accentColor, logoSrc, orgName, orgTagline,
|
||||
primaryColor, secondaryColor, accentColor, logoSrc, orgName, orgTagline, onUsePrimary,
|
||||
}: {
|
||||
primaryColor: string;
|
||||
/** Offered when the primary colour is too light to read: switches to a darker shade. */
|
||||
onUsePrimary?: (hex: string) => void;
|
||||
secondaryColor: string;
|
||||
accentColor: string;
|
||||
logoSrc: string | null;
|
||||
@@ -35,9 +37,29 @@ export function BrandingPreviewPanel({
|
||||
const secondary = useMemo(() => buildSecondaryTokens(safeSecondary), [safeSecondary]);
|
||||
const accent = useMemo(() => buildAccentTokens(safeAccent), [safeAccent]);
|
||||
const primaryFg = useMemo(() => contrastForeground(safePrimary), [safePrimary]);
|
||||
const primaryContrast = useMemo(() => contrastRatio(safePrimary, "#ffffff"), [safePrimary]);
|
||||
const suggested = useMemo(() => accessiblePrimary(safePrimary), [safePrimary]);
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl bg-white shadow-sm overflow-hidden">
|
||||
{primaryContrast < MIN_TEXT_CONTRAST && (
|
||||
<div role="status" className="px-4 py-3 border-b bg-amber-50 text-sm text-amber-900 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<p className="flex-1 min-w-[12rem]">
|
||||
This primary colour is too light for white text and links to be easy to read
|
||||
({primaryContrast.toFixed(1)}:1 — at least {MIN_TEXT_CONTRAST}:1 is needed for accessibility).
|
||||
</p>
|
||||
{onUsePrimary && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUsePrimary(suggested)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium hover:bg-amber-100"
|
||||
>
|
||||
<span className="w-4 h-4 rounded border" style={{ background: suggested }} aria-hidden="true" />
|
||||
Use a darker shade ({suggested.toUpperCase()})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">Live Preview</h3>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { CheckSquare, X, Download } from "lucide-react";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { downloadReportExcel } from "@/lib/export";
|
||||
import type { Event, Registration } from "@/types";
|
||||
|
||||
type Action = "" | "message" | "markPaid" | "moveOption" | "allocate" | "cancel" | "export";
|
||||
type Result = { id: string; ok: boolean; skipped?: boolean; message: string };
|
||||
type BulkResponse = { action: string; done: number; skipped: number; failed: number; results: Result[] };
|
||||
type AllocationSet = { id: string; name: string; groups: { id: string; name: string }[] };
|
||||
|
||||
const ACTIONS: [Action, string][] = [
|
||||
["message", "Send a message"],
|
||||
["markPaid", "Mark paid (record the balance)"],
|
||||
["moveOption", "Move to another ticket"],
|
||||
["allocate", "Put in a room or group"],
|
||||
["export", "Export to Excel"],
|
||||
["cancel", "Cancel"],
|
||||
];
|
||||
|
||||
const owing = (r: Registration) => {
|
||||
const due = Number(r.totalDueComputed ?? 0);
|
||||
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
return Math.max(0, due - paid);
|
||||
};
|
||||
|
||||
/**
|
||||
* The bar above the registrations list while some are ticked (1.49.1): one action for all of
|
||||
* them, run through the same rules as doing it one at a time, with a result per registration.
|
||||
*/
|
||||
export function RegistrationBulkBar({ selected, events, token, onClear, onDone }: {
|
||||
selected: Registration[]; events: Event[]; token: string; onClear: () => void; onDone: () => void;
|
||||
}) {
|
||||
const [action, setAction] = useState<Action>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<BulkResponse | null>(null);
|
||||
// Per-action inputs.
|
||||
const [channel, setChannel] = useState<"email" | "whatsapp">("email");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [method, setMethod] = useState("cash");
|
||||
const [fromOptionId, setFromOptionId] = useState("");
|
||||
const [toOptionId, setToOptionId] = useState("");
|
||||
const [sets, setSets] = useState<AllocationSet[]>([]);
|
||||
const [groupKey, setGroupKey] = useState("");
|
||||
|
||||
const eventIds = useMemo(() => [...new Set(selected.map(r => r.eventId))], [selected]);
|
||||
const oneEvent = eventIds.length === 1 ? events.find(e => e.id === eventIds[0]) || null : null;
|
||||
const options = useMemo(() => (oneEvent?.eventOptions || []).filter(o => o.id !== oneEvent?.kidsOptionId && o.id !== oneEvent?.volunteerOptionId), [oneEvent]);
|
||||
const totalOwing = useMemo(() => selected.filter(r => r.status !== "cancelled").reduce((s, r) => s + owing(r), 0), [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (action !== "allocate" || !oneEvent) { setSets([]); return; }
|
||||
apiFetch<{ sets?: AllocationSet[] }>(`/api/allocations/event/${oneEvent.id}`, { authToken: token })
|
||||
.then(b => setSets((b.sets || []).filter(s => s.groups.length)))
|
||||
.catch(() => setSets([]));
|
||||
}, [action, oneEvent, token]);
|
||||
|
||||
const needsOneEvent = (action === "moveOption" || action === "allocate") && !oneEvent;
|
||||
|
||||
const exportSelected = async () => {
|
||||
await downloadReportExcel(API_BASE, token, {
|
||||
title: "Registrations", subtitle: `${selected.length} selected · ${new Date().toLocaleString()}`, kind: "table", orientation: "landscape",
|
||||
table: {
|
||||
columns: ["Reference", "Name", "Email", "Phone", "Event", "Status", "Total", "Paid", "Owing", "Tickets"],
|
||||
rows: selected.map(r => {
|
||||
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
return [
|
||||
String(r.id).slice(0, 8), r.user?.name || "", r.user?.email || "", r.user?.phoneNumber || "", r.event?.title || "", r.status,
|
||||
`R ${Number(r.totalDueComputed ?? 0).toFixed(2)}`, `R ${paid.toFixed(2)}`, `R ${owing(r).toFixed(2)}`,
|
||||
(r.registrationOptions || []).map(o => `${o.quantity} × ${o.eventOption?.name || ""}${o.variant?.name ? ` (${o.variant.name})` : ""}`).join(", "),
|
||||
];
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
setError(null); setResult(null);
|
||||
if (action === "export") {
|
||||
setBusy(true);
|
||||
try { await exportSelected(); } catch (e) { setError(errorMessage(e) || "Couldn't export"); } finally { setBusy(false); }
|
||||
return;
|
||||
}
|
||||
const confirmText: Record<string, string> = {
|
||||
cancel: `Cancel ${selected.length} registration(s)? Cancelling doesn't refund — record refunds on the Payments page. Coupon uses are released and the waitlist is offered the places.`,
|
||||
markPaid: `Record ${method.toUpperCase()} payments for what ${selected.length} registration(s) still owe (about R ${totalOwing.toFixed(2)})? Paid registrations get their tickets by email.`,
|
||||
moveOption: `Move ${selected.length} registration(s) to the other ticket? Tickets already issued move with them (same QR code). The price follows the new ticket, so anything now owing or overpaid shows on each registration.`,
|
||||
message: `Send this ${channel === "email" ? "email" : "WhatsApp"} to the people on ${selected.length} registration(s)?`,
|
||||
allocate: `Put the named people on ${selected.length} registration(s) in this group?`,
|
||||
};
|
||||
if (!confirm(confirmText[action])) return;
|
||||
const [setId, groupId] = groupKey.split("::");
|
||||
const params = action === "message" ? { channel, subject, text }
|
||||
: action === "markPaid" ? { method }
|
||||
: action === "moveOption" ? { fromOptionId, toOptionId }
|
||||
: action === "allocate" ? { setId, groupId }
|
||||
: {};
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<BulkResponse>("/api/registrations/bulk", { method: "POST", authToken: token, body: { action, registrationIds: selected.map(r => r.id), ...params } });
|
||||
setResult(res);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't do that");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ready = !busy && !needsOneEvent && (
|
||||
action === "export" || action === "cancel"
|
||||
|| (action === "markPaid" && !!method)
|
||||
|| (action === "message" && text.trim() && (channel === "whatsapp" || subject.trim()))
|
||||
|| (action === "moveOption" && fromOptionId && toOptionId && fromOptionId !== toOptionId)
|
||||
|| (action === "allocate" && !!groupKey)
|
||||
);
|
||||
const nameOf = (id: string) => selected.find(r => r.id === id)?.user?.name || id.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="border border-brand-200 bg-brand-50/60 rounded-xl p-3 mb-3 space-y-3" data-testid="bulk-bar">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-brand-700" />
|
||||
<span className="text-sm font-medium text-brand-900">{selected.length} selected</span>
|
||||
<select aria-label="Bulk action" className="border rounded px-2 py-1.5 text-sm bg-white" value={action} onChange={e => { setAction(e.target.value as Action); setResult(null); setError(null); }}>
|
||||
<option value="">Choose an action…</option>
|
||||
{ACTIONS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={onClear} className="ml-auto inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><X className="w-3.5 h-3.5" /> Clear selection</button>
|
||||
</div>
|
||||
|
||||
{needsOneEvent && <p className="text-xs text-amber-800">Tick registrations from one event only (filter by event) to {action === "moveOption" ? "move tickets" : "put people in a group"}.</p>}
|
||||
|
||||
{action === "message" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-3 text-sm">
|
||||
<label className="flex items-center gap-1"><input type="radio" checked={channel === "email"} onChange={() => setChannel("email")} /> Email</label>
|
||||
<label className="flex items-center gap-1"><input type="radio" checked={channel === "whatsapp"} onChange={() => setChannel("whatsapp")} /> WhatsApp</label>
|
||||
</div>
|
||||
{channel === "email" && <input aria-label="Subject" className="w-full border rounded px-3 py-1.5 text-sm" placeholder="Subject" value={subject} onChange={e => setSubject(e.target.value)} />}
|
||||
<textarea aria-label="Message" rows={4} className="w-full border rounded px-3 py-1.5 text-sm" placeholder="Hi {name}, …" value={text} onChange={e => setText(e.target.value)} />
|
||||
<p className="text-[11px] text-gray-500">Sent as an event update to the person who registered. You can use {"{name}"}, {"{eventTitle}"} and {"{eventStart}"}.</p>
|
||||
</div>
|
||||
)}
|
||||
{action === "markPaid" && (
|
||||
<label className="text-sm flex items-center gap-2">Paid by
|
||||
<select aria-label="Paid by" className="border rounded px-2 py-1.5 text-sm bg-white" value={method} onChange={e => setMethod(e.target.value)}>
|
||||
<option value="cash">Cash</option><option value="eft">EFT</option><option value="card">Card (card machine)</option><option value="other">Other</option>
|
||||
</select>
|
||||
<span className="text-xs text-gray-500">Records what each still owes (about R {totalOwing.toFixed(2)} in all).</span>
|
||||
</label>
|
||||
)}
|
||||
{action === "moveOption" && oneEvent && (
|
||||
<div className="flex flex-wrap gap-2 items-center text-sm">
|
||||
<select aria-label="Move from" className="border rounded px-2 py-1.5 bg-white" value={fromOptionId} onChange={e => setFromOptionId(e.target.value)}>
|
||||
<option value="">From ticket…</option>{options.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
→
|
||||
<select aria-label="Move to" className="border rounded px-2 py-1.5 bg-white" value={toOptionId} onChange={e => setToOptionId(e.target.value)}>
|
||||
<option value="">To ticket…</option>{options.filter(o => o.id !== fromOptionId).map(o => <option key={o.id} value={o.id}>{o.name} (R{Number(o.price).toFixed(0)})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{action === "allocate" && oneEvent && (
|
||||
sets.length === 0 ? <p className="text-xs text-gray-600">This event has no rooms or groups yet — set them up under Rooms & groups.</p> : (
|
||||
<select aria-label="Group" className="border rounded px-2 py-1.5 text-sm bg-white" value={groupKey} onChange={e => setGroupKey(e.target.value)}>
|
||||
<option value="">Choose a group…</option>
|
||||
{sets.map(s => <optgroup key={s.id} label={s.name}>{s.groups.map(g => <option key={g.id} value={`${s.id}::${g.id}`}>{g.name}</option>)}</optgroup>)}
|
||||
</select>
|
||||
)
|
||||
)}
|
||||
{action === "cancel" && <p className="text-xs text-gray-700">Cancelling doesn't refund anyone — record refunds on the Payments page.</p>}
|
||||
|
||||
{action && (
|
||||
<button type="button" disabled={!ready} onClick={run} className={`inline-flex items-center gap-1.5 px-4 py-1.5 text-sm rounded font-medium disabled:opacity-50 ${action === "cancel" ? "bg-red-600 text-white hover:bg-red-700" : "bg-brand-600 text-white hover:bg-brand-700"}`}>
|
||||
{action === "export" && <Download className="w-4 h-4" />}
|
||||
{busy ? "Working…" : `${ACTIONS.find(a => a[0] === action)?.[1]} — ${selected.length}`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <div className="p-2 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
{result && (
|
||||
<div className="p-2 border rounded bg-white text-sm" role="status" data-testid="bulk-result">
|
||||
<div><strong>{result.done}</strong> done{result.skipped ? `, ${result.skipped} skipped` : ""}{result.failed ? `, ${result.failed} not done` : ""}.</div>
|
||||
{result.results.filter(r => !r.ok || r.skipped).length > 0 && (
|
||||
<ul className="mt-1 text-xs text-gray-700 max-h-40 overflow-auto">
|
||||
{result.results.filter(r => !r.ok || r.skipped).map(r => <li key={r.id}><span className={r.ok ? "text-gray-500" : "text-red-700"}>{nameOf(r.id)}</span>: {r.message}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ type ListResponse = { eventTypes: EventType[]; keepDays: number; endpoints: Endp
|
||||
type Draft = { id?: string; name: string; url: string; events: string[] };
|
||||
|
||||
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
// Deliveries shown at a time in the log ("Load older" fetches the next lot).
|
||||
const PAGE = 50;
|
||||
|
||||
const STATUS: Record<string, string> = {
|
||||
succeeded: "bg-green-50 text-green-800", failed: "bg-red-50 text-red-700", retrying: "bg-amber-50 text-amber-800",
|
||||
pending: "bg-gray-100 text-gray-600", sending: "bg-blue-50 text-blue-700",
|
||||
@@ -50,21 +53,46 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
|
||||
const [rows, setRows] = useState<Delivery[] | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [more, setMore] = useState(false);
|
||||
const fetchPage = useCallback((before?: string) => {
|
||||
const qs = new URLSearchParams({ limit: String(PAGE) });
|
||||
if (status) qs.set("status", status);
|
||||
if (before) qs.set("before", before);
|
||||
return apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries?${qs.toString()}`, { authToken: token }).catch(() => [] as Delivery[]);
|
||||
}, [endpointId, token, status]);
|
||||
const load = useCallback(async () => {
|
||||
setRows(await apiFetch<Delivery[]>(`/api/outbound-webhooks/${endpointId}/deliveries`, { authToken: token }).catch(() => []));
|
||||
}, [endpointId, token]);
|
||||
const page = await fetchPage();
|
||||
setRows(page);
|
||||
setMore(page.length === PAGE);
|
||||
}, [fetchPage]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
const loadOlder = async () => {
|
||||
if (!rows?.length) return;
|
||||
const page = await fetchPage(rows[rows.length - 1].createdAt);
|
||||
setRows([...rows, ...page]);
|
||||
setMore(page.length === PAGE);
|
||||
};
|
||||
const resend = async (id: string) => {
|
||||
setBusy(id);
|
||||
await apiFetch(`/api/outbound-webhooks/deliveries/${id}/resend`, { method: "POST", authToken: token }).catch(() => {});
|
||||
setBusy(null);
|
||||
load();
|
||||
};
|
||||
if (!rows) return <p className="text-xs text-gray-400">Loading…</p>;
|
||||
if (!rows.length) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
|
||||
if (!rows) return <p className="text-xs text-gray-500">Loading…</p>;
|
||||
if (!rows.length && !status) return <p className="text-xs text-gray-500">Nothing sent yet.</p>;
|
||||
return (
|
||||
<div className="space-y-1" data-testid="webhook-deliveries">
|
||||
<div className="flex justify-end"><button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" /> Refresh</button></div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<select aria-label="Show deliveries" className="border rounded px-2 py-1 text-xs" value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<option value="">All deliveries</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="retrying">Retrying</option>
|
||||
<option value="succeeded">Succeeded</option>
|
||||
</select>
|
||||
<button type="button" onClick={load} className="inline-flex items-center gap-1 text-xs text-gray-600 hover:text-gray-900"><RefreshCw className="w-3 h-3" aria-hidden="true" /> Refresh</button>
|
||||
</div>
|
||||
{!rows.length && <p className="text-xs text-gray-500">None with this status.</p>}
|
||||
<ul className="divide-y border rounded-lg bg-white">
|
||||
{rows.map(d => (
|
||||
<li key={d.id} className="text-xs" data-testid="webhook-delivery">
|
||||
@@ -89,6 +117,11 @@ function Deliveries({ endpointId, token }: { endpointId: string; token: string }
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{more && (
|
||||
<div className="flex justify-center">
|
||||
<button type="button" onClick={loadOlder} className="text-xs px-3 py-1 rounded border bg-white hover:bg-gray-50">Load older</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,8 +129,9 @@ export function LoginForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Verification code</label>
|
||||
<label htmlFor="login-code" className="block text-sm font-medium mb-1">Verification code</label>
|
||||
<input
|
||||
id="login-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
@@ -153,7 +154,7 @@ export function LoginForm() {
|
||||
<span>Trust this device for 30 days. Only tick this on a device that's yours.</span>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
@@ -188,8 +189,10 @@ export function LoginForm() {
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email or phone</label>
|
||||
<label htmlFor="login-email" className="block text-sm font-medium mb-1">Email or phone</label>
|
||||
<input
|
||||
id="login-email"
|
||||
autoComplete="username"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
@@ -199,9 +202,11 @@ export function LoginForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Password</label>
|
||||
<label htmlFor="login-password" className="block text-sm font-medium mb-1">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
autoComplete="current-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -211,7 +216,7 @@ export function LoginForm() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-brand-600"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-sm text-brand-600 px-2 py-1 min-h-6"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? "Hide" : "Show"}
|
||||
@@ -236,7 +241,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600">
|
||||
<div role="alert" className="text-sm text-red-600">
|
||||
<p>{error}</p>
|
||||
{error.toLowerCase().includes('not yet active') && (
|
||||
<p className="mt-1 text-gray-600">Check your inbox for an activation email. If you didn't receive it, try logging in again to resend it.</p>
|
||||
@@ -257,7 +262,7 @@ export function LoginForm() {
|
||||
<Link
|
||||
href="/legal/terms"
|
||||
target="_blank"
|
||||
className="text-brand-600 hover:underline"
|
||||
className="text-brand-700 underline hover:no-underline"
|
||||
>
|
||||
Terms of Use
|
||||
</Link>{" "}
|
||||
@@ -265,7 +270,7 @@ export function LoginForm() {
|
||||
<Link
|
||||
href="/legal/privacy"
|
||||
target="_blank"
|
||||
className="text-brand-600 hover:underline"
|
||||
className="text-brand-700 underline hover:no-underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>.
|
||||
|
||||
@@ -68,8 +68,10 @@ export function RegisterForm() {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Full Name</label>
|
||||
<label htmlFor="signup-full-name" className="block text-sm font-medium mb-1">Full Name</label>
|
||||
<input
|
||||
id="signup-full-name"
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
@@ -78,8 +80,10 @@ export function RegisterForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email</label>
|
||||
<label htmlFor="signup-email" className="block text-sm font-medium mb-1">Email</label>
|
||||
<input
|
||||
id="signup-email"
|
||||
autoComplete="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
@@ -89,8 +93,10 @@ export function RegisterForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Password</label>
|
||||
<label htmlFor="signup-password" className="block text-sm font-medium mb-1">Password</label>
|
||||
<input
|
||||
id="signup-password"
|
||||
autoComplete="new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -100,8 +106,11 @@ export function RegisterForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Phone Number (optional)</label>
|
||||
<label htmlFor="signup-phone-number" className="block text-sm font-medium mb-1">Phone Number (optional)</label>
|
||||
<input
|
||||
id="signup-phone-number"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="e.g. 082 123 4567"
|
||||
@@ -111,8 +120,9 @@ export function RegisterForm() {
|
||||
|
||||
{hasValidPhone && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Notification Preference</label>
|
||||
<label htmlFor="signup-notification-preference" className="block text-sm font-medium mb-1">Notification Preference</label>
|
||||
<select
|
||||
id="signup-notification-preference"
|
||||
value={notificationPreference}
|
||||
onChange={(e) => setNotificationPreference(e.target.value as "email" | "whatsapp" | "both")}
|
||||
className="w-full border rounded px-3 py-2"
|
||||
@@ -138,7 +148,7 @@ export function RegisterForm() {
|
||||
<Link
|
||||
href="/legal/terms"
|
||||
target="_blank"
|
||||
className="text-brand-600 hover:underline"
|
||||
className="text-brand-700 underline hover:no-underline"
|
||||
>
|
||||
Terms of Use
|
||||
</Link>{" "}
|
||||
@@ -146,7 +156,7 @@ export function RegisterForm() {
|
||||
<Link
|
||||
href="/legal/privacy"
|
||||
target="_blank"
|
||||
className="text-brand-600 hover:underline"
|
||||
className="text-brand-700 underline hover:no-underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>.
|
||||
@@ -175,7 +185,7 @@ export function RegisterForm() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CalendarClock, ArrowRight, Baby, HandHeart, UserCheck, Banknote, Inbox, CheckCircle2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { CampusBadge } from "@/components/shared/CampusFilter";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
type TodayEvent = {
|
||||
id: string; title: string; location: string | null; campus: Event["campus"]; isHidden: boolean; kidsCheckIn: boolean;
|
||||
startsAt: string; endsAt: string; sessionLabel: string | null;
|
||||
places: number; checkedIn: number; kidsIn: number | null;
|
||||
volunteers: { needed: number; rostered: number; checkedIn: number } | null;
|
||||
};
|
||||
type Pending = { key: string; label: string; count: number; href: string; tone: string };
|
||||
type Today = { events: TodayEvent[]; money: { today: number; todayCount: number; week: number } | null; pending: Pending[] };
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
amber: "bg-amber-50 text-amber-800", rose: "bg-rose-50 text-rose-700", blue: "bg-blue-50 text-blue-700",
|
||||
violet: "bg-violet-50 text-violet-700", green: "bg-green-50 text-green-700",
|
||||
};
|
||||
const time = (d: string) => new Date(d).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
const rand = (n: number) => `R ${n.toFixed(2)}`;
|
||||
|
||||
/**
|
||||
* The top of every staff dashboard (1.49.0): what's on today and how it's going, and — for
|
||||
* supervisors and admins — money in and what's waiting for someone, each linking to where it's
|
||||
* dealt with. Refreshes every 30 seconds while the tab is visible.
|
||||
*/
|
||||
export function TodayPanel({ supervisor }: { supervisor: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [data, setData] = useState<Today | null>(null);
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try { setData(await apiFetch<Today>("/api/dashboard/today", { authToken: token })); } catch { /* keep what's shown */ }
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useVisiblePolling(load, 30000, !!token);
|
||||
|
||||
if (!data) return null;
|
||||
return (
|
||||
<div className={`grid gap-6 mb-6 ${supervisor ? "lg:grid-cols-3" : ""}`} data-testid="today-panel">
|
||||
<div className={`border rounded-xl p-4 bg-white shadow-sm min-w-0 ${supervisor ? "lg:col-span-2" : ""}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CalendarClock className="w-5 h-5 text-brand-600" />
|
||||
<h2 className="text-lg font-semibold">Today</h2>
|
||||
<span className="text-xs text-gray-500">{new Date().toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long" })}</span>
|
||||
</div>
|
||||
{data.events.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Nothing on today.</p>
|
||||
) : (
|
||||
<ul className="divide-y" data-testid="today-events">
|
||||
{data.events.map(e => {
|
||||
const pct = e.places ? Math.min(100, Math.round((e.checkedIn / e.places) * 100)) : 0;
|
||||
return (
|
||||
<li key={e.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 flex items-center gap-2 flex-wrap">
|
||||
{e.title}{e.sessionLabel && <span className="text-xs font-normal text-gray-500">· {e.sessionLabel}</span>}
|
||||
<CampusBadge campus={e.campus} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{time(e.startsAt)}–{time(e.endsAt)}{e.location ? ` · ${e.location}` : ""}</div>
|
||||
</div>
|
||||
<Link href={e.kidsCheckIn ? "/dashboard/staff/kids" : `/dashboard/staff/ticket-scanning`} className="inline-flex items-center gap-1 text-xs text-brand-700 hover:underline shrink-0">
|
||||
{e.kidsCheckIn ? "Kids desk" : "Scanner"} <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
{!e.kidsCheckIn && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<UserCheck className="w-3.5 h-3.5" /> {e.checkedIn} of {e.places} checked in
|
||||
<span className="inline-block w-20 h-1.5 rounded bg-gray-100 overflow-hidden"><span className="block h-full bg-brand-500" style={{ width: `${pct}%` }} /></span>
|
||||
</span>
|
||||
)}
|
||||
{e.kidsIn !== null && <span className="inline-flex items-center gap-1"><Baby className="w-3.5 h-3.5" /> {e.kidsIn} children in now</span>}
|
||||
{e.volunteers && (
|
||||
<span className="inline-flex items-center gap-1"><HandHeart className="w-3.5 h-3.5" /> {e.volunteers.checkedIn} of {e.volunteers.rostered} volunteers here{e.volunteers.rostered < e.volunteers.needed ? ` (${e.volunteers.needed - e.volunteers.rostered} short)` : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{data.money && (
|
||||
<div className="mt-4 pt-3 border-t flex flex-wrap gap-x-6 gap-y-1 text-sm">
|
||||
<span className="inline-flex items-center gap-1.5"><Banknote className="w-4 h-4 text-green-600" /> Today <strong>{rand(data.money.today)}</strong> <span className="text-xs text-gray-500">({data.money.todayCount} payment{data.money.todayCount === 1 ? "" : "s"})</span></span>
|
||||
<span>Last 7 days <strong>{rand(data.money.week)}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{supervisor && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Inbox className="w-5 h-5 text-brand-600" />
|
||||
<h2 className="text-lg font-semibold">Waiting for you</h2>
|
||||
</div>
|
||||
{data.pending.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 inline-flex items-center gap-1.5"><CheckCircle2 className="w-4 h-4 text-green-600" /> All caught up.</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="waiting">
|
||||
{data.pending.map(p => (
|
||||
<li key={p.key}>
|
||||
<Link href={p.href} className="flex items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-gray-50">
|
||||
<span className={`min-w-[2rem] text-center text-sm font-semibold rounded-md px-1.5 py-0.5 ${TONE[p.tone] || "bg-gray-100 text-gray-700"}`}>{p.count}</span>
|
||||
<span className="text-sm text-gray-700 flex-1">{p.label}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-gray-400" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Phone, Mail, User } from "lucide-react";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
|
||||
type ContactButtonProps = {
|
||||
contactName?: string | null;
|
||||
@@ -25,15 +26,17 @@ export function ContactButton({ contactName, contactPhone, contactEmail, classNa
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
<ModalOverlay
|
||||
labelledBy="contact-title"
|
||||
onClose={() => setOpen(false)}
|
||||
closeOnBackdrop
|
||||
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5" onClick={e => e.stopPropagation()}>
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold">Contact us</h3>
|
||||
<h3 id="contact-title" className="text-lg font-semibold">Contact us</h3>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-500"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
@@ -44,19 +47,19 @@ export function ContactButton({ contactName, contactPhone, contactEmail, classNa
|
||||
<div className="space-y-2 text-sm">
|
||||
{contactName && (
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<User className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<User className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
<span>{contactName}</span>
|
||||
</div>
|
||||
)}
|
||||
{contactPhone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<Phone className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
<a href={`tel:${contactPhone}`} className="text-brand-600 hover:underline">{contactPhone}</a>
|
||||
</div>
|
||||
)}
|
||||
{contactEmail && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<Mail className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">{contactEmail}</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -65,7 +68,7 @@ export function ContactButton({ contactName, contactPhone, contactEmail, classNa
|
||||
<p className="text-sm text-gray-500">No contact details have been provided for this event.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ListPlus, X } from "lucide-react";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { WaitlistLine } from "@/lib/waitlist";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
|
||||
export type { WaitlistLine };
|
||||
|
||||
@@ -72,11 +73,11 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-xl w-full max-w-md p-5 space-y-4" onClick={e => e.stopPropagation()}>
|
||||
<ModalOverlay labelledBy="waitlist-title" onClose={() => setOpen(false)} closeOnBackdrop className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4">
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-xl w-full max-w-md p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold">Join the waitlist</h2>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
<h2 id="waitlist-title" className="font-semibold">Join the waitlist</h2>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-1 rounded hover:bg-gray-100"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
|
||||
{done ? (
|
||||
@@ -93,16 +94,16 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
<p className="text-sm text-gray-600">If a place opens up, we'll email or WhatsApp you a link to claim it. We hold it for you for a limited time.</p>
|
||||
{lines.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ticket</label>
|
||||
<select className={inputCls} value={lineKey} onChange={e => setLineKey(e.target.value)}>
|
||||
<label htmlFor="waitlist-ticket" className="block text-sm font-medium text-gray-700 mb-1">Ticket</label>
|
||||
<select id="waitlist-ticket" className={inputCls} value={lineKey} onChange={e => setLineKey(e.target.value)}>
|
||||
{lines.map(l => <option key={keyOf(l)} value={keyOf(l)}>{l.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{lines.length === 1 && <p className="text-sm"><span className="text-gray-500">Ticket:</span> {line.label}</p>}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">How many?</label>
|
||||
<input type="number" min={1} max={20} className={`${inputCls} w-24`} value={qty} onChange={e => setQty(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))} />
|
||||
<label htmlFor="waitlist-qty" className="block text-sm font-medium text-gray-700 mb-1">How many?</label>
|
||||
<input id="waitlist-qty" type="number" min={1} max={20} className={`${inputCls} w-24`} value={qty} onChange={e => setQty(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))} />
|
||||
</div>
|
||||
{!loggedIn && (
|
||||
<div className="space-y-2">
|
||||
@@ -111,14 +112,14 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
|
||||
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={guest.phone} onChange={e => setGuest({ ...guest, phone: e.target.value })} aria-label="Phone" />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={busy} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Joining…" : "Join waitlist"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export function LocationMap({ address, className }: { address?: string | null; c
|
||||
<div className={className}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-gray-700 flex items-start gap-1.5">
|
||||
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-400" />
|
||||
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-500" />
|
||||
<span>{address}</span>
|
||||
</p>
|
||||
<a
|
||||
|
||||
@@ -34,7 +34,7 @@ export function SessionPicker({ event, option, selected, onChange, quantity = 1,
|
||||
const full = s.left !== null && s.left < Math.max(1, quantity);
|
||||
const name = sessionLabel(s, event.sessions);
|
||||
return (
|
||||
<div key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
|
||||
<div key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-500" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
|
||||
<label className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
|
||||
<span className="font-medium">{name}</span>
|
||||
|
||||
@@ -24,9 +24,9 @@ export function SessionSchedule({ sessions, defaultLocation }: { sessions: Event
|
||||
const isNext = next?.id === s.id;
|
||||
const place = s.location && s.location !== defaultLocation ? s.location : null;
|
||||
return (
|
||||
<li key={s.id} className={`flex flex-wrap items-baseline gap-x-3 gap-y-0.5 rounded-lg px-3 py-2 text-sm ${isNext ? "bg-brand-50 border border-brand-200" : "border border-transparent"} ${past ? "text-gray-400" : "text-gray-700"}`}>
|
||||
<span className={`font-medium ${cancelled ? "line-through text-gray-400" : ""}`}>{cancelled ? (s.label || "Session") : sessionLabel(s, sessions)}</span>
|
||||
<span className={cancelled ? "line-through text-gray-400" : ""}>{formatSessionWhen(s)}</span>
|
||||
<li key={s.id} className={`flex flex-wrap items-baseline gap-x-3 gap-y-0.5 rounded-lg px-3 py-2 text-sm ${isNext ? "bg-brand-50 border border-brand-200" : "border border-transparent"} ${past ? "text-gray-500" : "text-gray-700"}`}>
|
||||
<span className={`font-medium ${cancelled ? "line-through text-gray-500" : ""}`}>{cancelled ? (s.label || "Session") : sessionLabel(s, sessions)}</span>
|
||||
<span className={cancelled ? "line-through text-gray-500" : ""}>{formatSessionWhen(s)}</span>
|
||||
{cancelled && <span className="text-xs font-medium text-amber-700">Cancelled</span>}
|
||||
{isNext && <span className="text-xs font-medium text-brand-700">Next</span>}
|
||||
{place && !cancelled && <span className="flex items-center gap-1 text-xs text-gray-500"><MapPin className="w-3 h-3" />{place}</span>}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
|
||||
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
|
||||
|
||||
@@ -97,18 +98,18 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<ModalOverlay labelledBy="sponsor-title" onClose={() => setOpen(false)} closeOnBackdrop className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2"><HeartHandshake className="w-5 h-5 text-rose-600" /> Sponsor a spot</h3>
|
||||
<h3 id="sponsor-title" className="text-lg font-semibold flex items-center gap-2"><HeartHandshake className="w-5 h-5 text-rose-600" aria-hidden="true" /> Sponsor a spot</h3>
|
||||
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-1 rounded hover:bg-gray-100"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Help someone come to {eventTitle} who couldn't otherwise afford it. Your gift goes into a sponsorship pool that the organisers use to cover places.</p>
|
||||
|
||||
{price ? (
|
||||
<div className="flex gap-2 text-sm">
|
||||
<button type="button" onClick={() => setMode("spots")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "spots" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Whole spots</button>
|
||||
<button type="button" onClick={() => setMode("amount")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "amount" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Any amount</button>
|
||||
<button type="button" onClick={() => setMode("spots")} aria-pressed={mode === "spots"} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "spots" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Whole spots</button>
|
||||
<button type="button" onClick={() => setMode("amount")} aria-pressed={mode === "amount"} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "amount" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Any amount</button>
|
||||
</div>
|
||||
) : null}
|
||||
{mode === "spots" && price ? (
|
||||
@@ -118,32 +119,32 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Amount (R)</label>
|
||||
<input type="number" min={15} step="0.01" className={inputCls} value={amount} onChange={e => setAmount(e.target.value)} placeholder="e.g. 200" required />
|
||||
<label htmlFor="sponsor-amount" className="block text-xs text-gray-600 mb-1">Amount (R)</label>
|
||||
<input id="sponsor-amount" type="number" min={15} step="0.01" className={inputCls} value={amount} onChange={e => setAmount(e.target.value)} placeholder="e.g. 200" required />
|
||||
<p className="text-xs text-gray-500 mt-1">Minimum R15.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loggedIn && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input className={inputCls} placeholder="Your name" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required />
|
||||
<input type="email" className={inputCls} placeholder="Email (for your receipt)" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required />
|
||||
<input className={inputCls} placeholder="Your name" aria-label="Your name" autoComplete="name" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required />
|
||||
<input type="email" className={inputCls} placeholder="Email (for your receipt)" aria-label="Email (for your receipt)" autoComplete="email" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required />
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={anonymous} onChange={e => setAnonymous(e.target.checked)} /> Give anonymously
|
||||
</label>
|
||||
{!anonymous && (
|
||||
<input className={inputCls} placeholder={`Name to show the organisers (e.g. "The Smith family")`} value={sponsorName} onChange={e => setSponsorName(e.target.value)} maxLength={100} />
|
||||
<input className={inputCls} placeholder={`Name to show the organisers (e.g. "The Smith family")`} aria-label="Name to show the organisers" value={sponsorName} onChange={e => setSponsorName(e.target.value)} maxLength={100} />
|
||||
)}
|
||||
<textarea className={inputCls} rows={2} placeholder="A message for the organisers (optional)" value={message} onChange={e => setMessage(e.target.value)} maxLength={300} />
|
||||
<textarea className={inputCls} rows={2} placeholder="A message for the organisers (optional)" aria-label="A message for the organisers (optional)" value={message} onChange={e => setMessage(e.target.value)} maxLength={300} />
|
||||
<p className="text-xs text-gray-500">You'll get a receipt by email. We never share who is helped by sponsorships.</p>
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{error && <div role="alert" className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<button type="submit" disabled={busy || !(total >= 15)} className="w-full px-4 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 font-medium">
|
||||
{busy ? "Opening payment…" : `Pay ${total > 0 ? rand(total) : ""} securely`}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Skip to main content" — the first thing a keyboard user reaches on every page. Hidden
|
||||
* until focused. Pages put their content in a <main> of their own, so the link finds it
|
||||
* when used rather than relying on a fixed id.
|
||||
*/
|
||||
export function SkipLink() {
|
||||
const skip = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
const main = document.querySelector("main");
|
||||
if (!main) return;
|
||||
e.preventDefault();
|
||||
if (!main.hasAttribute("tabindex")) main.setAttribute("tabindex", "-1");
|
||||
main.focus();
|
||||
main.scrollIntoView();
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#main"
|
||||
onClick={skip}
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-md focus:bg-white focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-gray-900 focus:shadow-lg"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import { CampusFilter, matchesCampus } from "@/components/shared/CampusFilter";
|
||||
import { SavedFilters } from "@/components/shared/SavedFilters";
|
||||
import type { ConsentRow, Event, EventCashup, EventFinancials, EventSeries, MedicalRow, Payment, Registration, SeriesComparison, Ticket, UserSummary } from "@/types";
|
||||
|
||||
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
|
||||
@@ -1512,15 +1513,29 @@ export default function ReportsV2({ backHref }: { backHref?: string } = {}) {
|
||||
)}
|
||||
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<CampusFilter
|
||||
value={campus}
|
||||
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
|
||||
withChurchWide
|
||||
everyLabel="All campuses (church-wide total)"
|
||||
className="mb-3"
|
||||
id="reports-campus"
|
||||
mine
|
||||
/>
|
||||
<div className="flex flex-wrap items-start gap-3 mb-3">
|
||||
<CampusFilter
|
||||
value={campus}
|
||||
onChange={v => { setCampus(v); setSelectedEventIds([]); }}
|
||||
withChurchWide
|
||||
everyLabel="All campuses (church-wide total)"
|
||||
id="reports-campus"
|
||||
mine
|
||||
/>
|
||||
<SavedFilters
|
||||
page="reports"
|
||||
value={{ report, events: selectedEventIds, from: dateFrom, to: dateTo, campus, past: showPastEvents, inactive: showInactiveEvents, closed: showClosedEvents }}
|
||||
onApply={p => {
|
||||
if (typeof p.report === "string" && REPORTS.some(r => r.key === p.report)) setReport(p.report as ReportKey);
|
||||
setCampus(String(p.campus || ""));
|
||||
setShowPastEvents(p.past === true); setShowInactiveEvents(p.inactive === true); setShowClosedEvents(p.closed !== false);
|
||||
setSelectedEventIds(Array.isArray(p.events) ? p.events : []);
|
||||
setDateFrom(String(p.from || "")); setDateTo(String(p.to || ""));
|
||||
setReady(false);
|
||||
}}
|
||||
skipDefault={!!searchParams.get("report")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ReportsShell
|
||||
events={filteredEvents}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function AttendeeNamesEditor({
|
||||
)}
|
||||
{consent && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
<p aria-hidden="true" className="block text-xs text-gray-600 mb-1">Date of birth</p>
|
||||
<input type="date" className={`${inputCls} sm:w-48`} value={e.dateOfBirth || ""} max={new Date().toISOString().slice(0, 10)} onChange={ev => set(s.key, "dateOfBirth", ev.target.value)} aria-label={`${s.label} date of birth`} />
|
||||
{isMinorOn(e.dateOfBirth, consentEventStart) && (
|
||||
<div className="mt-2 rounded-lg bg-amber-50 border border-amber-200 p-2">
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search, Calendar, User, Banknote, ClipboardList, CornerDownLeft, Zap, FileText, Settings, BarChart2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { matchPages, type CommandPage } from "@/lib/commandPages";
|
||||
|
||||
type Result = { type: "event" | "payment" | "person" | "registration"; id: string; title: string; subtitle: string; href: string };
|
||||
type Item = { key: string; group: string; title: string; subtitle?: string; href: string; icon: React.ElementType };
|
||||
|
||||
const RESULT_GROUP: Record<Result["type"], [string, React.ElementType]> = {
|
||||
event: ["Events", Calendar], payment: ["Payments", Banknote], person: ["People", User], registration: ["Registrations", ClipboardList],
|
||||
};
|
||||
const PAGE_ICON: Record<CommandPage["group"], React.ElementType> = { Actions: Zap, Pages: FileText, Settings, Reports: BarChart2 };
|
||||
|
||||
/** Opens with Ctrl+K / ⌘K (or the event below) for staff, supervisors and admins. */
|
||||
export const OPEN_PALETTE_EVENT = "cross-events:open-palette";
|
||||
|
||||
/**
|
||||
* Ctrl+K command palette (1.49.0): jump to a page, setting or report, start an action ("New
|
||||
* event"), or find an event, a payment by amount ("R450"), a person or a registration — what the
|
||||
* person may see. ↑/↓ to move, Enter to open, Esc to close.
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
const { user, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const role = user?.role || "user";
|
||||
const allowed = role === "staff" || role === "supervisor" || role === "admin";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<Result[]>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen(o => !o);
|
||||
}
|
||||
};
|
||||
const onOpen = () => setOpen(true);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener(OPEN_PALETTE_EVENT, onOpen);
|
||||
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener(OPEN_PALETTE_EVENT, onOpen); };
|
||||
}, [allowed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setQuery(""); setResults([]); setActive(0); setTimeout(() => inputRef.current?.focus(), 0); }
|
||||
}, [open]);
|
||||
|
||||
// Search as they type (after a short pause); pages are matched here, instantly.
|
||||
useEffect(() => {
|
||||
if (!open || !token) return;
|
||||
const q = query.trim();
|
||||
if (q.length < 2) { setResults([]); return; }
|
||||
const timer = setTimeout(() => {
|
||||
apiFetch<{ results: Result[] }>(`/api/dashboard/search?q=${encodeURIComponent(q)}`, { authToken: token })
|
||||
.then(r => setResults(r.results || []))
|
||||
.catch(() => setResults([]));
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, open, token]);
|
||||
|
||||
const items = useMemo<Item[]>(() => {
|
||||
const pages = matchPages(role, query).slice(0, query.trim() ? 8 : 10).map(p => ({ key: `page:${p.href}`, group: p.group, title: p.label, href: p.href, icon: PAGE_ICON[p.group] }));
|
||||
const found = results.map(r => ({ key: `${r.type}:${r.id}`, group: RESULT_GROUP[r.type][0], title: r.title, subtitle: r.subtitle, href: r.href, icon: RESULT_GROUP[r.type][1] }));
|
||||
return query.trim() ? [...found, ...pages] : pages;
|
||||
}, [role, query, results]);
|
||||
|
||||
useEffect(() => { setActive(a => Math.min(a, Math.max(0, items.length - 1))); }, [items.length]);
|
||||
useEffect(() => { listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [active]);
|
||||
|
||||
const go = useCallback((item: Item | undefined) => {
|
||||
if (!item) return;
|
||||
setOpen(false);
|
||||
router.push(item.href);
|
||||
}, [router]);
|
||||
|
||||
if (!allowed || !open) return null;
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") { e.preventDefault(); setOpen(false); }
|
||||
else if (e.key === "ArrowDown") { e.preventDefault(); setActive(a => Math.min(items.length - 1, a + 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(a => Math.max(0, a - 1)); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); go(items[active]); }
|
||||
};
|
||||
|
||||
let lastGroup = "";
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-start justify-center bg-black/40 p-4 pt-[12vh]" onMouseDown={() => setOpen(false)}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Go to or search" className="w-full max-w-xl rounded-xl bg-white shadow-2xl overflow-hidden" onMouseDown={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 border-b px-3">
|
||||
<Search className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<input
|
||||
ref={inputRef} value={query} onChange={e => { setQuery(e.target.value); setActive(0); }} onKeyDown={onKeyDown}
|
||||
placeholder={role === "admin" ? "Go to a page, or find an event, person, payment (R450)…" : role === "supervisor" ? "Go to a page, or find an event or payment (R450)…" : "Go to a page or find an event…"}
|
||||
className="flex-1 py-3 text-sm outline-none bg-transparent" aria-label="Search" role="combobox" aria-expanded="true" aria-controls="palette-results"
|
||||
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
|
||||
/>
|
||||
<kbd className="text-[10px] text-gray-400 border rounded px-1.5 py-0.5">Esc</kbd>
|
||||
</div>
|
||||
<ul id="palette-results" ref={listRef} role="listbox" className="max-h-[55vh] overflow-y-auto py-1" data-testid="palette-results">
|
||||
{items.length === 0 && <li className="px-4 py-6 text-sm text-gray-500 text-center">{query.trim().length < 2 ? "Type to search." : "Nothing found."}</li>}
|
||||
{items.map((item, i) => {
|
||||
const header = item.group !== lastGroup ? item.group : null;
|
||||
lastGroup = item.group;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<React.Fragment key={item.key}>
|
||||
{header && <li className="px-4 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400" role="presentation">{header}</li>}
|
||||
<li
|
||||
id={`palette-item-${i}`} data-index={i} role="option" aria-selected={i === active}
|
||||
className={`mx-1 flex items-center gap-3 rounded-lg px-3 py-2 cursor-pointer ${i === active ? "bg-brand-50" : "hover:bg-gray-50"}`}
|
||||
onMouseMove={() => setActive(i)} onClick={() => go(item)}
|
||||
>
|
||||
<Icon className="w-4 h-4 text-gray-500 shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-gray-900 truncate">{item.title}</span>
|
||||
{item.subtitle && <span className="block text-xs text-gray-500 truncate">{item.subtitle}</span>}
|
||||
</span>
|
||||
{i === active && <CornerDownLeft className="w-3.5 h-3.5 text-gray-400" />}
|
||||
</li>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="border-t px-4 py-2 text-[11px] text-gray-400 flex gap-4">
|
||||
<span>↑↓ to move</span><span>Enter to open</span><span>Ctrl K to open or close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A button that opens the palette (for people who don't use the keyboard shortcut). */
|
||||
export function CommandPaletteButton({ className = "" }: { className?: string }) {
|
||||
const { user } = useAuth();
|
||||
if (!user || !["staff", "supervisor", "admin"].includes(user.role || "")) return null;
|
||||
return (
|
||||
<button type="button" onClick={() => window.dispatchEvent(new Event(OPEN_PALETTE_EVENT))}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border bg-white px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-50 ${className}`}>
|
||||
<Search className="w-4 h-4" /> Search or jump to… <kbd className="ml-2 text-[10px] border rounded px-1.5 py-0.5">Ctrl K</kbd>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function CouponCodeField({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!compact && <label className="block text-sm font-medium text-gray-700 mb-1">Discount code</label>}
|
||||
{!compact && <p aria-hidden="true" className="block text-sm font-medium text-gray-700 mb-1">Discount code</p>}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={code}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* The dark backdrop for the app's own popups, with what a screen reader and keyboard need:
|
||||
* announced as a dialog (named by `labelledBy`, the id of its heading), focus moved inside
|
||||
* when it opens and kept there with Tab, Escape closes it (when `onClose` is given), and
|
||||
* focus goes back to whatever opened it afterwards.
|
||||
*/
|
||||
export function ModalOverlay({
|
||||
labelledBy,
|
||||
onClose,
|
||||
closeOnBackdrop = false,
|
||||
className = "fixed inset-0 z-50 flex items-center justify-center bg-black/40",
|
||||
children,
|
||||
}: {
|
||||
labelledBy: string;
|
||||
onClose?: () => void;
|
||||
/** Also close on a click on the dark backdrop (outside the popup). */
|
||||
closeOnBackdrop?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
// Taken while rendering: an autoFocus inside moves focus before the effect runs.
|
||||
const [opener] = useState(() => (typeof document === "undefined" ? null : (document.activeElement as HTMLElement | null)));
|
||||
|
||||
useEffect(() => {
|
||||
const box = ref.current;
|
||||
// Respect an autoFocus inside; otherwise focus the first control, or the dialog itself.
|
||||
if (box && !box.contains(document.activeElement)) {
|
||||
(box.querySelector<HTMLElement>(FOCUSABLE) ?? box).focus();
|
||||
}
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && closeRef.current) {
|
||||
e.stopPropagation();
|
||||
closeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !box) return;
|
||||
const items = Array.from(box.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.offsetParent !== null);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (e.shiftKey && (document.activeElement === first || !box.contains(document.activeElement))) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && (document.activeElement === last || !box.contains(document.activeElement))) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
if (opener && document.contains(opener)) opener.focus();
|
||||
};
|
||||
}, [opener]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
onClick={closeOnBackdrop && onClose ? (e) => { if (e.target === e.currentTarget) onClose(); } : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "21–40 of 312 · ← Prev · Next →" under a server-paged list. Hidden when everything fits on
|
||||
* one page.
|
||||
*/
|
||||
export function Pager({ page, pageSize, total, onPage, busy }: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPage: (page: number) => void;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (pages <= 1) return null;
|
||||
const btn = "px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40";
|
||||
return (
|
||||
<nav aria-label="Pages" className="flex items-center justify-between mt-4 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} · page {page} of {pages}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button className={btn} disabled={page <= 1 || busy} onClick={() => onPage(page - 1)}>← Prev</button>
|
||||
<button className={btn} disabled={page >= pages || busy} onClick={() => onPage(page + 1)}>Next →</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Star, Trash2, Check } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export type FilterValues = Record<string, string | number | boolean | string[]>;
|
||||
type Saved = { id: string; name: string; params: FilterValues; isDefault: boolean };
|
||||
|
||||
/**
|
||||
* Saved filters on a list page (1.49.2): apply one with a click, save the current filters under a
|
||||
* name, star one as the default (applied when the page opens, unless the link already says what
|
||||
* to show — `skipDefault`). Stored per person on the server.
|
||||
*/
|
||||
export function SavedFilters({ page, value, onApply, skipDefault = false, className = "" }: {
|
||||
page: "registrations" | "users" | "events" | "reports";
|
||||
value: FilterValues;
|
||||
onApply: (params: FilterValues) => void;
|
||||
skipDefault?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { token } = useAuth();
|
||||
const [list, setList] = useState<Saved[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [asDefault, setAsDefault] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applied, setApplied] = useState<string | null>(null);
|
||||
const defaultDone = useRef(false);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const rows = await apiFetch<Saved[]>(`/api/saved-filters?page=${page}`, { authToken: token });
|
||||
setList(rows);
|
||||
// The default, once, when the page opens.
|
||||
if (!defaultDone.current) {
|
||||
defaultDone.current = true;
|
||||
const def = rows.find(r => r.isDefault);
|
||||
if (def && !skipDefault) { onApply(def.params); setApplied(def.id); }
|
||||
}
|
||||
} catch { /* the page works without them */ }
|
||||
// onApply/skipDefault are read once, for the default.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, page]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Close when clicking elsewhere.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [open]);
|
||||
|
||||
const act = async (fn: () => Promise<unknown>) => {
|
||||
setError(null);
|
||||
try { await fn(); await load(); } catch (e) { setError(errorMessage(e) || "Couldn't save that"); }
|
||||
};
|
||||
const save = () => act(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const row = await apiFetch<Saved>("/api/saved-filters", { method: "POST", authToken: token || undefined, body: { page, name, params: value, isDefault: asDefault } });
|
||||
setApplied(row.id); setName(""); setAsDefault(false);
|
||||
} finally { setSaving(false); }
|
||||
});
|
||||
const toggleDefault = (f: Saved) => act(() => apiFetch(`/api/saved-filters/${f.id}`, { method: "PUT", authToken: token || undefined, body: { isDefault: !f.isDefault } }));
|
||||
const remove = (f: Saved) => act(async () => {
|
||||
if (!confirm(`Delete the saved filter "${f.name}"?`)) return;
|
||||
await apiFetch(`/api/saved-filters/${f.id}`, { method: "DELETE", authToken: token || undefined });
|
||||
});
|
||||
|
||||
const current = list.find(f => f.id === applied);
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`} ref={boxRef} data-testid="saved-filters">
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open}
|
||||
className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1.5 rounded border bg-white hover:bg-gray-50">
|
||||
<Bookmark className="w-4 h-4" /> {current ? current.name : "Saved filters"}{list.length > 0 && !current ? ` (${list.length})` : ""}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-30 mt-1 w-72 right-0 sm:left-0 sm:right-auto rounded-xl border bg-white shadow-lg p-2 space-y-2" role="dialog" aria-label="Saved filters">
|
||||
{list.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 px-1">No saved filters yet. Set the filters the way you like them and save them here.</p>
|
||||
) : (
|
||||
<ul className="max-h-60 overflow-y-auto">
|
||||
{list.map(f => (
|
||||
<li key={f.id} className="flex items-center gap-1 rounded hover:bg-gray-50">
|
||||
<button type="button" className="flex-1 text-left text-sm px-2 py-1.5 truncate" onClick={() => { onApply(f.params); setApplied(f.id); setOpen(false); }}>
|
||||
{applied === f.id && <Check className="inline w-3.5 h-3.5 mr-1 text-brand-600" />}{f.name}
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleDefault(f)} title={f.isDefault ? "Default — click to stop" : "Make this the default when the page opens"} aria-label={f.isDefault ? `${f.name} is the default` : `Make ${f.name} the default`} className="p-1">
|
||||
<Star className={`w-4 h-4 ${f.isDefault ? "fill-amber-400 text-amber-500" : "text-gray-300 hover:text-gray-500"}`} />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove(f)} aria-label={`Delete ${f.name}`} className="p-1 text-gray-300 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="border-t pt-2 space-y-1.5">
|
||||
<label className="block text-xs text-gray-600" htmlFor={`save-filter-${page}`}>Save the current filters as</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input id={`save-filter-${page}`} className="flex-1 min-w-0 border rounded px-2 py-1 text-sm" placeholder="e.g. Camp — unpaid" value={name} onChange={e => setName(e.target.value)} onKeyDown={e => { if (e.key === "Enter" && name.trim()) save(); }} />
|
||||
<button type="button" disabled={saving || !name.trim()} onClick={save} className="px-2.5 py-1 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">Save</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600"><input type="checkbox" checked={asDefault} onChange={e => setAsDefault(e.target.checked)} /> Use when this page opens</label>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ArrowRightLeft } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { ModalOverlay } from "@/components/shared/ModalOverlay";
|
||||
|
||||
type TransferTicket = { id: string; isUsed?: boolean; quantity?: number; usages?: unknown[] };
|
||||
type TransferEvent = {
|
||||
@@ -60,11 +61,11 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
|
||||
const input = "w-full border rounded-lg px-3 py-2 text-sm";
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<ModalOverlay labelledBy="transfer-title" onClose={onClose} closeOnBackdrop className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-5 h-5 text-brand-600" />
|
||||
<h3 className="text-lg font-semibold">Transfer ticket</h3>
|
||||
<ArrowRightLeft className="w-5 h-5 text-brand-600" aria-hidden="true" />
|
||||
<h3 id="transfer-title" className="text-lg font-semibold">Transfer ticket</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
We'll send them a link to accept the ticket. Nothing changes until they accept — then{" "}
|
||||
@@ -74,23 +75,23 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
{" "}The booking and payment stay with the person who booked.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">New holder's name</label>
|
||||
<input className={input} value={f.name} onChange={set("name")} required maxLength={100} />
|
||||
<label htmlFor="transfer-new-holders-name" className="block text-xs text-gray-600 mb-1">New holder's name</label>
|
||||
<input id="transfer-new-holders-name" className={input} value={f.name} onChange={set("name")} required maxLength={100} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Email</label>
|
||||
<input type="email" className={input} value={f.email} onChange={set("email")} />
|
||||
<label htmlFor="transfer-email" className="block text-xs text-gray-600 mb-1">Email</label>
|
||||
<input id="transfer-email" type="email" className={input} value={f.email} onChange={set("email")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
|
||||
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
|
||||
<label htmlFor="transfer-phone" className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
|
||||
<input id="transfer-phone" type="tel" className={input} value={f.phone} onChange={set("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
{event?.collectGender && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Gender</label>
|
||||
<select className={input} value={f.gender} onChange={set("gender")} required>
|
||||
<label htmlFor="transfer-gender" className="block text-xs text-gray-600 mb-1">Gender</label>
|
||||
<select id="transfer-gender" className={input} value={f.gender} onChange={set("gender")} required>
|
||||
<option value="">Choose…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
@@ -101,22 +102,22 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
{youth && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
<input type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
|
||||
<label htmlFor="transfer-date-of-birth" className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
<input id="transfer-date-of-birth" type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
|
||||
</div>
|
||||
{minor && (
|
||||
<>
|
||||
<p className="text-xs text-amber-800 bg-amber-50 rounded p-2">They're under 18, so a parent or guardian will be asked for consent before they can check in.</p>
|
||||
<input className={input} placeholder="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
|
||||
<input className={input} placeholder="Parent or guardian's name" aria-label="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input type="email" className={input} placeholder="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
|
||||
<input type="tel" className={input} placeholder="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
|
||||
<input type="email" className={input} placeholder="Parent's email" aria-label="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
|
||||
<input type="tel" className={input} placeholder="Parent's phone" aria-label="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{error && <div role="alert" className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
@@ -124,6 +125,6 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { ClipboardList, Filter, Ban } from "lucide-react";
|
||||
import { ClipboardList, Filter, Ban, CheckSquare, Bookmark } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -26,6 +26,12 @@ export const dashboardAdminRegistrationsHelpContent: HelpContent = {
|
||||
<div className="space-y-4">
|
||||
<p>Click a row to expand it and see ticket options (with early-bird flags where applied), the payment history, and any submitted form responses.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Bookmark} title="Saved filters" tone="amber">
|
||||
Set the filters the way you use them, then Saved filters → name them and Save. Click one to apply it; the star makes it the one used when the page opens. They're yours (other people don't see them) and follow you to any device. The Users, Events and Reports pages have them too.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CheckSquare} title="Doing many at once" tone="violet">
|
||||
Tick registrations (or "Select all shown" after filtering) and choose an action: send a message (email or WhatsApp, as an event update), mark paid (records what each still owes, in cash, EFT, card or other — tickets go out by email), move to another ticket (their tickets and QR codes stay the same; the price follows the new ticket), put their named people in a room or group, export to Excel, or cancel. Each one follows the same rules as doing it one at a time, and you see what happened to each. Moving tickets and groups need registrations from one event.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ClipboardList} title="Changing status" tone="brand">
|
||||
Use the status dropdown inside the expanded row to move a registration between pending, confirmed, partially paid, paid, or cancelled.
|
||||
</GuideItem>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user