Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/data/db.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@
"COVID_FLU:65+",
"RSV:Adult"
],
"vaccinatorCount": 2,
"slotLength": 10,
"startDate": "2026-02-25",
"endDate": "2026-04-30",
Expand Down
1 change: 1 addition & 0 deletions app/data/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
144 changes: 138 additions & 6 deletions app/routes/availability.js
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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`)
})
Expand Down Expand Up @@ -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`)
})
Expand All @@ -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`
})
})
Expand All @@ -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`
})
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`
})
}
Expand Down Expand Up @@ -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`,
Expand All @@ -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,
Expand Down Expand Up @@ -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`)
})

Expand Down Expand Up @@ -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`,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion app/routes/shared/availability-forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions app/routes/shared/availability-impact.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions app/views/sites/availability-shared-check.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ <h2 class="app-summary-card__title">Session details</h2>
</div>
</div>

<div class="app-summary-card">
<div class="app-summary-card__title-wrapper">
<h2 class="app-summary-card__title">Vaccinators</h2>
<ul class="app-summary-card__actions">
<li class="app-summary-card__action">
<a href="{{ vaccinatorsHref }}">Change<span class="nhsuk-u-visually-hidden"> vaccinators</span></a>
</li>
</ul>
</div>
<div class="app-summary-card__content">
{% set vaccinatorCount = draft.vaccinatorCount if draft.vaccinatorCount else 1 %}
{{ summaryList({ rows: [
{
key: { text: "Vaccinators" },
value: { text: vaccinatorCount + " " + ("vaccinator" if vaccinatorCount === 1 else "vaccinators") }
}
] }) }}
</div>
</div>

{# ══════════════════════════════════════════════════════════════════════ #}
{# Card 2 — Day pattern (recurring only) #}
{# ══════════════════════════════════════════════════════════════════════ #}
Expand Down
67 changes: 67 additions & 0 deletions app/views/sites/availability-shared-vaccinators.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{% extends 'layout.html' %}

{% set pageName = "Vaccinators — " + site.name %}

{% block beforeContent %}
{{ backLink({ href: backHref }) }}
{% endblock %}

{% block content %}
<div class="nhsuk-grid-row">
<div class="nhsuk-grid-column-two-thirds">

<span class="nhsuk-caption-xl">{{ type }}</span>
<h1 class="nhsuk-heading-xl">Vaccinators</h1>

{% if errors.length %}
{{ errorSummary({
titleText: "There is a problem",
errorList: errors
}) }}
{% endif %}

<form method="post" action="{{ formAction }}">

{% 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" }) }}

</form>

</div>
</div>
{% endblock %}
5 changes: 5 additions & 0 deletions app/views/sites/availability.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,15 @@ <h1 class="nhsuk-heading-xl">Availability</h1>
</div>
{% 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" }
Expand All @@ -85,6 +89,7 @@ <h1 class="nhsuk-heading-xl">Availability</h1>
{ text: "Type" },
{ text: "Date" },
{ text: "Days" },
{ text: "Vaccinators" },
{ text: "Time" },
{ text: "Appointment length" },
{ text: "Actions" }
Expand Down