Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f4fd7c040 | ||
|
|
1516e104df | ||
|
|
e9588aeba8 | ||
|
|
83df90b531 | ||
|
|
87ffe3b84b | ||
|
|
9a4bf51ad4 |
@@ -7,6 +7,40 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.19.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Rooms & groups (Supervisor → Rooms & groups), for camps and lockdowns: put attendees into rooms, cabins, small groups, teams or buses.
|
||||
- Each event can have several allocation sets (e.g. "Rooms" and "Buses"). Each set has groups with an optional capacity, an optional gender restriction and an optional leader (a staff member).
|
||||
- A drag-and-drop board puts people not yet placed on the left and groups, with capacity counters, on the right. Full groups and groups for the other gender won't accept a person.
|
||||
- Auto-allocate fills groups evenly with everyone not yet placed, respecting capacity and gender. It can keep people on the same booking together or spread them out.
|
||||
- Printable lists per group (with leader) and a master list. On youth events they can include medical and emergency details from consent forms; viewing those is recorded in the audit log.
|
||||
- "Show on tickets" prints the group on each person's ticket and shows it in My Events.
|
||||
- Email and WhatsApp attendees can be sent to one group ("Bus 2 leaves at 07:30"). Messages go to whoever booked for the people in it.
|
||||
- Group leaders see their people under Staff → My groups. Medical details are shown to supervisors only.
|
||||
- New event setting "Ask each person's gender" (under Named attendees). It adds a gender choice for each named person on the registration page, at the door, on the kiosk, in manual registration and in My Events, and is needed for gender-split rooms.
|
||||
- New `/api/allocations` endpoints.
|
||||
- Requires the `add_room_allocation` database migration.
|
||||
|
||||
## [1.18.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Ticket transfer. On My Events, "Transfer" hands a ticket to someone else before the event: enter their name and email or phone.
|
||||
- The new holder gets the ticket by email/WhatsApp with a new QR code. If they're new, an inactive account is created and they get an activation link.
|
||||
- The old QR code stops working, so screenshots of it won't scan. Scanning it says who the ticket was transferred to, and offline-mode sync flags it.
|
||||
- For a ticket covering several people, one place is split off to the new holder.
|
||||
- The payer stays responsible for the registration.
|
||||
- Transfer rules:
|
||||
- Only before the event starts, and only for tickets that haven't been scanned.
|
||||
- It can be turned off per event (new "Ticket transfer" setting in the event wizard, on by default). Supervisors can always transfer, from At the door → Tickets.
|
||||
- On youth events, the new holder's date of birth, and a parent's details for a minor, are needed, and a new consent request is sent.
|
||||
- The previous holder's attendee form answers and consent medical details are removed.
|
||||
- Every transfer is recorded in the audit log.
|
||||
- New endpoints `POST /api/tickets/:id/transfer` and `GET /api/tickets/:id/transfers`.
|
||||
- Requires the `add_ticket_transfer` database migration.
|
||||
|
||||
## [1.17.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
+21
-1
@@ -266,6 +266,15 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
- Registration detail: itemised bill (early-bird lines grouped), payments,
|
||||
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
|
||||
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
|
||||
- **Ticket transfer** — hand a ticket to someone else before the event (name plus
|
||||
email/phone). They get a new ticket and QR code, and an inactive account with an
|
||||
activation link if they're new. The old code stops working, and scanning it says it
|
||||
was transferred, both online and in offline-mode sync reports. One place can be split
|
||||
off a ticket covering several people. Billing stays with the payer. It's not allowed
|
||||
once a ticket has been scanned, and it can be turned off per event (supervisors can
|
||||
always transfer, from At the door → Tickets). On youth events the new holder needs
|
||||
their own parental consent, and the previous holder's form answers are removed.
|
||||
Every transfer is in the audit log.
|
||||
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
|
||||
date, method and payment/refund.
|
||||
- **Donate** page.
|
||||
@@ -306,6 +315,17 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **Rooms & groups** (`/dashboard/supervisor/allocations`):
|
||||
- Per event, allocation sets such as "Rooms", "Small groups" or "Buses". Each set
|
||||
has groups with an optional capacity, gender restriction and leader (staff).
|
||||
- A drag-and-drop board, plus auto-allocate: fill evenly, respecting capacity and
|
||||
gender, keeping bookings together or splitting them.
|
||||
- Printable per-group and master lists. On youth events they can include medical
|
||||
details, and viewing those is audited.
|
||||
- The group can show on tickets and in My Events.
|
||||
- Email and WhatsApp attendees can be filtered to one group.
|
||||
- Leaders see their people under Staff → **My groups**.
|
||||
- Works on named attendees. Events can ask each person's gender.
|
||||
- **Offline scanning** (`/dashboard/supervisor/offline-scans`) — devices holding
|
||||
offline locks (with force release) and sync reports to review.
|
||||
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
|
||||
@@ -478,5 +498,5 @@ falling back to a general guide.
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -22,6 +22,8 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
- [Offline scanning](#offline-scanning)
|
||||
- [Ticket transfer](#ticket-transfer)
|
||||
- [Rooms & groups](#rooms--groups)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -279,6 +281,46 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Rooms & groups
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/allocations/event/:eventId` | supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places |
|
||||
| POST | `/api/allocations/sets` | supervisor+ | `{ eventId, name, showOnTicket? }` |
|
||||
| PUT / DELETE | `/api/allocations/sets/:id` | supervisor+ | Rename / show on tickets; delete |
|
||||
| POST | `/api/allocations/sets/:id/groups` | supervisor+ | `{ name, capacity?, gender? ('male'\|'female'), leaderUserId? }` |
|
||||
| PUT / DELETE | `/api/allocations/groups/:id` | supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) |
|
||||
| PUT | `/api/allocations/sets/:id/assignments` | supervisor+ | `{ attendeeId, groupId }` (null unassigns); enforces capacity and gender |
|
||||
| DELETE | `/api/allocations/sets/:id/assignments` | supervisor+ | Clear the set |
|
||||
| POST | `/api/allocations/sets/:id/auto` | supervisor+ | `{ mode: 'together' \| 'split' }` — places everyone not yet placed |
|
||||
| GET | `/api/allocations/sets/:id/lists?medical=true` | supervisor+ | Groups with members for printing (medical view is audited) |
|
||||
| GET | `/api/allocations/my-groups` | staff+ | Groups the caller leads (medical details for supervisors only) |
|
||||
|
||||
Related changes:
|
||||
- `Event.collectGender` makes a gender (`male` / `female`) required for each named attendee.
|
||||
- Email and WhatsApp attendees accept `filter.allocationGroupId`.
|
||||
|
||||
## Ticket transfer
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) |
|
||||
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
|
||||
|
||||
Rules:
|
||||
- Only before the event starts, and only for tickets that have never been scanned.
|
||||
- `Event.allowTicketTransfer` turns it off for non-supervisors.
|
||||
|
||||
What a transfer does:
|
||||
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
|
||||
- An account is matched by email/phone, or created inactive with an activation link.
|
||||
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
|
||||
|
||||
Old QR codes:
|
||||
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
|
||||
- Offline sync reports them as `transferred`.
|
||||
- Each transfer is audited as `ticket_transferred`.
|
||||
|
||||
## Offline scanning
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TicketTransfer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"fromHolderName" TEXT NOT NULL,
|
||||
"fromUserId" TEXT,
|
||||
"toName" TEXT NOT NULL,
|
||||
"toUserId" TEXT,
|
||||
"byUserId" TEXT,
|
||||
"oldQrCode" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "collectGender" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationSet" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"showOnTicket" BOOLEAN NOT NULL DEFAULT false,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AllocationSet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationGroup" (
|
||||
"id" TEXT NOT NULL,
|
||||
"setId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"capacity" INTEGER,
|
||||
"gender" TEXT,
|
||||
"leaderUserId" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AllocationGroup_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AllocationAssignment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"setId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AllocationAssignment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationSet_eventId_idx" ON "AllocationSet"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationGroup_setId_idx" ON "AllocationGroup"("setId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationGroup_leaderUserId_idx" ON "AllocationGroup"("leaderUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationAssignment_groupId_idx" ON "AllocationAssignment"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AllocationAssignment_attendeeId_idx" ON "AllocationAssignment"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AllocationAssignment_setId_attendeeId_key" ON "AllocationAssignment"("setId", "attendeeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationSet" ADD CONSTRAINT "AllocationSet_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_leaderUserId_fkey" FOREIGN KEY ("leaderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -106,6 +106,9 @@ model User {
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
waitlistEntries WaitlistEntry[]
|
||||
scanLocks ScanLock[] @relation("ScanLockOwner")
|
||||
transfersTo TicketTransfer[] @relation("TransferRecipient")
|
||||
transfersBy TicketTransfer[] @relation("TransferActor")
|
||||
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
|
||||
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
|
||||
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
|
||||
|
||||
@@ -141,6 +144,9 @@ model Event {
|
||||
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
|
||||
// fewer places than asked for (otherwise skip to the next person who fits).
|
||||
waitlistEnabled Boolean @default(false)
|
||||
allowTicketTransfer Boolean @default(true)
|
||||
collectGender Boolean @default(false)
|
||||
allocationSets AllocationSet[]
|
||||
waitlistOfferHours Int @default(24)
|
||||
waitlistPartialOffers Boolean @default(true)
|
||||
contactName String?
|
||||
@@ -365,6 +371,7 @@ model Ticket {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
usages TicketUsage[]
|
||||
transfers TicketTransfer[]
|
||||
|
||||
@@index([holderUserId])
|
||||
@@index([eventId])
|
||||
@@ -476,6 +483,7 @@ enum AdminAuditAction {
|
||||
scan_lock_released
|
||||
scan_lock_force_released
|
||||
offline_sync
|
||||
ticket_transferred
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -990,6 +998,77 @@ enum TicketDelivery {
|
||||
|
||||
// One named person on a group registration, for one main-ticket place. Gets their
|
||||
// own quantity-1 ticket and their own attendee form response.
|
||||
/// A way of splitting an event's attendees up: "Rooms", "Small groups", "Buses".
|
||||
model AllocationSet {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
name String
|
||||
showOnTicket Boolean @default(false)
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
groups AllocationGroup[]
|
||||
assignments AllocationAssignment[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
/// One room / group / bus in a set. gender ('male' | 'female') restricts who can join.
|
||||
model AllocationGroup {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
name String
|
||||
capacity Int?
|
||||
gender String?
|
||||
leaderUserId String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
|
||||
assignments AllocationAssignment[]
|
||||
|
||||
@@index([setId])
|
||||
@@index([leaderUserId])
|
||||
}
|
||||
|
||||
/// An attendee placed in a group. One group per set.
|
||||
model AllocationAssignment {
|
||||
id String @id @default(uuid())
|
||||
setId String
|
||||
groupId String
|
||||
attendeeId String
|
||||
createdAt DateTime @default(now())
|
||||
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
|
||||
group AllocationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([setId, attendeeId])
|
||||
@@index([groupId])
|
||||
@@index([attendeeId])
|
||||
}
|
||||
|
||||
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
|
||||
/// when one place was split off a shared group ticket, whose code stays valid).
|
||||
model TicketTransfer {
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
fromHolderName String
|
||||
fromUserId String?
|
||||
toName String
|
||||
toUserId String?
|
||||
byUserId String?
|
||||
oldQrCode String?
|
||||
createdAt DateTime @default(now())
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull)
|
||||
byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([oldQrCode])
|
||||
}
|
||||
|
||||
model Attendee {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
@@ -1014,6 +1093,7 @@ model Attendee {
|
||||
ticket Ticket?
|
||||
formResponses FormResponse[]
|
||||
consentRequests ConsentRequest[]
|
||||
allocations AllocationAssignment[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([registrationOptionId])
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { allocationError, cleanGender, joinBlocker, autoAllocate } = require('../utils/allocation');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const cleanName = (v, what) => {
|
||||
const name = String(v || '').trim();
|
||||
if (!name) throw allocationError(`Enter a name for the ${what}.`);
|
||||
return name.slice(0, 100);
|
||||
};
|
||||
const cleanCapacity = (v) => {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = parseInt(v, 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
|
||||
return n;
|
||||
};
|
||||
|
||||
/** Named attendees on the event's live registrations. */
|
||||
async function eventAttendees(eventId) {
|
||||
return prisma.attendee.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } } },
|
||||
select: {
|
||||
id: true, name: true, gender: true, dateOfBirth: true, registrationId: true, email: true, phone: true,
|
||||
registration: { select: { user: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSet(id) {
|
||||
const set = await prisma.allocationSet.findUnique({ where: { id }, include: { groups: { orderBy: { order: 'asc' } } } });
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
return set;
|
||||
}
|
||||
|
||||
// @desc Everything the allocation board needs for an event
|
||||
// @route GET /api/allocations/event/:eventId
|
||||
// @access Private/Supervisor
|
||||
const getBoard = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: req.params.eventId },
|
||||
select: { id: true, title: true, startDate: true, collectGender: true, allowNamedAttendees: true, requiresParentalConsent: true },
|
||||
});
|
||||
if (!event) throw allocationError('Event not found', 404);
|
||||
const [sets, attendees, leaders, places] = await Promise.all([
|
||||
prisma.allocationSet.findMany({
|
||||
where: { eventId: event.id },
|
||||
include: { groups: { orderBy: { order: 'asc' }, include: { leader: { select: { id: true, name: true } } } }, assignments: { select: { attendeeId: true, groupId: true } } },
|
||||
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
|
||||
}),
|
||||
eventAttendees(event.id),
|
||||
prisma.user.findMany({ where: { role: { in: ['staff', 'supervisor', 'admin'] }, isActive: true }, select: { id: true, name: true }, orderBy: { name: 'asc' } }),
|
||||
prisma.registrationOption.aggregate({
|
||||
where: { registration: { eventId: event.id, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
|
||||
_sum: { quantity: true },
|
||||
}),
|
||||
]);
|
||||
res.json({
|
||||
event,
|
||||
sets,
|
||||
attendees: attendees.map(a => ({ id: a.id, name: a.name, gender: a.gender, dateOfBirth: a.dateOfBirth, registrationId: a.registrationId, bookedBy: a.registration.user?.name || '' })),
|
||||
leaders,
|
||||
unnamedPlaces: Math.max(0, (places._sum.quantity || 0) - attendees.length),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/allocations/sets { eventId, name, showOnTicket? }
|
||||
const createSet = async (req, res) => {
|
||||
try {
|
||||
const { eventId, name, showOnTicket } = req.body || {};
|
||||
if (!(await prisma.event.findUnique({ where: { id: String(eventId || '') }, select: { id: true } }))) throw allocationError('Event not found', 404);
|
||||
const order = await prisma.allocationSet.count({ where: { eventId } });
|
||||
res.status(201).json(await prisma.allocationSet.create({ data: { eventId, name: cleanName(name, 'set'), showOnTicket: showOnTicket === true, order } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/allocations/sets/:id { name?, showOnTicket? }
|
||||
const updateSet = async (req, res) => {
|
||||
try {
|
||||
await loadSet(req.params.id);
|
||||
const { name, showOnTicket } = req.body || {};
|
||||
res.json(await prisma.allocationSet.update({
|
||||
where: { id: req.params.id },
|
||||
data: { ...(name !== undefined ? { name: cleanName(name, 'set') } : {}), ...(showOnTicket !== undefined ? { showOnTicket: showOnTicket === true } : {}) },
|
||||
}));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/sets/:id
|
||||
const deleteSet = async (req, res) => {
|
||||
try {
|
||||
await loadSet(req.params.id);
|
||||
await prisma.allocationSet.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
async function groupData(body, setId, existing = null) {
|
||||
const { name, capacity, gender, leaderUserId } = body || {};
|
||||
const data = {};
|
||||
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
|
||||
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
|
||||
if (gender !== undefined) data.gender = cleanGender(gender);
|
||||
if (leaderUserId !== undefined) {
|
||||
if (leaderUserId) {
|
||||
const leader = await prisma.user.findUnique({ where: { id: String(leaderUserId) }, select: { role: true } });
|
||||
if (!leader || !['staff', 'supervisor', 'admin'].includes(leader.role)) throw allocationError('A group leader must be a staff member.');
|
||||
}
|
||||
data.leaderUserId = leaderUserId || null;
|
||||
}
|
||||
// Changing capacity or gender mustn't leave the group over capacity or mixed.
|
||||
if (existing && (data.capacity !== undefined || data.gender !== undefined)) {
|
||||
const members = await prisma.allocationAssignment.findMany({ where: { groupId: existing.id }, include: { attendee: { select: { name: true, gender: true } } } });
|
||||
const next = { ...existing, ...data };
|
||||
const blocker = joinBlocker(next, 0, members.map(m => m.attendee));
|
||||
if (blocker) throw allocationError(`${blocker} Move people out first.`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
|
||||
const createGroup = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const data = await groupData(req.body, set.id);
|
||||
res.status(201).json(await prisma.allocationGroup.create({ data: { ...data, setId: set.id, order: set.groups.length } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/allocations/groups/:id
|
||||
const updateGroup = async (req, res) => {
|
||||
try {
|
||||
const group = await prisma.allocationGroup.findUnique({ where: { id: req.params.id } });
|
||||
if (!group) throw allocationError('Group not found', 404);
|
||||
const data = await groupData(req.body, group.setId, group);
|
||||
res.json(await prisma.allocationGroup.update({ where: { id: group.id }, data }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/groups/:id (its people go back to unassigned)
|
||||
const deleteGroup = async (req, res) => {
|
||||
try {
|
||||
await prisma.allocationGroup.delete({ where: { id: req.params.id } });
|
||||
res.json({ message: 'Deleted' });
|
||||
} catch (error) {
|
||||
if (error.code === 'P2025') return res.status(404).json({ message: 'Group not found' });
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Put an attendee in a group (groupId null = unassign)
|
||||
// @route PUT /api/allocations/sets/:id/assignments { attendeeId, groupId }
|
||||
const assign = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const { attendeeId, groupId } = req.body || {};
|
||||
const attendee = await prisma.attendee.findFirst({
|
||||
where: { id: String(attendeeId || ''), registration: { eventId: set.eventId, status: { not: 'cancelled' } } },
|
||||
select: { id: true, name: true, gender: true },
|
||||
});
|
||||
if (!attendee) throw allocationError('Attendee not found for this event', 404);
|
||||
if (!groupId) {
|
||||
await prisma.allocationAssignment.deleteMany({ where: { setId: set.id, attendeeId: attendee.id } });
|
||||
return res.json({ message: 'Unassigned' });
|
||||
}
|
||||
const group = set.groups.find(g => g.id === groupId);
|
||||
if (!group) throw allocationError('Group not found in this set', 404);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'alloc:' + group.id}))`;
|
||||
const count = await tx.allocationAssignment.count({ where: { groupId: group.id, NOT: { attendeeId: attendee.id } } });
|
||||
const blocker = joinBlocker(group, count, [attendee]);
|
||||
if (blocker) throw allocationError(blocker);
|
||||
await tx.allocationAssignment.upsert({
|
||||
where: { setId_attendeeId: { setId: set.id, attendeeId: attendee.id } },
|
||||
update: { groupId: group.id },
|
||||
create: { setId: set.id, groupId: group.id, attendeeId: attendee.id },
|
||||
});
|
||||
});
|
||||
res.json({ message: `${attendee.name} → ${group.name}` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Place everyone not yet in a group
|
||||
// @route POST /api/allocations/sets/:id/auto { mode: 'together' | 'split' }
|
||||
const autoAssign = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
if (set.groups.length === 0) throw allocationError('Add some groups first.');
|
||||
const mode = req.body?.mode === 'split' ? 'split' : 'together';
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'allocset:' + set.id}))`;
|
||||
const [attendees, existing] = await Promise.all([
|
||||
eventAttendees(set.eventId),
|
||||
tx.allocationAssignment.findMany({ where: { setId: set.id } }),
|
||||
]);
|
||||
const r = autoAllocate({ groups: set.groups, attendees, assigned: new Map(existing.map(a => [a.attendeeId, a.groupId])), mode });
|
||||
if (r.placements.length) {
|
||||
await tx.allocationAssignment.createMany({ data: r.placements.map(p => ({ setId: set.id, groupId: p.groupId, attendeeId: p.attendeeId })), skipDuplicates: true });
|
||||
}
|
||||
return r;
|
||||
});
|
||||
res.json({
|
||||
placed: result.placements.length,
|
||||
unplaced: result.unplaced.length,
|
||||
message: `${result.placements.length} placed${result.unplaced.length ? `; ${result.unplaced.length} couldn't be placed (groups full, or gender doesn't match)` : ''}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route DELETE /api/allocations/sets/:id/assignments (clear the whole set)
|
||||
const clearAssignments = async (req, res) => {
|
||||
try {
|
||||
const set = await loadSet(req.params.id);
|
||||
const { count } = await prisma.allocationAssignment.deleteMany({ where: { setId: set.id } });
|
||||
res.json({ message: `${count} removed from groups.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
/** Members of groups, with contact and (for supervisors) medical details. */
|
||||
async function groupMembers(groupIds, { withMedical }) {
|
||||
const assignments = await prisma.allocationAssignment.findMany({
|
||||
where: { groupId: { in: groupIds } },
|
||||
include: {
|
||||
attendee: {
|
||||
select: {
|
||||
id: true, name: true, gender: true, dateOfBirth: true, phone: true, parentName: true, parentPhone: true,
|
||||
registration: { select: { user: { select: { name: true, phoneNumber: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { attendee: { name: 'asc' } },
|
||||
});
|
||||
let consent = new Map();
|
||||
if (withMedical && assignments.length) {
|
||||
const { latestRequestsFor, openJson } = require('../utils/consent');
|
||||
const latest = await latestRequestsFor(assignments.map(a => a.attendeeId));
|
||||
consent = new Map([...latest].map(([id, r]) => [id, r.status === 'given' ? { medical: openJson(r.medical), emergency: openJson(r.emergency) } : null]));
|
||||
}
|
||||
return assignments.map(a => ({
|
||||
groupId: a.groupId,
|
||||
id: a.attendee.id,
|
||||
name: a.attendee.name,
|
||||
gender: a.attendee.gender,
|
||||
dateOfBirth: a.attendee.dateOfBirth,
|
||||
phone: a.attendee.phone,
|
||||
parentName: a.attendee.parentName,
|
||||
parentPhone: a.attendee.parentPhone,
|
||||
bookedBy: a.attendee.registration.user?.name || '',
|
||||
bookedByPhone: a.attendee.registration.user?.phoneNumber || null,
|
||||
...(withMedical ? consent.get(a.attendee.id) || {} : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
// @desc Lists for printing: every group of a set, with members
|
||||
// @route GET /api/allocations/sets/:id/lists?medical=true
|
||||
// @access Private/Supervisor
|
||||
const getSetLists = async (req, res) => {
|
||||
try {
|
||||
const set = await prisma.allocationSet.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { event: { select: { id: true, title: true, startDate: true } }, groups: { orderBy: { order: 'asc' }, include: { leader: { select: { name: true } } } } },
|
||||
});
|
||||
if (!set) throw allocationError('Allocation set not found', 404);
|
||||
const withMedical = req.query.medical === 'true';
|
||||
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
|
||||
if (withMedical) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
res.json({ set: { id: set.id, name: set.name }, event: set.event, groups: set.groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, leader: g.leader?.name || null, members: members.filter(m => m.groupId === g.id) })) });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Groups the signed-in staff member leads, with their people
|
||||
// @route GET /api/allocations/my-groups
|
||||
// @access Private/Staff
|
||||
const getMyGroups = async (req, res) => {
|
||||
try {
|
||||
const groups = await prisma.allocationGroup.findMany({
|
||||
where: { leaderUserId: req.user.id, set: { event: { endDate: { gte: new Date(Date.now() - 7 * 864e5) } } } },
|
||||
include: { set: { include: { event: { select: { id: true, title: true, startDate: true } } } } },
|
||||
orderBy: [{ set: { event: { startDate: 'asc' } } }, { order: 'asc' }],
|
||||
});
|
||||
const withMedical = SUPERVISORS.includes(req.user.role);
|
||||
const members = await groupMembers(groups.map(g => g.id), { withMedical });
|
||||
if (withMedical && members.length) {
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationGroup', targetId: null, metadata: { groups: groups.length, attendees: members.length }, ip: getClientIp(req) });
|
||||
}
|
||||
res.json({
|
||||
medicalShown: withMedical,
|
||||
groups: groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, setName: g.set.name, event: g.set.event, members: members.filter(m => m.groupId === g.id) })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup, assign, autoAssign, clearAssignments, getSetLists, getMyGroups };
|
||||
@@ -44,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -70,6 +70,8 @@ const createEvent = async (req, res) => {
|
||||
consentCollectEmergency: consentCollectEmergency === true,
|
||||
consentCollectPhoto: consentCollectPhoto === true,
|
||||
waitlistEnabled: waitlistEnabled === true,
|
||||
allowTicketTransfer: allowTicketTransfer !== false,
|
||||
collectGender: collectGender === true,
|
||||
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
|
||||
waitlistPartialOffers: waitlistPartialOffers !== false,
|
||||
contactName: contactName || null,
|
||||
@@ -536,7 +538,7 @@ const updateEvent = async (req, res) => {
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -559,6 +561,8 @@ const updateEvent = async (req, res) => {
|
||||
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
|
||||
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
||||
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
||||
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
|
||||
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
|
||||
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
|
||||
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
|
||||
// Editing the consent text starts a new version; parents who already agreed keep
|
||||
@@ -1329,6 +1333,11 @@ const emailEventAttendees = async (req, res) => {
|
||||
where.status = 'cancelled';
|
||||
}
|
||||
|
||||
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
||||
if (filter?.allocationGroupId) {
|
||||
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
||||
}
|
||||
|
||||
// Optional explicit attendee selection by userIds
|
||||
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
||||
if (attendeeIds.length > 0) {
|
||||
@@ -1567,6 +1576,11 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
where.status = 'cancelled';
|
||||
}
|
||||
|
||||
// Only registrations with someone in this allocation group ("everyone on Bus 2").
|
||||
if (filter?.allocationGroupId) {
|
||||
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
|
||||
}
|
||||
|
||||
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
|
||||
if (attendeeIds.length > 0) {
|
||||
where.userId = { in: attendeeIds };
|
||||
|
||||
@@ -2220,7 +2220,7 @@ const updateAttendees = async (req, res) => {
|
||||
where: { id: a.id },
|
||||
data: {
|
||||
name: c.name, email: c.email, phone: c.phone,
|
||||
dateOfBirth: c.dateOfBirth, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
||||
...(contactChanged ? { userId: match?.user?.id || null } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,7 +95,12 @@ const getUserTickets = async (req, res) => {
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
attendee: { select: { id: true, name: true } },
|
||||
attendee: {
|
||||
select: {
|
||||
id: true, name: true,
|
||||
allocations: { where: { set: { showOnTicket: true } }, select: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } },
|
||||
},
|
||||
},
|
||||
user: { select: { id: true, name: true } },
|
||||
event: true,
|
||||
usages: {
|
||||
@@ -287,6 +292,9 @@ const getScanPreview = async (req, res) => {
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
@@ -351,6 +359,9 @@ const scanTicket = async (req, res) => {
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
|
||||
const transfer = await findTransferByOldCode(req.params.qrCode);
|
||||
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
|
||||
res.status(404);
|
||||
throw new Error('Ticket not found');
|
||||
}
|
||||
@@ -482,6 +493,7 @@ const getTicketsByEvent = async (req, res) => {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
attendee: { select: { id: true, name: true, email: true, phone: true } },
|
||||
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -691,6 +703,83 @@ const sendTicketsTo = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
|
||||
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
|
||||
// @access Private (payer or holder; supervisors always)
|
||||
const transferTicketHandler = async (req, res) => {
|
||||
try {
|
||||
const { transferTicket } = require('../utils/ticketTransfer');
|
||||
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
|
||||
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
|
||||
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
|
||||
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
// Tell the new holder: activation link for a new account, their ticket, and
|
||||
// anything they need to do themselves (consent, attendee form).
|
||||
if (r.accountCreated) {
|
||||
const { sendActivationLink } = require('./userController');
|
||||
sendActivationLink(r.toUser);
|
||||
}
|
||||
const a = r.attendee;
|
||||
const hasEmail = !!(a.email && a.email.includes('@'));
|
||||
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
|
||||
const silent = { status: () => silent, json: () => silent };
|
||||
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
|
||||
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
|
||||
if (r.needsConsent) {
|
||||
const { createConsentRequest } = require('../utils/consent');
|
||||
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
|
||||
}
|
||||
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
|
||||
if (form && form.fields.length > 0) {
|
||||
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
|
||||
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
|
||||
sendAttendeeFormLinkMessage(full).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
|
||||
ticketId: r.target.id,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Transfer history of a ticket
|
||||
// @route GET /api/tickets/:id/transfers
|
||||
// @access Private (payer or holder; staff)
|
||||
const getTicketTransfers = async (req, res) => {
|
||||
try {
|
||||
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
|
||||
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
|
||||
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
|
||||
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
|
||||
const transfers = await prisma.ticketTransfer.findMany({
|
||||
where: { ticketId: req.params.id },
|
||||
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(transfers);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/** "Room: Cabin 3 · Bus: Bus 2" for the sets shown on tickets, or ''. */
|
||||
function allocationLabel(attendee) {
|
||||
return [...(attendee?.allocations || [])]
|
||||
.sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0))
|
||||
.map(a => `${a.set.name}: ${a.group.name}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// Internal helper used by both emailTickets and sendTicketsTo
|
||||
async function emailTicketsInternal(req, res, userOverride) {
|
||||
const { ticketIds, registrationId } = req.body;
|
||||
@@ -713,7 +802,7 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
const ticketInclude = {
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: true,
|
||||
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
|
||||
user: { select: { name: true } },
|
||||
usages: true,
|
||||
};
|
||||
@@ -814,7 +903,8 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
||||
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const groupLabel = allocationLabel(ticket.attendee);
|
||||
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}${groupLabel ? ` · ${groupLabel}` : ''}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const eventDate = new Date(ticket.event.startDate);
|
||||
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
@@ -888,5 +978,7 @@ module.exports = {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
getTicketTransfers
|
||||
};
|
||||
@@ -179,6 +179,7 @@ app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
|
||||
app.use('/api/allocations', require('./routes/allocationRoutes'));
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||
@@ -635,6 +636,26 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
]},
|
||||
|
||||
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
|
||||
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
|
||||
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Rename a set or show it on tickets' },
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Delete a set and its groups' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/groups', auth:'supervisor+', desc:'Add a group', request:{ body:{ name:'Cabin 1', capacity:8, gender:'female', leaderUserId:'user-uuid-...' }}},
|
||||
{ method:'PUT', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Edit a group' },
|
||||
{ method:'DELETE', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Delete a group (its people become unassigned)' },
|
||||
{ method:'PUT', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Place (or with groupId null, unplace) an attendee; enforces capacity and gender', request:{ body:{ attendeeId:'att-uuid-...', groupId:'grp-uuid-...' }}},
|
||||
{ method:'DELETE', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Clear every assignment in a set' },
|
||||
{ method:'POST', path:'/api/allocations/sets/:id/auto', auth:'supervisor+', desc:'Auto-allocate everyone not yet placed', request:{ body:{ mode:'together' }}, responses:[{ status:200, desc:'Done', body:{ placed:12, unplaced:1 }}]},
|
||||
{ method:'GET', path:'/api/allocations/sets/:id/lists', auth:'supervisor+', desc:'Per-group lists for printing (?medical=true adds medical details; audited)' },
|
||||
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
|
||||
]},
|
||||
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
|
||||
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
|
||||
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
|
||||
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
|
||||
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
|
||||
]},
|
||||
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
|
||||
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
|
||||
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup,
|
||||
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
|
||||
} = require('../controllers/allocationController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/my-groups', protect, staff, getMyGroups);
|
||||
router.get('/event/:eventId', protect, supervisor, getBoard);
|
||||
router.post('/sets', protect, supervisor, createSet);
|
||||
router.put('/sets/:id', protect, supervisor, updateSet);
|
||||
router.delete('/sets/:id', protect, supervisor, deleteSet);
|
||||
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
|
||||
router.post('/sets/:id/groups', protect, supervisor, createGroup);
|
||||
router.put('/sets/:id/assignments', protect, supervisor, assign);
|
||||
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
|
||||
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
|
||||
router.put('/groups/:id', protect, supervisor, updateGroup);
|
||||
router.delete('/groups/:id', protect, supervisor, deleteGroup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -13,7 +13,9 @@ const {
|
||||
emailTickets,
|
||||
sendTicketsTo,
|
||||
getRecentScans,
|
||||
getScanStats
|
||||
getScanStats,
|
||||
transferTicketHandler,
|
||||
getTicketTransfers
|
||||
} = require('../controllers/ticketController');
|
||||
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -21,6 +23,8 @@ const { protect, admin, supervisor, staff } = require('../middleware/authMiddlew
|
||||
router.get('/mytickets', protect, getUserTickets);
|
||||
router.get('/:id', protect, getTicketById);
|
||||
router.post('/email', protect, emailTickets);
|
||||
router.post('/:id/transfer', protect, transferTicketHandler);
|
||||
router.get('/:id/transfers', protect, getTicketTransfers);
|
||||
|
||||
// Staff routes
|
||||
router.post('/send-to', protect, staff, sendTicketsTo);
|
||||
|
||||
@@ -33,4 +33,35 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
|
||||
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
|
||||
}
|
||||
|
||||
module.exports = { findAccountByContact, hasValidEmail };
|
||||
/**
|
||||
* The account for these contact details, or a new inactive one (same as guest
|
||||
* registration: a placeholder email for phone-only people, a random password, and an
|
||||
* activation link sent by the caller when `created` is true).
|
||||
*/
|
||||
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
|
||||
const match = await findAccountByContact({ email, phone }, db);
|
||||
if (match.conflict) {
|
||||
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
if (match.user) return { user: match.user, created: false };
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { hashPassword } = require('../config/auth');
|
||||
const validEmail = hasValidEmail(email);
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`,
|
||||
password: await hashPassword(uuidv4()),
|
||||
phoneNumber: match.phone || null,
|
||||
isActive: false,
|
||||
notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return { user, created: true };
|
||||
}
|
||||
|
||||
module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail };
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Group / room allocation — placing named attendees into the groups of an allocation
|
||||
* set ("Rooms", "Buses"…). Groups can have a capacity and a gender restriction.
|
||||
* The placement rules and auto-allocate are pure, so they're unit-tested.
|
||||
*/
|
||||
|
||||
const GENDERS = ['male', 'female'];
|
||||
|
||||
function allocationError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Normalises a gender value ('male' | 'female' | null); throws on anything else. */
|
||||
function cleanGender(value) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const g = String(value).trim().toLowerCase();
|
||||
if (!GENDERS.includes(g)) throw allocationError('Gender must be male or female.');
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `attendees` can't all join `group` (which has `count` members already), or null. Pure.
|
||||
* @param {object} group - { name, capacity?, gender? }
|
||||
*/
|
||||
function joinBlocker(group, count, attendees) {
|
||||
if (group.capacity != null && count + attendees.length > group.capacity) {
|
||||
return `${group.name} is full (${group.capacity} places).`;
|
||||
}
|
||||
if (group.gender) {
|
||||
const wrong = attendees.find(a => a.gender !== group.gender);
|
||||
if (wrong) return `${group.name} is for ${group.gender === 'male' ? 'males' : 'females'} only${wrong.gender ? '' : ` — ${wrong.name} has no gender recorded`}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places every unassigned attendee it can. Pure.
|
||||
*
|
||||
* Groups fill evenly (the emptiest group that fits goes first). With mode 'together',
|
||||
* people on the same registration (siblings) stay in one group when a group can take
|
||||
* them all, otherwise they're placed one by one. With mode 'split', people on the same
|
||||
* registration (friends booking together) are spread across groups where possible.
|
||||
*
|
||||
* @param {object} p
|
||||
* @param {Array} p.groups - [{ id, name, capacity?, gender?, order? }]
|
||||
* @param {Array} p.attendees - [{ id, name, registrationId, gender? }]
|
||||
* @param {Map} p.assigned - attendeeId → groupId for attendees already placed
|
||||
* @param {'together'|'split'} [p.mode]
|
||||
* @returns {{ placements: {attendeeId, groupId}[], unplaced: string[] }}
|
||||
*/
|
||||
function autoAllocate({ groups, attendees, assigned = new Map(), mode = 'together' }) {
|
||||
const count = new Map(groups.map(g => [g.id, 0]));
|
||||
const regInGroup = new Map(groups.map(g => [g.id, new Map()]));
|
||||
const byId = new Map(attendees.map(a => [a.id, a]));
|
||||
const place = (groupId, a) => {
|
||||
count.set(groupId, count.get(groupId) + 1);
|
||||
const regs = regInGroup.get(groupId);
|
||||
regs.set(a.registrationId, (regs.get(a.registrationId) || 0) + 1);
|
||||
};
|
||||
for (const [attendeeId, groupId] of assigned) {
|
||||
const a = byId.get(attendeeId);
|
||||
if (a && count.has(groupId)) place(groupId, a);
|
||||
}
|
||||
|
||||
const todo = attendees.filter(a => !assigned.has(a.id));
|
||||
let units;
|
||||
if (mode === 'together') {
|
||||
const byReg = new Map();
|
||||
for (const a of todo) byReg.set(a.registrationId, [...(byReg.get(a.registrationId) || []), a]);
|
||||
units = [...byReg.values()].sort((x, y) => y.length - x.length);
|
||||
} else {
|
||||
units = todo.map(a => [a]);
|
||||
}
|
||||
|
||||
const orderOf = new Map(groups.map((g, i) => [g.id, g.order ?? i]));
|
||||
const best = (members) => {
|
||||
const reg = members[0].registrationId;
|
||||
const fitting = groups.filter(g => !joinBlocker(g, count.get(g.id), members));
|
||||
if (fitting.length === 0) return null;
|
||||
fitting.sort((x, y) =>
|
||||
(mode === 'split' ? (regInGroup.get(x.id).get(reg) || 0) - (regInGroup.get(y.id).get(reg) || 0) : 0)
|
||||
|| count.get(x.id) - count.get(y.id)
|
||||
|| orderOf.get(x.id) - orderOf.get(y.id));
|
||||
return fitting[0];
|
||||
};
|
||||
|
||||
const placements = [];
|
||||
const unplaced = [];
|
||||
const queue = [...units];
|
||||
while (queue.length) {
|
||||
const unit = queue.shift();
|
||||
const g = best(unit);
|
||||
if (g) {
|
||||
for (const a of unit) { place(g.id, a); placements.push({ attendeeId: a.id, groupId: g.id }); }
|
||||
} else if (unit.length > 1) {
|
||||
// Can't keep them together — place them one by one instead.
|
||||
queue.unshift(...unit.map(a => [a]));
|
||||
} else {
|
||||
unplaced.push(unit[0].id);
|
||||
}
|
||||
}
|
||||
return { placements, unplaced };
|
||||
}
|
||||
|
||||
module.exports = { GENDERS, allocationError, cleanGender, joinBlocker, autoAllocate };
|
||||
@@ -69,6 +69,13 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
} else if (consent) {
|
||||
throw attendeeError(`${name}: enter a date of birth.`);
|
||||
}
|
||||
let gender = null;
|
||||
if (a.gender) {
|
||||
gender = String(a.gender).trim().toLowerCase();
|
||||
if (!['male', 'female'].includes(gender)) throw attendeeError(`${name}: gender must be male or female.`);
|
||||
} else if (event.collectGender) {
|
||||
throw attendeeError(`${name}: choose a gender.`);
|
||||
}
|
||||
const parentName = String(a.parentName || '').trim() || null;
|
||||
const parentEmail = String(a.parentEmail || '').trim() || null;
|
||||
const parentPhone = String(a.parentPhone || '').trim() || null;
|
||||
@@ -78,7 +85,7 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
}
|
||||
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
|
||||
}
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone };
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone };
|
||||
});
|
||||
|
||||
// Youth events need every main place named (with a date of birth).
|
||||
@@ -187,6 +194,7 @@ async function createAttendees(registrationId, attendees, db = prisma) {
|
||||
email: a.email,
|
||||
phone: a.phone,
|
||||
dateOfBirth: a.dateOfBirth || null,
|
||||
gender: a.gender || null,
|
||||
parentName: a.parentName || null,
|
||||
parentEmail: a.parentEmail || null,
|
||||
parentPhone: a.parentPhone || null,
|
||||
|
||||
@@ -52,6 +52,7 @@ function lockCovers(lock, { sectionId = null, optionId }) {
|
||||
*/
|
||||
function judgeSyncedScan(ticket, qty) {
|
||||
if (!ticket) return { redeem: 0, conflict: 'not_found' };
|
||||
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
|
||||
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
|
||||
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
|
||||
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
|
||||
@@ -61,7 +62,8 @@ function judgeSyncedScan(ticket, qty) {
|
||||
}
|
||||
|
||||
const CONFLICT_TEXT = {
|
||||
not_found: 'Ticket not found (possibly transferred or reissued)',
|
||||
not_found: 'Ticket not found',
|
||||
transferred: 'Old QR code of a ticket that was transferred to someone else',
|
||||
cancelled: 'Registration was cancelled',
|
||||
wrong_scope: 'Ticket is not valid for this section',
|
||||
already_used: 'Ticket had already been fully used',
|
||||
@@ -172,7 +174,10 @@ async function syncScans(lock, scans) {
|
||||
include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } },
|
||||
});
|
||||
let state = null;
|
||||
if (ticket && ticket.eventId === lock.eventId) {
|
||||
if (!ticket) {
|
||||
const { findTransferByOldCode } = require('./ticketTransfer');
|
||||
if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true };
|
||||
} else if (ticket.eventId === lock.eventId) {
|
||||
const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
|
||||
state = {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Ticket transfer — handing a ticket to someone else before the event.
|
||||
*
|
||||
* - The new holder becomes the ticket's attendee (name, contact details, account). The
|
||||
* payer stays responsible for the registration; billing doesn't move.
|
||||
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
|
||||
* is kept on the TicketTransfer so scanners can say "transferred" instead of "not found".
|
||||
* - A shared (unnamed) ticket for several places has one place split off into a new
|
||||
* ticket for the new holder; the rest stay on the shared ticket and its code.
|
||||
* - The previous holder's attendee form answers are removed, and on youth events the new
|
||||
* holder needs their own parental consent.
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const prisma = require('../config/db');
|
||||
const { validateAttendees } = require('./attendees');
|
||||
const { isMinor } = require('./consent');
|
||||
|
||||
const SUPERVISORS = ['admin', 'supervisor'];
|
||||
|
||||
function transferError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this ticket can't be transferred by `actor`, or null when it can. Pure.
|
||||
* @param {object} t - { userId, holderUserId, usages[], event: { startDate, allowTicketTransfer }, registrationStatus }
|
||||
* @param {object} actor - { id, role }
|
||||
*/
|
||||
function transferBlocker(t, actor, now = new Date()) {
|
||||
const supervisor = SUPERVISORS.includes(actor.role);
|
||||
if (!supervisor && t.userId !== actor.id && t.holderUserId !== actor.id) return { status: 403, message: 'Only the person who booked or holds this ticket can transfer it.' };
|
||||
if (t.registrationStatus === 'cancelled') return { status: 400, message: 'This registration was cancelled.' };
|
||||
if (new Date(t.event.startDate) <= now) return { status: 400, message: 'Tickets can only be transferred before the event starts.' };
|
||||
if ((t.usages || []).length > 0) return { status: 400, message: 'This ticket has already been used (or partly used), so it can’t be transferred.' };
|
||||
if (t.event.allowTicketTransfer === false && !supervisor) return { status: 400, message: 'Ticket transfers are turned off for this event.' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers a ticket. Returns what the caller needs to notify people afterwards.
|
||||
* @param {object} input - { ticketId, actor, name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
|
||||
*/
|
||||
async function transferTicket({ ticketId, actor, ...holder }) {
|
||||
const { findOrCreateAccount } = require('./accountMatch');
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
usages: { select: { id: true } },
|
||||
attendee: true,
|
||||
holder: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
event: { include: { eventOptions: true } },
|
||||
registrationOption: { include: { registration: { select: { id: true, status: true } } } },
|
||||
},
|
||||
});
|
||||
if (!ticket) throw transferError('Ticket not found', 404);
|
||||
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
|
||||
if (blocker) throw transferError(blocker.message, blocker.status);
|
||||
|
||||
const ro = ticket.registrationOption;
|
||||
const event = ticket.event;
|
||||
// Same rules as naming an attendee at registration (incl. date of birth and parent
|
||||
// details on youth events), for exactly one place.
|
||||
const [clean] = validateAttendees({ ...event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
|
||||
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
|
||||
}]);
|
||||
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
|
||||
|
||||
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
|
||||
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
|
||||
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
|
||||
|
||||
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
|
||||
const attendeeData = {
|
||||
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
|
||||
dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
|
||||
};
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Re-check inside the transaction: a scan or another transfer may have just happened.
|
||||
const fresh = await tx.ticket.findUnique({ where: { id: ticket.id }, include: { usages: { select: { id: true } } } });
|
||||
if (!fresh || fresh.qrCode !== ticket.qrCode || fresh.usages.length > 0) throw transferError('This ticket changed while you were transferring it. Please try again.', 409);
|
||||
|
||||
let target, attendee, oldQrCode;
|
||||
if (ticket.attendeeId) {
|
||||
attendee = await tx.attendee.update({ where: { id: ticket.attendeeId }, data: attendeeData });
|
||||
// The previous holder's answers and medical details aren't the new holder's.
|
||||
await tx.formResponse.deleteMany({ where: { attendeeId: attendee.id } });
|
||||
await tx.consentRequest.updateMany({ where: { attendeeId: attendee.id }, data: { medical: null, emergency: null, medicalPurgedAt: new Date() } });
|
||||
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), holderUserId: toUser.id, emailSent: false } });
|
||||
oldQrCode = ticket.qrCode;
|
||||
} else {
|
||||
const order = await tx.attendee.count({ where: { registrationId: ro.registration.id } });
|
||||
attendee = await tx.attendee.create({
|
||||
data: { registrationId: ro.registration.id, registrationOptionId: ro.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId, order, ...attendeeData },
|
||||
});
|
||||
if ((ticket.quantity || 1) > 1) {
|
||||
// Split one place off the shared ticket; its code stays valid for the rest.
|
||||
await tx.ticket.update({ where: { id: ticket.id }, data: { quantity: ticket.quantity - 1 } });
|
||||
target = await tx.ticket.create({
|
||||
data: { qrCode: uuidv4(), registrationOptionId: ro.id, userId: ticket.userId, eventId: ticket.eventId, quantity: 1, attendeeId: attendee.id, holderUserId: toUser.id },
|
||||
});
|
||||
oldQrCode = null;
|
||||
} else {
|
||||
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), attendeeId: attendee.id, holderUserId: toUser.id, emailSent: false } });
|
||||
oldQrCode = ticket.qrCode;
|
||||
}
|
||||
}
|
||||
const transfer = await tx.ticketTransfer.create({
|
||||
data: { ticketId: target.id, fromHolderName, fromUserId: currentHolderId || null, toName: clean.name, toUserId: toUser.id, byUserId: actor.id, oldQrCode },
|
||||
});
|
||||
return { target, attendee, transfer };
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
toUser,
|
||||
accountCreated: created,
|
||||
fromHolderName,
|
||||
event,
|
||||
registrationId: ro.registration.id,
|
||||
needsConsent: !!event.requiresParentalConsent && isMinor(clean.dateOfBirth, event.startDate),
|
||||
};
|
||||
}
|
||||
|
||||
/** The transfer that retired this QR code, if any. */
|
||||
async function findTransferByOldCode(qrCode) {
|
||||
if (!qrCode) return null;
|
||||
return prisma.ticketTransfer.findFirst({ where: { oldQrCode: String(qrCode) }, orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
function transferredMessage(transfer) {
|
||||
const when = new Date(transfer.createdAt).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short' });
|
||||
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
|
||||
}
|
||||
|
||||
module.exports = { transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage };
|
||||
@@ -0,0 +1,84 @@
|
||||
const { joinBlocker, autoAllocate, cleanGender } = require('../src/utils/allocation');
|
||||
|
||||
const person = (id, registrationId, gender = null) => ({ id, name: id, registrationId, gender });
|
||||
const groupsOf = (result) => {
|
||||
const out = {};
|
||||
for (const p of result.placements) (out[p.groupId] = out[p.groupId] || []).push(p.attendeeId);
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('cleanGender', () => {
|
||||
test('accepts male/female in any case, blank as null', () => {
|
||||
expect(cleanGender('Male')).toBe('male');
|
||||
expect(cleanGender('')).toBeNull();
|
||||
expect(() => cleanGender('other')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinBlocker', () => {
|
||||
test('capacity', () => {
|
||||
expect(joinBlocker({ name: 'Cabin 1', capacity: 2 }, 1, [person('a', 'r')])).toBeNull();
|
||||
expect(joinBlocker({ name: 'Cabin 1', capacity: 2 }, 2, [person('a', 'r')])).toMatch(/full/);
|
||||
});
|
||||
|
||||
test('gender', () => {
|
||||
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r', 'male')])).toBeNull();
|
||||
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r', 'female')])).toMatch(/males only/);
|
||||
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r')])).toMatch(/no gender recorded/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoAllocate', () => {
|
||||
test('fills groups evenly', () => {
|
||||
const groups = [{ id: 'g1', name: 'A' }, { id: 'g2', name: 'B' }];
|
||||
const attendees = ['a', 'b', 'c', 'd'].map(id => person(id, id));
|
||||
const r = autoAllocate({ groups, attendees });
|
||||
const g = groupsOf(r);
|
||||
expect(g.g1.length).toBe(2);
|
||||
expect(g.g2.length).toBe(2);
|
||||
expect(r.unplaced).toEqual([]);
|
||||
});
|
||||
|
||||
test('respects capacity and reports who could not be placed', () => {
|
||||
const groups = [{ id: 'g1', name: 'A', capacity: 1 }];
|
||||
const r = autoAllocate({ groups, attendees: [person('a', 'r1'), person('b', 'r2')] });
|
||||
expect(r.placements.length).toBe(1);
|
||||
expect(r.unplaced.length).toBe(1);
|
||||
});
|
||||
|
||||
test('respects gender', () => {
|
||||
const groups = [{ id: 'boys', name: 'Boys', gender: 'male' }, { id: 'girls', name: 'Girls', gender: 'female' }];
|
||||
const r = autoAllocate({ groups, attendees: [person('a', 'r1', 'male'), person('b', 'r2', 'female'), person('c', 'r3')] });
|
||||
expect(groupsOf(r)).toEqual({ boys: ['a'], girls: ['b'] });
|
||||
expect(r.unplaced).toEqual(['c']);
|
||||
});
|
||||
|
||||
test('keeps a registration together when a group can take them all', () => {
|
||||
const groups = [{ id: 'g1', name: 'A', capacity: 3 }, { id: 'g2', name: 'B', capacity: 3 }];
|
||||
const attendees = [person('s1', 'fam'), person('s2', 'fam'), person('x', 'solo')];
|
||||
const g = groupsOf(autoAllocate({ groups, attendees, mode: 'together' }));
|
||||
const famGroup = Object.keys(g).find(k => g[k].includes('s1'));
|
||||
expect(g[famGroup]).toContain('s2');
|
||||
});
|
||||
|
||||
test('places siblings one by one when no group fits them all', () => {
|
||||
const groups = [{ id: 'boys', name: 'Boys', gender: 'male' }, { id: 'girls', name: 'Girls', gender: 'female' }];
|
||||
const r = autoAllocate({ groups, attendees: [person('bro', 'fam', 'male'), person('sis', 'fam', 'female')], mode: 'together' });
|
||||
expect(groupsOf(r)).toEqual({ boys: ['bro'], girls: ['sis'] });
|
||||
});
|
||||
|
||||
test('split mode spreads a registration across groups', () => {
|
||||
const groups = [{ id: 'g1', name: 'A' }, { id: 'g2', name: 'B' }];
|
||||
const attendees = [person('f1', 'friends'), person('f2', 'friends')];
|
||||
const g = groupsOf(autoAllocate({ groups, attendees, mode: 'split' }));
|
||||
expect(g.g1.length).toBe(1);
|
||||
expect(g.g2.length).toBe(1);
|
||||
});
|
||||
|
||||
test('counts people already placed and leaves them where they are', () => {
|
||||
const groups = [{ id: 'g1', name: 'A', capacity: 2 }, { id: 'g2', name: 'B', capacity: 2 }];
|
||||
const attendees = [person('a', 'r1'), person('b', 'r2'), person('c', 'r3')];
|
||||
const r = autoAllocate({ groups, attendees, assigned: new Map([['a', 'g1'], ['b', 'g1']]) });
|
||||
expect(r.placements).toEqual([{ attendeeId: 'c', groupId: 'g2' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
const { transferBlocker } = require('../src/utils/ticketTransfer');
|
||||
const { judgeSyncedScan } = require('../src/utils/scanLocks');
|
||||
|
||||
describe('transferBlocker', () => {
|
||||
const now = new Date('2026-10-01T10:00:00Z');
|
||||
const ticket = {
|
||||
userId: 'payer', holderUserId: 'holder', usages: [], registrationStatus: 'paid',
|
||||
event: { startDate: '2026-10-05T08:00:00Z', allowTicketTransfer: true },
|
||||
};
|
||||
const user = (id, role = 'user') => ({ id, role });
|
||||
|
||||
test('the payer and the holder can transfer', () => {
|
||||
expect(transferBlocker(ticket, user('payer'), now)).toBeNull();
|
||||
expect(transferBlocker(ticket, user('holder'), now)).toBeNull();
|
||||
});
|
||||
|
||||
test('anyone else needs to be a supervisor', () => {
|
||||
expect(transferBlocker(ticket, user('someone'), now).status).toBe(403);
|
||||
expect(transferBlocker(ticket, user('staffer', 'staff'), now).status).toBe(403);
|
||||
expect(transferBlocker(ticket, user('sup', 'supervisor'), now)).toBeNull();
|
||||
});
|
||||
|
||||
test('not once the event has started', () => {
|
||||
expect(transferBlocker({ ...ticket, event: { ...ticket.event, startDate: '2026-10-01T09:00:00Z' } }, user('sup', 'supervisor'), now).message).toMatch(/before the event starts/);
|
||||
});
|
||||
|
||||
test('not once any of it has been scanned', () => {
|
||||
expect(transferBlocker({ ...ticket, usages: [{ id: 'u1' }] }, user('payer'), now).message).toMatch(/already been used/);
|
||||
});
|
||||
|
||||
test('not for a cancelled registration', () => {
|
||||
expect(transferBlocker({ ...ticket, registrationStatus: 'cancelled' }, user('payer'), now)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('turned off per event, except for supervisors', () => {
|
||||
const off = { ...ticket, event: { ...ticket.event, allowTicketTransfer: false } };
|
||||
expect(transferBlocker(off, user('payer'), now).message).toMatch(/turned off/);
|
||||
expect(transferBlocker(off, user('admin', 'admin'), now)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('offline sync of a transferred code', () => {
|
||||
test('is flagged and not recorded', () => {
|
||||
expect(judgeSyncedScan({ transferred: true }, 1)).toEqual({ redeem: 0, conflict: 'transferred' });
|
||||
});
|
||||
});
|
||||
Generated
+42
-2
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
@@ -343,6 +344,45 @@
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -9,6 +9,7 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
|
||||
@@ -29,6 +29,7 @@ const ACTIONS = [
|
||||
"scan_lock_released",
|
||||
"scan_lock_force_released",
|
||||
"offline_sync",
|
||||
"ticket_transferred",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -52,6 +53,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
scan_lock_released: "Offline scanning ended",
|
||||
scan_lock_force_released: "Offline lock force-released",
|
||||
offline_sync: "Offline scans synced",
|
||||
ticket_transferred: "Ticket transferred",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -27,6 +27,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { Users } from "lucide-react";
|
||||
|
||||
type Member = {
|
||||
id: string; name: string; gender: string | null; dateOfBirth: string | null; phone: string | null;
|
||||
parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
|
||||
medical?: Record<string, string> | null; emergency?: Record<string, string> | null;
|
||||
};
|
||||
type MyGroup = { id: string; name: string; capacity: number | null; setName: string; event: { id: string; title: string; startDate: string }; members: Member[] };
|
||||
|
||||
const ageOn = (dob: string | null, on: string) => {
|
||||
if (!dob) return null;
|
||||
const d = new Date(dob), e = new Date(on);
|
||||
let age = e.getFullYear() - d.getFullYear();
|
||||
if (e.getMonth() < d.getMonth() || (e.getMonth() === d.getMonth() && e.getDate() < d.getDate())) age -= 1;
|
||||
return age;
|
||||
};
|
||||
|
||||
/** Groups the signed-in staff member leads (rooms, small groups, buses) and who's in them. */
|
||||
export default function MyGroupsPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<{ medicalShown: boolean; groups: MyGroup[] } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<{ medicalShown: boolean; groups: MyGroup[] }>("/api/allocations/my-groups", { authToken: token })
|
||||
.then(setData).catch(e => setError(e?.message || "Failed to load your groups"));
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Users className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">My groups</h1>
|
||||
<p className="text-sm text-gray-500">Rooms, small groups and buses you lead</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{data && data.groups.length === 0 && <p className="text-sm text-gray-600">You aren't leading any groups for upcoming events.</p>}
|
||||
<div className="space-y-4">
|
||||
{data?.groups.map(g => (
|
||||
<section key={g.id} className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||
<h2 className="font-semibold">{g.name} <span className="text-sm font-normal text-gray-500">· {g.setName}</span></h2>
|
||||
<span className="text-xs text-gray-500">{g.event.title} · {formatDate(new Date(g.event.startDate))} · {g.members.length}{g.capacity ? ` / ${g.capacity}` : ""} people</span>
|
||||
</div>
|
||||
{g.members.length === 0 ? <p className="text-sm text-gray-500">Nobody has been placed in this group yet.</p> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-gray-500 border-b">
|
||||
<th className="py-1 pr-3">Name</th><th className="py-1 pr-3">Age</th><th className="py-1 pr-3">Contact</th>
|
||||
{data.medicalShown && <th className="py-1 pr-3">Medical</th>}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{g.members.map(m => {
|
||||
const age = ageOn(m.dateOfBirth, g.event.startDate);
|
||||
return (
|
||||
<tr key={m.id} className="border-b last:border-0 align-top">
|
||||
<td className="py-1.5 pr-3 font-medium">{m.name}</td>
|
||||
<td className="py-1.5 pr-3">{age ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-xs">
|
||||
{m.phone && <div>{m.phone}</div>}
|
||||
{m.parentName ? <div>Parent: {m.parentName}{m.parentPhone ? ` · ${m.parentPhone}` : ""}</div> : m.bookedBy && <div>Booked by {m.bookedBy}{m.bookedByPhone ? ` · ${m.bookedByPhone}` : ""}</div>}
|
||||
</td>
|
||||
{data.medicalShown && (
|
||||
<td className="py-1.5 pr-3 text-xs">
|
||||
{m.medical ? Object.values(m.medical).filter(Boolean).join("; ") || "—" : "—"}
|
||||
{m.emergency && Object.values(m.emergency).some(Boolean) && <div className="text-gray-500">Emergency: {Object.values(m.emergency).filter(Boolean).join(", ")}</div>}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
{data && !data.medicalShown && data.groups.length > 0 && <p className="text-xs text-gray-500 mt-3">Medical details are only shown to supervisors. Ask one if you need them.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock } from "lucide-react";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
|
||||
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
|
||||
] as const;
|
||||
|
||||
export default function StaffDashboardPage() {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
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";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
|
||||
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
|
||||
|
||||
const UNASSIGNED = "unassigned";
|
||||
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
|
||||
const genderLabel = (g: string | null) => (g === "male" ? "Male" : g === "female" ? "Female" : "");
|
||||
const ageOn = (dob: string | null, on: string) => {
|
||||
if (!dob) return null;
|
||||
const d = new Date(dob), e = new Date(on);
|
||||
let age = e.getFullYear() - d.getFullYear();
|
||||
if (e.getMonth() < d.getMonth() || (e.getMonth() === d.getMonth() && e.getDate() < d.getDate())) age -= 1;
|
||||
return age;
|
||||
};
|
||||
|
||||
function PersonChip({ person, eventStart }: { person: Person; eventStart: string }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: person.id });
|
||||
const age = ageOn(person.dateOfBirth, eventStart);
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
style={transform ? { transform: `translate(${transform.x}px, ${transform.y}px)`, zIndex: 50 } : undefined}
|
||||
className={`px-2 py-1.5 rounded-lg border bg-white text-sm cursor-grab active:cursor-grabbing select-none touch-none ${isDragging ? "shadow-lg ring-2 ring-brand-300" : "shadow-sm"}`}
|
||||
title={person.bookedBy ? `Booked by ${person.bookedBy}` : undefined}
|
||||
>
|
||||
<span className="font-medium">{person.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{[genderLabel(person.gender), age != null ? `${age}` : "", person.bookedBy ? `· ${person.bookedBy}` : ""].filter(Boolean).map(s => ` ${s}`).join("")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({ id, title, subtitle, full, children, actions }: { id: string; title: string; subtitle?: string; full?: boolean; children: React.ReactNode; actions?: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
return (
|
||||
<div ref={setNodeRef} className={`rounded-xl border p-3 min-h-[8rem] transition-colors ${isOver ? "bg-brand-50 border-brand-300" : full ? "bg-gray-50" : "bg-white"}`}>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{title}</div>
|
||||
{subtitle && <div className={`text-xs ${full ? "text-amber-700" : "text-gray-500"}`}>{subtitle}</div>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AllocationsPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [setId, setSetId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [groupForm, setGroupForm] = useState<{ id?: string; name: string; capacity: string; gender: string; leaderUserId: string } | null>(null);
|
||||
const [newSetName, setNewSetName] = useState("");
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
setEvents(upcoming);
|
||||
if (upcoming.length) setEventId(id => id || upcoming[0].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
}, [token, canView, setError]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
setBoard(b);
|
||||
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load allocations");
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const current = board?.sets.find(s => s.id === setId) || null;
|
||||
const groupOf = useMemo(() => new Map((current?.assignments || []).map(a => [a.attendeeId, a.groupId])), [current]);
|
||||
const membersOf = (groupId: string) => (board?.attendees || []).filter(p => groupOf.get(p.id) === groupId);
|
||||
const unassigned = (board?.attendees || []).filter(p => !groupOf.has(p.id));
|
||||
|
||||
const call = async <T,>(path: string, method: string, body?: unknown): Promise<T> =>
|
||||
apiFetch<T>(path, { method, authToken: token!, ...(body !== undefined ? { body } : {}) });
|
||||
|
||||
const onDragEnd = async (e: DragEndEvent) => {
|
||||
if (!current || !e.over) return;
|
||||
const attendeeId = String(e.active.id);
|
||||
const target = String(e.over.id);
|
||||
const groupId = target === UNASSIGNED ? null : target;
|
||||
if ((groupOf.get(attendeeId) || null) === groupId) return;
|
||||
// Optimistic move; reload on failure.
|
||||
setBoard(b => b && ({
|
||||
...b,
|
||||
sets: b.sets.map(s => s.id !== current.id ? s : {
|
||||
...s,
|
||||
assignments: [...s.assignments.filter(a => a.attendeeId !== attendeeId), ...(groupId ? [{ attendeeId, groupId }] : [])],
|
||||
}),
|
||||
}));
|
||||
try {
|
||||
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't move them");
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<string | void>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const msg = await fn();
|
||||
if (msg) setInfo(msg);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addSet = () => run(async () => {
|
||||
const s = await call<{ id: string }>("/api/allocations/sets", "POST", { eventId, name: newSetName || "Rooms" });
|
||||
setNewSetName("");
|
||||
setSetId(s.id);
|
||||
});
|
||||
const saveGroup = () => groupForm && current && run(async () => {
|
||||
const body = { name: groupForm.name, capacity: groupForm.capacity || null, gender: groupForm.gender || null, leaderUserId: groupForm.leaderUserId || null };
|
||||
if (groupForm.id) await call(`/api/allocations/groups/${groupForm.id}`, "PUT", body);
|
||||
else await call(`/api/allocations/sets/${current.id}/groups`, "POST", body);
|
||||
setGroupForm(null);
|
||||
});
|
||||
|
||||
const printLists = async (master: boolean, medical: boolean) => {
|
||||
if (!current || !board) return;
|
||||
try {
|
||||
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
|
||||
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
|
||||
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
|
||||
let body = "";
|
||||
if (master) {
|
||||
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
} else {
|
||||
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
|
||||
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
|
||||
}
|
||||
w.document.write(`<!doctype html><html><head><title>${esc(data.set.name)}</title><style>
|
||||
body{font-family:Arial,sans-serif;font-size:11pt;margin:12mm} h1{font-size:15pt;margin:0 0 2mm} p{margin:0 0 3mm;color:#555}
|
||||
table{border-collapse:collapse;width:100%;margin-bottom:6mm} th,td{border:1px solid #bbb;padding:3px 6px;text-align:left;vertical-align:top} th{background:#f1f1f1}
|
||||
section{page-break-after:always} section:last-child{page-break-after:auto}
|
||||
</style></head><body>${body}<script>window.onload=function(){window.print()}</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't load the lists");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<BedDouble className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Rooms & groups</h1>
|
||||
<p className="text-sm text-gray-500">Put attendees into rooms, small groups, teams or buses</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage allocations.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="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>
|
||||
</div>
|
||||
{board && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{board.sets.map(s => (
|
||||
<button key={s.id} onClick={() => setSetId(s.id)} className={`px-3 py-1.5 text-sm rounded-lg border ${s.id === setId ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>{s.name}</button>
|
||||
))}
|
||||
<input className="border rounded-lg px-2 py-1.5 text-sm w-36" placeholder="New set, e.g. Buses" value={newSetName} onChange={e => setNewSetName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") addSet(); }} />
|
||||
<button onClick={addSet} disabled={busy} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Plus className="w-4 h-4" /> Add set</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{board && board.attendees.length === 0 && (
|
||||
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">
|
||||
No named attendees yet. Allocation works per person, so it needs names — turn on "Named attendees" for the event{board.event.collectGender ? "" : " (and \"Ask each person's gender\" if rooms are split by gender)"}.
|
||||
</div>
|
||||
)}
|
||||
{board && board.unnamedPlaces > 0 && (
|
||||
<p className="text-xs text-gray-500">{board.unnamedPlaces} place(s) have no name yet, so they can't be placed until the person booking adds names.</p>
|
||||
)}
|
||||
|
||||
{current && board && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 border rounded-xl p-3 bg-white">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={current.showOnTicket} onChange={e => run(async () => { await call(`/api/allocations/sets/${current.id}`, "PUT", { showOnTicket: e.target.checked }); })} />
|
||||
Show {current.name.toLowerCase()} on tickets and in My Events
|
||||
</label>
|
||||
<div className="ml-auto flex flex-wrap gap-2">
|
||||
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "together" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" title="Fill groups evenly, keeping people on the same booking (e.g. siblings) together where possible"><Wand2 className="w-4 h-4" /> Auto-allocate (keep bookings together)</button>
|
||||
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "split" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50" title="Fill groups evenly, spreading people on the same booking across groups">Auto-allocate (split bookings)</button>
|
||||
<button onClick={() => printLists(false, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Group lists</button>
|
||||
<button onClick={() => printLists(true, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Master list</button>
|
||||
{board.event.requiresParentalConsent && (
|
||||
<button onClick={() => printLists(false, true)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50" title="Includes medical details from consent forms. Viewing is recorded in the audit log."><Printer className="w-4 h-4" /> Lists with medical info</button>
|
||||
)}
|
||||
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/assignments`, "DELETE")).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50"><Eraser className="w-4 h-4" /> Clear</button>
|
||||
<button disabled={busy} onClick={() => run(async () => { await call(`/api/allocations/sets/${current.id}`, "DELETE"); setSetId(""); return `Deleted ${current.name}.`; })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 className="w-4 h-4" /> Delete set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupForm ? (
|
||||
<div className="border rounded-xl p-3 bg-white flex flex-wrap items-end gap-2">
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Name</label><input className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.name} onChange={e => setGroupForm({ ...groupForm, name: e.target.value })} placeholder="Cabin 1" autoFocus /></div>
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Capacity</label><input type="number" min="1" className="border rounded-lg px-2 py-1.5 text-sm w-24" value={groupForm.capacity} onChange={e => setGroupForm({ ...groupForm, capacity: e.target.value })} placeholder="No limit" /></div>
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Gender</label>
|
||||
<select className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.gender} onChange={e => setGroupForm({ ...groupForm, gender: e.target.value })}>
|
||||
<option value="">Anyone</option><option value="male">Males only</option><option value="female">Females only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="block text-xs text-gray-600 mb-1">Leader</label>
|
||||
<select className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.leaderUserId} onChange={e => setGroupForm({ ...groupForm, leaderUserId: e.target.value })}>
|
||||
<option value="">None</option>
|
||||
{board.leaders.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button disabled={busy || !groupForm.name.trim()} onClick={saveGroup} className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white disabled:opacity-50">{groupForm.id ? "Save" : "Add group"}</button>
|
||||
<button onClick={() => setGroupForm(null)} className="px-3 py-1.5 text-sm rounded-lg bg-gray-100">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setGroupForm({ name: "", capacity: "", gender: "", leaderUserId: "" })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Plus className="w-4 h-4" /> Add a group to {current.name}</button>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[minmax(14rem,1fr)_3fr] gap-4 items-start">
|
||||
<div className="md:sticky md:top-4">
|
||||
<Column id={UNASSIGNED} title="Not placed yet" subtitle={`${unassigned.length} people · drag them into a group`}>
|
||||
{unassigned.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
|
||||
</Column>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{current.groups.length === 0 && <p className="text-sm text-gray-500">Add groups (rooms, cabins, buses…) to start placing people.</p>}
|
||||
{current.groups.map(g => {
|
||||
const members = membersOf(g.id);
|
||||
const full = g.capacity != null && members.length >= g.capacity;
|
||||
const subtitle = [`${members.length}${g.capacity ? ` / ${g.capacity}` : ""}${full ? " · full" : ""}`, g.gender ? (g.gender === "male" ? "males" : "females") : "", g.leader ? `leader ${g.leader.name}` : ""].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<Column key={g.id} id={g.id} title={g.name} subtitle={subtitle} full={full} actions={
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button aria-label={`Edit ${g.name}`} onClick={() => setGroupForm({ id: g.id, name: g.name, capacity: g.capacity ? String(g.capacity) : "", gender: g.gender || "", leaderUserId: g.leaderUserId || "" })} className="p-1 rounded hover:bg-gray-100"><Pencil className="w-3.5 h-3.5" /></button>
|
||||
<button aria-label={`Delete ${g.name}`} onClick={() => run(async () => { await call(`/api/allocations/groups/${g.id}`, "DELETE"); return `${g.name} deleted; its people are back in "Not placed yet".`; })} className="p-1 rounded hover:bg-red-50 text-red-700"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
}>
|
||||
{members.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
|
||||
</Column>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</>
|
||||
)}
|
||||
{board && board.sets.length === 0 && board.attendees.length > 0 && (
|
||||
<p className="text-sm text-gray-600">Start by adding a set, e.g. "Rooms", "Small groups" or "Buses". An event can have several.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
|
||||
@@ -105,6 +106,7 @@ export default function AtTheDoorPage() {
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
const [doorCollectGender, setDoorCollectGender] = useState(false);
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
@@ -163,6 +165,7 @@ export default function AtTheDoorPage() {
|
||||
setEventOptions(ev.options || ev.eventOptions || []);
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||
setDoorCollectGender(!!ev.collectGender);
|
||||
} catch {
|
||||
setEventOptions([]);
|
||||
}
|
||||
@@ -513,6 +516,7 @@ export default function AtTheDoorPage() {
|
||||
entries: doorEntries, onEntriesChange: setDoorEntries,
|
||||
delivery: doorDelivery, onDeliveryChange: setDoorDelivery,
|
||||
consentEventStart: doorYouthStart || undefined,
|
||||
collectGender: doorCollectGender,
|
||||
} : null}
|
||||
/>
|
||||
<DonationModal
|
||||
@@ -869,6 +873,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
const [allTickets, setAllTickets] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendTarget, setSendTarget] = useState<any | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
// Load all tickets for the event once
|
||||
useEffect(() => {
|
||||
@@ -1000,6 +1006,7 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && <div className="mt-2 p-2 rounded bg-green-50 text-green-800 text-sm">{notice}</div>}
|
||||
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
|
||||
{tickets.map(t => (
|
||||
<div
|
||||
@@ -1008,8 +1015,11 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{t.user?.name || "Guest"}
|
||||
{t.attendee?.name || t.user?.name || "Guest"}
|
||||
</div>
|
||||
{t.attendee?.name && t.user?.name && t.attendee.name !== t.user.name && (
|
||||
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
{ticketLabel(t)}
|
||||
</div>
|
||||
@@ -1028,6 +1038,14 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
{canOfferTransfer(t, t.event, true) && (
|
||||
<button
|
||||
onClick={() => setTransferTarget(t)}
|
||||
className="px-3 py-1 text-xs rounded border hover:bg-gray-50"
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -1039,6 +1057,15 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{transferTarget && (
|
||||
<TransferTicketModal
|
||||
ticket={transferTarget}
|
||||
event={transferTarget.event}
|
||||
token={token}
|
||||
onClose={() => setTransferTarget(null)}
|
||||
onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }}
|
||||
/>
|
||||
)}
|
||||
{sendTarget && (
|
||||
<SendTicketsModal
|
||||
open={true}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Mail, Users, Zap, Clock } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
|
||||
@@ -336,6 +337,7 @@ function EmailAttendeesPageInner() {
|
||||
const [subjectDirty, setSubjectDirty] = useState(false);
|
||||
const [bodyDirty, setBodyDirty] = useState(false);
|
||||
const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
// Attendees list and selection
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
|
||||
@@ -420,7 +422,7 @@ function EmailAttendeesPageInner() {
|
||||
}
|
||||
const payload: any = {
|
||||
subject: subject || "(no subject)",
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
dryRun: true,
|
||||
template: templateKey,
|
||||
};
|
||||
@@ -460,7 +462,7 @@ function EmailAttendeesPageInner() {
|
||||
setSending(true);
|
||||
const payload: any = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
};
|
||||
if (templateKey !== 'tickets') {
|
||||
@@ -607,6 +609,7 @@ function EmailAttendeesPageInner() {
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<AllocationGroupFilter token={token} eventId={eventId} value={groupId} onChange={setGroupId} />
|
||||
</div>
|
||||
|
||||
{templateKey !== 'tickets' ? (
|
||||
@@ -659,7 +662,7 @@ function EmailAttendeesPageInner() {
|
||||
const whenIso = new Date(scheduledAtLocal).toISOString();
|
||||
const payload = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
scheduledAt: whenIso,
|
||||
} as any;
|
||||
|
||||
@@ -773,6 +773,7 @@ interface EventDraft {
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
allowTicketTransfer: boolean; collectGender: boolean;
|
||||
}
|
||||
|
||||
const blankDraft = (location = ""): EventDraft => ({
|
||||
@@ -784,6 +785,7 @@ const blankDraft = (location = ""): EventDraft => ({
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
allowTicketTransfer: true, collectGender: false,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -822,6 +824,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
|
||||
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
|
||||
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== false,
|
||||
allowTicketTransfer: ev.allowTicketTransfer !== false,
|
||||
collectGender: !!ev.collectGender,
|
||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||
location: ev.location || "",
|
||||
} : blankDraft(settings.org_address || ""));
|
||||
@@ -1102,6 +1106,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
consentCollectEmergency: draft.consentCollectEmergency,
|
||||
consentCollectPhoto: draft.consentCollectPhoto,
|
||||
waitlistEnabled: draft.waitlistEnabled,
|
||||
allowTicketTransfer: draft.allowTicketTransfer,
|
||||
collectGender: draft.collectGender,
|
||||
waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24,
|
||||
waitlistPartialOffers: draft.waitlistPartialOffers,
|
||||
contactName: draft.contactName || undefined,
|
||||
@@ -1362,6 +1368,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowNamedAttendees} onChange={e => upd({ allowNamedAttendees: e.target.checked })} />
|
||||
<span><strong>Named attendees (group tickets)</strong> — someone registering several people can give each a name, so each gets their own ticket and attendee form</span>
|
||||
</label>
|
||||
{(draft.allowNamedAttendees || draft.requiresParentalConsent) && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer ml-6">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.collectGender} onChange={e => upd({ collectGender: e.target.checked })} />
|
||||
<span><strong>Ask each person's gender</strong> — needed to split rooms or cabins by gender (Rooms & groups)</span>
|
||||
</label>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.isActive} onChange={e => upd({ isActive: e.target.checked })} />
|
||||
@@ -1391,6 +1403,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ticket transfer</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowTicketTransfer} onChange={e => upd({ allowTicketTransfer: e.target.checked })} />
|
||||
<span><strong>Let people transfer their tickets</strong> — before the event, a ticket that hasn't been scanned can be handed to someone else, who gets a new QR code (supervisors can always transfer)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Youth event</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
|
||||
@@ -624,6 +624,7 @@ export default function ManualRegistrationPage() {
|
||||
onDeliveryChange={setDelivery}
|
||||
payerLabel="the person registering"
|
||||
consentEventStart={selectedIsYouth ? selectedEv.startDate : undefined}
|
||||
collectGender={!!selectedEv?.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
|
||||
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -402,6 +403,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageDirty, setMessageDirty] = useState(false);
|
||||
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
|
||||
const [loadingAttendees, setLoadingAttendees] = useState(false);
|
||||
@@ -449,7 +451,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
|
||||
const buildPayload = (dryRun?: boolean) => {
|
||||
const payload: any = {
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
dryRun: dryRun || undefined,
|
||||
};
|
||||
@@ -499,7 +501,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const whenIso = new Date(scheduledAtLocal).toISOString();
|
||||
const payload: any = {
|
||||
message: message || undefined,
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey, scheduledAt: whenIso,
|
||||
};
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
|
||||
@@ -715,6 +717,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<AllocationGroupFilter token={token} eventId={eventId} value={groupId} onChange={setGroupId} />
|
||||
</div>
|
||||
|
||||
{templateKey !== "tickets" ? (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
|
||||
import { MyWaitlists } from "@/components/shared/MyWaitlists";
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
|
||||
|
||||
// Helper formatters
|
||||
@@ -534,6 +535,8 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
|
||||
// Re-fetch registrations and tickets after something on one changes (attendee names).
|
||||
const reloadAfterAttendees = async () => {
|
||||
if (!token) return;
|
||||
@@ -992,6 +995,11 @@ export default function UserDashboardPage() {
|
||||
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
|
||||
{t.attendee?.name ? <>For: {t.attendee.name}</> : <>Qty: {t.quantity || 1}</>}
|
||||
</div>
|
||||
{(t.attendee?.allocations || []).length > 0 && (
|
||||
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
|
||||
{[...t.attendee.allocations].sort((a: any, b: any) => (a.set?.order || 0) - (b.set?.order || 0)).map((a: any) => `${a.set.name}: ${a.group.name}`).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
|
||||
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
|
||||
)}
|
||||
@@ -1003,6 +1011,9 @@ export default function UserDashboardPage() {
|
||||
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
|
||||
)}
|
||||
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
|
||||
{canOfferTransfer(t, t.event) && (
|
||||
<button onClick={() => setTransferTarget(t)} className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-100 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" title="Give this ticket to someone else">Transfer</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -1012,6 +1023,16 @@ export default function UserDashboardPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{transferTarget && token && (
|
||||
<TransferTicketModal
|
||||
ticket={transferTarget}
|
||||
event={transferTarget.event}
|
||||
token={token}
|
||||
onClose={() => setTransferTarget(null)}
|
||||
onDone={(msg) => { setTransferTarget(null); setInfo(msg); reloadAfterAttendees(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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()}>
|
||||
|
||||
@@ -44,6 +44,7 @@ type Event = {
|
||||
requiresAuth?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -603,6 +604,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={isYouthEvent ? event.startDate : undefined}
|
||||
collectGender={!!event.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -42,6 +42,7 @@ type KioskEvent = {
|
||||
form?: EventForm | null;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
};
|
||||
|
||||
type Screen = "setup" | "form" | "eventform" | "confirmation";
|
||||
@@ -1025,6 +1026,7 @@ export default function SelfServicePage() {
|
||||
delivery={delivery}
|
||||
onDeliveryChange={setDelivery}
|
||||
consentEventStart={selectedEvent.requiresParentalConsent ? selectedEvent.startDate : undefined}
|
||||
collectGender={!!selectedEvent.collectGender}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type SetWithGroups = { id: string; name: string; groups: { id: string; name: string }[] };
|
||||
|
||||
/**
|
||||
* "Only people in…" a room / group / bus, for messaging attendees. Renders nothing when
|
||||
* the event has no allocation sets.
|
||||
*/
|
||||
export function AllocationGroupFilter({ token, eventId, value, onChange }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
value: string;
|
||||
onChange: (groupId: string) => void;
|
||||
}) {
|
||||
const [sets, setSets] = useState<SetWithGroups[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
onChange("");
|
||||
if (!token || !eventId) { setSets([]); return; }
|
||||
apiFetch<{ sets: SetWithGroups[] }>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token })
|
||||
.then(b => setSets((b.sets || []).filter(s => s.groups.length > 0)))
|
||||
.catch(() => setSets([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, eventId]);
|
||||
|
||||
if (sets.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Room / group</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">Everyone</option>
|
||||
{sets.map(s => (
|
||||
<optgroup key={s.id} label={s.name}>
|
||||
{s.groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{value && <p className="text-xs text-gray-500 mt-1">Sent to whoever booked for the people in this group.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,14 +11,14 @@ export type AttendeeOption = {
|
||||
};
|
||||
|
||||
export type AttendeeEntry = {
|
||||
name: string; email: string; phone: string;
|
||||
name: string; email: string; phone: string; gender?: string;
|
||||
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
||||
};
|
||||
export type AttendeeEntries = Record<string, AttendeeEntry>;
|
||||
export type TicketDelivery = "payer" | "each";
|
||||
|
||||
export type AttendeePayload = {
|
||||
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string;
|
||||
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string; gender?: string;
|
||||
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
|
||||
};
|
||||
|
||||
@@ -67,6 +67,7 @@ export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: s
|
||||
...(e.email.trim() ? { email: e.email.trim() } : {}),
|
||||
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
|
||||
...(e.dateOfBirth ? { dateOfBirth: e.dateOfBirth } : {}),
|
||||
...(e.gender ? { gender: e.gender } : {}),
|
||||
...(e.parentName?.trim() ? { parentName: e.parentName.trim() } : {}),
|
||||
...(e.parentEmail?.trim() ? { parentEmail: e.parentEmail.trim() } : {}),
|
||||
...(e.parentPhone?.trim() ? { parentPhone: e.parentPhone.trim() } : {}),
|
||||
@@ -105,6 +106,7 @@ export function AttendeeNamesEditor({
|
||||
onDeliveryChange,
|
||||
payerLabel = "me",
|
||||
consentEventStart,
|
||||
collectGender = false,
|
||||
}: {
|
||||
options: AttendeeOption[];
|
||||
qtyFor: (optionId: string, variantId: string | null) => number;
|
||||
@@ -117,6 +119,8 @@ export function AttendeeNamesEditor({
|
||||
payerLabel?: string;
|
||||
/** Set for youth events: names are required, with a date of birth, and parent details for minors. */
|
||||
consentEventStart?: string;
|
||||
/** Ask each person's gender (for gender-split rooms). */
|
||||
collectGender?: boolean;
|
||||
}) {
|
||||
const consent = !!consentEventStart;
|
||||
const slots = slotsFor(options, qtyFor);
|
||||
@@ -159,6 +163,13 @@ export function AttendeeNamesEditor({
|
||||
<input type="email" className={inputCls} placeholder="Email (optional)" value={e.email} onChange={ev => set(s.key, "email", ev.target.value)} aria-label={`${s.label} email`} />
|
||||
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={e.phone} onChange={ev => set(s.key, "phone", ev.target.value)} aria-label={`${s.label} phone`} />
|
||||
</div>
|
||||
{collectGender && (
|
||||
<select className={`${inputCls} mt-2 sm:w-48`} value={e.gender || ""} onChange={ev => set(s.key, "gender", ev.target.value)} aria-label={`${s.label} gender`}>
|
||||
<option value="">Gender…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{consent && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
|
||||
@@ -13,13 +13,13 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
|
||||
type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
dateOfBirth?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
type Row = {
|
||||
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
||||
dateOfBirth: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,6 +37,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
}) {
|
||||
const attendees: Attendee[] = registration.attendees || [];
|
||||
const youth = !!registration.event?.requiresParentalConsent;
|
||||
const collectGender = !!registration.event?.collectGender;
|
||||
const eventStart: string | undefined = registration.event?.startDate;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
@@ -57,6 +58,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
|
||||
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
|
||||
dateOfBirth: a?.dateOfBirth ? String(a.dateOfBirth).slice(0, 10) : "",
|
||||
gender: a?.gender || "",
|
||||
parentName: a?.parentName || "", parentEmail: a?.parentEmail || "", parentPhone: a?.parentPhone || "",
|
||||
locked: !!a?.ticket?.isUsed,
|
||||
});
|
||||
@@ -68,7 +70,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
if (!registration.event?.allowNamedAttendees && !youth && attendees.length === 0) return null;
|
||||
|
||||
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
|
||||
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
||||
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "gender" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
|
||||
setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
|
||||
|
||||
const save = async () => {
|
||||
@@ -88,6 +90,7 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
email: r.email.trim() || undefined,
|
||||
phone: r.phone.trim() || undefined,
|
||||
...(r.dateOfBirth ? { dateOfBirth: r.dateOfBirth } : {}),
|
||||
...(r.gender ? { gender: r.gender } : {}),
|
||||
...(r.parentName.trim() ? { parentName: r.parentName.trim() } : {}),
|
||||
...(r.parentEmail.trim() ? { parentEmail: r.parentEmail.trim() } : {}),
|
||||
...(r.parentPhone.trim() ? { parentPhone: r.parentPhone.trim() } : {}),
|
||||
@@ -171,6 +174,13 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
|
||||
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
|
||||
</div>
|
||||
{collectGender && (
|
||||
<select className="mt-1 border rounded px-2 py-1 text-sm" value={r.gender} disabled={r.locked} onChange={e => update(i, "gender", e.target.value)} aria-label={`${r.label} gender`}>
|
||||
<option value="">Gender…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
)}
|
||||
{youth && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<input type="date" className="border rounded px-2 py-1 text-sm" value={r.dateOfBirth} disabled={r.locked} onChange={e => update(i, "dateOfBirth", e.target.value)} aria-label={`${r.label} date of birth`} />
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
|
||||
/** Whether to offer "Transfer" on a ticket (the server checks the rules again). */
|
||||
export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): boolean {
|
||||
if (!ticket || !event) return false;
|
||||
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
|
||||
if (event.startDate && new Date(event.startDate) <= new Date()) return false;
|
||||
return isSupervisor || event.allowTicketTransfer !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a ticket to someone else: their name and email/phone (plus date of birth and a
|
||||
* parent's details on youth events). They get a new QR code; the old one stops working.
|
||||
*/
|
||||
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
ticket: any;
|
||||
event: any;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const youth = !!event?.requiresParentalConsent;
|
||||
const [f, setF] = useState({ name: "", email: "", phone: "", gender: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const minor = youth && isMinorOn(f.dateOfBirth, event?.startDate);
|
||||
const places = ticket.quantity || 1;
|
||||
const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setF(prev => ({ ...prev, [k]: e.target.value }));
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone, ...(f.gender ? { gender: f.gender } : {}) };
|
||||
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
|
||||
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
|
||||
onDone(res.message);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't transfer the ticket");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const input = "w-full border rounded-lg px-3 py-2 text-sm";
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRightLeft className="w-5 h-5 text-brand-600" />
|
||||
<h3 className="text-lg font-semibold">Transfer ticket</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{places > 1
|
||||
? `This ticket is for ${places} people. One place moves to the new holder on their own ticket; the other ${places - 1} stay on this one.`
|
||||
: "The new holder gets their own ticket with a new QR code. The current QR code (including any screenshots of it) will stop working."}
|
||||
{" "}The booking and payment stay with the person who booked.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">New holder's name</label>
|
||||
<input className={input} value={f.name} onChange={set("name")} required maxLength={100} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Email</label>
|
||||
<input type="email" className={input} value={f.email} onChange={set("email")} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
|
||||
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
{event?.collectGender && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Gender</label>
|
||||
<select className={input} value={f.gender} onChange={set("gender")} required>
|
||||
<option value="">Choose…</option>
|
||||
<option value="male">Male</option>
|
||||
<option value="female">Female</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">An email or phone number is needed to send them the ticket. If they don't have an account yet, one is created and they get a link to activate it.</p>
|
||||
{youth && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
||||
<input type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
|
||||
</div>
|
||||
{minor && (
|
||||
<>
|
||||
<p className="text-xs text-amber-800 bg-amber-50 rounded p-2">They're under 18, so a parent or guardian will be asked for consent before they can check in.</p>
|
||||
<input className={input} placeholder="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<input type="email" className={input} placeholder="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
|
||||
<input type="tel" className={input} placeholder="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Transferring…" : "Transfer"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users } from "lucide-react";
|
||||
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users, ArrowRightLeft } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -60,6 +60,9 @@ export const dashboardUserHelpContent: HelpContent = {
|
||||
<GuideItem icon={Printer} title="Print, email, or WhatsApp" tone="green">
|
||||
Get your selected tickets onto your phone or paper before the event — as a printed page, an email, or a WhatsApp message.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ArrowRightLeft} title="Transfer a ticket" tone="violet">
|
||||
Can't make it? Tap "Transfer" to give your ticket to someone else before the event. Enter their name and email or phone, and they get a new ticket with a new QR code. The old code (and any screenshots of it) stops working. For a ticket covering several people, one place moves to them. You stay responsible for the booking and payment. A ticket can't be transferred once it has been scanned.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { supervisorCouponsHelpContent } from "./supervisor-coupons";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
|
||||
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
|
||||
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
|
||||
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
|
||||
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
|
||||
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
|
||||
@@ -151,6 +152,16 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/payments",
|
||||
content: supervisorPaymentsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-allocations",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/allocations",
|
||||
content: supervisorAllocationsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "staff-my-groups",
|
||||
matches: pathname => pathname === "/dashboard/staff/my-groups",
|
||||
content: staffMyGroupsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-offline-scans",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/offline-scans",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { BedDouble, Layers, Move, Wand2, Printer, Ticket, Users } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for /dashboard/supervisor/allocations. */
|
||||
export const supervisorAllocationsHelpContent: HelpContent = {
|
||||
title: "Rooms & groups",
|
||||
subtitle: "Put attendees into rooms, cabins, small groups, teams or buses.",
|
||||
tabs: [
|
||||
{
|
||||
key: "page",
|
||||
label: "This page",
|
||||
icon: BedDouble,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Layers} title="Sets and groups" tone="brand">
|
||||
A set is one way of splitting people up, such as "Rooms", "Small groups" or "Buses". An event can have several sets, and each person can be in one group per set. Groups can have a capacity, be for males or females only, and have a leader (a staff member).
|
||||
</GuideItem>
|
||||
<GuideItem icon={Move} title="Drag and drop" tone="green">
|
||||
Drag people from "Not placed yet" into a group, between groups, or back again. A full group, or one for the other gender, won't take them. Allocation works per named person, so the event needs named attendees. Turn on "Ask each person's gender" if rooms are split by gender.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Wand2} title="Auto-allocate" tone="violet">
|
||||
Fills groups evenly with everyone not placed yet, respecting capacity and gender. "Keep bookings together" keeps people on the same booking (siblings) in one group where possible; "Split bookings" spreads them out (friends). People already placed stay where they are.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Printer} title="Lists" tone="amber">
|
||||
Print a list per group (with its leader) or one master list. On youth events you can include medical and emergency details from consent forms. Viewing those is recorded in the audit log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="On tickets" tone="rose">
|
||||
Tick "Show on tickets" to print the group on each person's ticket and show it in My Events. To message one group ("Bus 2 leaves at 07:30"), pick it under Room / group on Email or WhatsApp attendees.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Users} title="Leaders" tone="gray">
|
||||
Group leaders see their people (with ages and parent contacts) under Staff → My groups. Medical details are only shown to supervisors.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Help content for /dashboard/staff/my-groups. */
|
||||
export const staffMyGroupsHelpContent: HelpContent = {
|
||||
title: "My groups",
|
||||
subtitle: "Rooms, small groups and buses you lead.",
|
||||
tabs: [
|
||||
{
|
||||
key: "page",
|
||||
label: "This page",
|
||||
icon: Users,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Users} title="Your people" tone="brand">
|
||||
Everyone a supervisor has placed in a group you lead, with their age and a contact number (theirs, a parent's, or the person who booked). Supervisors also see medical details here.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
|
||||
Per ticket-type totals with a stepper to check attendees in against the scan record.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Ticket} title="Tickets" tone="amber">
|
||||
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually.
|
||||
Browse all tickets for the event; print or send (email/WhatsApp) any of them individually, or transfer one to someone else before the event starts (supervisors can transfer even when transfers are turned off for the event). Scanning a transferred ticket's old QR code says who it went to.
|
||||
</GuideItem>
|
||||
<GuideItem icon={RotateCcw} title="Refund" tone="rose">
|
||||
Issue a refund against an existing payment for the selected registration.
|
||||
|
||||
@@ -30,6 +30,8 @@ It runs in any web browser — on a phone, tablet, or computer — with nothing
|
||||
### For staff on the day
|
||||
|
||||
- **QR code scanner** — check people in by scanning their ticket with any phone camera. A single ticket can cover a whole family and be checked in a few people at a time.
|
||||
- **Rooms & groups for camps** — drag campers into cabins, small groups or buses (or auto-fill by capacity and gender), print lists for leaders, and show the cabin on each ticket.
|
||||
- **Ticket transfer** — can't make it? Pass your ticket on to a friend; they get a fresh QR code and the old one stops working.
|
||||
- **Works without Wi-Fi** — lock a check-in point to a phone and keep scanning with no signal; scans sync automatically when the connection is back.
|
||||
- **Scan points** — set up separate check-in points (e.g. main hall, dinner) that only accept the right tickets.
|
||||
- **At-the-door desk** — search by name, phone, or ticket number; register walk-ins; take cash, card, or EFT payments; check people in; and print or resend tickets on the spot.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.17.0",
|
||||
"version": "1.19.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user