diff --git a/CHANGELOG.md b/CHANGELOG.md index e1cfb9d..50ce192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Multi-day events.** An event can run over several sessions, e.g. every Wednesday 18:30–20:30 for 5 weeks. In the event wizard choose "Multiple sessions", create the dates from a repeat pattern (daily or weekly, every N, on chosen weekdays, for a number of sessions or until a date), then edit any session's label, times or location, add one-off dates, or cancel a session (kept for history). People register and pay once and get one ticket for every session. +- The event page shows the schedule (past sessions greyed out, the next one highlighted, cancelled ones struck through). Event cards show "5 sessions · Wed 4 Nov – Wed 2 Dec", and multi-day events stay listed until their last session, sorted by their next one. Calendar files have one entry per session. Ticket PDFs, printed tickets and the ticket email give the session range. +- **Late joining**: a multi-day event closes registration when its first session starts, unless late joining is on (until session N, set per event). Late joiners pay full price, a pro-rata price for the sessions still to come (rounded to the nearest R10, at least R10), or a fixed late price — main tickets only, and never more than the normal price. The event and registration pages say when the late price applies. +- **Per-session check-in**: a multi-day ticket is checked in once per session. The scanner uses today's session (staff can pick another), shows the session on the confirm screen, and says "Already checked in for Week 3 at 18:42" on a second scan. Each event chooses whether check-in is open the whole day of a session (the default) or only from 2 hours before it until it ends. Offline mode downloads the list for today's session, and synced scans count for the session at their scan time. +- **Session reminders**: optional per event — 1 hour, 3 hours and/or 1 day before each session, by email and/or WhatsApp (each person's notification preference still applies). Off by default. +- **Attendance register** (Dashboard → Attendance): attendees × sessions with ticks, totals per session, completion, search and filters (completed, not completed, missed the last two sessions), and Excel/PDF export. Supervisors can tick someone present or absent, recorded in the audit log. Also works for single-date events (one "Checked in" column). +- **Completion certificates**: optional per event, in the wizard's new Attendance step. Four designs (Classic, Modern, Church, Kids) with the site logo and colour, your title and wording ({name}, {event}, {sessionsAttended}, {totalSessions}, {endDate}), an optional signature name, role and image, and who qualifies (every session, at least N sessions, or at least N%). "Preview certificate" shows a sample. Certificates are issued automatically when the event ends (and can be sent automatically), or from the attendance register: issue now, issue to one person anyway, send, download all as one PDF, or revoke. People download theirs under My Certificates on My Events. Each has a code and QR code that anyone can check on the public page `/certificates/verify/`, which shows only the name, event and dates. +- Saving an event where sessions moved or were cancelled offers a "Message attendees" link, since registrants aren't told automatically. +- A background job every 5 minutes sends due session reminders and issues certificates (`SESSION_JOBS_ENABLED=false` to turn it off). The church's timezone for "today" defaults to Africa/Johannesburg (`APP_TIMEZONE` to change). + +### Changed + +- The event wizard has a ninth step, Attendance, for late joining, check-in window, session reminders and certificates. + ## [1.32.0] - 2026-10-22 ### Added diff --git a/FEATURES.md b/FEATURES.md index 927db14..dca1f53 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -79,14 +79,33 @@ Four roles, each including everything the roles below it can do: ## Events -Created and edited by supervisors through a 6-step wizard: **Basic Details → -Items & Pricing → Sections → Form → Visibility → Attachments**. +Created and edited by supervisors through a 9-step wizard: **Basic Details → +Items & Pricing → Sections → Form → Visibility → Notifications → Attachments → +Feedback → Attendance**. - **Basic details** — title, description, start/end dates, registration deadline, picture (upload or URL), location (defaults to the org's address), contact person. - **Contact-only events** — for things like baptisms: shown publicly with a "Contact us" popup instead of registration, with no tickets. +- **Multi-day events (sessions)** — one event on several dates, e.g. every + Wednesday 18:30–20:30 for 5 weeks. A repeat pattern (daily/weekly, every N, + chosen weekdays, a number of sessions or an end date) creates the sessions, + which can then be edited one by one (label, times, location), added to, or + cancelled (kept for history; one with check-ins can't be deleted). People + register and pay once and get one ticket. The event page shows the schedule + with the next session highlighted; cards show "5 sessions · Wed 4 Nov – Wed + 2 Dec" and stay listed until the last session. Calendar files get one entry + per session. Saving moved or cancelled sessions offers to message attendees. +- **Late joining** (multi-day) — registration closes when the first session + starts, or later (until session N) with late joining on. Late joiners pay + full price, a pro-rata price for the sessions left (rounded to the nearest + R10), or a fixed late price — main tickets only. +- **Completion certificates** — optional per event: four designs (Classic, + Modern, Church, Kids), custom wording with placeholders, signature name/role/ + image, and a completion rule (every session, at least N sessions, or N%). + Issued automatically when the event ends; each has a code and QR code checked + on a public verify page. - **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner, T-shirt), each with a price and optional **stock limit**. One option is marked as the main ticket, which is what gets checked in at the door. @@ -242,6 +261,15 @@ Items & Pricing → Sections → Form → Visibility → Attachments**. and remaining quantity. - **Check-in confirmation** — holders get an email/WhatsApp saying how many were checked in and how many remain. +- **Per-session check-in** — multi-day tickets are checked in once per session. + The scanner uses today's session (or one picked from a list). Check-in is open + the whole day of a session, or only from 2 hours before it until it ends, per + event. Optional reminders (1 h / 3 h / 1 day before each session) by email + and/or WhatsApp. +- **Attendance register** — attendees × sessions with ticks, totals per session, + completion, filters (completed, missed the last two), Excel/PDF export. + Supervisors can tick someone present or absent (audit-logged), and issue, + send, download or revoke certificates. - **Scan stats** — recent scans and per-event scan counts. - **Offline scanning** — while online, a staff member locks an event section (or the whole event) to their device and downloads its tickets. They can then keep scanning @@ -346,7 +374,7 @@ logout controls. Disconnected sessions are recovered automatically. - **Dashboard** — KPIs (active events, revenue, donations, registrations, tickets sold, each vs. the previous month), revenue trend chart, top events, and recent scans. Tiles link to the matching report. -- **Manage events** — the 6-step event wizard, ticket options, variants, +- **Manage events** — the 9-step event wizard, ticket options, variants, sections, form and attachments. - **At the door** — search by name/phone/ticket ID, walk-in registration, payments, a **Check-In** tab for redeeming main tickets by quantity, and @@ -508,6 +536,7 @@ falling back to a general guide. | Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) | | Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) | | Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to disable) | +| Session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false` to disable) | | Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) | | Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) | | Temp file cleanup (files older than 7 days in `/temp`) | Daily | @@ -543,9 +572,10 @@ falling back to a general guide. | `/setup` | First run | Setup wizard | | `/consent/[token]` | Parent/guardian | Give or decline parental consent | | `/waitlist/claim/[token]` | Public | Claim a waitlist place | +| `/certificates/verify/[code]` | Public | Check a completion certificate | | `/self-service` | Supervisor-unlocked | Kiosk | | `/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`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools | +| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools | | `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools | diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 2414f98..fd119f6 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -21,6 +21,7 @@ Tokens are issued by `POST /api/users/login`. - [Coupons](#coupons) - [Parental consent](#parental-consent) - [Waitlist](#waitlist) +- [Multi-day events & attendance](#multi-day-events--attendance) - [Offline scanning](#offline-scanning) - [Ticket transfer](#ticket-transfer) - [Rooms & groups](#rooms--groups) @@ -422,6 +423,53 @@ How syncing works: - Check-in messages are sent after the sync. - Locks, releases, force-releases and syncs are written to the admin audit log. +## Multi-day events & attendance + +An event with `scheduleType: "sessions"` runs on several dates (e.g. every Wednesday for +5 weeks). People register and pay once and get one ticket; check-in is per session. +`startDate`/`endDate` always follow the first and last active session. + +Create/update fields (`POST`/`PUT /api/events`): + +| Field | Notes | +|-------|-------| +| `scheduleType` | `single` (default) or `sessions`. Switching back to `single` is refused once there are session check-ins | +| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing | +| `lateJoinEnabled`, `lateJoinUntilSessionIndex` | Registration stays open until that session (index into the sent list, sorted by start) starts; otherwise it closes when the first session starts | +| `latePricing`, `lateFixedPrice` | `full`, `prorata` (price × sessions left ÷ sessions, nearest R10, at least R10) or `fixed` (only ever lowers the price). Main tickets only | +| `checkInWindow` | `whole_day` (default — any time on the session's date, in `APP_TIMEZONE`, default Africa/Johannesburg) or `session_only` (2 h before start until the end) | +| `sessionReminders` | `null` (off) or `{ offsets: [60 \| 180 \| 1440], channels: ["email", "whatsapp"] }` | +| `certificatesEnabled`, `certificateConfig` | `{ template: classic \| modern \| church \| kids, title, body ({name} {event} {sessionsAttended} {totalSessions} {endDate}), signatureName, signatureRole, signatureImage, rule: { type: all \| count \| percent, value }, sendOnIssue }` | + +Responses for events include `sessions[]` (with `cancelled` and `hasCheckIns`), +`registrationClosesAt`, `nextSession` and `lateJoin: { active, mode, sessionsLeft, totalSessions, fixedPrice }`. +`PUT` also returns `sessionChanges: { added, moved, cancelled, restored, removed }`. + +Scanning: `GET /api/tickets/scan-preview/:qr?sessionId=` and `POST /api/tickets/scan/:qr` +(`{ sessionId? }`) default to today's session and return `session` plus `remaining` for it; +`sessionError` / 400 `NO_SESSION` when no session is open for check-in. Offline snapshots +are for the session running when downloaded; synced scans count for the session at their scan time. + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/api/attendance/:eventId` | staff+ | Register: `columns` (sessions), `rows` (one per main ticket: `checkIns` per session, `attended`, `total`, `completed`, `certificate`), `totals` | +| GET | `/api/attendance/:eventId/export?format=xlsx\|pdf` | staff+ | Download the register | +| POST | `/api/attendance/:eventId/mark` | supervisor+ | `{ ticketId, sessionId, present }` — single-date events use `sessionId: "event"` | +| POST | `/api/attendance/:eventId/certificates` | supervisor+ | Issue now to everyone eligible, or `{ ticketId }` for one (even if not eligible) | +| POST | `/api/attendance/:eventId/certificates/send` | supervisor+ | Send unsent certificates, or `{ certificateId }` | +| GET | `/api/attendance/:eventId/certificates.pdf` | supervisor+ | All certificates in one PDF | +| POST | `/api/attendance/certificates/preview` | supervisor+ | Sample PDF: `{ title, config }` | +| POST | `/api/attendance/certificates/:id/revoke` | supervisor+ | Revoke (never re-issued automatically) | +| GET | `/api/attendance/certificates/mine` | user+ | My certificates | +| GET | `/api/attendance/certificates/:id/pdf` | user+ | Download one (its person, the booker, or a supervisor) | +| GET | `/api/attendance/certificates/verify/:code` | public | `{ valid, revoked?, name, event, completedOn, issuedAt }` | + +A job every 5 minutes (`SESSION_JOBS_ENABLED`, `SESSION_JOBS_INTERVAL_MS`) sends due session +reminders (each once; when several are due at once only the closest is sent) and issues +certificates for events with certificates on that ended in the last 14 days. + +--- + ## Waitlist Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines. diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml index 044d57c..fbffa92 100644 --- a/backend/prisma/migrations/migration_lock.toml +++ b/backend/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/backend/src/controllers/attendanceController.js b/backend/src/controllers/attendanceController.js index 111a0bf..80505ae 100644 --- a/backend/src/controllers/attendanceController.js +++ b/backend/src/controllers/attendanceController.js @@ -176,13 +176,23 @@ const exportRegister = async (req, res) => { const totalW = 50; const usable = doc.page.width - 72 - nameW - totalW; const colW = Math.max(28, Math.min(70, usable / Math.max(1, cols.length))); + // Header height fits the longest session label. + const headText = (c) => `${c.label}\n${fmtColumnDate(c.startsAt)}`; + doc.font('Helvetica-Bold').fontSize(8); + const headH = Math.max(12, ...cols.map(c => doc.heightOfString(headText(c), { width: colW - 2 }))); const header = () => { const y = doc.y; - doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y, { width: nameW }); - cols.forEach((c, i) => doc.text(`${c.label}\n${fmtColumnDate(c.startsAt)}`, 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' })); - doc.text('Total', 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center' }); - doc.y = y + 24; + doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y + headH - 10, { width: nameW }); + cols.forEach((c, i) => doc.text(headText(c), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' })); + doc.text('Total', 36 + nameW + cols.length * colW, y + headH - 10, { width: totalW, align: 'center' }); + doc.y = y + headH + 6; doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke(); + doc.y += 2; + }; + // The built-in PDF fonts have no ✓, so the tick is drawn. + const tick = (cx, cy) => { + doc.save().lineWidth(1.4).strokeColor('#15803d') + .moveTo(cx - 3.5, cy).lineTo(cx - 1, cy + 2.8).lineTo(cx + 4, cy - 3).stroke().restore(); }; header(); for (const r of reg.rows) { @@ -191,7 +201,9 @@ const exportRegister = async (req, res) => { doc.font('Helvetica').fontSize(8.5).fillColor('#1e293b').text(r.name + (r.quantity > 1 ? ` (×${r.quantity})` : ''), 36, y, { width: nameW, ellipsis: true, lineBreak: false }); cols.forEach((c, i) => { const qty = r.checkIns[c.id] || 0; - doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? (r.quantity > 1 ? String(qty) : '✓') : '·', 36 + nameW + i * colW, y, { width: colW - 2, align: 'center', lineBreak: false }); + const x = 36 + nameW + i * colW; + if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4); + else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false }); }); doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false }); doc.y = y + 14; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index 9ed270e..a201859 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -9,12 +9,12 @@ const { logAdminAction } = require('../utils/adminAudit'); const { getClientIp } = require('../utils/requestUtils'); const sessionsUtil = require('../utils/sessions'); -const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' } }; +const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { select: { usages: true } } } }; // Adds the multi-day schedule to an event for API responses: sessions (cancelled ones // flagged), when registration closes, the next session, and late-joiner pricing. function withSchedule(ev) { - const sessions = (ev.sessions || []).map(s => ({ ...s, cancelled: !!s.cancelledAt })); + const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({ ...s, cancelled: !!s.cancelledAt, hasCheckIns: (_count?.usages || 0) > 0 })); const active = sessionsUtil.activeSessions(sessions); const now = new Date(); return { @@ -594,7 +594,7 @@ const getEventIcs = async (req, res) => { const eventId = req.params.id; const event = await prisma.event.findUnique({ where: { id: eventId }, - select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: SESSIONS_INCLUDE }, + select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: { orderBy: { startsAt: 'asc' } } }, }); if (!event) { diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index 96147de..dc28a3a 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -102,7 +102,7 @@ const getUserTickets = async (req, res) => { }, }, user: { select: { id: true, name: true } }, - event: true, + event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } }, transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } }, usages: { include: { @@ -547,7 +547,7 @@ const getTicketsByEvent = async (req, res) => { registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, attendee: { select: { id: true, name: true, email: true, phone: true } }, - event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } }, + event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } }, usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } } }, orderBy: { createdAt: 'desc' }, @@ -914,7 +914,8 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) { // Event title, with the date on the right of the same line let cy = y + pad; const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null; - const dateStr = eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : ''; + const dateStr = require('../utils/sessions').ticketDateText(ticket.event) + || (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : ''); doc.font('Helvetica').fontSize(9); const dateW = dateStr ? doc.widthOfString(dateStr) : 0; if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false }); @@ -978,7 +979,7 @@ async function emailTicketsInternal(req, res, userOverride) { } const ticketInclude = { - event: true, + event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } }, registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } }, attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } }, user: { select: { name: true } }, @@ -1087,9 +1088,9 @@ async function emailTicketsInternal(req, res, userOverride) { await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); }); const eventTitle = tickets[0].event.title; - const eventDate = tickets[0].event.startDate + const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long') || (tickets[0].event.startDate ? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) - : ''; + : ''); const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`; const sentChannels = []; diff --git a/backend/src/index.js b/backend/src/index.js index 11e36f3..97663cb 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -547,7 +547,7 @@ app.get('/docs', async (req, res) => { responses:[ { status:201, desc:'Created', body:{ id:'ev-uuid-new', title:'Camp 2025', createdAt:'2024-11-01T09:00:00.000Z' }}, ]}, - { method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event', + { method:'PUT', path:'/api/events/:id', auth:'supervisor+', desc:'Update an event. Multi-day events send scheduleType:"sessions" with the full sessions list (rows left out are deleted; ones with check-ins must be cancelled instead), plus lateJoinEnabled, lateJoinUntilSessionIndex, latePricing, lateFixedPrice, checkInWindow, sessionReminders, certificatesEnabled and certificateConfig. The response includes sessionChanges.', pathParams:{ ':id':'Event UUID' }, request:{ body:{ title:'Camp 2025 Updated', price:500 }}, responses:[{ status:200, desc:'Updated', body:{ id:'ev-uuid-...', title:'Camp 2025 Updated', price:500 }}]}, @@ -740,6 +740,20 @@ app.get('/docs', async (req, res) => { { method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} }, { method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' }, ]}, + { title: 'Attendance & certificates', base: '/api/attendance', endpoints: [ + { method:'GET', path:'/api/attendance/:eventId', auth:'staff+', desc:'Attendance register: sessions (columns), one row per main ticket with check-ins per session, completion and certificate', + responses:[{ status:200, desc:'Register', body:{ event:{ id:'ev-uuid-...', scheduleType:'sessions' }, columns:[{ id:'ses-uuid-...', label:'Week 1', startsAt:'2026-11-04T16:30:00.000Z', cancelled:false }], rows:[{ ticketId:'t-uuid-...', name:'Thandi', checkIns:{ 'ses-uuid-...':1 }, attended:1, total:5, completed:false, certificate:null }], totals:{ 'ses-uuid-...':12 } }}]}, + { method:'GET', path:'/api/attendance/:eventId/export?format=xlsx|pdf', auth:'staff+', desc:'Download the register as Excel or PDF' }, + { method:'POST', path:'/api/attendance/:eventId/mark', auth:'supervisor+', desc:'Mark a ticket present/absent for a session (single-date events use sessionId "event")', request:{ body:{ ticketId:'t-uuid-...', sessionId:'ses-uuid-...', present:true }} }, + { method:'POST', path:'/api/attendance/:eventId/certificates', auth:'supervisor+', desc:'Issue certificates now to everyone eligible, or to one ticket (even if not eligible)', request:{ body:{ ticketId:'t-uuid-... (optional)' }} }, + { method:'POST', path:'/api/attendance/:eventId/certificates/send', auth:'supervisor+', desc:'Email/WhatsApp unsent certificates, or one again', request:{ body:{ certificateId:'(optional)' }} }, + { method:'GET', path:'/api/attendance/:eventId/certificates.pdf', auth:'supervisor+', desc:'All of an event\'s certificates in one PDF' }, + { method:'POST', path:'/api/attendance/certificates/preview', auth:'supervisor+', desc:'Sample certificate PDF with unsaved settings', request:{ body:{ title:'Alpha Course', config:{ template:'classic' } }} }, + { method:'POST', path:'/api/attendance/certificates/:id/revoke', auth:'supervisor+', desc:'Revoke a certificate (never re-issued automatically)' }, + { method:'GET', path:'/api/attendance/certificates/mine', auth:'user+', desc:'My certificates' }, + { method:'GET', path:'/api/attendance/certificates/:id/pdf', auth:'user+', desc:'Download a certificate (its person, the booker, or a supervisor)' }, + { method:'GET', path:'/api/attendance/certificates/verify/:code', auth:'public', desc:'Check a certificate code — name, event and dates only', responses:[{ status:200, desc:'Result', body:{ valid:true, name:'Thandi Mokoena', event:'Alpha Course', completedOn:'2026-12-02T18:30:00.000Z' }}] }, + ]}, { title: 'Parental consent', base: '/api/consent', endpoints: [ { method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)', responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]}, diff --git a/backend/src/utils/sessions.js b/backend/src/utils/sessions.js index 64fd2aa..34c9034 100644 --- a/backend/src/utils/sessions.js +++ b/backend/src/utils/sessions.js @@ -279,6 +279,26 @@ function scanSession(event, sessions, requestedId = null, now = new Date()) { return { session, error: null }; } +/** + * How a ticket shows an event's date. Multi-day events: "5 sessions · 04 Nov – 02 Dec" + * (short) or "Wednesday 4 November 2026 (first of 5 sessions — one ticket for all)" (long). + * Returns null for single-date events (callers keep their own date format). + */ +function ticketDateText(event, style = 'short') { + if (event?.scheduleType !== 'sessions') return null; + const active = activeSessions(event.sessions); + if (active.length === 0) return null; + const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts }); + if (style === 'long') { + const first = fmt(active[0].startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); + return active.length === 1 ? first : `${first} (first of ${active.length} sessions — one ticket for all)`; + } + const short = { day: '2-digit', month: 'short' }; + return active.length === 1 + ? fmt(active[0].startsAt, { day: '2-digit', month: 'long', year: 'numeric' }) + : `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`; +} + /** Places already checked in for one session, from a ticket's usages. */ function redeemedForSession(usages, sessionId) { return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0); @@ -314,7 +334,7 @@ async function attendanceBySession(eventId) { module.exports = { TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError, dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession, - sessionLabel, scanSession, redeemedForSession, + sessionLabel, scanSession, redeemedForSession, ticketDateText, registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig, syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession, }; diff --git a/frontend/src/app/certificates/verify/[code]/page.tsx b/frontend/src/app/certificates/verify/[code]/page.tsx new file mode 100644 index 0000000..111a199 --- /dev/null +++ b/frontend/src/app/certificates/verify/[code]/page.tsx @@ -0,0 +1,61 @@ +import { Metadata } from "next"; +import { BadgeCheck, ShieldX } from "lucide-react"; +import { Navbar } from "@/components/layout/Navbar"; +import { Footer } from "@/components/layout/Footer"; +import { apiFetch } from "@/lib/api"; +import { formatDate } from "@/lib/date"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Verify a certificate", + robots: { index: false }, +}; + +type Verification = { valid: boolean; revoked?: boolean; name?: string; event?: string; completedOn?: string; issuedAt?: string }; + +// Public check for the QR code / code printed on a completion certificate. Shows only the +// name, event and dates — nothing else about the person. +export default async function VerifyCertificatePage({ params }: { params: Promise<{ code: string }> }) { + const { code } = await params; + let result: Verification = { valid: false }; + try { + result = await apiFetch(`/api/attendance/certificates/verify/${encodeURIComponent(code)}`, { nextOptions: { cache: "no-store" } }); + } catch { + result = { valid: false }; + } + + return ( +
+ +
+
+ {result.valid ? ( + <> + +

Valid certificate

+

{result.name} completed

+

{result.event}

+

+ {result.completedOn && <>Completed {formatDate(new Date(result.completedOn))} · } + {result.issuedAt && <>Issued {formatDate(new Date(result.issuedAt))}} +

+ + ) : ( + <> + +

{result.revoked ? "This certificate was withdrawn" : "Certificate not found"}

+

+ {result.revoked + ? "The organiser has withdrawn this certificate, so it's no longer valid." + : "Check the code on the certificate and try again. Codes look like K7M2Q-9XRTB."} +

+ + )} +

{decodeURIComponent(code).toUpperCase()}

+
+
+
+
+ ); +} diff --git a/frontend/src/app/dashboard/admin/page.tsx b/frontend/src/app/dashboard/admin/page.tsx index 22f4db0..b45cc2a 100644 --- a/frontend/src/app/dashboard/admin/page.tsx +++ b/frontend/src/app/dashboard/admin/page.tsx @@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState"; import { useVisiblePolling } from "@/hooks/useVisiblePolling"; import { Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList, - UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, + UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, } from "lucide-react"; import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; @@ -30,6 +30,7 @@ const QUICK_ACTIONS = [ { 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/attendance", label: "Attendance", description: "Who came to each session, and certificates", icon: ClipboardCheck }, { 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 }, { href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent }, diff --git a/frontend/src/app/dashboard/staff/page.tsx b/frontend/src/app/dashboard/staff/page.tsx index e4fb8c8..1a9c7f4 100644 --- a/frontend/src/app/dashboard/staff/page.tsx +++ b/frontend/src/app/dashboard/staff/page.tsx @@ -6,7 +6,7 @@ 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, Users } from "lucide-react"; +import { QrCode, Ticket, Activity, UserCheck, Clock, Users, ClipboardCheck } from "lucide-react"; import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; import type { DashboardStats, RecentScan, ScanStats } from "@/types"; @@ -15,6 +15,7 @@ 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 }, + { href: "/dashboard/supervisor/attendance", label: "Attendance", description: "Who came to each session of an event", icon: ClipboardCheck }, ] as const; export default function StaffDashboardPage() { diff --git a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx index 2700d55..d6f50e7 100644 --- a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx +++ b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx @@ -11,6 +11,7 @@ import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel"; import type { SnapshotTicket } from "@/lib/offlineScan"; import { errorMessage } from "@/lib/utils"; import type { Event, RecentScan, ScanStats, Section } from "@/types"; +import { activeSessions, currentSessionLabel, formatSessionWhen, sessionLabel } from "@/lib/sessions"; import { BackLink } from "@/components/shared/BackLink"; // GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline @@ -30,6 +31,10 @@ type ScanTicket = { attendee?: { name: string } | null; user?: { name?: string; email?: string } | null; usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[]; + // Multi-day events: the session this scan counts for, and what's left for it. + remaining?: number; + session?: { id: string; label: string } | null; + sessionError?: string | null; }; export default function TicketScanningPage() { @@ -73,6 +78,8 @@ export default function TicketScanningPage() { const [sections, setSections] = useState([]); const [selectedSectionId, setSelectedSectionId] = useState("all"); const [loadingSections, setLoadingSections] = useState(false); + // Multi-day events: "auto" = today's session (the server picks it). + const [selectedSessionId, setSelectedSessionId] = useState("auto"); // All events from API (including past); frontend filters to 12h window or all past useEffect(() => { @@ -100,6 +107,11 @@ export default function TicketScanningPage() { }); }, [allEvents, includePastEvents]); + const selectedEvent = useMemo(() => allEvents.find(ev => ev.id === selectedEventId) || null, [allEvents, selectedEventId]); + const eventSessions = useMemo(() => selectedEvent?.scheduleType === "sessions" ? activeSessions(selectedEvent.sessions) : [], [selectedEvent]); + useEffect(() => { setSelectedSessionId("auto"); }, [selectedEventId]); + const sessionParam = selectedEventId !== "all" && selectedSessionId !== "auto" ? selectedSessionId : null; + useEffect(() => { (async () => { if (!token || !selectedEventId || selectedEventId === "all") { @@ -136,7 +148,7 @@ export default function TicketScanningPage() { useEffect(() => { if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; } scannerRef.current?.stop(); - }, [selectedEventId, selectedSectionId]); + }, [selectedEventId, selectedSectionId, selectedSessionId]); async function handleResult(text: string) { setError(null); @@ -177,7 +189,7 @@ export default function TicketScanningPage() { // Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal) let preview: ScanTicket; try { - preview = await apiFetch(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token }); + preview = await apiFetch(`/api/tickets/scan-preview/${encodeURIComponent(text)}${sessionParam ? `?sessionId=${encodeURIComponent(sessionParam)}` : ""}`, { authToken: token }); } catch (pe) { setErrorModal(errorMessage(pe) || 'Failed to validate ticket'); return; @@ -208,13 +220,19 @@ export default function TicketScanningPage() { } } - // Compute remaining qty + // Multi-day events: no session open for check-in now (wrong day, cancelled…). + if (preview?.sessionError) { + setErrorModal(preview.sessionError); + return; + } + + // Compute remaining qty (the server counts it per session for multi-day events) const ticketQty = preview?.quantity || 1; const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0); - const remaining = ticketQty - totalRedeemed; + const remaining = typeof preview?.remaining === "number" ? preview.remaining : ticketQty - totalRedeemed; if (remaining <= 0) { - setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview }); + setAlreadyUsedModal({ message: preview?.session ? `Already checked in for ${preview.session.label}` : 'Ticket has already been fully used', ticket: preview }); return; } @@ -236,7 +254,7 @@ export default function TicketScanningPage() { const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket"; const variantName = ticket?.registrationOption?.variant?.name; const optionName = variantName ? `${baseName} — ${variantName}` : baseName; - const eventTitle = ticket?.event?.title || 'Event'; + const eventTitle = `${ticket?.event?.title || 'Event'}${ticket?.session ? ` — ${ticket.session.label}` : ''}`; const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : ''; const newRemaining = remaining - qty; @@ -276,6 +294,7 @@ export default function TicketScanningPage() { qty, deviceId: offline.deviceId(), ...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}), + ...(ticket?.session ? { sessionId: ticket.session.id } : {}), ...(overrideConsent ? { overrideConsent: true } : {}), }, }); @@ -378,6 +397,23 @@ export default function TicketScanningPage() { )} + {eventSessions.length > 0 && ( +
+ + +
+ )} +
Ticket type: {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}
Event: {confirmModal.ticket?.event?.title || '—'}
+ {confirmModal.ticket?.session &&
Session: {confirmModal.ticket.session.label}
}
Holder: {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}
{confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && (
Booked by {confirmModal.ticket.user.name}
diff --git a/frontend/src/app/dashboard/supervisor/attendance/page.tsx b/frontend/src/app/dashboard/supervisor/attendance/page.tsx new file mode 100644 index 0000000..89b4687 --- /dev/null +++ b/frontend/src/app/dashboard/supervisor/attendance/page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ClipboardCheck, Check, Download, FileSpreadsheet, FileText, Send, Award, Search, Ban } from "lucide-react"; +import { useAuth } from "@/hooks/useAuth"; +import { apiFetch, API_BASE } from "@/lib/api"; +import { useDismissingState } from "@/hooks/useDismissingState"; +import { errorMessage } from "@/lib/utils"; +import { formatDate } from "@/lib/date"; +import { fetchApiFile, saveBlob } from "@/lib/export"; +import type { AttendanceRegister, AttendanceRow, Event } from "@/types"; +import { BackLink } from "@/components/shared/BackLink"; + +type Filter = "all" | "completed" | "not_completed" | "missed_recent"; + +const colDate = new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" }); + +function AttendancePageInner() { + const { user, loading, token } = useAuth(); + const router = useRouter(); + const search = useSearchParams(); + const canView = ["admin", "supervisor", "staff"].includes(user?.role || ""); + const canManage = user?.role === "admin" || user?.role === "supervisor"; + + useEffect(() => { + if (loading) return; + if (!user) router.replace("/login"); + }, [user, loading, router]); + + const [events, setEvents] = useState([]); + const [eventId, setEventId] = useState(search.get("eventId") || ""); + const [reg, setReg] = useState(null); + const [busy, setBusy] = useState(false); + const [pending, setPending] = useState(null); + const [query, setQuery] = useState(""); + const [filter, setFilter] = useState("all"); + const [showCancelled, setShowCancelled] = useState(false); + const [error, setError] = useDismissingState(null); + const [info, setInfo] = useDismissingState(null); + + useEffect(() => { + if (!token || !canView) return; + apiFetch("/api/events/all?includePast=true", { authToken: token }) + .then(evs => { + // Multi-day events first (that's what the register is mostly for), newest first. + const list = (evs || []).filter(e => e.requiresRegistration !== false) + .sort((a, b) => Number(b.scheduleType === "sessions") - Number(a.scheduleType === "sessions") || new Date(b.startDate).getTime() - new Date(a.startDate).getTime()); + setEvents(list); + if (!eventId && list.length) setEventId(list[0].id); + }) + .catch(e => setError(errorMessage(e) || "Failed to load events")); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, canView]); + + const load = useCallback(async () => { + if (!token || !eventId) return; + setBusy(true); + try { + setReg(await apiFetch(`/api/attendance/${encodeURIComponent(eventId)}`, { authToken: token })); + } catch (e) { + setError(errorMessage(e) || "Failed to load the register"); + } finally { + setBusy(false); + } + }, [token, eventId, setError]); + useEffect(() => { load(); }, [load]); + + const columns = useMemo(() => (reg?.columns || []).filter(c => showCancelled || !c.cancelled), [reg, showCancelled]); + const multi = reg?.event.scheduleType === "sessions"; + // Sessions already held (for "missed the last two"). + const heldIds = useMemo(() => (reg?.columns || []).filter(c => !c.cancelled && new Date(c.startsAt).getTime() < Date.now()).map(c => c.id), [reg]); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + return (reg?.rows || []).filter(r => { + if (q && ![r.name, r.bookedBy, r.email, r.phone].some(v => v?.toLowerCase().includes(q))) return false; + if (filter === "completed") return r.completed; + if (filter === "not_completed") return !r.completed; + if (filter === "missed_recent") { + const lastTwo = heldIds.slice(-2); + return lastTwo.length > 0 && lastTwo.every(id => !r.checkIns[id]); + } + return true; + }); + }, [reg, query, filter, heldIds]); + + const toggle = async (row: AttendanceRow, sessionId: string) => { + if (!canManage || !token) return; + const present = !row.checkIns[sessionId]; + const key = `${row.ticketId}:${sessionId}`; + setPending(key); + try { + await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId: row.ticketId, sessionId, present } }); + const col = reg?.columns.find(c => c.id === sessionId); + setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`); + await load(); + } catch (e) { + setError(errorMessage(e) || "Couldn't update attendance"); + } finally { + setPending(null); + } + }; + + const post = async (path: string, body: object, done: (r: Record) => string) => { + if (!token) return; + setBusy(true); + try { + const r = await apiFetch>(path, { method: "POST", authToken: token, body }); + setInfo(done(r)); + await load(); + } catch (e) { + setError(errorMessage(e) || "Something went wrong"); + } finally { + setBusy(false); + } + }; + + const download = async (path: string, filename: string) => { + if (!token) return; + try { + saveBlob(await fetchApiFile(API_BASE, token, path), filename); + } catch (e) { + setError(errorMessage(e) || "Download failed"); + } + }; + + const safeTitle = (reg?.event.title || "event").replace(/[^a-z0-9 -]/gi, "").slice(0, 60); + const certsOn = !!reg?.event.certificatesEnabled; + const issued = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt).length; + const unsent = (reg?.rows || []).filter(r => r.certificate && !r.certificate.revokedAt && !r.certificate.emailedAt).length; + const completed = (reg?.rows || []).filter(r => r.completed).length; + + return ( +
+
+
+ +
+
+ +

Attendance

+

Who came to each session, and completion certificates

+
+
+ + {!canView &&
You need staff access to view attendance.
} + {error &&
{error}
} + {info &&
{info}
} + + {canView && ( +
+
+
+ + +
+
+ + setQuery(e.target.value)} /> +
+ + {multi && (reg?.columns || []).some(c => c.cancelled) && ( + + )} +
+ + +
+
+ + {reg && ( +
+ {reg.rows.length} attendee{reg.rows.length === 1 ? "" : "s"} + {completed} completed + {certsOn && {issued} certificate{issued === 1 ? "" : "s"} issued} + {!multi && Single-date event — one check-in column} +
+ )} + + {reg && certsOn && canManage && ( +
+ + Certificates are issued automatically when the event ends. You can also: + + + +
+ )} + + {busy && !reg &&

Loading…

} + {reg && rows.length === 0 &&

{reg.rows.length === 0 ? "No tickets for this event yet." : "Nobody matches."}

} + + {reg && rows.length > 0 && ( +
+ + + + + {columns.map(c => ( + + ))} + + {certsOn && } + + + + {rows.map(r => ( + + + {columns.map(c => { + const qty = r.checkIns[c.id] || 0; + const key = `${r.ticketId}:${c.id}`; + const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed"; + return ( + + ); + })} + + {certsOn && ( + + )} + + ))} + + + + + {columns.map(c => )} + + +
Name +
{c.label}
+
{multi ? colDate.format(new Date(c.startsAt)) : ""}
+
TotalCertificate
+
{r.name || "—"}{r.quantity > 1 && ×{r.quantity}}
+
{r.variant ? `${r.option} — ${r.variant}` : r.option}{r.bookedBy ? ` · booked by ${r.bookedBy}` : ""}
+
+ + + {r.attended}/{r.total} + + {r.certificate && !r.certificate.revokedAt ? ( +
+ {r.certificate.code} + + {canManage && } + {canManage && } +
+ ) : canManage ? ( + + ) : —} +
Present{c.cancelled ? "—" : reg.totals[c.id] || 0} + {certsOn && } +
+
+ )} + {reg && canManage && ( +

Click a box to mark someone present or absent (e.g. they were let in without a scan). Changes are recorded in the audit log.

+ )} +
+ )} +
+ ); +} + +export default function AttendancePage() { + return ( + + + + ); +} diff --git a/frontend/src/app/dashboard/supervisor/events/page.tsx b/frontend/src/app/dashboard/supervisor/events/page.tsx index ba3296f..158a80a 100644 --- a/frontend/src/app/dashboard/supervisor/events/page.tsx +++ b/frontend/src/app/dashboard/supervisor/events/page.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta import { createPortal } from "react-dom"; import { useAuth } from "@/hooks/useAuth"; import { useRouter } from "next/navigation"; -import { apiFetch } from "@/lib/api"; +import { apiFetch, API_BASE } from "@/lib/api"; import { ApiImage } from "@/components/shared/ApiImage"; import { useDismissingState } from "@/hooks/useDismissingState"; import { useSiteSettings } from "@/contexts/SiteSettingsContext"; @@ -13,6 +13,12 @@ import { ChoicesEditor } from "@/components/shared/FormFieldExtras"; import { errorMessage } from "@/lib/utils"; import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types"; import { BackLink } from "@/components/shared/BackLink"; +import { + SessionsEditor, AttendanceSettings, keyed, sortSessions, sessionsProblem, defaultCertificate, + type KeyedSession, type AttendanceDraft, +} from "@/components/events/ScheduleEditor"; +import { toSessionDraft, scheduleSummary } from "@/lib/sessions"; +import { fetchApiFile, openBlob } from "@/lib/export"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -803,8 +809,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even // ─── steps ─────────────────────────────────────────────────────────────────── -const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback"] as const; -type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; +const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback", "Attendance"] as const; +type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const; type PricingSubstep = 0 | 1 | 2; @@ -819,6 +825,7 @@ interface EventDraft { waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean; allowTicketTransfer: boolean; collectGender: boolean; allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean; + scheduleType: "single" | "sessions"; } const blankDraft = (location = ""): EventDraft => ({ @@ -832,8 +839,12 @@ const blankDraft = (location = ""): EventDraft => ({ waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true, allowTicketTransfer: true, collectGender: false, allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true, + scheduleType: "single", }); +// "YYYY-MM-DDTHH:MM" (local) → ISO +const isoOf = (local: string) => new Date(local).toISOString(); + const blankOptions = (): OptionDraft[] => [ { name: "Ticket", price: "", isMainTicket: true, stockLimit: "0", earlyBirdTiers: [], variants: [] } ]; @@ -874,8 +885,35 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false, contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "", location: ev.location || "", + scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single", } : blankDraft(settings.org_address || "")); + // ── multi-day schedule + attendance settings ── + const [sessions, setSessions] = useState(() => (ev?.sessions || []).map(s => keyed(toSessionDraft(s)))); + const [attendance, setAttendance] = useState(() => ({ + lateJoinEnabled: !!ev?.lateJoinEnabled, + lateJoinUntilKey: ev?.lateJoinUntilSessionId || "", + latePricing: ev?.latePricing || "full", + lateFixedPrice: ev?.lateFixedPrice !== null && ev?.lateFixedPrice !== undefined ? String(ev.lateFixedPrice) : "", + checkInWindow: ev?.checkInWindow || "whole_day", + remindersEnabled: !!ev?.sessionReminders, + reminderOffsets: ev?.sessionReminders?.offsets || [180], + reminderChannels: ev?.sessionReminders?.channels || ["email", "whatsapp"], + certificatesEnabled: !!ev?.certificatesEnabled, + certificate: { ...defaultCertificate(), ...(ev?.certificateConfig || {}) }, + })); + const updAttendance = (patch: Partial) => setAttendance(a => ({ ...a, ...patch })); + const multi = draft.scheduleType === "sessions"; + // A multi-session event's dates come from its sessions. + const scheduleBounds = useMemo(() => { + const active = sortSessions(sessions).filter(s => !s.cancelled && s.startsAt && s.endsAt); + if (!active.length) return null; + const end = active.reduce((m, s) => (s.endsAt > m ? s.endsAt : m), active[0].endsAt); + return { start: active[0].startsAt, end }; + }, [sessions]); + // Shown after saving when sessions moved or were cancelled, to offer telling registrants. + const [changedNotice, setChangedNotice] = useState<{ eventId: string; moved: number; cancelled: number } | null>(null); + // ── options (with per-variant tiers) ── const [options, setOptions] = useState(() => { if (!ev?.eventOptions?.length) return blankOptions(); @@ -951,8 +989,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { { type: "text", label: "What could we do better?", isRequired: false, helpText: "" }, ] }); } - if (on && !fbSettings.sendAt && draft.endDate) { - const next = new Date(draft.endDate); + const endForFeedback = multi ? scheduleBounds?.end : draft.endDate; + if (on && !fbSettings.sendAt && endForFeedback) { + const next = new Date(endForFeedback); next.setDate(next.getDate() + 1); next.setHours(9, 0, 0, 0); setFbSettings(s => ({ ...s, sendAt: toLocalDT(next) })); @@ -1176,16 +1215,35 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { const handleSave = async () => { if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; } - if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; } + if (multi) { + const problem = sessionsProblem(sessions); + if (problem) { setError(problem); setStep(0); return; } + if (attendance.lateJoinEnabled && !attendance.lateJoinUntilKey) { setError("Choose until which session late joining is allowed"); setStep(8); return; } + if (attendance.lateJoinEnabled && attendance.latePricing === "fixed" && attendance.lateFixedPrice.trim() === "") { setError("Enter the fixed late price"); setStep(8); return; } + } else if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; } if (mode === "create" && draft.requiresRegistration) { if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; } if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; } } setSaving(true); setError(null); try { + const sortedSessions = sortSessions(sessions); const body: Record = { title: draft.title.trim(), description: draft.description || undefined, - startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(), + startDate: multi && scheduleBounds ? isoOf(scheduleBounds.start) : new Date(draft.startDate).toISOString(), + endDate: multi && scheduleBounds ? isoOf(scheduleBounds.end) : new Date(draft.endDate).toISOString(), + scheduleType: draft.scheduleType, + ...(multi ? { + sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled })), + lateJoinEnabled: attendance.lateJoinEnabled, + lateJoinUntilSessionIndex: attendance.lateJoinEnabled ? sortedSessions.findIndex(s => s.key === attendance.lateJoinUntilKey) : -1, + latePricing: attendance.latePricing, + lateFixedPrice: attendance.latePricing === "fixed" ? attendance.lateFixedPrice : null, + checkInWindow: attendance.checkInWindow, + sessionReminders: attendance.remindersEnabled ? { offsets: attendance.reminderOffsets, channels: attendance.reminderChannels } : null, + } : {}), + certificatesEnabled: attendance.certificatesEnabled, + certificateConfig: attendance.certificate, registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined, goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined, price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0, @@ -1212,13 +1270,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { location: draft.location.trim(), }; + let notice: { eventId: string; moved: number; cancelled: number } | null = null; if (mode === "edit") { body.isActive = draft.isActive; if (!ev) throw new Error("No event loaded to save"); - await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body }); + const saved = await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body }); await saveOptions(ev.id); // Form is saved inside saveOptions for edit mode; avoid double call await saveFeedback(ev.id); + const ch = saved?.sessionChanges; + if (ch && (ch.moved > 0 || ch.cancelled > 0)) notice = { eventId: ev.id, moved: ch.moved, cancelled: ch.cancelled }; } else { if (formDef.isRequired || formDef.fields.length > 0) { body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) }; @@ -1247,7 +1308,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { } } - onSuccess(); onClose(); + onSuccess(); + if (notice) setChangedNotice(notice); + else onClose(); } catch (e) { setError(errorMessage(e) || "Failed to save event"); } finally { @@ -1282,7 +1345,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { const isOnLastSubstep = step !== 1 || pricingSubstep === 2; // Basic Details step requires title, start/end dates, and a base price before moving on // (mirrors handleSave's own checks). Base price is only compulsory when creating a new event. - const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price))); + const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || (multi ? !!sessionsProblem(sessions) : (!draft.startDate || !draft.endDate)) || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price))); // Items & Pricing: every option needs a valid price before leaving the step (checked across // all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate). // Not applicable to contact-only events, which have no ticket options at all. @@ -1339,6 +1402,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) { )} {error &&
{error}
} + {changedNotice && ( +
+

+ Saved. You {[changedNotice.moved ? `moved ${changedNotice.moved} session${changedNotice.moved === 1 ? "" : "s"}` : "", changedNotice.cancelled ? `cancelled ${changedNotice.cancelled}` : ""].filter(Boolean).join(" and ")}. + Tickets and calendar files show the new schedule, but people who registered aren't told automatically. +

+
+ Message attendees + +
+
+ )} {/* ── Step 0: Basic Details ── */} {step === 0 && ( @@ -1351,10 +1426,26 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {