From 1516e104df0f5a9db80b18dc2c3e0b28482bfddb Mon Sep 17 00:00:00 2001 From: joshua Date: Mon, 19 Oct 2026 14:44:58 +0200 Subject: [PATCH 1/2] Add rooms & groups allocation Supervisors can split an event's named attendees into allocation sets (rooms, small groups, buses) with capacity, gender and leader per group, using a drag-and-drop board or auto-allocate (keep bookings together or split them). Adds printable group and master lists (medical view audited), the group on tickets and in My Events, messaging by group, a "My groups" page for leaders, and an optional per-event gender question for named attendees. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 + FEATURES.md | 13 +- backend/API_DOCUMENTATION.md | 20 ++ .../migration.sql | 78 +++++ backend/prisma/schema.prisma | 55 +++ .../src/controllers/allocationController.js | 323 ++++++++++++++++++ backend/src/controllers/eventController.js | 16 +- .../src/controllers/registrationController.js | 2 +- backend/src/controllers/ticketController.js | 26 +- backend/src/index.js | 15 + backend/src/routes/allocationRoutes.js | 22 ++ backend/src/utils/allocation.js | 107 ++++++ backend/src/utils/attendees.js | 10 +- backend/src/utils/ticketTransfer.js | 2 +- backend/tests/allocation.test.js | 84 +++++ frontend/package-lock.json | 40 +++ frontend/package.json | 1 + frontend/src/app/dashboard/admin/page.tsx | 3 +- .../app/dashboard/staff/my-groups/page.tsx | 96 ++++++ frontend/src/app/dashboard/staff/page.tsx | 3 +- .../dashboard/supervisor/allocations/page.tsx | 314 +++++++++++++++++ .../dashboard/supervisor/at-the-door/page.tsx | 3 + .../supervisor/email-attendees/page.tsx | 9 +- .../app/dashboard/supervisor/events/page.tsx | 12 +- .../app/dashboard/supervisor/manual/page.tsx | 1 + .../src/app/dashboard/supervisor/page.tsx | 3 +- .../supervisor/whatsapp-attendees/page.tsx | 7 +- frontend/src/app/dashboard/user/page.tsx | 5 + .../app/register/[eventId]/RegisterForm.tsx | 2 + frontend/src/app/self-service/page.tsx | 2 + .../shared/AllocationGroupFilter.tsx | 44 +++ .../components/shared/AttendeeNamesEditor.tsx | 15 +- .../src/components/shared/AttendeesPanel.tsx | 16 +- .../components/shared/TransferTicketModal.tsx | 16 +- frontend/src/content/help/registry.ts | 11 + .../content/help/supervisor-allocations.tsx | 61 ++++ info.md | 1 + 37 files changed, 1422 insertions(+), 30 deletions(-) create mode 100644 backend/prisma/migrations/20261019142529_add_room_allocation/migration.sql create mode 100644 backend/src/controllers/allocationController.js create mode 100644 backend/src/routes/allocationRoutes.js create mode 100644 backend/src/utils/allocation.js create mode 100644 backend/tests/allocation.test.js create mode 100644 frontend/src/app/dashboard/staff/my-groups/page.tsx create mode 100644 frontend/src/app/dashboard/supervisor/allocations/page.tsx create mode 100644 frontend/src/components/shared/AllocationGroupFilter.tsx create mode 100644 frontend/src/content/help/supervisor-allocations.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de3426..acece31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### 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 diff --git a/FEATURES.md b/FEATURES.md index d45c5f0..e2a2cfc 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -315,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 @@ -487,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 | diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index c8d514b..50235a8 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -23,6 +23,7 @@ Tokens are issued by `POST /api/users/login`. - [Waitlist](#waitlist) - [Offline scanning](#offline-scanning) - [Ticket transfer](#ticket-transfer) +- [Rooms & groups](#rooms--groups) - [Payments](#payments) - [Tickets](#tickets) - [Broadcasts (Email)](#broadcasts-email) @@ -280,6 +281,25 @@ 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 | diff --git a/backend/prisma/migrations/20261019142529_add_room_allocation/migration.sql b/backend/prisma/migrations/20261019142529_add_room_allocation/migration.sql new file mode 100644 index 0000000..2d1decc --- /dev/null +++ b/backend/prisma/migrations/20261019142529_add_room_allocation/migration.sql @@ -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; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ab17864..6f8e122 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -108,6 +108,7 @@ model User { scanLocks ScanLock[] @relation("ScanLockOwner") transfersTo TicketTransfer[] @relation("TransferRecipient") transfersBy TicketTransfer[] @relation("TransferActor") + ledGroups AllocationGroup[] @relation("AllocationGroupLeader") scanLocksForced ScanLock[] @relation("ScanLockForcedBy") syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer") @@ -144,6 +145,8 @@ model Event { // 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? @@ -995,6 +998,57 @@ 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 { @@ -1039,6 +1093,7 @@ model Attendee { ticket Ticket? formResponses FormResponse[] consentRequests ConsentRequest[] + allocations AllocationAssignment[] @@index([registrationId]) @@index([registrationOptionId]) diff --git a/backend/src/controllers/allocationController.js b/backend/src/controllers/allocationController.js new file mode 100644 index 0000000..c352358 --- /dev/null +++ b/backend/src/controllers/allocationController.js @@ -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 }; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index b48a0b9..af2e4d2 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -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, allowTicketTransfer, 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(), @@ -71,6 +71,7 @@ const createEvent = async (req, res) => { 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, @@ -537,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, allowTicketTransfer, 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, @@ -561,6 +562,7 @@ const updateEvent = async (req, res) => { 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 @@ -1331,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) { @@ -1569,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 }; diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index ce31e59..465afd7 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -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 } : {}), }, }); diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index 26e723d..8984ae4 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -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: { @@ -488,7 +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 } }, + 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' }, @@ -704,8 +709,8 @@ const sendTicketsTo = async (req, res) => { const transferTicketHandler = async (req, res) => { try { const { transferTicket } = require('../utils/ticketTransfer'); - const { name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone } = req.body || {}; - const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone }); + 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'); @@ -767,6 +772,14 @@ const getTicketTransfers = async (req, res) => { } }; +/** "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; @@ -789,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, }; @@ -890,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 }); diff --git a/backend/src/index.js b/backend/src/index.js index 7dbbe3f..5488530 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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,20 @@ 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' }}, diff --git a/backend/src/routes/allocationRoutes.js b/backend/src/routes/allocationRoutes.js new file mode 100644 index 0000000..859e447 --- /dev/null +++ b/backend/src/routes/allocationRoutes.js @@ -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; diff --git a/backend/src/utils/allocation.js b/backend/src/utils/allocation.js new file mode 100644 index 0000000..982d6e3 --- /dev/null +++ b/backend/src/utils/allocation.js @@ -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 }; diff --git a/backend/src/utils/attendees.js b/backend/src/utils/attendees.js index df737b3..612ce25 100644 --- a/backend/src/utils/attendees.js +++ b/backend/src/utils/attendees.js @@ -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, diff --git a/backend/src/utils/ticketTransfer.js b/backend/src/utils/ticketTransfer.js index e1dad52..82fb278 100644 --- a/backend/src/utils/ticketTransfer.js +++ b/backend/src/utils/ticketTransfer.js @@ -76,7 +76,7 @@ async function transferTicket({ ticketId, actor, ...holder }) { 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, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone, + dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone, }; const result = await prisma.$transaction(async (tx) => { diff --git a/backend/tests/allocation.test.js b/backend/tests/allocation.test.js new file mode 100644 index 0000000..f9704b3 --- /dev/null +++ b/backend/tests/allocation.test.js @@ -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' }]); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bf724d4..e321489 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "hope-events-frontend", "version": "1.18.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", diff --git a/frontend/package.json b/frontend/package.json index be986e5..6d1da82 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/dashboard/admin/page.tsx b/frontend/src/app/dashboard/admin/page.tsx index 62df68f..9a6d119 100644 --- a/frontend/src/app/dashboard/admin/page.tsx +++ b/frontend/src/app/dashboard/admin/page.tsx @@ -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 }, diff --git a/frontend/src/app/dashboard/staff/my-groups/page.tsx b/frontend/src/app/dashboard/staff/my-groups/page.tsx new file mode 100644 index 0000000..14b04bd --- /dev/null +++ b/frontend/src/app/dashboard/staff/my-groups/page.tsx @@ -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 | null; emergency?: Record | 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(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 ( +
+
+
+ +
+
+ +

My groups

+

Rooms, small groups and buses you lead

+
+
+ {error &&
{error}
} + {data && data.groups.length === 0 &&

You aren't leading any groups for upcoming events.

} +
+ {data?.groups.map(g => ( +
+
+

{g.name} · {g.setName}

+ {g.event.title} · {formatDate(new Date(g.event.startDate))} · {g.members.length}{g.capacity ? ` / ${g.capacity}` : ""} people +
+ {g.members.length === 0 ?

Nobody has been placed in this group yet.

: ( +
+ + + + {data.medicalShown && } + + + {g.members.map(m => { + const age = ageOn(m.dateOfBirth, g.event.startDate); + return ( + + + + + {data.medicalShown && ( + + )} + + ); + })} + +
NameAgeContactMedical
{m.name}{age ?? "—"} + {m.phone &&
{m.phone}
} + {m.parentName ?
Parent: {m.parentName}{m.parentPhone ? ` · ${m.parentPhone}` : ""}
: m.bookedBy &&
Booked by {m.bookedBy}{m.bookedByPhone ? ` · ${m.bookedByPhone}` : ""}
} +
+ {m.medical ? Object.values(m.medical).filter(Boolean).join("; ") || "—" : "—"} + {m.emergency && Object.values(m.emergency).some(Boolean) &&
Emergency: {Object.values(m.emergency).filter(Boolean).join(", ")}
} +
+
+ )} +
+ ))} +
+ {data && !data.medicalShown && data.groups.length > 0 &&

Medical details are only shown to supervisors. Ask one if you need them.

} +
+ ); +} diff --git a/frontend/src/app/dashboard/staff/page.tsx b/frontend/src/app/dashboard/staff/page.tsx index 571a5f0..43e84b9 100644 --- a/frontend/src/app/dashboard/staff/page.tsx +++ b/frontend/src/app/dashboard/staff/page.tsx @@ -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() { diff --git a/frontend/src/app/dashboard/supervisor/allocations/page.tsx b/frontend/src/app/dashboard/supervisor/allocations/page.tsx new file mode 100644 index 0000000..f874414 --- /dev/null +++ b/frontend/src/app/dashboard/supervisor/allocations/page.tsx @@ -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 ( +
+ {person.name} + + {[genderLabel(person.gender), age != null ? `${age}` : "", person.bookedBy ? `· ${person.bookedBy}` : ""].filter(Boolean).map(s => ` ${s}`).join("")} + +
+ ); +} + +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 ( +
+
+
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {actions} +
+
{children}
+
+ ); +} + +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([]); + const [eventId, setEventId] = useState(""); + const [board, setBoard] = useState(null); + const [setId, setSetId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(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("/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(`/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 (path: string, method: string, body?: unknown): Promise => + apiFetch(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) => { + 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(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! }); + const w = window.open("", "_blank"); + if (!w) return; + const row = (m: any) => `${esc(m.name)}${esc(genderLabel(m.gender))}${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}` + + `${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}${esc(m.parentName || m.bookedBy || "")}` + + (medical ? `${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}` : "") + ``; + const head = `NameGenderAgePhoneParent / booked by${medical ? "MedicalEmergency contact" : ""}`; + 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 = `

${esc(data.event.title)} — ${esc(data.set.name)} (master list)

${head.slice(4)}${all.map((m: any) => row(m).replace("", ``)).join("")}
${esc(data.set.name)}
${esc(m.group)}
`; + } else { + body = data.groups.map((g: any) => `

${esc(data.event.title)} — ${esc(g.name)}

` + + `

${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people

` + + `${head}${g.members.map(row).join("")}
`).join(""); + } + w.document.write(`${esc(data.set.name)}${body}`); + w.document.close(); + } catch (e: any) { + setError(e?.message || "Couldn't load the lists"); + } + }; + + return ( +
+
+
+ +
+
+ +

Rooms & groups

+

Put attendees into rooms, small groups, teams or buses

+
+
+ + {!canView &&
You need supervisor access to manage allocations.
} + {error &&
{error}
} + {info &&
{info}
} + + {canView && ( +
+
+
+ + +
+ {board && ( +
+ {board.sets.map(s => ( + + ))} + setNewSetName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") addSet(); }} /> + +
+ )} +
+ + {board && board.attendees.length === 0 && ( +
+ 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)"}. +
+ )} + {board && board.unnamedPlaces > 0 && ( +

{board.unnamedPlaces} place(s) have no name yet, so they can't be placed until the person booking adds names.

+ )} + + {current && board && ( + <> +
+ +
+ + + + + {board.event.requiresParentalConsent && ( + + )} + + +
+
+ + {groupForm ? ( +
+
setGroupForm({ ...groupForm, name: e.target.value })} placeholder="Cabin 1" autoFocus />
+
setGroupForm({ ...groupForm, capacity: e.target.value })} placeholder="No limit" />
+
+ +
+
+ +
+ + +
+ ) : ( + + )} + + +
+
+ + {unassigned.map(p => )} + +
+
+ {current.groups.length === 0 &&

Add groups (rooms, cabins, buses…) to start placing people.

} + {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 ( + + + +
+ }> + {members.map(p => )} + + ); + })} +
+
+ + + )} + {board && board.sets.length === 0 && board.attendees.length > 0 && ( +

Start by adding a set, e.g. "Rooms", "Small groups" or "Buses". An event can have several.

+ )} +
+ )} + + ); +} diff --git a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx index bb9d56b..ca58f85 100644 --- a/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx +++ b/frontend/src/app/dashboard/supervisor/at-the-door/page.tsx @@ -106,6 +106,7 @@ export default function AtTheDoorPage() { const [doorCoupon, setDoorCoupon] = useState(null); const [doorAllowsNames, setDoorAllowsNames] = useState(false); const [doorYouthStart, setDoorYouthStart] = useState(null); + const [doorCollectGender, setDoorCollectGender] = useState(false); const [doorNaming, setDoorNaming] = useState(false); const [doorEntries, setDoorEntries] = useState({}); const [doorDelivery, setDoorDelivery] = useState("payer"); @@ -164,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([]); } @@ -514,6 +516,7 @@ export default function AtTheDoorPage() { entries: doorEntries, onEntriesChange: setDoorEntries, delivery: doorDelivery, onDeliveryChange: setDoorDelivery, consentEventStart: doorYouthStart || undefined, + collectGender: doorCollectGender, } : null} /> ("any"); + const [groupId, setGroupId] = useState(""); // Attendees list and selection const [attendees, setAttendees] = useState([]); const [selectedAttendeeIds, setSelectedAttendeeIds] = useState([]); @@ -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() { + {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; diff --git a/frontend/src/app/dashboard/supervisor/events/page.tsx b/frontend/src/app/dashboard/supervisor/events/page.tsx index e240c10..7602962 100644 --- a/frontend/src/app/dashboard/supervisor/events/page.tsx +++ b/frontend/src/app/dashboard/supervisor/events/page.tsx @@ -773,7 +773,7 @@ interface EventDraft { requiresParentalConsent: boolean; consentText: string; consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean; waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean; - allowTicketTransfer: boolean; + allowTicketTransfer: boolean; collectGender: boolean; } const blankDraft = (location = ""): EventDraft => ({ @@ -785,7 +785,7 @@ const blankDraft = (location = ""): EventDraft => ({ requiresParentalConsent: false, consentText: "", consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true, waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true, - allowTicketTransfer: true, + allowTicketTransfer: true, collectGender: false, }); const blankOptions = (): OptionDraft[] => [ @@ -825,6 +825,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { 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 || "")); @@ -1106,6 +1107,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { 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, @@ -1366,6 +1368,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { upd({ allowNamedAttendees: e.target.checked })} /> Named attendees (group tickets) — someone registering several people can give each a name, so each gets their own ticket and attendee form + {(draft.allowNamedAttendees || draft.requiresParentalConsent) && ( + + )} {mode === "edit" && (