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 @@

Session details

+
+
+

Vaccinators

+ +
+
+ {% set vaccinatorCount = draft.vaccinatorCount if draft.vaccinatorCount else 1 %} + {{ summaryList({ rows: [ + { + key: { text: "Vaccinators" }, + value: { text: vaccinatorCount + " " + ("vaccinator" if vaccinatorCount === 1 else "vaccinators") } + } + ] }) }} +
+
+ {# ══════════════════════════════════════════════════════════════════════ #} {# Card 2 — Day pattern (recurring only) #} {# ══════════════════════════════════════════════════════════════════════ #} diff --git a/app/views/sites/availability-shared-vaccinators.html b/app/views/sites/availability-shared-vaccinators.html new file mode 100644 index 0000000..185b31d --- /dev/null +++ b/app/views/sites/availability-shared-vaccinators.html @@ -0,0 +1,67 @@ +{% extends 'layout.html' %} + +{% set pageName = "Vaccinators — " + site.name %} + +{% block beforeContent %} + {{ backLink({ href: backHref }) }} +{% endblock %} + +{% block content %} +
+
+ + {{ type }} +

Vaccinators

+ + {% if errors.length %} + {{ errorSummary({ + titleText: "There is a problem", + errorList: errors + }) }} + {% endif %} + +
+ + {% set vaccinatorCountHtml %} + {{ input({ + id: "vaccinatorCount", + name: "vaccinatorCount", + value: vaccinatorCountValue, + label: { text: "Number of vaccinators", classes: "nhsuk-label--m" }, + hint: { text: "Enter how many vaccinators will work in this session." }, + errorMessage: vaccinatorCountError, + classes: "nhsuk-input--width-4", + inputmode: "numeric" + }) }} + {% endset %} + + {{ radios({ + idPrefix: "hasMultipleVaccinators", + name: "hasMultipleVaccinators", + fieldset: { + legend: { text: "Does this session have more than one vaccinator?", classes: "nhsuk-fieldset__legend--m" } + }, + hint: { text: "Choose yes if this session should represent more than one vaccinator working at the same time." }, + errorMessage: hasMultipleVaccinatorsError, + items: [ + { + value: "yes", + text: "Yes", + checked: hasMultipleVaccinatorsValue === "yes", + conditional: { html: vaccinatorCountHtml } + }, + { + value: "no", + text: "No", + checked: hasMultipleVaccinatorsValue === "no" + } + ] + }) }} + + {{ button({ text: "Continue" }) }} + +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/app/views/sites/availability.html b/app/views/sites/availability.html index 37b01cb..5487b08 100644 --- a/app/views/sites/availability.html +++ b/app/views/sites/availability.html @@ -65,11 +65,15 @@

Availability

{% endset %} + {% set vaccinatorCount = avail.vaccinatorCount if avail.vaccinatorCount else 1 %} + {% set vaccinatorDisplay = vaccinatorCount + " " + ("vaccinator" if vaccinatorCount === 1 else "vaccinators") %} + {% set rows = (rows.push([ { text: avail.label, header: "Session" }, { html: typeTag, header: "Type" }, { text: dateDisplay, header: "Date" }, { text: daysDisplay, header: "Days" }, + { text: vaccinatorDisplay, header: "Vaccinators" }, { text: (avail.startTime | formatTime) + " to " + (avail.endTime | formatTime), header: "Time" }, { text: avail.slotLength + " min", header: "Appointment length" }, { html: actionsHtml, header: "Actions" } @@ -85,6 +89,7 @@

Availability

{ text: "Type" }, { text: "Date" }, { text: "Days" }, + { text: "Vaccinators" }, { text: "Time" }, { text: "Appointment length" }, { text: "Actions" }