Compare commits

..
Author SHA1 Message Date
joshua 7bcaec6284 Merge branch 'feat/room-allocation' 2026-10-19 14:45:09 +02:00
joshuaandClaude Opus 5.5 2f4fd7c040 Bump to 1.19.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:45:06 +02:00
joshuaandClaude Opus 5.5 1516e104df 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 <noreply@anthropic.com>
2026-10-19 14:44:58 +02:00
joshua e9588aeba8 Merge branch 'feat/ticket-transfer' 2026-10-19 14:23:34 +02:00
41 changed files with 1433 additions and 39 deletions
+16
View File
@@ -7,6 +7,22 @@ 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
+12 -1
View File
@@ -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 |
+20
View File
@@ -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 |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.18.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.18.0",
"version": "1.19.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.18.0",
"version": "1.19.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -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;
+55
View File
@@ -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])
@@ -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 };
+14 -2
View File
@@ -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 };
@@ -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 } : {}),
},
});
+20 -6
View File
@@ -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 });
+15
View File
@@ -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' }},
+22
View File
@@ -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;
+107
View File
@@ -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 };
+9 -1
View File
@@ -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,
+1 -1
View File
@@ -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) => {
+84
View File
@@ -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' }]);
});
});
+42 -2
View File
@@ -1,13 +1,14 @@
{
"name": "hope-events-frontend",
"version": "1.18.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.18.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",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.18.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",
+2 -1
View File
@@ -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&apos;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>
);
}
+2 -1
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 &amp; 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 &quot;Named attendees&quot; 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&apos;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. &quot;Rooms&quot;, &quot;Small groups&quot; or &quot;Buses&quot;. An event can have several.</p>
)}
</div>
)}
</div>
);
}
@@ -106,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");
@@ -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}
/>
<DonationModal
@@ -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,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) {
<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&apos;s gender</strong> — needed to split rooms or cabins by gender (Rooms &amp; 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 })} />
@@ -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" ? (
+5
View File
@@ -995,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>
)}
@@ -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>
)}
+2
View File
@@ -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`} />
@@ -25,19 +25,19 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
onDone: (message: string) => void;
}) {
const youth = !!event?.requiresParentalConsent;
const [f, setF] = useState({ name: "", email: "", phone: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" });
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>) => setF(prev => ({ ...prev, [k]: e.target.value }));
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 };
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);
@@ -76,6 +76,16 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
<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&apos;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">
+11
View File
@@ -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 &quot;Rooms&quot;, &quot;Small groups&quot; or &quot;Buses&quot;. 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 &quot;Not placed yet&quot; into a group, between groups, or back again. A full group, or one for the other gender, won&apos;t take them. Allocation works per named person, so the event needs named attendees. Turn on &quot;Ask each person&apos;s gender&quot; 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. &quot;Keep bookings together&quot; keeps people on the same booking (siblings) in one group where possible; &quot;Split bookings&quot; 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 &quot;Show on tickets&quot; to print the group on each person&apos;s ticket and show it in My Events. To message one group (&quot;Bus 2 leaves at 07:30&quot;), 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&apos;s, or the person who booked). Supervisors also see medical details here.
</GuideItem>
</div>
),
},
],
};
+1
View File
@@ -30,6 +30,7 @@ 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.18.0",
"version": "1.19.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.18.0",
"version": "1.19.0",
"license": "ISC",
"devDependencies": {
"concurrently": "^9.2.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.18.0",
"version": "1.19.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",