diff --git a/app/data/db.json b/app/data/db.json index 32c8ba3..1948547 100644 --- a/app/data/db.json +++ b/app/data/db.json @@ -283,6 +283,7 @@ "COVID_FLU:65+", "RSV:Adult" ], + "vaccinatorCount": 2, "slotLength": 10, "startDate": "2026-02-25", "endDate": "2026-04-30", diff --git a/app/data/seed.js b/app/data/seed.js index 66b6f6d..a1dd881 100644 --- a/app/data/seed.js +++ b/app/data/seed.js @@ -488,6 +488,7 @@ module.exports = { label: 'Weekday vaccination clinic', type: 'recurring', services: ['FLU:18-64', 'FLU:65+', 'COVID_FLU:18-64', 'COVID_FLU:65+', 'RSV:Adult'], + vaccinatorCount: 2, slotLength: 10, startDate: '2026-02-25', endDate: '2026-04-30', diff --git a/app/routes/availability.js b/app/routes/availability.js index 9b25a77..8dff911 100644 --- a/app/routes/availability.js +++ b/app/routes/availability.js @@ -1,5 +1,46 @@ // Availability routes cover creating, editing, copying, removing, and generating bookings for sessions. // This file owns the multi-step availability journeys and their session-backed draft state. +function getVaccinatorCount (item) { + const count = parseInt(item && item.vaccinatorCount, 10) + return Number.isFinite(count) && count > 1 ? count : 1 +} + +function getHasMultipleVaccinatorsValue (item) { + return getVaccinatorCount(item) > 1 ? 'yes' : 'no' +} + +function buildVaccinatorViewModel (item, submitted = {}) { + const choice = submitted.choice || getHasMultipleVaccinatorsValue(item) + const countValue = Object.prototype.hasOwnProperty.call(submitted, 'vaccinatorCountValue') + ? submitted.vaccinatorCountValue + : (choice === 'yes' ? String(getVaccinatorCount(item)) : '') + + return { + hasMultipleVaccinatorsValue: choice, + vaccinatorCountValue: choice === 'yes' ? countValue : '' + } +} + +function validateVaccinatorFields (body) { + const choice = String(body.hasMultipleVaccinators || '').trim() + const vaccinatorCountRaw = String(body.vaccinatorCount || '').trim() + const errors = [] + let vaccinatorCount = 1 + + if (choice !== 'yes' && choice !== 'no') { + errors.push({ text: 'Select whether this session has more than one vaccinator', href: '#hasMultipleVaccinators' }) + } else if (choice === 'yes') { + const parsedCount = parseInt(vaccinatorCountRaw, 10) + if (!/^\d+$/.test(vaccinatorCountRaw) || !Number.isFinite(parsedCount) || parsedCount < 2) { + errors.push({ text: 'Enter the number of vaccinators as 2 or more', href: '#vaccinatorCount' }) + } else { + vaccinatorCount = parsedCount + } + } + + return { choice, vaccinatorCountRaw, vaccinatorCount, errors } +} + function availability (router, shared) { const { DEFAULT_GENERATED_BOOKING_FILL_RATE, @@ -212,7 +253,7 @@ function availability (router, shared) { type, label: '', date: '', startDate: '', endDate: '', dayPattern: [], startTime: '', endTime: '', - slotLength: '', services: [], breakTimes: [], exceptions: [] + slotLength: '', services: [], breakTimes: [], exceptions: [], vaccinatorCount: 1 } res.redirect(`/sites/${req.params.siteId}/add-availability/${type}/details`) }) @@ -285,7 +326,49 @@ function availability (router, shared) { draft.endDate = endDateCheck.value } - res.redirect(type === 'recurring' + res.redirect(`${base}/vaccinators`) + }) + + router.get('/sites/:siteId/add-availability/:type/vaccinators', (req, res) => { + const draft = initAddDraft(req, req.params.type) + const base = `/sites/${req.params.siteId}/add-availability/${req.params.type}` + + res.render('sites/availability-shared-vaccinators', { + site: req.site, + draft, + type: req.params.type, + errors: [], + hasMultipleVaccinatorsError: null, + vaccinatorCountError: null, + ...buildVaccinatorViewModel(draft), + formAction: `${base}/vaccinators`, + backHref: `${base}/details`, + cancelHref: `/sites/${req.params.siteId}/availability` + }) + }) + + router.post('/sites/:siteId/add-availability/:type/vaccinators', (req, res) => { + const draft = initAddDraft(req, req.params.type) + const base = `/sites/${req.params.siteId}/add-availability/${req.params.type}` + const vaccinatorCheck = validateVaccinatorFields(req.body) + + if (vaccinatorCheck.errors.length) { + return res.render('sites/availability-shared-vaccinators', { + site: req.site, + draft, + type: req.params.type, + errors: vaccinatorCheck.errors, + hasMultipleVaccinatorsError: vaccinatorCheck.errors.find(e => e.href === '#hasMultipleVaccinators') ? { text: vaccinatorCheck.errors.find(e => e.href === '#hasMultipleVaccinators').text } : null, + vaccinatorCountError: vaccinatorCheck.errors.find(e => e.href === '#vaccinatorCount') ? { text: vaccinatorCheck.errors.find(e => e.href === '#vaccinatorCount').text } : null, + ...buildVaccinatorViewModel(draft, { choice: vaccinatorCheck.choice, vaccinatorCountValue: vaccinatorCheck.vaccinatorCountRaw }), + formAction: `${base}/vaccinators`, + backHref: `${base}/details`, + cancelHref: `/sites/${req.params.siteId}/availability` + }) + } + + draft.vaccinatorCount = vaccinatorCheck.vaccinatorCount + res.redirect(req.params.type === 'recurring' ? `/sites/${req.params.siteId}/add-availability/recurring/day-pattern` : `/sites/${req.params.siteId}/add-availability/single/times`) }) @@ -299,7 +382,7 @@ function availability (router, shared) { errors: [], dayPatternError: null, formAction: `${base}/day-pattern`, - backHref: `${base}/details`, + backHref: `${base}/vaccinators`, cancelHref: `/sites/${req.params.siteId}/availability` }) }) @@ -317,7 +400,7 @@ function availability (router, shared) { errors, dayPatternError: { text: errors[0].text }, formAction: `${base}/day-pattern`, - backHref: `${base}/details`, + backHref: `${base}/vaccinators`, cancelHref: `/sites/${req.params.siteId}/availability` }) } @@ -483,7 +566,7 @@ function availability (router, shared) { const base = `/sites/${req.params.siteId}/add-availability/${req.params.type}` const backHref = req.params.type === 'recurring' ? `/sites/${req.params.siteId}/add-availability/recurring/day-pattern` - : `${base}/details` + : `${base}/vaccinators` res.render('sites/availability-shared-times', { site: req.site, @@ -511,7 +594,7 @@ function availability (router, shared) { timeBlocksError: errorMessage, timeBlocksHint: 'Add time when bookings can be made. Enter times like 9am, 9:00 or 13:00. We will automatically add breaks between periods. This pattern will repeat on all available days.', formAction: `${base}/times`, - backHref: req.params.type === 'recurring' ? `/sites/${req.params.siteId}/add-availability/recurring/day-pattern` : `${base}/details`, + backHref: req.params.type === 'recurring' ? `/sites/${req.params.siteId}/add-availability/recurring/day-pattern` : `${base}/vaccinators`, cancelHref: `/sites/${req.params.siteId}/availability` }) } @@ -627,6 +710,7 @@ function availability (router, shared) { backHref: req.params.type === 'recurring' ? `${base}/exceptions` : `${base}/services`, cancelHref: `/sites/${req.params.siteId}/availability`, detailsHref: `${base}/details`, + vaccinatorsHref: `${base}/vaccinators`, dayPatternHref: req.params.type === 'recurring' ? `/sites/${req.params.siteId}/add-availability/recurring/day-pattern` : null, exceptionsHref: req.params.type === 'recurring' ? `/sites/${req.params.siteId}/add-availability/recurring/exceptions` : null, capacityHref: `${base}/times`, @@ -646,6 +730,7 @@ function availability (router, shared) { label: draft.label || 'New session', type: draft.type, services: draft.services, + vaccinatorCount: getVaccinatorCount(draft), slotLength: parseInt(draft.slotLength, 10) || 15, startTime, endTime, @@ -774,6 +859,51 @@ function availability (router, shared) { working.endDate = endDateCheck.value } clearAvailabilityEditBookingDecision(req) + res.redirect(`${base}/vaccinators`) + }) + + router.get('/sites/:siteId/availability/:availId/edit/vaccinators', (req, res) => { + const working = req.session.data.editAvail + if (!working) return res.redirect(`/sites/${req.params.siteId}/availability/${req.params.availId}/edit`) + const base = `/sites/${req.params.siteId}/availability/${req.params.availId}/edit` + + res.render('sites/availability-shared-vaccinators', { + site: req.site, + draft: working, + type: working.type, + errors: [], + hasMultipleVaccinatorsError: null, + vaccinatorCountError: null, + ...buildVaccinatorViewModel(working), + formAction: `${base}/vaccinators`, + backHref: `${base}/details`, + cancelHref: `/sites/${req.params.siteId}/availability` + }) + }) + + router.post('/sites/:siteId/availability/:availId/edit/vaccinators', (req, res) => { + const working = req.session.data.editAvail + if (!working) return res.redirect(`/sites/${req.params.siteId}/availability/${req.params.availId}/edit`) + const base = `/sites/${req.params.siteId}/availability/${req.params.availId}/edit` + const vaccinatorCheck = validateVaccinatorFields(req.body) + + if (vaccinatorCheck.errors.length) { + return res.render('sites/availability-shared-vaccinators', { + site: req.site, + draft: working, + type: working.type, + errors: vaccinatorCheck.errors, + hasMultipleVaccinatorsError: vaccinatorCheck.errors.find(e => e.href === '#hasMultipleVaccinators') ? { text: vaccinatorCheck.errors.find(e => e.href === '#hasMultipleVaccinators').text } : null, + vaccinatorCountError: vaccinatorCheck.errors.find(e => e.href === '#vaccinatorCount') ? { text: vaccinatorCheck.errors.find(e => e.href === '#vaccinatorCount').text } : null, + ...buildVaccinatorViewModel(working, { choice: vaccinatorCheck.choice, vaccinatorCountValue: vaccinatorCheck.vaccinatorCountRaw }), + formAction: `${base}/vaccinators`, + backHref: `${base}/details`, + cancelHref: `/sites/${req.params.siteId}/availability` + }) + } + + working.vaccinatorCount = vaccinatorCheck.vaccinatorCount + clearAvailabilityEditBookingDecision(req) res.redirect(`${base}/check`) }) @@ -1179,6 +1309,7 @@ function availability (router, shared) { backHref: `/sites/${req.params.siteId}/availability`, cancelHref: `/sites/${req.params.siteId}/availability`, detailsHref: `${base}/details`, + vaccinatorsHref: `${base}/vaccinators`, dayPatternHref: working.type === 'recurring' ? `${base}/day-pattern` : null, exceptionsHref: working.type === 'recurring' ? `${base}/exceptions` : null, capacityHref: `${base}/times`, @@ -1200,6 +1331,7 @@ function availability (router, shared) { label: working.label || 'New session', type: working.type, services: working.services, + vaccinatorCount: getVaccinatorCount(working), slotLength: parseInt(working.slotLength, 10) || 15, startTime, endTime, diff --git a/app/routes/shared/availability-forms.js b/app/routes/shared/availability-forms.js index ffcb521..ca19456 100644 --- a/app/routes/shared/availability-forms.js +++ b/app/routes/shared/availability-forms.js @@ -7,7 +7,7 @@ function initAddDraft (req, type) { type, label: '', date: '', startDate: '', endDate: '', dayPattern: [], timeBlocks: [{ start: '', end: '' }], - slotLength: '', services: [], exceptions: [] + slotLength: '', services: [], exceptions: [], vaccinatorCount: 1 } } return req.session.data.addAvailDraft diff --git a/app/routes/shared/availability-impact.js b/app/routes/shared/availability-impact.js index 73a85bc..1418a2e 100644 --- a/app/routes/shared/availability-impact.js +++ b/app/routes/shared/availability-impact.js @@ -100,8 +100,10 @@ function buildAffectedSessionData (originalAvail, draftAvail, siteBookings) { function availabilityDescription (avail) { if (!avail) return '' - if (avail.type === 'single') return `${avail.label} - single on ${avail.date}` - return `${avail.label} - repeating ${avail.startDate} to ${avail.endDate}` + const vaccinatorCount = parseInt(avail.vaccinatorCount, 10) > 1 ? parseInt(avail.vaccinatorCount, 10) : 1 + const vaccinatorText = `${vaccinatorCount} ${vaccinatorCount === 1 ? 'vaccinator' : 'vaccinators'}` + if (avail.type === 'single') return `${avail.label} - single on ${avail.date} - ${vaccinatorText}` + return `${avail.label} - repeating ${avail.startDate} to ${avail.endDate} - ${vaccinatorText}` } function clearAvailabilityEditBookingDecision (req) { diff --git a/app/views/sites/availability-shared-check.html b/app/views/sites/availability-shared-check.html index 8003ced..ccc7d2c 100644 --- a/app/views/sites/availability-shared-check.html +++ b/app/views/sites/availability-shared-check.html @@ -70,6 +70,26 @@