Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6f90a151 | ||
|
|
dacd49652b | ||
|
|
4c066e67d9 | ||
|
|
0ffbb13a1f | ||
|
|
e9d1b1fb24 | ||
|
|
98173c1380 | ||
|
|
6297613d0d | ||
|
|
5efb4cd6dd | ||
|
|
e40803cbbc | ||
|
|
fc393a90c1 | ||
|
|
c1211db8cf | ||
|
|
fb42d6ac19 | ||
|
|
2fc9e08f89 |
+141
@@ -7,6 +7,147 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.50.2] - 2026-10-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Searchable event picker** (`components/shared/EventPicker.tsx`) instead of the plain event
|
||||
dropdowns on staff and supervisor pages (scanner, event tickets, at the door, payments, manual,
|
||||
email and WhatsApp attendees, forms, attendance, consent, waitlist, allocations, bundles, offline
|
||||
scans, kids and volunteer set-up, manage registrations, the kiosk). It opens on upcoming events
|
||||
(soonest first); past ones are behind **Show past events** (latest first), and typing searches
|
||||
every event by name, campus or date. Keyboard: arrows, Page Up/Down, Enter, Escape.
|
||||
- **Reports:** the events list can be searched; "Select these N" / "Clear these" act on the matches.
|
||||
- **Site settings → Webhooks → How webhooks work:** what is sent and when, the envelope and
|
||||
headers, retries and duplicates, checking the signature (Node.js and PHP), Zapier/Make set-up,
|
||||
and a copyable example of every event type. `GET /api/outbound-webhooks` event types now carry
|
||||
an `example`; `tests/webhooks.test.js` checks each one has the same shape as the real payload.
|
||||
|
||||
### Changed
|
||||
|
||||
- Pages that listed every event ever made in their dropdown (email and WhatsApp attendees, forms,
|
||||
bundles, scanner, payments) now open on upcoming ones; their separate "Include past events"
|
||||
checkboxes are gone (the picker has its own). Event tickets and Payments can now reach past
|
||||
events too.
|
||||
- e2e: `pickEvent()` in `e2e/fixtures.ts`; `e2e/event-picker.spec.ts`.
|
||||
|
||||
## [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
|
||||
|
||||
+53
-13
@@ -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,24 @@ 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.
|
||||
- **Choosing an event** (staff and supervisor pages: scanner, event tickets, at the door, payments,
|
||||
manual registration, email and WhatsApp, forms, attendance, consent, waitlist, allocations,
|
||||
bundles, offline scans, kids and volunteer set-up, manage registrations, kiosk): a searchable
|
||||
picker instead of a long dropdown. It opens on upcoming events, soonest first; **Show past
|
||||
events** lists past ones (latest first), and typing searches every event by name, campus or date.
|
||||
Arrow keys, Enter and Escape work. `components/shared/EventPicker.tsx`.
|
||||
- **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 +483,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.
|
||||
|
||||
---
|
||||
@@ -498,7 +522,7 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
## Reports
|
||||
|
||||
A full-width workspace with shared filters (events, date range with presets,
|
||||
A full-width workspace with shared filters (events, searchable, with "Select these N" for the matches; date range with presets,
|
||||
include past/inactive/closed) and a searchable grid of reports, each opening in
|
||||
a viewer with its own filters, stat tiles and bar charts.
|
||||
|
||||
@@ -529,7 +553,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 +781,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 +792,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,11 +823,15 @@ 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
|
||||
is added to the operator register; deliveries are deleted after 30 days and with an account.
|
||||
- **How webhooks work** (1.50.2), a guide on the tab (open until the first webhook is added): the
|
||||
envelope and headers, retries and duplicates, checking the signature (Node.js and PHP examples),
|
||||
Zapier/Make set-up, and a copyable example message for every event type. The examples come from
|
||||
`EVENT_TYPES[].example` in `utils/webhooks.js`; a test keeps them the same shape as what is sent.
|
||||
|
||||
## Background jobs
|
||||
|
||||
|
||||
+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,60 @@ 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.2
|
||||
|
||||
- **Finding an event quickly.** Wherever staff and supervisors choose an event (the scanner, at
|
||||
the door, payments, messages, forms, attendance and more) there is now a search box. The list
|
||||
opens on upcoming events, past ones are one click away, and typing finds any event by name,
|
||||
campus or date. The events list in Reports can be searched too.
|
||||
- **Webhook guide.** Site settings → Webhooks has a "How webhooks work" guide for whoever connects
|
||||
another system: what is sent and when, how to check it came from you, and an example message for
|
||||
every kind of event.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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 |
|
||||
@@ -967,13 +999,13 @@ where v1 = HMAC-SHA256(secret, `<t>.<raw body>`). Answer 2xx within 10 s; anythi
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types |
|
||||
| GET | `/api/outbound-webhooks` | Endpoints with last-week counts, and the event types (each with an `example` of its `data`) |
|
||||
| POST | `/api/outbound-webhooks` | `{ name, url, events }` → endpoint plus `secret` (only here) |
|
||||
| PUT | `/api/outbound-webhooks/:id` | `{ name?, url?, events?, isActive? }` |
|
||||
| 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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.50.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.50.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.50.2",
|
||||
"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])
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -34,6 +34,48 @@ const EVENT_TYPES = [
|
||||
];
|
||||
const TYPES = new Set(EVENT_TYPES.map(t => t.type));
|
||||
|
||||
// What `data` looks like for each type — shown in Site settings → Webhooks so whoever builds the
|
||||
// receiving side knows what arrives. tests/webhooks.test.js checks these match buildPayload.
|
||||
const EXAMPLE_EVENT = { id: 'c1a2b3c4-0000-4000-8000-000000000001', title: 'Youth Camp 2026', startDate: '2026-12-04T16:00:00.000Z', endDate: '2026-12-06T12:00:00.000Z' };
|
||||
const EXAMPLE_PERSON = { id: 'u1a2b3c4-0000-4000-8000-000000000002', name: 'Thandi Mokoena', email: 'thandi@example.com', phone: '+27821234567' };
|
||||
const exampleRegistration = (status, amountPaid) => ({
|
||||
registration: {
|
||||
id: 'r1a2b3c4-0000-4000-8000-000000000003', status, createdAt: '2026-10-29T08:15:00.000Z', totalDue: 1200, amountPaid,
|
||||
seasonPassPurchaseId: null,
|
||||
items: [{ ticket: 'Camper', variant: 'Bunk bed', quantity: 2, unitPrice: 600 }],
|
||||
},
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const examplePayment = (amount) => ({
|
||||
payment: { id: 'p1a2b3c4-0000-4000-8000-000000000004', amount, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1a2b3c4-0000-4000-8000-000000000003', createdAt: '2026-10-29T08:16:00.000Z' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
});
|
||||
const EXAMPLES = {
|
||||
'registration.created': exampleRegistration('pending', 0),
|
||||
'registration.paid': exampleRegistration('paid', 1200),
|
||||
'registration.cancelled': exampleRegistration('cancelled', 0),
|
||||
'payment.received': examplePayment(1200),
|
||||
// Refunds are recorded as negative payments.
|
||||
'payment.refunded': examplePayment(-600),
|
||||
'ticket.checked_in': {
|
||||
checkIn: { id: 't1a2b3c4-0000-4000-8000-000000000005', scannedAt: '2026-12-04T16:20:00.000Z', people: 1, manual: false, offline: false, session: { id: 's1a2b3c4-0000-4000-8000-000000000006', label: 'Friday evening', startsAt: '2026-12-04T16:00:00.000Z' } },
|
||||
ticket: { id: 'k1a2b3c4-0000-4000-8000-000000000007', registrationOptionId: 'o1a2b3c4-0000-4000-8000-000000000008', holderName: 'Thandi Mokoena' },
|
||||
event: EXAMPLE_EVENT,
|
||||
person: EXAMPLE_PERSON,
|
||||
},
|
||||
'user.created': { person: { ...EXAMPLE_PERSON, role: 'user', createdAt: '2026-10-29T08:10:00.000Z' } },
|
||||
'event.published': {
|
||||
event: {
|
||||
...EXAMPLE_EVENT, description: 'A weekend away for grades 8–12.', location: 'Camp Joy, Magaliesburg', price: 600,
|
||||
goLiveAt: '2026-10-29T06:00:00.000Z', url: 'https://events.example.org/events/c1a2b3c4-0000-4000-8000-000000000001',
|
||||
campus: { name: 'North', slug: 'north' }, series: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
for (const t of EVENT_TYPES) t.example = EXAMPLES[t.type];
|
||||
|
||||
const RETRY_AFTER_MS = [60e3, 5 * 60e3, 30 * 60e3, 2 * 3600e3, 12 * 3600e3];
|
||||
const MAX_ATTEMPTS = RETRY_AFTER_MS.length + 1;
|
||||
const KEEP_DAYS = 30;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -3,8 +3,12 @@ const mockDb = {
|
||||
webhookDelivery: { updateMany: jest.fn(), findUnique: jest.fn(), update: jest.fn(), create: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
registration: { findUnique: jest.fn() },
|
||||
payment: { findUnique: jest.fn() },
|
||||
ticketUsage: { findUnique: jest.fn() },
|
||||
event: { findUnique: jest.fn() },
|
||||
};
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: () => 1200 }));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const webhooks = require('../src/utils/webhooks');
|
||||
@@ -112,4 +116,25 @@ describe('outbound webhooks', () => {
|
||||
expect(made[0].eventId).toBe(made[1].eventId);
|
||||
expect(made[0].payload.data.person).toMatchObject({ id: 'u1', email: 't@test.local' });
|
||||
});
|
||||
// The examples shown in Site settings → Webhooks must have the same shape as what is sent.
|
||||
test('each event type has an example shaped like the real payload', async () => {
|
||||
const shape = (v) => (v && typeof v === 'object' && !(v instanceof Date) && !Array.isArray(v)
|
||||
? Object.fromEntries(Object.keys(v).sort().map(k => [k, shape(v[k])]))
|
||||
: Array.isArray(v) ? v.map(shape) : '·');
|
||||
const ev = { id: 'e1', title: 'Camp', startDate: new Date(), endDate: new Date(), kidsOptionId: null, volunteerOptionId: null };
|
||||
const user = { id: 'u1', name: 'T', email: 't@x', phoneNumber: '+27' };
|
||||
const reg = (status) => ({ id: 'r1', status, createdAt: new Date(), userId: 'u1', seasonPassPurchaseId: null, event: ev, user, payments: [{ amount: 1200 }],
|
||||
registrationOptions: [{ eventOptionId: 'o1', quantity: 2, priceSnapshot: 600, eventOption: { id: 'o1', name: 'Camper' }, variant: { name: 'Bunk' }, tranches: [] }] });
|
||||
mockDb.payment.findUnique.mockResolvedValue({ id: 'p1', amount: 1200, method: 'card', provider: 'yoco', isDonation: false, registrationId: 'r1', createdAt: new Date(), userId: 'u1', user, event: ev });
|
||||
mockDb.ticketUsage.findUnique.mockResolvedValue({ id: 't1', scannedAt: new Date(), quantityRedeemed: 1, manual: false, offline: false, session: { id: 's1', label: 'Fri', startsAt: new Date() },
|
||||
ticket: { id: 'k1', userId: 'u1', registrationOptionId: 'o1', event: ev, user, holder: null, attendee: { name: 'T' }, registrationOption: { eventOptionId: 'o1' } } });
|
||||
mockDb.user.findUnique.mockResolvedValue({ ...user, role: 'user', createdAt: new Date() });
|
||||
mockDb.event.findUnique.mockResolvedValue({ ...ev, description: 'd', location: 'l', price: 600, isActive: true, isHidden: false, goLiveAt: new Date(), campus: { name: 'N', slug: 'n' }, series: null });
|
||||
for (const t of webhooks.EVENT_TYPES) {
|
||||
const status = { 'registration.paid': 'paid', 'registration.cancelled': 'cancelled' }[t.type] || 'pending';
|
||||
mockDb.registration.findUnique.mockResolvedValue(reg(status));
|
||||
const built = await webhooks.buildPayload(t.type, 'x');
|
||||
expect([t.type, shape(t.example)]).toEqual([t.type, shape(built.data)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+86
-5
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-28, after **1.48.2** (and Hub 1.5.0). The plan itself is in
|
||||
Last updated: 2026-10-29, after **1.50.2** (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.2 | Searchable **event picker** (`components/shared/EventPicker.tsx`) on staff/supervisor pages and the kiosk; searchable reports events list; **How webhooks work** guide on Site settings → Webhooks (examples from `EVENT_TYPES[].example`). e2e `e2e/event-picker.spec.ts` |
|
||||
| 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` |
|
||||
@@ -55,8 +61,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
|
||||
|
||||
@@ -377,6 +383,83 @@ a test server (both done 2026-10-28).
|
||||
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; the coupons list; `GET /api/registrations` without `page` (other callers still use the plain array).
|
||||
|
||||
## Event picker and webhook guide (1.50.2)
|
||||
|
||||
- **Choosing an event:** use `EventPicker` (`components/shared/EventPicker.tsx`), not a `<select>`.
|
||||
Give it the whole list (`/api/events/all?includePast=true`); it opens on upcoming events, keeps
|
||||
past ones behind "Show past events", and searches all of them. `emptyLabel` adds a clearable
|
||||
choice ("All events"), `graceHours` keeps just-ended events current (the scanner uses 12),
|
||||
`describe` adds detail per row, and `defaultEventId(events)` picks the next upcoming one. In e2e,
|
||||
`pickEvent(page, title)` from `e2e/fixtures.ts`.
|
||||
- `/api/events/all` still returns full events (options, form, sessions) — fine for a few hundred;
|
||||
a slim `?fields=` mode would help if a church gets into the thousands.
|
||||
- **Webhook examples:** a new webhook event type needs an entry in `EXAMPLES` in
|
||||
`utils/webhooks.js`; `tests/webhooks.test.js` fails if an example and `buildPayload` differ in
|
||||
shape.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
|
||||
@@ -387,8 +470,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** and 1.50.2 **event picker and webhook guide** 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 ✅, 1.50.2 ✅ | Admin dashboard/Ctrl+K/bulk actions/saved filters, Accessibility pass, pagination and search on long lists, event picker and webhook guide (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,134 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect, seed, loginAs, pickEvent } 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");
|
||||
await pickEvent(page, "E2E Free Evening");
|
||||
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();
|
||||
await pickEvent(page, "E2E Free Evening");
|
||||
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,52 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { test, expect, loginAs, api } from "./fixtures";
|
||||
|
||||
// 1.50.2: event dropdowns are a searchable picker — upcoming events first, past ones a click
|
||||
// away, and typing searches them all.
|
||||
test.describe("event picker", () => {
|
||||
test("opens on upcoming events; past ones are behind a button or found by searching", async ({ page }) => {
|
||||
const start = new Date(Date.now() - 40 * 864e5);
|
||||
await api(page, "supervisor", "POST", "/api/events", {
|
||||
title: "E2E Picker Old Gathering", description: "A past event for the picker test.", price: 0, location: "1 Test Street, Pretoria",
|
||||
startDate: start.toISOString(), endDate: new Date(start.getTime() + 2 * 3600e3).toISOString(),
|
||||
});
|
||||
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/email-attendees");
|
||||
await page.getByRole("button", { name: /^Event: Select an event/ }).click();
|
||||
const list = page.getByRole("listbox", { name: "Events" });
|
||||
await expect(list.getByRole("option", { name: /E2E Free Evening/ })).toBeVisible();
|
||||
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toHaveCount(0);
|
||||
// The open picker passes the WCAG 2.2 AA checks.
|
||||
const axe = await new AxeBuilder({ page }).include("[data-testid=event-picker]").withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]).analyze();
|
||||
expect(axe.violations.map((v) => `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`)).toEqual([]);
|
||||
|
||||
await page.getByRole("button", { name: /^Show past events/ }).click();
|
||||
await expect(list.getByRole("option", { name: /E2E Picker Old Gathering/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Hide past events" }).click();
|
||||
|
||||
// Searching finds past events too; the keyboard chooses.
|
||||
const search = page.getByRole("combobox", { name: "Search events" });
|
||||
await search.fill("old gathering");
|
||||
await expect(list.getByRole("option")).toHaveCount(1);
|
||||
await search.press("Enter");
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeFocused();
|
||||
|
||||
// Escape closes without changing anything.
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(list).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(list).toBeHidden();
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Picker Old Gathering/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the reports event list can be searched", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/supervisor/reports");
|
||||
await page.getByRole("button", { name: /^Events:/ }).click();
|
||||
await page.getByRole("button", { name: "Clear", exact: true }).click();
|
||||
await page.getByLabel("Search events").fill("alpha");
|
||||
await page.getByRole("button", { name: /^Select these 1$/ }).click();
|
||||
await expect(page.getByRole("button", { name: "Events: E2E Alpha Course" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -51,4 +51,12 @@ export async function api<T = unknown>(page: Page, role: Role, method: string, u
|
||||
return (text ? JSON.parse(text) : null) as T;
|
||||
}
|
||||
|
||||
/** Chooses an event in an EventPicker (the first on the page, or the one labelled `label`) by searching its title. */
|
||||
export async function pickEvent(page: Page, title: string, label?: string) {
|
||||
const button = label ? page.getByRole("button", { name: new RegExp(`^${label}:`) }) : page.getByTestId("event-picker").first().getByRole("button").first();
|
||||
await button.click();
|
||||
await page.getByRole("combobox", { name: /^Search/ }).fill(title);
|
||||
await page.getByRole("option", { name: new RegExp(title) }).first().click();
|
||||
}
|
||||
|
||||
export { expect };
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, pickEvent } from "./fixtures";
|
||||
|
||||
// Kids check-in (1.45.0): a supervisor sets it up, a parent adds a child, the desk checks the child
|
||||
// in (pickup code + sticker panel), the parent sees the code, and a wrong code is refused before
|
||||
@@ -7,7 +7,7 @@ test.describe.serial("kids check-in", () => {
|
||||
test("a supervisor sets up the kids ticket, a room and switches it on", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/kids");
|
||||
await page.getByRole("combobox").first().selectOption(seed().events.kids);
|
||||
await pickEvent(page, "E2E Kids Church");
|
||||
await page.getByRole("button", { name: /Add a free "Kids check-in" ticket/ }).click();
|
||||
await expect(page.getByLabel("Kids ticket")).not.toHaveValue("");
|
||||
await page.getByRole("button", { name: /Add "Kids rooms"/ }).click();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
|
||||
|
||||
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
|
||||
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
|
||||
@@ -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();
|
||||
@@ -67,12 +67,11 @@ test.describe("pick sessions", () => {
|
||||
test("at the door: book a class for a walk-in", async ({ page }) => {
|
||||
await loginAs(page, "supervisor");
|
||||
await page.goto("/dashboard/supervisor/at-the-door");
|
||||
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
|
||||
// The page picks a default event once its list loads; choose ours after that.
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.locator("select").first().selectOption(value!);
|
||||
await pickEvent(page, "E2E Drop-in Class");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator("select").first()).toHaveValue(value!);
|
||||
await expect(page.getByRole("button", { name: /^Event: E2E Drop-in Class/ })).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
|
||||
await page.getByText('New attendee "Door Walk In"…').click();
|
||||
@@ -80,7 +79,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("");
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
|
||||
|
||||
test.describe("multi-day events", () => {
|
||||
test("check-in is once per session, and the register and certificates follow", async ({ page }) => {
|
||||
@@ -44,7 +44,7 @@ test.describe("multi-day events", () => {
|
||||
test("the scanner offers today's session for a multi-day event", async ({ page }) => {
|
||||
await loginAs(page, "staff");
|
||||
await page.goto("/dashboard/staff/ticket-scanning");
|
||||
await page.locator("select").first().selectOption({ label: "E2E Alpha Course" });
|
||||
await pickEvent(page, "E2E Alpha Course");
|
||||
await expect(page.locator("select", { has: page.locator("option", { hasText: "Today's session" }) })).toBeVisible();
|
||||
await expect(page.getByText("Today's session (Week 2)")).toBeAttached();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
import { test, expect, seed, loginAs, api, pickEvent } from "./fixtures";
|
||||
|
||||
type EventRow = { id: string; title: string; scheduleType?: string; sessions?: { cancelled?: boolean }[] };
|
||||
|
||||
@@ -80,15 +80,14 @@ test.describe("supervisor tools", () => {
|
||||
await page.getByPlaceholder("••••••••").fill("E2e-password-123");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
await expect(page.getByText("Signed in as supervisor")).toBeVisible();
|
||||
const value = await page.locator("option", { hasText: "E2E Free Evening" }).getAttribute("value");
|
||||
await page.locator("select").selectOption(value!);
|
||||
await pickEvent(page, "E2E Free Evening");
|
||||
await page.getByRole("button", { name: "Start Kiosk" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "E2E Free Evening" })).toBeVisible();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -67,4 +67,20 @@ test.describe.serial("outbound webhooks", () => {
|
||||
await endpoint.getByRole("button", { name: "Delivery log" }).click();
|
||||
await expect(endpoint.getByTestId("webhook-delivery").filter({ hasText: "user.created" })).toContainText("succeeded");
|
||||
});
|
||||
|
||||
test("the guide explains the signature and shows an example of each event", async ({ page }) => {
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/settings?tab=webhooks");
|
||||
const guide = page.getByTestId("webhook-guide");
|
||||
const toggle = guide.getByRole("button", { name: /How webhooks work/ });
|
||||
await expect(toggle).toBeVisible();
|
||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click();
|
||||
await expect(guide).toContainText("X-CrossEvents-Signature");
|
||||
await guide.getByRole("button", { name: /registration.paid/ }).click();
|
||||
const example = guide.getByTestId("webhook-example-registration.paid");
|
||||
await expect(example).toContainText("\"type\": \"registration.paid\"");
|
||||
await expect(example).toContainText("\"amountPaid\"");
|
||||
await guide.getByRole("button", { name: "PHP" }).click();
|
||||
await expect(guide).toContainText("hash_hmac");
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.50.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.50.2",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.50.2",
|
||||
"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,9 @@ 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";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -54,6 +57,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 +180,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";
|
||||
@@ -245,13 +266,8 @@ export default function AdminRegistrationsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm max-w-48" value={eventFilter} onChange={e => setEventFilter(e.target.value)}>
|
||||
<option value="">All events</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="registrations-event">Event</label>
|
||||
<EventPicker id="registrations-event" className="min-w-[14rem]" events={events} value={eventFilter} onChange={setEventFilter} emptyLabel="All events" />
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
|
||||
@@ -273,11 +289,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 +326,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>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, Ticket as TicketRecord } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
@@ -40,7 +41,7 @@ function EventTicketsContent() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter((ev) => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -169,12 +170,14 @@ function EventTicketsContent() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
|
||||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
|
||||
<label className="text-sm text-gray-700 shrink-0" htmlFor="tickets-event">Event</label>
|
||||
<EventPicker
|
||||
id="tickets-event"
|
||||
className="w-full sm:w-72"
|
||||
events={events}
|
||||
emptyLabel="All events"
|
||||
value={eventId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
onChange={(v) => {
|
||||
setEventId(v);
|
||||
setSelectedUserId("");
|
||||
router.replace(
|
||||
@@ -185,14 +188,7 @@ function EventTicketsContent() {
|
||||
}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
|
||||
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
|
||||
<select
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
|
||||
import type { SnapshotTicket } from "@/lib/offlineScan";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, RecentScan, ScanStats, Section } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
@@ -72,7 +73,6 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Event selection state
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
@@ -98,14 +98,12 @@ export default function TicketScanningPage() {
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
const events = useMemo(() => {
|
||||
if (includePastEvents) return allEvents;
|
||||
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
|
||||
return allEvents.filter(ev => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
return !isNaN(t) && t >= cutoff;
|
||||
});
|
||||
}, [allEvents, includePastEvents]);
|
||||
// The picker: every event, with the offline-locked one even if it has gone from the list.
|
||||
const pickerEvents = useMemo(() => {
|
||||
const lock = offline.lock;
|
||||
if (!lock || allEvents.some(ev => ev.id === lock.eventId)) return allEvents;
|
||||
return [...allEvents, { id: lock.eventId, title: lock.eventTitle, startDate: new Date().toISOString() } as Event];
|
||||
}, [allEvents, offline.lock]);
|
||||
|
||||
const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]);
|
||||
const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]);
|
||||
@@ -360,23 +358,18 @@ export default function TicketScanningPage() {
|
||||
|
||||
{/* Event filter */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
|
||||
<label className="text-sm text-gray-700 shrink-0">Event</label>
|
||||
<select
|
||||
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
|
||||
value={selectedEventId}
|
||||
onChange={(e) => setSelectedEventId(e.target.value)}
|
||||
<label className="text-sm text-gray-700 shrink-0" htmlFor="scan-event">Event</label>
|
||||
{/* Events that ended in the last 12 hours still count as current (late scanning). */}
|
||||
<EventPicker
|
||||
id="scan-event"
|
||||
className="w-full sm:w-72"
|
||||
events={pickerEvents}
|
||||
value={selectedEventId === "all" ? "" : selectedEventId}
|
||||
onChange={id => setSelectedEventId(id || "all")}
|
||||
emptyLabel="All events"
|
||||
graceHours={12}
|
||||
disabled={!!offline.lock}
|
||||
>
|
||||
<option value="all">All events</option>
|
||||
{offline.lock && !events.some(ev => ev.id === offline.lock!.eventId) && <option value={offline.lock.eventId}>{offline.lock.eventTitle}</option>}
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
/>
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading events…</span>}
|
||||
</div>
|
||||
{sections.length > 0 && (
|
||||
@@ -418,7 +411,7 @@ export default function TicketScanningPage() {
|
||||
offline={offline}
|
||||
token={token}
|
||||
eventId={selectedEventId}
|
||||
eventTitle={events.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
|
||||
eventTitle={allEvents.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
|
||||
sectionId={selectedSectionId !== "all" ? selectedSectionId : null}
|
||||
sectionName={sections.find(s => s.id === selectedSectionId)?.name || null}
|
||||
onError={setError}
|
||||
|
||||
@@ -4,13 +4,13 @@ import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import type { RosterEvent } from "@/lib/volunteers";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
/** Staff → Volunteers: the roster on the day — who's here, ticking people in, and the check-in QR code. */
|
||||
export default function StaffVolunteersPage() {
|
||||
@@ -48,13 +48,10 @@ export default function StaffVolunteersPage() {
|
||||
{events && !events.length && <div className="p-3 border rounded bg-amber-50 text-amber-800 text-sm">No events have volunteer rosters yet. A supervisor adds serving roles under Supervisor → Volunteer rosters.</div>}
|
||||
{events && events.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 items-end print:hidden">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[14rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{!eventId && <option value="">Choose an event…</option>}
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-sm">
|
||||
<label className="block text-xs text-gray-500 mb-1" htmlFor="staff-volunteers-event">Event</label>
|
||||
<EventPicker id="staff-volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} />
|
||||
</div>
|
||||
{eventId && <button onClick={() => setShowQr(q => !q)} className="px-3 py-2 rounded-lg border text-sm">{showQr ? "Show the roster" : "Show the check-in QR code"}</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
@@ -222,10 +222,8 @@ export default function AllocationsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => { setEventId(e.target.value); setSetId(""); }}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="allocations-event">Event</label>
|
||||
<EventPicker id="allocations-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); setSetId(""); }} />
|
||||
</div>
|
||||
{board && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
|
||||
@@ -18,6 +18,7 @@ import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPr
|
||||
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
@@ -426,18 +427,7 @@ export default function AtTheDoorPage() {
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">At The Door</h1>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
|
||||
value={eventId}
|
||||
onChange={e => setEventId(e.target.value)}
|
||||
>
|
||||
<option value="">Select event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full sm:w-72" events={events} value={eventId} onChange={setEventId} placeholder="Select event" />
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 shrink-0 self-start sm:self-auto"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
@@ -1314,9 +1304,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 +1328,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>
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { fetchApiFile, saveBlob } from "@/lib/export";
|
||||
import type { AttendanceRegister, AttendanceRow, Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Filter = "all" | "completed" | "not_completed" | "missed_recent";
|
||||
|
||||
@@ -154,13 +154,8 @@ function AttendancePageInner() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem] max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No events</option>}
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))}{ev.scheduleType === "sessions" ? ` · ${(ev.sessions || []).filter(s => !s.cancelled).length} sessions` : ""})</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="attendance-event">Event</label>
|
||||
<EventPicker id="attendance-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Gift, Plus, X, Trash2, Pencil, Power, CalendarClock } from "lucide-reac
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import type { BundleGroupPricing, BundleKind, EventBundle } from "@/types";
|
||||
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
|
||||
|
||||
type OptionLite = { id: string; name: string; price: number; variants?: { id: string; name: string; price?: number | null }[] };
|
||||
type EventLite = { id: string; title: string; startDate: string; endDate?: string; eventOptions: OptionLite[] };
|
||||
@@ -56,7 +57,6 @@ const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
// fieldCls has no width, for fields sized by their row (inputCls is full width).
|
||||
const fieldCls = "border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
const inputCls = `w-full ${fieldCls}`;
|
||||
const isPast = (ev: EventLite) => new Date(ev.endDate || ev.startDate).getTime() < Date.now();
|
||||
|
||||
const KIND_INFO: Record<BundleKind, { label: string; hint: string; example: string }> = {
|
||||
exact: { label: "Exact set", hint: "These tickets in these numbers", example: "2 adults + 3 kids for R220" },
|
||||
@@ -92,7 +92,6 @@ export default function BundlesPage() {
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [allEvents, setAllEvents] = useState<EventLite[]>([]);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [bundles, setBundles] = useState<EventBundle[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
@@ -107,19 +106,12 @@ export default function BundlesPage() {
|
||||
.catch(e => setError(errorMessage(e) || "Failed to load events"));
|
||||
}, [token, canView, setError]);
|
||||
|
||||
// Upcoming events soonest first; past ones (when shown) after them, most recent first.
|
||||
const events = useMemo(() => {
|
||||
const upcoming = allEvents.filter(e => !isPast(e)).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
const past = allEvents.filter(isPast).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
return showPast ? [...upcoming, ...past] : upcoming;
|
||||
}, [allEvents, showPast]);
|
||||
|
||||
// Keep the picked event while it's in the list; otherwise start on the next upcoming one.
|
||||
// Start on the next upcoming event (or the latest one).
|
||||
useEffect(() => {
|
||||
if (!events.some(e => e.id === eventId)) setEventId(events[0]?.id || "");
|
||||
}, [events, eventId]);
|
||||
if (!allEvents.some(e => e.id === eventId)) setEventId(defaultEventId(allEvents));
|
||||
}, [allEvents, eventId]);
|
||||
|
||||
const event = events.find(e => e.id === eventId) || null;
|
||||
const event = allEvents.find(e => e.id === eventId) || null;
|
||||
const options = event?.eventOptions || [];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -194,17 +186,9 @@ export default function BundlesPage() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-3 mb-4">
|
||||
<div className="w-full sm:w-auto">
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm w-full sm:min-w-[20rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">{showPast ? "No events" : "No upcoming events"}</option>}
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title} ({fmtDate(ev.startDate)}){isPast(ev) ? " · past" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="bundles-event">Event</label>
|
||||
<EventPicker id="bundles-event" className="w-full sm:min-w-[20rem]" events={allEvents} value={eventId} onChange={setEventId} placeholder="No events" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 sm:pb-2">
|
||||
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Show past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{event && options.length === 0 && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, ConsentRow, MedicalRow } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker, defaultEventId } from "@/components/shared/EventPicker";
|
||||
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
@@ -49,7 +50,7 @@ export default function ConsentTrackerPage() {
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
if (!eventId && youth.length) setEventId(youth[youth.length - 1].id);
|
||||
if (!eventId && youth.length) setEventId(defaultEventId(youth));
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -149,11 +150,8 @@ export default function ConsentTrackerPage() {
|
||||
<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">Youth event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No youth events</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="consent-event">Youth event</label>
|
||||
<EventPicker id="consent-event" label="Youth event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No youth events" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Show</label>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
@@ -190,16 +191,8 @@ function EmailAttendeesPageInner() {
|
||||
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
|
||||
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
const now = Date.now();
|
||||
if (evIncludePast) return allEvents;
|
||||
return allEvents.filter(ev => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
return !isNaN(t) && t > now;
|
||||
});
|
||||
}, [allEvents, evIncludePast]);
|
||||
// Every event; the pickers open on upcoming ones and keep past ones a click away.
|
||||
const events = allEvents;
|
||||
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
@@ -544,18 +537,9 @@ function EmailAttendeesPageInner() {
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0 max-w-full" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
|
||||
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
@@ -740,12 +724,7 @@ function EmailAttendeesPageInner() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={e => setBroadcastEventId(e.target.value)}>
|
||||
<option value="">No event</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1122,12 +1101,7 @@ With gratitude`);
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={e => setAutoEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
@@ -1219,12 +1193,7 @@ With gratitude`);
|
||||
<div className="grid sm:grid-cols-2 gap-3 mb-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={e => setPromoEventId(e.target.value)}>
|
||||
<option value="">Select event to promote…</option>
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Send at</label>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventOption } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
|
||||
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
|
||||
@@ -228,12 +229,7 @@ function EventOptionsContent() {
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
|
||||
<div className="text-lg font-semibold mb-3">Select event</div>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
|
||||
{selectedEventId && (
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
{(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
|
||||
|
||||
@@ -493,19 +494,8 @@ export default function FormsBrowserPage() {
|
||||
<div className="text-base font-semibold mb-3">Filters</div>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1.5 text-sm w-full"
|
||||
value={selectedEventId}
|
||||
onChange={e => setSelectedEventId(e.target.value)}
|
||||
>
|
||||
<option value="">All events</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title}{!ev.isActive ? ' (inactive)' : ''}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-view-event">Event</label>
|
||||
<EventPicker id="forms-view-event" className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} emptyLabel="All events" />
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} /> Include past events
|
||||
@@ -629,18 +619,9 @@ export default function FormsBrowserPage() {
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-base font-semibold mb-3">Edit form structure</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-gray-600 mb-1">Select event</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1.5 text-sm w-full sm:max-w-sm"
|
||||
value={selectedEventId}
|
||||
onChange={e => { setSelectedEventId(e.target.value); setFormLoadedFor(""); }}
|
||||
>
|
||||
<option value="">Choose an event…</option>
|
||||
{allEvents.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}{!ev.isActive ? ' (inactive)' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-gray-400 mt-1">All events shown (including inactive/past) so you can edit historical forms.</div>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="forms-manage-event">Select event</label>
|
||||
<EventPicker id="forms-manage-event" className="w-full sm:max-w-sm" events={allEvents} value={selectedEventId} onChange={id => { setSelectedEventId(id); setFormLoadedFor(""); }} />
|
||||
<div className="text-xs text-gray-500 mt-1">Inactive and past events are included (search, or Show past events), so you can edit historical forms.</div>
|
||||
{formLoading && <span className="text-xs text-gray-400 mt-1 block">Loading form…</span>}
|
||||
</div>
|
||||
{!selectedEventId ? (
|
||||
@@ -678,10 +659,7 @@ export default function FormsBrowserPage() {
|
||||
<div className="grid sm:grid-cols-2 gap-3 mb-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillEventId} onChange={e => setFillEventId(e.target.value)}>
|
||||
<option value="">Select event…</option>
|
||||
{allEvents.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={allEvents} value={fillEventId} onChange={setFillEventId} placeholder="Select event" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
||||
|
||||
@@ -5,10 +5,10 @@ import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Baby, Plus, Trash2 } from "lucide-react";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Room = { id: string; name: string; capacity: number | null; minAge: number | null; maxAge: number | null; ratio: number | null; order: number };
|
||||
type Settings = {
|
||||
@@ -96,13 +96,10 @@ export default function KidsSetupPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm block max-w-md">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.kidsCheckIn ? " · kids check-in on" : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-sm block max-w-md">
|
||||
<label className="block text-xs text-gray-500 mb-1" htmlFor="kids-setup-event">Event</label>
|
||||
<EventPicker id="kids-setup-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.kidsCheckIn ? "kids check-in on" : null)} />
|
||||
</div>
|
||||
|
||||
{msg && <div role="status" className={`mt-3 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>}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { choosesSessions, isPickOption } from "@/lib/sessions";
|
||||
import { SessionPicker } from "@/components/events/SessionPicker";
|
||||
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -430,12 +431,7 @@ export default function ManualRegistrationPage() {
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="text-lg font-semibold mb-3">1) Choose event</div>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map(ev => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
|
||||
{selectedEventId && (
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
{(() => {
|
||||
|
||||
@@ -4,12 +4,12 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
|
||||
type Report = {
|
||||
@@ -101,10 +101,8 @@ export default function OfflineScansPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="offline-scans-event">Event</label>
|
||||
<EventPicker id="offline-scans-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 pb-2">
|
||||
<input type="checkbox" checked={onlyOpen} onChange={e => setOnlyOpen(e.target.checked)} /> Only reports that need review
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Event, Payment, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||
@@ -53,6 +54,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 +175,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 +191,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 +209,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);
|
||||
@@ -352,22 +368,20 @@ function PaymentsContent() {
|
||||
if (uid) setSelectedUserId(String(uid));
|
||||
}, [registrationId, registrations]);
|
||||
|
||||
const [eventsIncludePast, setEventsIncludePast] = useState(false);
|
||||
const [eventsIncludeInactive, setEventsIncludeInactive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const params = new URLSearchParams();
|
||||
if (eventsIncludePast) params.set("includePast", "true");
|
||||
// Past events too: the pickers open on upcoming ones and keep past ones a click away.
|
||||
const params = new URLSearchParams({ includePast: "true" });
|
||||
if (eventsIncludeInactive) params.set("includeInactive", "true");
|
||||
const qs = params.toString() ? `?${params.toString()}` : "";
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all?${params.toString()}`, { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch {}
|
||||
})();
|
||||
}, [token, eventsIncludePast, eventsIncludeInactive]);
|
||||
}, [token, eventsIncludeInactive]);
|
||||
|
||||
useEffect(() => {
|
||||
// Keep inputs sensible when toggling donation
|
||||
@@ -420,11 +434,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");
|
||||
|
||||
@@ -650,10 +664,7 @@ function PaymentsContent() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
|
||||
<option value="">Select event…</option>
|
||||
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={reconcileForm.eventId} onChange={id => setReconcileForm(prev => ({ ...prev, eventId: id }))} placeholder="Select event" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 rounded bg-emerald-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.eventId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
|
||||
@@ -707,15 +718,8 @@ function PaymentsContent() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (for donations)</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)} disabled={!isDonation}>
|
||||
<option value="">Select event…</option>
|
||||
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select event" disabled={!isDonation} />
|
||||
<div className="flex items-center gap-4 mt-1.5 text-xs text-gray-500">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={eventsIncludePast} onChange={e => setEventsIncludePast(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
{user?.role === "admin" && (
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={eventsIncludeInactive} onChange={e => setEventsIncludeInactive(e.target.checked)} />
|
||||
@@ -741,10 +745,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 +755,7 @@ function PaymentsContent() {
|
||||
{mode === 'donation' && (
|
||||
<>
|
||||
<DonationAssignSection
|
||||
payments={payments}
|
||||
payments={donationPayments}
|
||||
allUsers={allUsers}
|
||||
registrations={registrations}
|
||||
regOutstanding={regOutstanding}
|
||||
@@ -762,7 +763,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 +816,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 +835,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 +865,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 +883,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 +925,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 +965,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>
|
||||
|
||||
@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { RosterSheet } from "@/components/volunteers/RosterSheet";
|
||||
import { CheckInQr } from "@/components/volunteers/CheckInQr";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { hoursText, type HoursRow, type RosterEvent } from "@/lib/volunteers";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Settings = {
|
||||
event: { id: string; title: string; volunteerOptionId: string | null };
|
||||
@@ -91,13 +91,10 @@ export default function VolunteerRostersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="text-sm block max-w-md print:hidden">
|
||||
<span className="block text-xs text-gray-500 mb-1">Event</span>
|
||||
<select className="w-full border rounded-lg px-3 py-2 text-sm" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
<option value="">Choose an event…</option>
|
||||
{events.map(e => <option key={e.id} value={e.id}>{e.title} · {formatDate(new Date(e.startDate))}{e.roles ? ` · ${e.roles} role${e.roles === 1 ? "" : "s"}` : ""}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="text-sm block max-w-md print:hidden">
|
||||
<label className="block text-xs text-gray-500 mb-1" htmlFor="volunteers-event">Event</label>
|
||||
<EventPicker id="volunteers-event" className="w-full" events={events} value={eventId} onChange={setEventId} describe={e => (e.roles ? `${e.roles} role${e.roles === 1 ? "" : "s"}` : null)} />
|
||||
</div>
|
||||
|
||||
{msg && <div role="status" className={`mt-3 p-3 rounded-lg text-sm print:hidden ${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>}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { entryLineName } from "@/lib/waitlist";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -117,11 +118,8 @@ export default function WaitlistPage() {
|
||||
<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">Event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No events with a waitlist</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
<label className="block text-xs text-gray-600 mb-1" htmlFor="waitlist-event">Event</label>
|
||||
<EventPicker id="waitlist-event" label="Event" events={events} value={eventId} onChange={id => { setEventId(id); }} placeholder="No events with a waitlist" />
|
||||
</div>
|
||||
{selected && (
|
||||
<p className="text-xs text-gray-500">
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
import { CampusAudiencePicker } from "@/components/shared/CampusAudiencePicker";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -263,12 +264,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={autoEventId} onChange={setAutoEventId} placeholder="Select an event" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
@@ -328,12 +324,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[
|
||||
<div className="grid sm:grid-cols-2 gap-3 mb-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
|
||||
<option value="">Select event to promote…</option>
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={promoEventId} onChange={setPromoEventId} placeholder="Select event to promote" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Send at</label>
|
||||
@@ -379,13 +370,8 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
|
||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
const now = Date.now();
|
||||
if (evIncludePast) return allEvents;
|
||||
return allEvents.filter((ev) => !isNaN(new Date(ev.endDate).getTime()) && new Date(ev.endDate).getTime() > now);
|
||||
}, [allEvents, evIncludePast]);
|
||||
// Every event; the pickers open on upcoming ones and keep past ones a click away.
|
||||
const events = allEvents;
|
||||
|
||||
useEffect(() => {
|
||||
const loadEvents = async () => {
|
||||
@@ -651,18 +637,9 @@ function WhatsAppAttendeesPageInner() {
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0" value={eventId} onChange={(e) => setEventId(e.target.value)}>
|
||||
<option value="">Select an event…</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? " (past)" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={eventId} onChange={setEventId} placeholder="Select an event" />
|
||||
{loadingEvents && <span className="text-xs text-gray-500">Loading…</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
|
||||
<input type="checkbox" checked={evIncludePast} onChange={(e) => setEvIncludePast(e.target.checked)} />
|
||||
Include past events
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
@@ -814,12 +791,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={(e) => setBroadcastEventId(e.target.value)}>
|
||||
<option value="">No event</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<EventPicker className="w-full" events={events} value={broadcastEventId} onChange={setBroadcastEventId} emptyLabel="No event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,8 @@ 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";
|
||||
import { EventPicker } from "@/components/shared/EventPicker";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -116,8 +118,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 +694,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 +709,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 +730,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 +749,7 @@ export default function SelfServicePage() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
|
||||
{/* ── Setup Screen ───────────────────────────────────────────── */}
|
||||
@@ -762,8 +764,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 +777,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 +794,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 +821,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,19 +841,8 @@ 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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="">— Select an event —</option>
|
||||
{events.map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>
|
||||
{ev.title} ({fmtDate(ev.startDate)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label htmlFor="kiosk-event" className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<EventPicker id="kiosk-event" className="w-full text-base" events={events} value={selectedEventId} onChange={setSelectedEventId} placeholder="Select an event" />
|
||||
</div>
|
||||
)}
|
||||
{selectedEvent && (
|
||||
@@ -885,12 +879,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 +901,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 +939,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 +954,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 +980,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 +1010,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 +1046,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 +1121,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 +1160,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 +1179,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 +1189,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 +1211,7 @@ export default function SelfServicePage() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
|
||||
{/* ── Event Form Screen ──────────────────────────────────────── */}
|
||||
@@ -1241,7 +1250,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 +1260,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 +1278,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 +1287,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 +1296,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 +1305,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 +1317,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 +1395,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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user