From ecfd65fac25030183e138c671500bf7484953835 Mon Sep 17 00:00:00 2001 From: rivalee Date: Mon, 3 Aug 2026 15:04:07 +0100 Subject: [PATCH 01/19] Add close clinic page --- app/routes/clinics.js | 42 ++++++++++ app/views/clinics/close.html | 144 +++++++++++++++++++++++++++++++++++ app/views/clinics/show.html | 8 ++ 3 files changed, 194 insertions(+) create mode 100644 app/views/clinics/close.html diff --git a/app/routes/clinics.js b/app/routes/clinics.js index faad4200..a68d471b 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -163,6 +163,48 @@ module.exports = (router) => { res.redirect(returnUrl) }) + // Close clinic page + router.get('/clinics/:id/close', (req, res) => { + const clinicData = getClinicData(req.session.data, req.params.id) + + if (!clinicData) { + return res.redirect('/clinics') + } + + res.render('clinics/close', { + clinicId: req.params.id, + clinic: clinicData.clinic, + allAppointments: clinicData.appointments + }) + }) + + // Mark appointment as attended not screened from close clinic page + router.get('/clinics/:id/close/attended-not-screened/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + updateAppointmentStatus(req.session.data, appointmentId, 'attended_not_screened') + res.redirect(`/clinics/${id}/close`) + }) + + // Mark appointment as did not attend from close clinic page + router.get('/clinics/:id/close/did-not-attend/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + updateAppointmentStatus(req.session.data, appointmentId, 'did_not_attend') + res.redirect(`/clinics/${id}/close`) + }) + + // Confirm and close clinic + router.post('/clinics/:id/close', (req, res) => { + const { id } = req.params + const data = req.session.data + const clinicIndex = data.clinics.findIndex((c) => c.id === id) + + if (clinicIndex !== -1) { + data.clinics[clinicIndex].status = 'closed' + } + + res.redirect(`/clinics/${id}`) + }) + // Single clinic view const VALID_FILTERS = [ 'remaining', diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html new file mode 100644 index 00000000..d597232a --- /dev/null +++ b/app/views/clinics/close.html @@ -0,0 +1,144 @@ +{# app/views/clinics/close.html #} + +{% extends 'layout-app.html' %} +{% set pageHeading = "Close clinic - " ~ clinic.clinicCode %} +{% set gridColumn = "nhsuk-grid-column-full" %} + +{% set back = { + href: "/clinics/" + clinicId, + text: "Back to clinic" +} %} + +{% block pageContent %} + + {% set unit = data.breastScreeningUnits | findById(clinic.breastScreeningUnitId) %} + +

+ {{ unit.name }} + {{ pageHeading }} +

+ +

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

+ + {{ insetText({ + html: "

Record an outcome for every participant to close this clinic.

" + }) }} + + {# In progress appointments #} + {% set inProgressAppointments = allAppointments | filterAppointmentsByStatus("in-progress") %} + + {% if inProgressAppointments | length %} +
+
+

In progress

+

Complete or end this appointment to close the clinic.

+ + + + + + + + + + + {% for appointment in inProgressAppointments %} + {% set participant = data.participants | findById(appointment.participantId) %} + + + + + + {% endfor %} + +
TimeDetailsActions
{{ appointment.statusHistory[0].timestamp | formatTimeString }} +

{{ participant | getFullName }}

+

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+
+ Go to appointment +
+
+
+ {% endif %} + + {# Checked in but not screened #} + {% set checkedInAppointments = allAppointments | filterAppointmentsByStatus("checked-in") %} + + {% if checkedInAppointments | length %} +
+
+

Checked in, not screened

+

These participants attended but their appointment did not take place.

+ + + + + + + + + + + {% for appointment in checkedInAppointments %} + {% set participant = data.participants | findById(appointment.participantId) %} + + + + + + {% endfor %} + +
TimeDetailsActions
{{ appointment.statusHistory[0].timestamp | formatTimeString }} +

{{ participant | getFullName }}

+

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+
+ Mark as attended not screened +
+
+
+ {% endif %} + + {# Did not check in #} + {% set remainingAppointments = allAppointments | filterAppointmentsByStatus("remaining") %} + + {% if remainingAppointments | length %} +
+
+

Did not check in

+

These participants did not arrive for their appointment.

+ + + + + + + + + + + {% for appointment in remainingAppointments %} + {% set participant = data.participants | findById(appointment.participantId) %} + + + + + + {% endfor %} + +
TimeDetailsActions
{{ appointment.statusHistory[0].timestamp | formatTimeString }} +

{{ participant | getFullName }}

+

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+
+ Mark as did not attend +
+
+
+ {% endif %} + +
+ {{ button({ + text: "Confirm and close clinic" + }) }} +
+ +{% endblock %} diff --git a/app/views/clinics/show.html b/app/views/clinics/show.html index e4a22690..a66b081a 100644 --- a/app/views/clinics/show.html +++ b/app/views/clinics/show.html @@ -50,6 +50,14 @@

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

+{% if clinic.status !== "closed" and clinic.status !== "scheduled" %} + {{ button({ + text: "Close clinic", + href: "/clinics/" + clinicId + "/close", + classes: "nhsuk-button--secondary" + }) }} +{% endif %} + {% set secondaryNavItems = [] %} {% set tabItems = [ From 6fb3750632238ac154574431d462f8108a865df1 Mon Sep 17 00:00:00 2001 From: rivalee Date: Mon, 3 Aug 2026 15:33:26 +0100 Subject: [PATCH 02/19] Add plumbing --- app/routes/clinics.js | 48 ++++++++++++++++++++++++++++++++---- app/views/clinics/close.html | 46 +++++++++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index a68d471b..ea2f84e7 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -12,8 +12,9 @@ const { urlWithReferrer, appendReferrer } = require('../lib/utils/referrers') -const { getParticipant } = require('../lib/utils/participants') +const { getParticipant, getFullName } = require('../lib/utils/participants') const { updateAppointmentStatus } = require('../lib/utils/appointment-status') +const { getAppointment } = require('../lib/utils/appointment-data') /** * Get clinic and its related data from id @@ -185,6 +186,13 @@ module.exports = (router) => { res.redirect(`/clinics/${id}/close`) }) + // Undo attended not screened + router.get('/clinics/:id/close/undo-attended-not-screened/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + updateAppointmentStatus(req.session.data, appointmentId, 'checked_in') + res.redirect(`/clinics/${id}/close`) + }) + // Mark appointment as did not attend from close clinic page router.get('/clinics/:id/close/did-not-attend/:appointmentId', (req, res) => { const { id, appointmentId } = req.params @@ -192,17 +200,47 @@ module.exports = (router) => { res.redirect(`/clinics/${id}/close`) }) + // Undo did not attend + router.get('/clinics/:id/close/undo-did-not-attend/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + updateAppointmentStatus(req.session.data, appointmentId, 'scheduled') + res.redirect(`/clinics/${id}/close`) + }) + + // Bulk mark all checked-in as attended not screened + router.get('/clinics/:id/close/attended-not-screened-all', (req, res) => { + const { id } = req.params + const data = req.session.data + const appointments = data.appointments.filter( + (a) => a.clinicId === id && a.status === 'checked_in' + ) + appointments.forEach((a) => updateAppointmentStatus(data, a.id, 'attended_not_screened')) + res.redirect(`/clinics/${id}/close`) + }) + + // Bulk mark all remaining as did not attend + router.get('/clinics/:id/close/did-not-attend-all', (req, res) => { + const { id } = req.params + const data = req.session.data + const appointments = data.appointments.filter( + (a) => a.clinicId === id && a.status === 'scheduled' + ) + appointments.forEach((a) => updateAppointmentStatus(data, a.id, 'did_not_attend')) + res.redirect(`/clinics/${id}/close`) + }) + // Confirm and close clinic router.post('/clinics/:id/close', (req, res) => { const { id } = req.params const data = req.session.data - const clinicIndex = data.clinics.findIndex((c) => c.id === id) + const clinic = data.clinics.find((c) => c.id === id) - if (clinicIndex !== -1) { - data.clinics[clinicIndex].status = 'closed' + if (clinic) { + clinic.status = 'closed' + req.flash('success', `Clinic ${clinic.clinicCode} closed`) } - res.redirect(`/clinics/${id}`) + res.redirect('/clinics/completed') }) // Single clinic view diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index d597232a..b81417f8 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -63,13 +63,18 @@

In progress

{# Checked in but not screened #} {% set checkedInAppointments = allAppointments | filterAppointmentsByStatus("checked-in") %} + {% set attendedNotScreenedAppointments = allAppointments | where("status", "attended_not_screened") %} - {% if checkedInAppointments | length %} + {% if checkedInAppointments | length or attendedNotScreenedAppointments | length %}

Checked in, not screened

These participants attended but their appointment did not take place.

+ {% if checkedInAppointments | length %} +

Mark all as attended not screened

+ {% endif %} + @@ -79,7 +84,8 @@

Checked in, not screened

- {% for appointment in checkedInAppointments %} + {% for appointment in allAppointments %} + {% if appointment.status == "checked_in" or appointment.status == "attended_not_screened" %} {% set participant = data.participants | findById(appointment.participantId) %} @@ -88,9 +94,20 @@

Checked in, not screened

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+ {% endif %} {% endfor %}
{{ appointment.statusHistory[0].timestamp | formatTimeString }} - Mark as attended not screened + {% if appointment.status == "attended_not_screened" %} + {{ appointment.status | toTag({ vocabulary: "appointment" }) }} +

+ View appointment +

+

+ Undo +

+ {% else %} + Mark as attended not screened + {% endif %}
@@ -100,13 +117,18 @@

Checked in, not screened

{# Did not check in #} {% set remainingAppointments = allAppointments | filterAppointmentsByStatus("remaining") %} + {% set didNotAttendAppointments = allAppointments | where("status", "did_not_attend") %} - {% if remainingAppointments | length %} + {% if remainingAppointments | length or didNotAttendAppointments | length %}

Did not check in

These participants did not arrive for their appointment.

+ {% if remainingAppointments | length %} +

Mark all as did not attend

+ {% endif %} + @@ -116,7 +138,8 @@

Did not check in

- {% for appointment in remainingAppointments %} + {% for appointment in allAppointments %} + {% if appointment.status == "scheduled" or appointment.status == "did_not_attend" %} {% set participant = data.participants | findById(appointment.participantId) %} @@ -125,9 +148,20 @@

Did not check in

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+ {% endif %} {% endfor %}
{{ appointment.statusHistory[0].timestamp | formatTimeString }} - Mark as did not attend + {% if appointment.status == "did_not_attend" %} + {{ appointment.status | toTag({ vocabulary: "appointment" }) }} +

+ View appointment +

+

+ Undo +

+ {% else %} + Mark as did not attend + {% endif %}
From fbf51721b2d199b2667ca30e5edce74c8ed57dc1 Mon Sep 17 00:00:00 2001 From: rivalee Date: Mon, 3 Aug 2026 15:39:00 +0100 Subject: [PATCH 03/19] Add success banners --- app/routes/clinics.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index ea2f84e7..f8cac97e 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -182,7 +182,10 @@ module.exports = (router) => { // Mark appointment as attended not screened from close clinic page router.get('/clinics/:id/close/attended-not-screened/:appointmentId', (req, res) => { const { id, appointmentId } = req.params + const appointment = getAppointment(req.session.data, appointmentId) + const participant = getParticipant(req.session.data, appointment.participantId) updateAppointmentStatus(req.session.data, appointmentId, 'attended_not_screened') + req.flash('success', `${getFullName(participant)} marked as attended not screened`) res.redirect(`/clinics/${id}/close`) }) @@ -196,7 +199,10 @@ module.exports = (router) => { // Mark appointment as did not attend from close clinic page router.get('/clinics/:id/close/did-not-attend/:appointmentId', (req, res) => { const { id, appointmentId } = req.params + const appointment = getAppointment(req.session.data, appointmentId) + const participant = getParticipant(req.session.data, appointment.participantId) updateAppointmentStatus(req.session.data, appointmentId, 'did_not_attend') + req.flash('success', `${getFullName(participant)} marked as did not attend`) res.redirect(`/clinics/${id}/close`) }) @@ -215,6 +221,7 @@ module.exports = (router) => { (a) => a.clinicId === id && a.status === 'checked_in' ) appointments.forEach((a) => updateAppointmentStatus(data, a.id, 'attended_not_screened')) + req.flash('success', `${appointments.length} participants marked as attended not screened`) res.redirect(`/clinics/${id}/close`) }) @@ -226,6 +233,7 @@ module.exports = (router) => { (a) => a.clinicId === id && a.status === 'scheduled' ) appointments.forEach((a) => updateAppointmentStatus(data, a.id, 'did_not_attend')) + req.flash('success', `${appointments.length} participants marked as did not attend`) res.redirect(`/clinics/${id}/close`) }) From b035adccb953e09be5dc053b727a3d3b6c6a44ee Mon Sep 17 00:00:00 2001 From: rivalee Date: Mon, 3 Aug 2026 16:05:25 +0100 Subject: [PATCH 04/19] Make content consistent --- app/views/clinics/close.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index b81417f8..c9a782e1 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -68,7 +68,7 @@

In progress

{% if checkedInAppointments | length or attendedNotScreenedAppointments | length %}
-

Checked in, not screened

+

Attended, not screened

These participants attended but their appointment did not take place.

{% if checkedInAppointments | length %} @@ -122,7 +122,7 @@

Checked in, not screened

{% if remainingAppointments | length or didNotAttendAppointments | length %}
-

Did not check in

+

Did not attend

These participants did not arrive for their appointment.

{% if remainingAppointments | length %} From 18bc49d2fd7f2acb99aa3440c1d7116b088ad7cb Mon Sep 17 00:00:00 2001 From: rivalee Date: Tue, 4 Aug 2026 15:10:01 +0100 Subject: [PATCH 05/19] restructure, add js to do inline magic --- app/assets/sass/components/_card.scss | 10 + app/assets/sass/components/_compact.scss | 19 +- app/routes/clinics.js | 65 ++++- app/views/clinics/close.html | 352 ++++++++++++++--------- 4 files changed, 290 insertions(+), 156 deletions(-) diff --git a/app/assets/sass/components/_card.scss b/app/assets/sass/components/_card.scss index 9837245c..3a39888b 100644 --- a/app/assets/sass/components/_card.scss +++ b/app/assets/sass/components/_card.scss @@ -8,3 +8,13 @@ background-color: nhsuk-colour("grey-1"); color: nhsuk-colour("white"); } + +.nhsuk-card--feature.app-card--feature-orange .nhsuk-card__heading { + background-color: nhsuk-colour("orange"); + color: nhsuk-colour("white"); +} + +.nhsuk-card--feature.app-card--feature-green .nhsuk-card__heading { + background-color: nhsuk-colour("green"); + color: nhsuk-colour("white"); +} diff --git a/app/assets/sass/components/_compact.scss b/app/assets/sass/components/_compact.scss index 9d13e658..4f3c3866 100644 --- a/app/assets/sass/components/_compact.scss +++ b/app/assets/sass/components/_compact.scss @@ -273,12 +273,6 @@ } .app-clinic-appointments-table { - table-layout: fixed; - width: 100%; - - .app-clinic-appointments-table__time-column { - width: 22%; - } .app-clinic-appointments-table__time-column .nhsuk-tag { display: inline-block; @@ -428,3 +422,16 @@ @include nhsuk-font($size: 22, $weight: bold); } } + +.app-clinic-appointments-table.nhsuk-u-margin-bottom-0 tbody tr:last-child td { + border-bottom: 0; +} + +.app-clinic-appointments-table { + table-layout: fixed; + width: 100%; + + .app-clinic-appointments-table__time-column { + width: 22%; + } +} diff --git a/app/routes/clinics.js b/app/routes/clinics.js index f8cac97e..3a003800 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -172,20 +172,33 @@ module.exports = (router) => { return res.redirect('/clinics') } + const resolvedKey = `closeClinicResolved_${req.params.id}` + const resolvedAppointmentIds = req.session[resolvedKey] || [] + + // Group by status so similar statuses appear together + const statusOrder = ["in_progress", "paused", "checked_in", "scheduled", "attended_not_screened", "did_not_attend", "complete", "partially_screened", "cancelled", "rescheduled"] + const sortedAppointments = [...clinicData.appointments].sort((a, b) => + statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status) + ) + res.render('clinics/close', { clinicId: req.params.id, clinic: clinicData.clinic, - allAppointments: clinicData.appointments + allAppointments: sortedAppointments, + resolvedAppointmentIds }) }) // Mark appointment as attended not screened from close clinic page router.get('/clinics/:id/close/attended-not-screened/:appointmentId', (req, res) => { const { id, appointmentId } = req.params - const appointment = getAppointment(req.session.data, appointmentId) - const participant = getParticipant(req.session.data, appointment.participantId) updateAppointmentStatus(req.session.data, appointmentId, 'attended_not_screened') - req.flash('success', `${getFullName(participant)} marked as attended not screened`) + const resolvedKey = `closeClinicResolved_${id}` + if (!req.session[resolvedKey]) req.session[resolvedKey] = [] + if (!req.session[resolvedKey].includes(appointmentId)) req.session[resolvedKey].push(appointmentId) + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success' }) + } res.redirect(`/clinics/${id}/close`) }) @@ -193,16 +206,24 @@ module.exports = (router) => { router.get('/clinics/:id/close/undo-attended-not-screened/:appointmentId', (req, res) => { const { id, appointmentId } = req.params updateAppointmentStatus(req.session.data, appointmentId, 'checked_in') + const resolvedKey = `closeClinicResolved_${id}` + if (req.session[resolvedKey]) req.session[resolvedKey] = req.session[resolvedKey].filter((i) => i !== appointmentId) + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success' }) + } res.redirect(`/clinics/${id}/close`) }) // Mark appointment as did not attend from close clinic page router.get('/clinics/:id/close/did-not-attend/:appointmentId', (req, res) => { const { id, appointmentId } = req.params - const appointment = getAppointment(req.session.data, appointmentId) - const participant = getParticipant(req.session.data, appointment.participantId) updateAppointmentStatus(req.session.data, appointmentId, 'did_not_attend') - req.flash('success', `${getFullName(participant)} marked as did not attend`) + const resolvedKey = `closeClinicResolved_${id}` + if (!req.session[resolvedKey]) req.session[resolvedKey] = [] + if (!req.session[resolvedKey].includes(appointmentId)) req.session[resolvedKey].push(appointmentId) + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success' }) + } res.redirect(`/clinics/${id}/close`) }) @@ -210,6 +231,11 @@ module.exports = (router) => { router.get('/clinics/:id/close/undo-did-not-attend/:appointmentId', (req, res) => { const { id, appointmentId } = req.params updateAppointmentStatus(req.session.data, appointmentId, 'scheduled') + const resolvedKey = `closeClinicResolved_${id}` + if (req.session[resolvedKey]) req.session[resolvedKey] = req.session[resolvedKey].filter((i) => i !== appointmentId) + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success' }) + } res.redirect(`/clinics/${id}/close`) }) @@ -220,8 +246,15 @@ module.exports = (router) => { const appointments = data.appointments.filter( (a) => a.clinicId === id && a.status === 'checked_in' ) - appointments.forEach((a) => updateAppointmentStatus(data, a.id, 'attended_not_screened')) - req.flash('success', `${appointments.length} participants marked as attended not screened`) + const resolvedKey = `closeClinicResolved_${id}` + if (!req.session[resolvedKey]) req.session[resolvedKey] = [] + appointments.forEach((a) => { + updateAppointmentStatus(data, a.id, 'attended_not_screened') + if (!req.session[resolvedKey].includes(a.id)) req.session[resolvedKey].push(a.id) + }) + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success', count: appointments.length }) + } res.redirect(`/clinics/${id}/close`) }) @@ -232,8 +265,15 @@ module.exports = (router) => { const appointments = data.appointments.filter( (a) => a.clinicId === id && a.status === 'scheduled' ) - appointments.forEach((a) => updateAppointmentStatus(data, a.id, 'did_not_attend')) - req.flash('success', `${appointments.length} participants marked as did not attend`) + const resolvedKey = `closeClinicResolved_${id}` + if (!req.session[resolvedKey]) req.session[resolvedKey] = [] + appointments.forEach((a) => { + updateAppointmentStatus(data, a.id, 'did_not_attend') + if (!req.session[resolvedKey].includes(a.id)) req.session[resolvedKey].push(a.id) + }) + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success', count: appointments.length }) + } res.redirect(`/clinics/${id}/close`) }) @@ -248,6 +288,9 @@ module.exports = (router) => { req.flash('success', `Clinic ${clinic.clinicCode} closed`) } + // Clean up resolved tracking + delete req.session[`closeClinicResolved_${id}`] + res.redirect('/clinics/completed') }) diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index c9a782e1..f1036c34 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -20,155 +20,133 @@

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

- {{ insetText({ - html: "

Record an outcome for every participant to close this clinic.

" - }) }} - - {# In progress appointments #} - {% set inProgressAppointments = allAppointments | filterAppointmentsByStatus("in-progress") %} - - {% if inProgressAppointments | length %} -
-
-

In progress

-

Complete or end this appointment to close the clinic.

- - - - - - - - - - - {% for appointment in inProgressAppointments %} - {% set participant = data.participants | findById(appointment.participantId) %} - - - - - - {% endfor %} - -
TimeDetailsActions
{{ appointment.statusHistory[0].timestamp | formatTimeString }} -

{{ participant | getFullName }}

-

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

-
- Go to appointment -
-
-
- {% endif %} + {# Needs an outcome: active appointments plus those just resolved on this page #} + {% set needsOutcomeAppointments = allAppointments | removeWhere("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} + {% set newlyResolvedIds = resolvedAppointmentIds or [] %} - {# Checked in but not screened #} - {% set checkedInAppointments = allAppointments | filterAppointmentsByStatus("checked-in") %} - {% set attendedNotScreenedAppointments = allAppointments | where("status", "attended_not_screened") %} +
- {% if checkedInAppointments | length or attendedNotScreenedAppointments | length %} -
-
-

Attended, not screened

-

These participants attended but their appointment did not take place.

+ {{ insetText({ + html: "

Record an appointment outcome for every participant to close this clinic.

There were " + allAppointments | length + " total participants in this clinic, and " + needsOutcomeAppointments | length + " still need a final outcome assigned.

" + }) }} - {% if checkedInAppointments | length %} -

Mark all as attended not screened

+ {# Macro for appointment table rows #} + {% macro appointmentRow(appointment, clinicId, showActions) %} + {% set participant = data.participants | findById(appointment.participantId) %} + + {{ appointment.statusHistory[0].timestamp | formatTimeString }} + +

+ {{ participant | getFullName }} +

+

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+ + {{ appointment.status | toTag({ vocabulary: "appointment" }) }} + + {% if showActions %} + {% if appointment.status == "in_progress" or appointment.status == "paused" %} + Go to appointment + {% elseif appointment.status == "checked_in" %} + Mark as attended not screened + {% elseif appointment.status == "scheduled" %} + Mark as did not attend + {% elseif appointment.status == "attended_not_screened" %} + Undo + {% elseif appointment.status == "did_not_attend" %} + Undo {% endif %} - - - - - - - - - - - {% for appointment in allAppointments %} - {% if appointment.status == "checked_in" or appointment.status == "attended_not_screened" %} - {% set participant = data.participants | findById(appointment.participantId) %} - - - - - - {% endif %} - {% endfor %} - -
TimeDetailsActions
{{ appointment.statusHistory[0].timestamp | formatTimeString }} -

{{ participant | getFullName }}

-

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

-
- {% if appointment.status == "attended_not_screened" %} - {{ appointment.status | toTag({ vocabulary: "appointment" }) }} -

- View appointment -

-

- Undo -

- {% else %} - Mark as attended not screened - {% endif %} -
-
-
+ {% endif %} + + + {% endmacro %} + + {# Macro for a status group table #} + {% macro statusGroupTable(appointments, clinicId, showActions) %} + + + + + + + + + + + {% for appointment in appointments %} + {{ appointmentRow(appointment, clinicId, showActions) }} + {% endfor %} + +
TimeDetailsStatus{{ "Actions" if showActions }}
+ {% endmacro %} + + {% if needsOutcomeAppointments | length or newlyResolvedIds | length %} + {% set needsOutcomeHtml %} + {# In progress #} + {% set inProgressAppointments = allAppointments | where("status", ["in_progress", "paused"]) %} + {% if inProgressAppointments | length %} +

In progress

+

Complete or end these appointments to close the clinic.

+ {{ statusGroupTable(inProgressAppointments, clinicId, true) }} + {% endif %} + + {# Checked in, not screened #} + {% set checkedInAppointments = allAppointments | where("status", "checked_in") %} + {% set newlyMarkedAns = allAppointments | where("status", "attended_not_screened") | where("id", newlyResolvedIds) %} + {% if checkedInAppointments | length or newlyMarkedAns | length %} +

Checked in, not screened

+

Mark all as attended not screened

+ {% set checkedInGroup = [] %} + {% for appointment in allAppointments %} + {% if appointment.status == "checked_in" or (appointment.status == "attended_not_screened" and appointment.id in newlyResolvedIds) %} + {% set checkedInGroup = checkedInGroup.concat(appointment) %} + {% endif %} + {% endfor %} + {{ statusGroupTable(checkedInGroup, clinicId, true) }} + {% endif %} + + {# Did not check in #} + {% set scheduledAppointments = allAppointments | where("status", "scheduled") %} + {% set newlyMarkedDna = allAppointments | where("status", "did_not_attend") | where("id", newlyResolvedIds) %} + {% if scheduledAppointments | length or newlyMarkedDna | length %} +

Did not check in

+

Mark all as did not attend

+ {% set didNotCheckInGroup = [] %} + {% for appointment in allAppointments %} + {% if appointment.status == "scheduled" or (appointment.status == "did_not_attend" and appointment.id in newlyResolvedIds) %} + {% set didNotCheckInGroup = didNotCheckInGroup.concat(appointment) %} + {% endif %} + {% endfor %} + {{ statusGroupTable(didNotCheckInGroup, clinicId, true) }} + {% endif %} + {% endset %} + + {{ card({ + heading: "Needs an outcome", + headingLevel: "2", + feature: true, + descriptionHtml: needsOutcomeHtml + }) }} {% endif %} - {# Did not check in #} - {% set remainingAppointments = allAppointments | filterAppointmentsByStatus("remaining") %} - {% set didNotAttendAppointments = allAppointments | where("status", "did_not_attend") %} - - {% if remainingAppointments | length or didNotAttendAppointments | length %} -
-
-

Did not attend

-

These participants did not arrive for their appointment.

+ {# Outcome recorded: appointments that already had a final status before visiting this page #} + {% set outcomeRecordedAppointments = allAppointments | where("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) | removeWhere("id", newlyResolvedIds) %} - {% if remainingAppointments | length %} -

Mark all as did not attend

- {% endif %} + {% if outcomeRecordedAppointments | length %} + {% set outcomeRecordedHtml %} + {{ statusGroupTable(outcomeRecordedAppointments, clinicId, false) }} + {% endset %} - - - - - - - - - - {% for appointment in allAppointments %} - {% if appointment.status == "scheduled" or appointment.status == "did_not_attend" %} - {% set participant = data.participants | findById(appointment.participantId) %} - - - - - - {% endif %} - {% endfor %} - -
TimeDetailsActions
{{ appointment.statusHistory[0].timestamp | formatTimeString }} -

{{ participant | getFullName }}

-

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

-
- {% if appointment.status == "did_not_attend" %} - {{ appointment.status | toTag({ vocabulary: "appointment" }) }} -

- View appointment -

-

- Undo -

- {% else %} - Mark as did not attend - {% endif %} -
-
-
+ {{ card({ + heading: "Outcome recorded", + headingLevel: "2", + feature: true, + classes: "app-card--feature-green", + descriptionHtml: outcomeRecordedHtml + }) }} {% endif %} +
+
{{ button({ text: "Confirm and close clinic" @@ -176,3 +154,99 @@

Did not attend

{% endblock %} + +{% block pageScripts %} + +{% endblock %} From 3a2436b8dde7420486c52405bf0e1a92cf820641 Mon Sep 17 00:00:00 2001 From: rivalee Date: Tue, 4 Aug 2026 15:26:22 +0100 Subject: [PATCH 06/19] various clinic list improvements inc showing the name of the clinic in the list of clinics --- app/routes/clinics.js | 8 ++++++++ app/views/clinics/close.html | 2 +- app/views/clinics/index.html | 24 +++++++++++++++--------- app/views/clinics/show.html | 15 ++++++--------- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index 3a003800..7261d5de 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -107,8 +107,12 @@ module.exports = (router) => { filter, clinics: clinicsWithData, filteredClinics, + justClosedClinicId: req.session.justClosedClinicId || null, formatDate: (date) => dayjs(date).format('D MMMM YYYY') }) + + // Clear after rendering so it only applies once + delete req.session.justClosedClinicId }) // Handle check-in @@ -285,12 +289,16 @@ module.exports = (router) => { if (clinic) { clinic.status = 'closed' + clinic.closedAt = new Date().toISOString() req.flash('success', `Clinic ${clinic.clinicCode} closed`) } // Clean up resolved tracking delete req.session[`closeClinicResolved_${id}`] + // Track just-closed clinic so it shows at top of list + req.session.justClosedClinicId = id + res.redirect('/clinics/completed') }) diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index f1036c34..50628acd 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -1,7 +1,7 @@ {# app/views/clinics/close.html #} {% extends 'layout-app.html' %} -{% set pageHeading = "Close clinic - " ~ clinic.clinicCode %} +{% set pageHeading = "Close clinic " ~ clinic.clinicCode %} {% set gridColumn = "nhsuk-grid-column-full" %} {% set back = { diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html index 7422368c..ed317034 100644 --- a/app/views/clinics/index.html +++ b/app/views/clinics/index.html @@ -54,7 +54,7 @@

{{pageHeading}}

- + @@ -62,7 +62,13 @@

{{pageHeading}}

- {% for clinic in filteredClinics | sort(false, false, 'date') %} + {% set sortedClinics = filteredClinics | sort(false, false, 'date') %} + {% if justClosedClinicId %} + {% set justClosed = sortedClinics | where("id", justClosedClinicId) %} + {% set rest = sortedClinics | removeWhere("id", justClosedClinicId) %} + {% set sortedClinics = justClosed.concat(rest) %} + {% endif %} + {% for clinic in sortedClinics %} {# {{ clinic | log }} #} {% set unit = clinic.unit %} {% set location = clinic.location %} @@ -70,14 +76,14 @@

{{pageHeading}}

- {% set sortedClinics = filteredClinics | sort(false, false, 'date') %} - {% if justClosedClinicId %} - {% set justClosed = sortedClinics | where("id", justClosedClinicId) %} - {% set rest = sortedClinics | removeWhere("id", justClosedClinicId) %} - {% set sortedClinics = justClosed.concat(rest) %} - {% endif %} - {% for clinic in sortedClinics %} + {% for clinic in filteredClinics | sort(false, false, 'date') %} {# {{ clinic | log }} #} {% set unit = clinic.unit %} {% set location = clinic.location %} From 37fd8d04a9d77efb770042b550fad1ba44fd57aa Mon Sep 17 00:00:00 2001 From: rivalee Date: Tue, 4 Aug 2026 15:50:37 +0100 Subject: [PATCH 08/19] change content to closed --- app/views/clinics/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html index ffe97ffd..d5d115ab 100644 --- a/app/views/clinics/index.html +++ b/app/views/clinics/index.html @@ -7,7 +7,7 @@ {% case 'upcoming' %} Upcoming clinics {% case 'completed' %} - Completed clinics + Closed clinics {% default %} All clinics {% endswitch %} @@ -28,7 +28,7 @@

{{pageHeading}}

{% for item in [ { id: 'today', label: 'Today' }, { id: 'upcoming', label: 'Upcoming' }, - { id: 'completed', label: 'Completed' }, + { id: 'completed', label: 'Closed' }, { id: 'all', label: 'All' } ] %} {% set href -%} From 6d3e465c909b3aacba07e5c4ad47e44bcd3aa8a1 Mon Sep 17 00:00:00 2001 From: rivalee Date: Wed, 5 Aug 2026 13:05:11 +0100 Subject: [PATCH 09/19] Replace mark all links with sec buttons --- app/routes/clinics.js | 4 +--- app/views/clinics/close.html | 43 +++++++++++++----------------------- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index a5afc7b5..88c4a731 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -173,7 +173,6 @@ module.exports = (router) => { } const resolvedKey = `closeClinicResolved_${req.params.id}` - const resolvedAppointmentIds = req.session[resolvedKey] || [] // Group by status so similar statuses appear together const statusOrder = ["in_progress", "paused", "checked_in", "scheduled", "attended_not_screened", "did_not_attend", "complete", "partially_screened", "cancelled", "rescheduled"] @@ -184,8 +183,7 @@ module.exports = (router) => { res.render('clinics/close', { clinicId: req.params.id, clinic: clinicData.clinic, - allAppointments: sortedAppointments, - resolvedAppointmentIds + allAppointments: sortedAppointments }) }) diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index 50628acd..f3bffaca 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -20,9 +20,7 @@

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

- {# Needs an outcome: active appointments plus those just resolved on this page #} {% set needsOutcomeAppointments = allAppointments | removeWhere("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} - {% set newlyResolvedIds = resolvedAppointmentIds or [] %}
@@ -36,10 +34,9 @@

LocationClinic name and location Date and time Clinic type Participants
- {% if location.type === 'mobile_unit' %} - {{ location.name }} at {{ clinic.siteName }} - {% else %} - {{ location.name }} - {% endif %} -
- ({{ clinic.sessionType | sentenceCase }}) + {{ clinic.clinicCode }} ({{ clinic.sessionType | lower }})
+
+ {% if location.type === 'mobile_unit' %} + {{ (location.name + " at " + clinic.siteName) | asHint }} + {% else %} + {{ location.name | asHint }} + {% endif %}
{{ clinic.date | formatDate | noWrap }}
{{clinic.sessionTimes | formatTimeRange | asHint }} diff --git a/app/views/clinics/show.html b/app/views/clinics/show.html index a66b081a..a4d2cb82 100644 --- a/app/views/clinics/show.html +++ b/app/views/clinics/show.html @@ -34,9 +34,14 @@

{{ clinic.status | toTag({ vocabulary: "clinic" }) }} -

+

View clinic report

+ {% if clinic.status !== "closed" and clinic.status !== "scheduled" %} +

+ Close clinic {{ clinic.clinicCode }} +

+ {% endif %}
@@ -50,14 +55,6 @@

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

-{% if clinic.status !== "closed" and clinic.status !== "scheduled" %} - {{ button({ - text: "Close clinic", - href: "/clinics/" + clinicId + "/close", - classes: "nhsuk-button--secondary" - }) }} -{% endif %} - {% set secondaryNavItems = [] %} {% set tabItems = [ From 6385d869833946723fc4a2140d5a7702cc3df3fb Mon Sep 17 00:00:00 2001 From: rivalee Date: Tue, 4 Aug 2026 15:49:17 +0100 Subject: [PATCH 07/19] when a clinic is closed, move it to the right list --- app/lib/utils/clinics.js | 7 +++++-- app/routes/clinics.js | 20 ++++++++------------ app/views/clinics/index.html | 8 +------- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/app/lib/utils/clinics.js b/app/lib/utils/clinics.js index 9c97d9c4..b6407298 100644 --- a/app/lib/utils/clinics.js +++ b/app/lib/utils/clinics.js @@ -114,7 +114,7 @@ const getFilteredClinics = (clinics, filter = 'all') => { switch (filter) { case 'today': return recentClinics.filter((clinic) => - dayjs(clinic.date).isSame(today, 'day') + dayjs(clinic.date).isSame(today, 'day') && clinic.status !== 'closed' ) case 'upcoming': @@ -124,7 +124,10 @@ const getFilteredClinics = (clinics, filter = 'all') => { case 'completed': return recentClinics - .filter((clinic) => dayjs(clinic.date).isBefore(today, 'day')) + .filter((clinic) => + dayjs(clinic.date).isBefore(today, 'day') || + (dayjs(clinic.date).isSame(today, 'day') && clinic.status === 'closed') + ) .sort((a, b) => new Date(b.date) - new Date(a.date)) // Most recent first case 'all': diff --git a/app/routes/clinics.js b/app/routes/clinics.js index 7261d5de..a5afc7b5 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -107,12 +107,8 @@ module.exports = (router) => { filter, clinics: clinicsWithData, filteredClinics, - justClosedClinicId: req.session.justClosedClinicId || null, formatDate: (date) => dayjs(date).format('D MMMM YYYY') }) - - // Clear after rendering so it only applies once - delete req.session.justClosedClinicId }) // Handle check-in @@ -285,20 +281,20 @@ module.exports = (router) => { router.post('/clinics/:id/close', (req, res) => { const { id } = req.params const data = req.session.data - const clinic = data.clinics.find((c) => c.id === id) + const clinicIndex = data.clinics.findIndex((c) => c.id === id) - if (clinic) { - clinic.status = 'closed' - clinic.closedAt = new Date().toISOString() - req.flash('success', `Clinic ${clinic.clinicCode} closed`) + if (clinicIndex !== -1) { + const updatedClinic = { ...data.clinics[clinicIndex], status: 'closed' } + data.clinics[clinicIndex] = updatedClinic + if (data._changes?.clinics) { + data._changes.clinics[id] = updatedClinic + } + req.flash('success', `Clinic ${updatedClinic.clinicCode} closed`) } // Clean up resolved tracking delete req.session[`closeClinicResolved_${id}`] - // Track just-closed clinic so it shows at top of list - req.session.justClosedClinicId = id - res.redirect('/clinics/completed') }) diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html index ed317034..ffe97ffd 100644 --- a/app/views/clinics/index.html +++ b/app/views/clinics/index.html @@ -62,13 +62,7 @@

{{pageHeading}}

{{ appointment.statusHistory[0].timestamp | formatTimeString }} -

- {{ participant | getFullName }} -

-

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+

{{ participant | getFullName }}

+

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+

View appointment

{{ appointment.status | toTag({ vocabulary: "appointment" }) }} @@ -79,7 +76,7 @@

{% endmacro %} - {% if needsOutcomeAppointments | length or newlyResolvedIds | length %} + {% if needsOutcomeAppointments | length %} {% set needsOutcomeHtml %} {# In progress #} {% set inProgressAppointments = allAppointments | where("status", ["in_progress", "paused"]) %} @@ -91,32 +88,22 @@

In progress

{# Checked in, not screened #} {% set checkedInAppointments = allAppointments | where("status", "checked_in") %} - {% set newlyMarkedAns = allAppointments | where("status", "attended_not_screened") | where("id", newlyResolvedIds) %} - {% if checkedInAppointments | length or newlyMarkedAns | length %} + {% if checkedInAppointments | length %}

Checked in, not screened

-

Mark all as attended not screened

- {% set checkedInGroup = [] %} - {% for appointment in allAppointments %} - {% if appointment.status == "checked_in" or (appointment.status == "attended_not_screened" and appointment.id in newlyResolvedIds) %} - {% set checkedInGroup = checkedInGroup.concat(appointment) %} - {% endif %} - {% endfor %} - {{ statusGroupTable(checkedInGroup, clinicId, true) }} +

+ Mark all as attended not screened +

+ {{ statusGroupTable(checkedInAppointments, clinicId, true) }} {% endif %} {# Did not check in #} {% set scheduledAppointments = allAppointments | where("status", "scheduled") %} - {% set newlyMarkedDna = allAppointments | where("status", "did_not_attend") | where("id", newlyResolvedIds) %} - {% if scheduledAppointments | length or newlyMarkedDna | length %} + {% if scheduledAppointments | length %}

Did not check in

-

Mark all as did not attend

- {% set didNotCheckInGroup = [] %} - {% for appointment in allAppointments %} - {% if appointment.status == "scheduled" or (appointment.status == "did_not_attend" and appointment.id in newlyResolvedIds) %} - {% set didNotCheckInGroup = didNotCheckInGroup.concat(appointment) %} - {% endif %} - {% endfor %} - {{ statusGroupTable(didNotCheckInGroup, clinicId, true) }} +

+ Mark all as did not attend +

+ {{ statusGroupTable(scheduledAppointments, clinicId, true) }} {% endif %} {% endset %} @@ -129,7 +116,7 @@

Did not check in

{% endif %} {# Outcome recorded: appointments that already had a final status before visiting this page #} - {% set outcomeRecordedAppointments = allAppointments | where("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) | removeWhere("id", newlyResolvedIds) %} + {% set outcomeRecordedAppointments = allAppointments | where("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} {% if outcomeRecordedAppointments | length %} {% set outcomeRecordedHtml %} From a13f635ca46263e415ef00c29d8616ffed2559ea Mon Sep 17 00:00:00 2001 From: rivalee Date: Wed, 5 Aug 2026 13:41:38 +0100 Subject: [PATCH 10/19] little bits --- app/routes/clinics.js | 46 +++++++++++++++++++ app/views/clinics/close.html | 86 ++++++++++++++++++++++++++++-------- 2 files changed, 113 insertions(+), 19 deletions(-) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index 88c4a731..9f63c4a8 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -256,6 +256,29 @@ module.exports = (router) => { res.redirect(`/clinics/${id}/close`) }) + // Bulk undo attended not screened (revert to checked_in) + router.get('/clinics/:id/close/undo-attended-not-screened-all', (req, res) => { + const { id } = req.params + const data = req.session.data + const resolvedKey = `closeClinicResolved_${id}` + const resolvedIds = req.session[resolvedKey] || [] + const appointments = data.appointments.filter( + (a) => a.clinicId === id && a.status === 'attended_not_screened' && resolvedIds.includes(a.id) + ) + appointments.forEach((a) => { + updateAppointmentStatus(data, a.id, 'checked_in') + }) + if (req.session[resolvedKey]) { + req.session[resolvedKey] = req.session[resolvedKey].filter( + (i) => !appointments.find((a) => a.id === i) + ) + } + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success', count: appointments.length }) + } + res.redirect(`/clinics/${id}/close`) + }) + // Bulk mark all remaining as did not attend router.get('/clinics/:id/close/did-not-attend-all', (req, res) => { const { id } = req.params @@ -275,6 +298,29 @@ module.exports = (router) => { res.redirect(`/clinics/${id}/close`) }) + // Bulk undo did not attend (revert to scheduled) + router.get('/clinics/:id/close/undo-did-not-attend-all', (req, res) => { + const { id } = req.params + const data = req.session.data + const resolvedKey = `closeClinicResolved_${id}` + const resolvedIds = req.session[resolvedKey] || [] + const appointments = data.appointments.filter( + (a) => a.clinicId === id && a.status === 'did_not_attend' && resolvedIds.includes(a.id) + ) + appointments.forEach((a) => { + updateAppointmentStatus(data, a.id, 'scheduled') + }) + if (req.session[resolvedKey]) { + req.session[resolvedKey] = req.session[resolvedKey].filter( + (i) => !appointments.find((a) => a.id === i) + ) + } + if (req.headers.accept?.includes('application/json')) { + return res.json({ status: 'success', count: appointments.length }) + } + res.redirect(`/clinics/${id}/close`) + }) + // Confirm and close clinic router.post('/clinics/:id/close', (req, res) => { const { id } = req.params diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index f3bffaca..18c4149b 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -90,8 +90,8 @@

In progress

{% set checkedInAppointments = allAppointments | where("status", "checked_in") %} {% if checkedInAppointments | length %}

Checked in, not screened

-

- Mark all as attended not screened +

+ Mark all as attended not screened

{{ statusGroupTable(checkedInAppointments, clinicId, true) }} {% endif %} @@ -100,37 +100,38 @@

Checked in, not screened

{% set scheduledAppointments = allAppointments | where("status", "scheduled") %} {% if scheduledAppointments | length %}

Did not check in

-

- Mark all as did not attend +

+ Mark all as did not attend

{{ statusGroupTable(scheduledAppointments, clinicId, true) }} {% endif %} {% endset %} {{ card({ - heading: "Needs an outcome", + heading: "Needs an outcome (" + needsOutcomeAppointments | length + ")", headingLevel: "2", feature: true, descriptionHtml: needsOutcomeHtml }) }} {% endif %} - {# Outcome recorded: appointments that already had a final status before visiting this page #} + {# Outcome recorded #} {% set outcomeRecordedAppointments = allAppointments | where("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} - {% if outcomeRecordedAppointments | length %} - {% set outcomeRecordedHtml %} + {% set outcomeRecordedHtml %} +

+ {% if outcomeRecordedAppointments | length %} {{ statusGroupTable(outcomeRecordedAppointments, clinicId, false) }} - {% endset %} - - {{ card({ - heading: "Outcome recorded", - headingLevel: "2", - feature: true, - classes: "app-card--feature-green", - descriptionHtml: outcomeRecordedHtml - }) }} - {% endif %} + {% endif %} + {% endset %} + + {{ card({ + heading: "Outcome recorded (" + outcomeRecordedAppointments | length + ")", + headingLevel: "2", + feature: true, + classes: "app-card--feature-green", + descriptionHtml: outcomeRecordedHtml + }) }}

@@ -180,6 +181,11 @@

Did not check in

return '' } + function showRefreshLink() { + var link = container.querySelector('.js-refresh-link') + if (link) link.hidden = false + } + function updateRow(row, newStatus) { var statusCell = row.querySelector('[data-cell="status"]') var actionsCell = row.querySelector('[data-cell="actions"]') @@ -192,6 +198,27 @@

Did not check in

} } + function replaceBulkButtonWithUndo(bulkLink, undoUrl, count, statusLabel) { + var containerEl = bulkLink.closest('.js-bulk-action-container') + if (!containerEl) return + var originalUrl = bulkLink.href + var originalLabel = bulkLink.textContent + var sourceStatus = bulkLink.getAttribute('data-source-status') + var newStatus = bulkLink.getAttribute('data-new-status') + containerEl.innerHTML = count + ' participants marked as ' + statusLabel + ' (Undo)' + bindActions() + } + + function replaceBulkUndoWithButton(undoLink, originalUrl, originalLabel) { + var containerEl = undoLink.closest('.js-bulk-action-container') + if (!containerEl) return + var sourceStatus = undoLink.getAttribute('data-new-status') + var newStatus = undoLink.getAttribute('data-source-status') + var undoUrl = undoLink.href + containerEl.innerHTML = '' + originalLabel + '' + bindActions() + } + function handleClick(e) { e.preventDefault() e.stopPropagation() @@ -200,23 +227,44 @@

Did not check in

var url = link.href var newStatus = link.getAttribute('data-new-status') var isBulk = link.getAttribute('data-bulk') === 'true' + var isBulkUndo = link.getAttribute('data-bulk-undo') === 'true' fetch(url, { headers: { 'Accept': 'application/json' } }) .then(function (response) { if (!response.ok) throw new Error('Request failed') return response.json() }) - .then(function () { + .then(function (data) { if (isBulk) { + var sourceStatus = link.getAttribute('data-source-status') + var count = 0 container.querySelectorAll('tr[data-appointment-id]').forEach(function (row) { var actionLink = row.querySelector('[data-cell="actions"] .js-close-clinic-action') if (actionLink && actionLink.getAttribute('data-new-status') === newStatus) { updateRow(row, newStatus) + count++ + } + }) + var undoUrl = link.getAttribute('data-undo-url') + var statusLabel = statusTags[newStatus] ? statusTags[newStatus].text.toLowerCase() : newStatus + replaceBulkButtonWithUndo(link, undoUrl, data.count || count, statusLabel) + showRefreshLink() + } else if (isBulkUndo) { + var revertStatus = link.getAttribute('data-new-status') + var sourceStatusUndo = link.getAttribute('data-source-status') + container.querySelectorAll('tr[data-appointment-id]').forEach(function (row) { + var actionLink = row.querySelector('[data-cell="actions"] .js-close-clinic-action') + if (actionLink && actionLink.getAttribute('data-new-status') === revertStatus) { + updateRow(row, revertStatus) } }) + var originalUrl = link.getAttribute('data-undo-url') + var originalLabel = link.getAttribute('data-undo-label') + replaceBulkUndoWithButton(link, originalUrl, originalLabel) } else { var row = link.closest('tr') if (row) updateRow(row, newStatus) + showRefreshLink() } }) .catch(function (error) { From ecd002e9d816204dc7ba7f6f18017850e8caf5b5 Mon Sep 17 00:00:00 2001 From: rivalee Date: Wed, 5 Aug 2026 15:29:06 +0100 Subject: [PATCH 11/19] Add validation --- app/routes/clinics.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index 9f63c4a8..966fc807 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -325,6 +325,19 @@ module.exports = (router) => { router.post('/clinics/:id/close', (req, res) => { const { id } = req.params const data = req.session.data + + // Check all appointments have a final outcome + const finalStatuses = ['complete', 'partially_screened', 'did_not_attend', 'attended_not_screened', 'cancelled', 'rescheduled'] + const clinicAppointments = data.appointments.filter((a) => a.clinicId === id) + const unresolved = clinicAppointments.filter((a) => !finalStatuses.includes(a.status)) + + if (unresolved.length > 0) { + req.flash('error', [{ + text: `${unresolved.length} participant${unresolved.length === 1 ? '' : 's'} still need${unresolved.length === 1 ? 's' : ''} an outcome recorded before the clinic can be closed` + }]) + return res.redirect(`/clinics/${id}/close`) + } + const clinicIndex = data.clinics.findIndex((c) => c.id === id) if (clinicIndex !== -1) { From 83da4ce21d1cff664f81da8c69746ffedccbb01b Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 6 Aug 2026 15:15:08 +0100 Subject: [PATCH 12/19] switch in small secondary buttons --- app/views/clinics/close.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index 18c4149b..8509cc8a 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -91,7 +91,7 @@

In progress

{% if checkedInAppointments | length %}

Checked in, not screened

- Mark all as attended not screened + Mark all as attended not screened

{{ statusGroupTable(checkedInAppointments, clinicId, true) }} {% endif %} @@ -101,7 +101,7 @@

Checked in, not screened

{% if scheduledAppointments | length %}

Did not check in

- Mark all as did not attend + Mark all as did not attend

{{ statusGroupTable(scheduledAppointments, clinicId, true) }} {% endif %} From 584d8705d3a04270946f964797799623d9b37ed7 Mon Sep 17 00:00:00 2001 From: rivalee Date: Tue, 11 Aug 2026 11:21:16 +0100 Subject: [PATCH 13/19] Add ANS reasons and rescheduling loop --- app/routes/clinics.js | 211 +++++++++++++++++ .../close-attended-not-screened-reason.html | 212 ++++++++++++++++++ app/views/clinics/close-reschedule.html | 67 ++++++ 3 files changed, 490 insertions(+) create mode 100644 app/views/clinics/close-attended-not-screened-reason.html create mode 100644 app/views/clinics/close-reschedule.html diff --git a/app/routes/clinics.js b/app/routes/clinics.js index 966fc807..c9295c79 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -321,6 +321,206 @@ module.exports = (router) => { res.redirect(`/clinics/${id}/close`) }) + // Sequential reason collection for attended-not-screened appointments + router.get('/clinics/:id/close/reason/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + const data = req.session.data + const queueKey = `closeClinicReasonQueue_${id}` + const queue = req.session[queueKey] || [] + + const appointment = data.appointments.find((a) => a.id === appointmentId) + if (!appointment) { + return res.redirect(`/clinics/${id}/close`) + } + + const participant = getParticipant(data, appointment.participantId) + const clinic = getClinic(data, id) + const currentIndex = queue.indexOf(appointmentId) + + // Ensure form data object exists for template access + if (!data.closeReasonForm) { + data.closeReasonForm = {} + } + + res.render('clinics/close-attended-not-screened-reason', { + clinicId: id, + clinic, + appointment, + participant, + currentIndex: currentIndex + 1, + totalCount: queue.length + }) + }) + + router.post('/clinics/:id/close/reason/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + const data = req.session.data + const queueKey = `closeClinicReasonQueue_${id}` + const queue = req.session[queueKey] || [] + + const appointment = data.appointments.find((a) => a.id === appointmentId) + if (!appointment) { + return res.redirect(`/clinics/${id}/close`) + } + + // Extract form values + const formData = data.closeReasonForm || {} + const stoppedReason = formData.stoppedReason + const needsReschedule = formData.needsReschedule + const otherDetails = formData.otherDetails + const hasOtherReasonButNoDetails = + stoppedReason?.includes('Other reason') && !otherDetails + + // Validation + if (!stoppedReason || !needsReschedule || hasOtherReasonButNoDetails) { + if (!stoppedReason) { + req.flash('error', { + text: 'Select why this appointment has been stopped', + name: 'closeReasonForm[stoppedReason]', + href: '#stoppedReason' + }) + } + if (hasOtherReasonButNoDetails) { + req.flash('error', { + text: 'Provide details about the other reason', + name: 'closeReasonForm[otherDetails]', + href: '#otherDetails' + }) + } + if (!needsReschedule) { + req.flash('error', { + text: 'Select whether the appointment should be rescheduled', + name: 'closeReasonForm[needsReschedule]', + href: '#needsReschedule' + }) + } + return res.redirect(`/clinics/${id}/close/reason/${appointmentId}`) + } + + // Save the reason data to the appointment + appointment.appointmentStopped = { + stoppedReason, + needsReschedule, + otherDetails: formData.otherDetails, + failedIdentityDetails: formData.failedIdentityDetails, + painDetails: formData.painDetails, + symptomaticDetails: formData.symptomaticDetails, + consentDetails: formData.consentDetails, + physicalHealthDetails: formData.physicalHealthDetails, + mentalHealthDetails: formData.mentalHealthDetails, + languageDetails: formData.languageDetails, + mammographerDetails: formData.mammographerDetails, + technicalDetails: formData.technicalDetails, + optOutDetails: formData.optOutDetails + } + + // Clear form data + delete data.closeReasonForm + + // If reschedule requested, go to reschedule step before advancing + if (needsReschedule === 'yes') { + return res.redirect(`/clinics/${id}/close/reschedule/${appointmentId}`) + } + + // Advance to next in queue or close the clinic + advanceCloseQueue(req, res, id, appointmentId) + }) + + // Reschedule step within the close loop + router.get('/clinics/:id/close/reschedule/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + const data = req.session.data + const queueKey = `closeClinicReasonQueue_${id}` + const queue = req.session[queueKey] || [] + + const appointment = data.appointments.find((a) => a.id === appointmentId) + if (!appointment) { + return res.redirect(`/clinics/${id}/close`) + } + + const participant = getParticipant(data, appointment.participantId) + const clinic = getClinic(data, id) + const currentIndex = queue.indexOf(appointmentId) + + // Ensure form data object exists for template access + if (!data.closeRescheduleForm) { + data.closeRescheduleForm = {} + } + + res.render('clinics/close-reschedule', { + clinicId: id, + clinic, + appointment, + participant, + currentIndex: currentIndex + 1, + totalCount: queue.length + }) + }) + + router.post('/clinics/:id/close/reschedule/:appointmentId', (req, res) => { + const { id, appointmentId } = req.params + const data = req.session.data + + const appointment = data.appointments.find((a) => a.id === appointmentId) + if (!appointment) { + return res.redirect(`/clinics/${id}/close`) + } + + const formData = data.closeRescheduleForm || {} + const timing = formData.timing + + if (!timing) { + req.flash('error', { + text: 'Select when the appointment should be rescheduled', + name: 'closeRescheduleForm[timing]', + href: '#timing' + }) + return res.redirect(`/clinics/${id}/close/reschedule/${appointmentId}`) + } + + // Save reschedule data to the appointment + appointment.reschedule = { + timing, + note: formData.note + } + updateAppointmentStatus(data, appointmentId, 'rescheduled') + + // Clear form data + delete data.closeRescheduleForm + + // Advance to next in queue or close the clinic + advanceCloseQueue(req, res, id, appointmentId) + }) + + // Shared helper: advance to the next appointment in queue or close the clinic + const advanceCloseQueue = (req, res, clinicId, currentAppointmentId) => { + const data = req.session.data + const queueKey = `closeClinicReasonQueue_${clinicId}` + const queue = req.session[queueKey] || [] + const currentIndex = queue.indexOf(currentAppointmentId) + const nextIndex = currentIndex + 1 + + if (nextIndex < queue.length) { + return res.redirect(`/clinics/${clinicId}/close/reason/${queue[nextIndex]}`) + } + + // Queue complete — close the clinic + delete req.session[queueKey] + + const clinicIndex = data.clinics.findIndex((c) => c.id === clinicId) + if (clinicIndex !== -1) { + const updatedClinic = { ...data.clinics[clinicIndex], status: 'closed' } + data.clinics[clinicIndex] = updatedClinic + if (data._changes?.clinics) { + data._changes.clinics[clinicId] = updatedClinic + } + req.flash('success', `Clinic ${updatedClinic.clinicCode} closed`) + } + + delete req.session[`closeClinicResolved_${clinicId}`] + res.redirect('/clinics/completed') + } + // Confirm and close clinic router.post('/clinics/:id/close', (req, res) => { const { id } = req.params @@ -338,6 +538,17 @@ module.exports = (router) => { return res.redirect(`/clinics/${id}/close`) } + // Check for attended-not-screened appointments missing a reason + const needsReason = clinicAppointments.filter( + (a) => a.status === 'attended_not_screened' && !a.appointmentStopped?.stoppedReason?.length + ) + + if (needsReason.length > 0) { + const queueKey = `closeClinicReasonQueue_${id}` + req.session[queueKey] = needsReason.map((a) => a.id) + return res.redirect(`/clinics/${id}/close/reason/${needsReason[0].id}`) + } + const clinicIndex = data.clinics.findIndex((c) => c.id === id) if (clinicIndex !== -1) { diff --git a/app/views/clinics/close-attended-not-screened-reason.html b/app/views/clinics/close-attended-not-screened-reason.html new file mode 100644 index 00000000..df661045 --- /dev/null +++ b/app/views/clinics/close-attended-not-screened-reason.html @@ -0,0 +1,212 @@ +{# app/views/clinics/close-attended-not-screened-reason.html #} + +{% extends 'layout-app.html' %} + +{% set pageHeading = "Why was this appointment stopped?" %} + +{% set back = { + href: "/clinics/" + clinicId + "/close", + text: "Back to close clinic" +} %} + +{% block pageContent %} + +

+ + {{ participant | getFullName }} ({{ currentIndex }} of {{ totalCount }}) + + {{ pageHeading }} +

+ +
+ + {{ checkboxes({ + name: "closeReasonForm[stoppedReason]", + values: data.closeReasonForm.stoppedReason, + fieldset: { + legend: { + text: "Why has this appointment been stopped?", + size: "m", + isPageHeading: false + }, + hint: { + text: "Select all that apply" + } + }, + items: [ + { + value: "Failed identity check", + text: "Failed identity check", + conditional: { + html: input({ + name: "closeReasonForm[failedIdentityDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.failedIdentityDetails, + autocomplete: "off" + }) + } + }, + { + value: "Pain during screening", + text: "Pain during screening", + conditional: { + html: input({ + name: "closeReasonForm[painDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.painDetails, + autocomplete: "off" + }) + } + }, + { + value: "Has a symptomatic appointment", + text: "Has a symptomatic appointment", + conditional: { + html: input({ + name: "closeReasonForm[symptomaticDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.symptomaticDetails, + autocomplete: "off" + }) + } + }, + { + value: "Consent withdrawn", + text: "Consent withdrawn", + conditional: { + html: input({ + name: "closeReasonForm[consentDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.consentDetails, + autocomplete: "off" + }) + } + }, + { + value: "Physical health issue", + text: "Physical health issue", + conditional: { + html: input({ + name: "closeReasonForm[physicalHealthDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.physicalHealthDetails, + autocomplete: "off" + }) + } + }, + { + value: "Mental health issue", + text: "Mental health issue", + conditional: { + html: input({ + name: "closeReasonForm[mentalHealthDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.mentalHealthDetails, + autocomplete: "off" + }) + } + }, + { + value: "Language difficulties", + text: "Language difficulties", + conditional: { + html: input({ + name: "closeReasonForm[languageDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.languageDetails, + autocomplete: "off" + }) + } + }, + { + value: "No qualified mammographer available", + text: "No qualified mammographer available", + conditional: { + html: input({ + name: "closeReasonForm[mammographerDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.mammographerDetails, + autocomplete: "off" + }) + } + }, + { + value: "Technical issues at clinic", + text: "Technical issues at clinic", + conditional: { + html: input({ + name: "closeReasonForm[technicalDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.technicalDetails, + autocomplete: "off" + }) + } + }, + { + divider: "or" + }, + { + value: "Other reason", + text: "Other reason", + conditional: { + html: textarea({ + name: "closeReasonForm[otherDetails]", + label: { text: "Provide details" }, + rows: 5, + value: data.closeReasonForm.otherDetails, + autocomplete: "off" + }) + } + } + ] + } | populateErrors) }} + + {{ radios({ + name: "closeReasonForm[needsReschedule]", + value: data.closeReasonForm.needsReschedule, + fieldset: { + legend: { + text: "Should the appointment be rescheduled?", + size: "m", + isPageHeading: false + } + }, + items: [ + { + value: "yes", + text: "Yes" + }, + { + value: "no-invite", + text: "No, invite to next routine appointment", + hint: { + text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" + } + }, + { + value: "no-opt-out", + text: "No, request opt out", + hint: { + text: "They will receive information explaining their options" + }, + conditional: { + html: input({ + name: "closeReasonForm[optOutDetails]", + label: { text: "Provide details (optional)" }, + value: data.closeReasonForm.optOutDetails, + autocomplete: "off" + }) + } + } + ] + } | populateErrors) }} + +
+ {{ button({ + text: "Continue" + }) }} +
+ +
+ +{% endblock %} diff --git a/app/views/clinics/close-reschedule.html b/app/views/clinics/close-reschedule.html new file mode 100644 index 00000000..9062d7c8 --- /dev/null +++ b/app/views/clinics/close-reschedule.html @@ -0,0 +1,67 @@ +{# app/views/clinics/close-reschedule.html #} + +{% extends 'layout-app.html' %} + +{% set pageHeading = "Request to reschedule this appointment" %} + +{% set back = { + href: "/clinics/" + clinicId + "/close/reason/" + appointment.id, + text: "Back" +} %} + +{% block pageContent %} + +

+ + {{ participant | getFullName }} ({{ currentIndex }} of {{ totalCount }}) + + {{ pageHeading }} +

+ +
+ + {{ radios({ + name: "closeRescheduleForm[timing]", + value: data.closeRescheduleForm.timing, + fieldset: { + legend: { + text: "When should the appointment be?", + size: "m", + isPageHeading: false + } + }, + items: [ + { + value: "within-6-weeks", + text: "Within the next 6 weeks" + }, + { + value: "more-than-6-weeks", + text: "More than 6 weeks away" + } + ] + } | populateErrors) }} + + {{ textarea({ + name: "closeRescheduleForm[note]", + label: { + text: "Note for rescheduling (optional)", + size: "m" + }, + hint: { + text: "Include any other relevant information" + }, + rows: 5, + value: data.closeRescheduleForm.note, + autocomplete: "off" + }) }} + +
+ {{ button({ + text: "Continue" + }) }} +
+ +
+ +{% endblock %} From a5a60d4614091d996d34fcc7ed350c7f60591fd5 Mon Sep 17 00:00:00 2001 From: rivalee Date: Tue, 11 Aug 2026 16:15:23 +0100 Subject: [PATCH 14/19] Try inline details for ANS and add new gen data --- app/lib/generators/appointment-generator.js | 11 +++ app/lib/utils/status.js | 2 +- app/routes/clinics.js | 84 +++++-------------- .../close-attended-not-screened-reason.html | 5 +- app/views/clinics/close-reschedule.html | 5 +- app/views/clinics/close.html | 61 ++++++++++++-- 6 files changed, 90 insertions(+), 78 deletions(-) diff --git a/app/lib/generators/appointment-generator.js b/app/lib/generators/appointment-generator.js index 2ee2c108..40659771 100644 --- a/app/lib/generators/appointment-generator.js +++ b/app/lib/generators/appointment-generator.js @@ -394,6 +394,17 @@ const generateAppointment = ({ startedBy: randomUser.id, endedAt: actualEndTime.toISOString() } + + appointment.appointmentStopped = { + stoppedReason: [faker.helpers.arrayElement([ + 'Consent withdrawn', + 'Physical health issue', + 'Pain during screening', + 'Technical issues at clinic', + 'No qualified mammographer available' + ])], + needsReschedule: faker.helpers.arrayElement(['no-invite', 'no-invite', 'yes']) + } } // Select image set for appointments with mammogram data diff --git a/app/lib/utils/status.js b/app/lib/utils/status.js index db120a17..545efc8f 100644 --- a/app/lib/utils/status.js +++ b/app/lib/utils/status.js @@ -210,7 +210,7 @@ const STATUS_TAGS = { complete: { label: 'Screened', colour: 'green' }, partially_screened: { label: 'Partially screened', colour: 'orange' }, did_not_attend: { label: 'Did not attend', colour: 'red' }, - attended_not_screened: { label: 'Attended not screened', colour: 'orange' }, + attended_not_screened: { label: 'Attended not screened', colour: 'red' }, cancelled: { label: 'Cancelled', colour: 'red' }, rescheduled: { label: 'Reschedule requested', colour: 'red' } }, diff --git a/app/routes/clinics.js b/app/routes/clinics.js index c9295c79..1bcb2f44 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -10,7 +10,8 @@ const { filterAppointmentsByStatus } = require('../lib/utils/status') const { getReturnUrl, urlWithReferrer, - appendReferrer + appendReferrer, + modalBreakout } = require('../lib/utils/referrers') const { getParticipant, getFullName } = require('../lib/utils/participants') const { updateAppointmentStatus } = require('../lib/utils/appointment-status') @@ -321,12 +322,10 @@ module.exports = (router) => { res.redirect(`/clinics/${id}/close`) }) - // Sequential reason collection for attended-not-screened appointments + // Attended-not-screened reason page (opens in modal from close page) router.get('/clinics/:id/close/reason/:appointmentId', (req, res) => { const { id, appointmentId } = req.params const data = req.session.data - const queueKey = `closeClinicReasonQueue_${id}` - const queue = req.session[queueKey] || [] const appointment = data.appointments.find((a) => a.id === appointmentId) if (!appointment) { @@ -335,9 +334,7 @@ module.exports = (router) => { const participant = getParticipant(data, appointment.participantId) const clinic = getClinic(data, id) - const currentIndex = queue.indexOf(appointmentId) - // Ensure form data object exists for template access if (!data.closeReasonForm) { data.closeReasonForm = {} } @@ -346,24 +343,19 @@ module.exports = (router) => { clinicId: id, clinic, appointment, - participant, - currentIndex: currentIndex + 1, - totalCount: queue.length + participant }) }) router.post('/clinics/:id/close/reason/:appointmentId', (req, res) => { const { id, appointmentId } = req.params const data = req.session.data - const queueKey = `closeClinicReasonQueue_${id}` - const queue = req.session[queueKey] || [] const appointment = data.appointments.find((a) => a.id === appointmentId) if (!appointment) { return res.redirect(`/clinics/${id}/close`) } - // Extract form values const formData = data.closeReasonForm || {} const stoppedReason = formData.stoppedReason const needsReschedule = formData.needsReschedule @@ -414,24 +406,24 @@ module.exports = (router) => { optOutDetails: formData.optOutDetails } - // Clear form data delete data.closeReasonForm - // If reschedule requested, go to reschedule step before advancing + // If reschedule requested, go to reschedule step if (needsReschedule === 'yes') { return res.redirect(`/clinics/${id}/close/reschedule/${appointmentId}`) } - // Advance to next in queue or close the clinic - advanceCloseQueue(req, res, id, appointmentId) + // In modal context, close without page reload + if (req.query._modal === '1' || req.body?._modal === '1') { + return res.send('') + } + res.redirect(`/clinics/${id}/close`) }) - // Reschedule step within the close loop + // Reschedule step (follows reason page when reschedule selected) router.get('/clinics/:id/close/reschedule/:appointmentId', (req, res) => { const { id, appointmentId } = req.params const data = req.session.data - const queueKey = `closeClinicReasonQueue_${id}` - const queue = req.session[queueKey] || [] const appointment = data.appointments.find((a) => a.id === appointmentId) if (!appointment) { @@ -440,9 +432,7 @@ module.exports = (router) => { const participant = getParticipant(data, appointment.participantId) const clinic = getClinic(data, id) - const currentIndex = queue.indexOf(appointmentId) - // Ensure form data object exists for template access if (!data.closeRescheduleForm) { data.closeRescheduleForm = {} } @@ -451,9 +441,7 @@ module.exports = (router) => { clinicId: id, clinic, appointment, - participant, - currentIndex: currentIndex + 1, - totalCount: queue.length + participant }) }) @@ -478,49 +466,16 @@ module.exports = (router) => { return res.redirect(`/clinics/${id}/close/reschedule/${appointmentId}`) } - // Save reschedule data to the appointment appointment.reschedule = { timing, note: formData.note } updateAppointmentStatus(data, appointmentId, 'rescheduled') - // Clear form data delete data.closeRescheduleForm - - // Advance to next in queue or close the clinic - advanceCloseQueue(req, res, id, appointmentId) + res.redirect(modalBreakout(`/clinics/${id}/close`)) }) - // Shared helper: advance to the next appointment in queue or close the clinic - const advanceCloseQueue = (req, res, clinicId, currentAppointmentId) => { - const data = req.session.data - const queueKey = `closeClinicReasonQueue_${clinicId}` - const queue = req.session[queueKey] || [] - const currentIndex = queue.indexOf(currentAppointmentId) - const nextIndex = currentIndex + 1 - - if (nextIndex < queue.length) { - return res.redirect(`/clinics/${clinicId}/close/reason/${queue[nextIndex]}`) - } - - // Queue complete — close the clinic - delete req.session[queueKey] - - const clinicIndex = data.clinics.findIndex((c) => c.id === clinicId) - if (clinicIndex !== -1) { - const updatedClinic = { ...data.clinics[clinicIndex], status: 'closed' } - data.clinics[clinicIndex] = updatedClinic - if (data._changes?.clinics) { - data._changes.clinics[clinicId] = updatedClinic - } - req.flash('success', `Clinic ${updatedClinic.clinicCode} closed`) - } - - delete req.session[`closeClinicResolved_${clinicId}`] - res.redirect('/clinics/completed') - } - // Confirm and close clinic router.post('/clinics/:id/close', (req, res) => { const { id } = req.params @@ -538,15 +493,16 @@ module.exports = (router) => { return res.redirect(`/clinics/${id}/close`) } - // Check for attended-not-screened appointments missing a reason - const needsReason = clinicAppointments.filter( + // Check attended-not-screened appointments have details recorded + const ansNeedsDetails = clinicAppointments.filter( (a) => a.status === 'attended_not_screened' && !a.appointmentStopped?.stoppedReason?.length ) - if (needsReason.length > 0) { - const queueKey = `closeClinicReasonQueue_${id}` - req.session[queueKey] = needsReason.map((a) => a.id) - return res.redirect(`/clinics/${id}/close/reason/${needsReason[0].id}`) + if (ansNeedsDetails.length > 0) { + req.flash('error', [{ + text: `${ansNeedsDetails.length} participant${ansNeedsDetails.length === 1 ? '' : 's'} marked as attended not screened still need${ansNeedsDetails.length === 1 ? 's' : ''} details added` + }]) + return res.redirect(`/clinics/${id}/close`) } const clinicIndex = data.clinics.findIndex((c) => c.id === id) diff --git a/app/views/clinics/close-attended-not-screened-reason.html b/app/views/clinics/close-attended-not-screened-reason.html index df661045..802cce17 100644 --- a/app/views/clinics/close-attended-not-screened-reason.html +++ b/app/views/clinics/close-attended-not-screened-reason.html @@ -1,8 +1,9 @@ {# app/views/clinics/close-attended-not-screened-reason.html #} -{% extends 'layout-app.html' %} +{% extends parentLayout or 'layout-app.html' %} {% set pageHeading = "Why was this appointment stopped?" %} +{% set formAction = "/clinics/" + clinicId + "/close/reason/" + appointment.id %} {% set back = { href: "/clinics/" + clinicId + "/close", @@ -13,7 +14,7 @@

- {{ participant | getFullName }} ({{ currentIndex }} of {{ totalCount }}) + {{ participant | getFullName }} {{ pageHeading }}

diff --git a/app/views/clinics/close-reschedule.html b/app/views/clinics/close-reschedule.html index 9062d7c8..8b7b7b32 100644 --- a/app/views/clinics/close-reschedule.html +++ b/app/views/clinics/close-reschedule.html @@ -1,8 +1,9 @@ {# app/views/clinics/close-reschedule.html #} -{% extends 'layout-app.html' %} +{% extends parentLayout or 'layout-app.html' %} {% set pageHeading = "Request to reschedule this appointment" %} +{% set formAction = "/clinics/" + clinicId + "/close/reschedule/" + appointment.id %} {% set back = { href: "/clinics/" + clinicId + "/close/reason/" + appointment.id, @@ -13,7 +14,7 @@

- {{ participant | getFullName }} ({{ currentIndex }} of {{ totalCount }}) + {{ participant | getFullName }} {{ pageHeading }}

diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index 8509cc8a..b7aad704 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -20,12 +20,21 @@

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

+ {# ANS with details is a final outcome; without details still needs action #} + {% set ansWithoutDetails = [] %} + {% for appointment in allAppointments %} + {% if appointment.status == "attended_not_screened" and not (appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length) %} + {% set ansWithoutDetails = ansWithoutDetails | push(appointment) %} + {% endif %} + {% endfor %} + {% set needsOutcomeAppointments = allAppointments | removeWhere("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} + {% set needsOutcomeCount = needsOutcomeAppointments | length + ansWithoutDetails | length %}
{{ insetText({ - html: "

Record an appointment outcome for every participant to close this clinic.

There were " + allAppointments | length + " total participants in this clinic, and " + needsOutcomeAppointments | length + " still need a final outcome assigned.

" + html: "

Record an appointment outcome for every participant to close this clinic.

There were " + allAppointments | length + " total participants in this clinic, and " + needsOutcomeCount + " still need a final outcome assigned.

" }) }} {# Macro for appointment table rows #} @@ -49,10 +58,18 @@

Mark as did not attend {% elseif appointment.status == "attended_not_screened" %} Undo + {% if appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} +
Manage details + {% else %} +
Add details + {% endif %} {% elseif appointment.status == "did_not_attend" %} Undo {% endif %} {% endif %} + {% if not showActions and appointment.status == "attended_not_screened" and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} + Manage details + {% endif %} {% endmacro %} @@ -76,7 +93,7 @@

{% endmacro %} - {% if needsOutcomeAppointments | length %} + {% if needsOutcomeCount %} {% set needsOutcomeHtml %} {# In progress #} {% set inProgressAppointments = allAppointments | where("status", ["in_progress", "paused"]) %} @@ -86,14 +103,21 @@

In progress

{{ statusGroupTable(inProgressAppointments, clinicId, true) }} {% endif %} - {# Checked in, not screened #} + {# Checked in, not screened (includes ANS without details) #} {% set checkedInAppointments = allAppointments | where("status", "checked_in") %} - {% if checkedInAppointments | length %} + {% set checkedInAndAns = [] %} + {% for appointment in checkedInAppointments %} + {% set checkedInAndAns = checkedInAndAns | push(appointment) %} + {% endfor %} + {% for appointment in ansWithoutDetails %} + {% set checkedInAndAns = checkedInAndAns | push(appointment) %} + {% endfor %} + {% if checkedInAndAns | length %}

Checked in, not screened

Mark all as attended not screened

- {{ statusGroupTable(checkedInAppointments, clinicId, true) }} + {{ statusGroupTable(checkedInAndAns, clinicId, true) }} {% endif %} {# Did not check in #} @@ -108,15 +132,20 @@

Did not check in

{% endset %} {{ card({ - heading: "Needs an outcome (" + needsOutcomeAppointments | length + ")", + heading: "Needs an outcome (" + needsOutcomeCount + ")", headingLevel: "2", feature: true, descriptionHtml: needsOutcomeHtml }) }} {% endif %} - {# Outcome recorded #} - {% set outcomeRecordedAppointments = allAppointments | where("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} + {# Outcome recorded — includes ANS with details #} + {% set outcomeRecordedAppointments = allAppointments | where("status", ["complete", "partially_screened", "did_not_attend", "cancelled", "rescheduled"]) %} + {% for appointment in allAppointments %} + {% if appointment.status == "attended_not_screened" and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} + {% set outcomeRecordedAppointments = outcomeRecordedAppointments | push(appointment) %} + {% endif %} + {% endfor %} {% set outcomeRecordedHtml %}

@@ -167,7 +196,8 @@

Did not check in

function renderActionCell(appointmentId, newStatus) { if (newStatus === 'attended_not_screened') { - return 'Undo' + return 'Undo' + + '
Add details' } if (newStatus === 'did_not_attend') { return 'Undo' @@ -282,6 +312,19 @@

Did not check in

} bindActions() + + // Open details modal without page reload on close + container.addEventListener('click', function (e) { + var link = e.target.closest('.js-open-details-modal') + if (!link) return + e.preventDefault() + window.openModal('app-form-modal', { + loadUrl: link.href, + onSuccess: function () { + link.textContent = 'Manage details' + } + }) + }) }) {% endblock %} From d7248eb4c0d5cf83d74f825eaec85631dbad17c7 Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 13 Aug 2026 13:46:14 +0100 Subject: [PATCH 15/19] fix data save issue when adding details for ANS --- app/routes/clinics.js | 64 +++++++++++++++++++++--------------- app/views/clinics/close.html | 9 +++-- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index 1bcb2f44..a12c8ec5 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -15,7 +15,7 @@ const { } = require('../lib/utils/referrers') const { getParticipant, getFullName } = require('../lib/utils/participants') const { updateAppointmentStatus } = require('../lib/utils/appointment-status') -const { getAppointment } = require('../lib/utils/appointment-data') +const { getAppointment, updateAppointmentData } = require('../lib/utils/appointment-data') /** * Get clinic and its related data from id @@ -335,9 +335,12 @@ module.exports = (router) => { const participant = getParticipant(data, appointment.participantId) const clinic = getClinic(data, id) - if (!data.closeReasonForm) { - data.closeReasonForm = {} - } + // Pre-populate form with existing data, syncing both session and locals + const formData = appointment.appointmentStopped + ? { ...appointment.appointmentStopped } + : {} + data.closeReasonForm = formData + res.locals.data.closeReasonForm = formData res.render('clinics/close-attended-not-screened-reason', { clinicId: id, @@ -389,22 +392,24 @@ module.exports = (router) => { return res.redirect(`/clinics/${id}/close/reason/${appointmentId}`) } - // Save the reason data to the appointment - appointment.appointmentStopped = { - stoppedReason, - needsReschedule, - otherDetails: formData.otherDetails, - failedIdentityDetails: formData.failedIdentityDetails, - painDetails: formData.painDetails, - symptomaticDetails: formData.symptomaticDetails, - consentDetails: formData.consentDetails, - physicalHealthDetails: formData.physicalHealthDetails, - mentalHealthDetails: formData.mentalHealthDetails, - languageDetails: formData.languageDetails, - mammographerDetails: formData.mammographerDetails, - technicalDetails: formData.technicalDetails, - optOutDetails: formData.optOutDetails - } + // Save the reason data to the appointment via updateAppointmentData (not direct mutation) + updateAppointmentData(data, appointmentId, { + appointmentStopped: { + stoppedReason, + needsReschedule, + otherDetails: formData.otherDetails, + failedIdentityDetails: formData.failedIdentityDetails, + painDetails: formData.painDetails, + symptomaticDetails: formData.symptomaticDetails, + consentDetails: formData.consentDetails, + physicalHealthDetails: formData.physicalHealthDetails, + mentalHealthDetails: formData.mentalHealthDetails, + languageDetails: formData.languageDetails, + mammographerDetails: formData.mammographerDetails, + technicalDetails: formData.technicalDetails, + optOutDetails: formData.optOutDetails + } + }) delete data.closeReasonForm @@ -433,9 +438,12 @@ module.exports = (router) => { const participant = getParticipant(data, appointment.participantId) const clinic = getClinic(data, id) - if (!data.closeRescheduleForm) { - data.closeRescheduleForm = {} - } + // Pre-populate form with existing data, syncing both session and locals + const rescheduleFormData = appointment.reschedule + ? { ...appointment.reschedule } + : {} + data.closeRescheduleForm = rescheduleFormData + res.locals.data.closeRescheduleForm = rescheduleFormData res.render('clinics/close-reschedule', { clinicId: id, @@ -466,10 +474,12 @@ module.exports = (router) => { return res.redirect(`/clinics/${id}/close/reschedule/${appointmentId}`) } - appointment.reschedule = { - timing, - note: formData.note - } + updateAppointmentData(data, appointmentId, { + reschedule: { + timing, + note: formData.note + } + }) updateAppointmentStatus(data, appointmentId, 'rescheduled') delete data.closeRescheduleForm diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index b7aad704..c960efaa 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -47,7 +47,12 @@

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

View appointment

- {{ appointment.status | toTag({ vocabulary: "appointment" }) }} + + {{ appointment.status | toTag({ vocabulary: "appointment" }) }} + {% if appointment.status == "rescheduled" and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} + {{ "attended_not_screened" | toTag({ vocabulary: "appointment" }) }} + {% endif %} + {% if showActions %} {% if appointment.status == "in_progress" or appointment.status == "paused" %} @@ -67,7 +72,7 @@

Undo {% endif %} {% endif %} - {% if not showActions and appointment.status == "attended_not_screened" and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} + {% if not showActions and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} Manage details {% endif %} From 1e80b06de0e839f8cf419eda97373a739611726a Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 14 Aug 2026 15:03:00 +0100 Subject: [PATCH 16/19] Clean up close clinic flow - fix validation losing form answers, and modal onSuccess never firing - collapse eight action routes into parameterised set-status routes - share the attended-not-screened form fields between both flows - replace inline page script with close-clinic.js using server-rendered row fragments, so rows update in place after actions and modal saves - track close-flow resolved ids in session data; add updateClinic helper - move table styles out of _compact.scss; revert ANS tag colour to orange - seed some attended-not-screened appointments without details --- app/assets/javascript/close-clinic.js | 129 ++++++ app/assets/javascript/modal.js | 8 +- app/assets/sass/_app-styles.scss | 1 + .../_clinic-appointments-table.scss | 25 + app/assets/sass/components/_compact.scss | 12 - app/lib/generators/appointment-generator.js | 17 +- app/lib/utils/appointment-data.js | 21 + app/lib/utils/clinics.js | 26 +- app/lib/utils/status.js | 13 +- app/routes/clinics.js | 433 ++++++++---------- .../close-clinic-appointment-row.njk | 54 +++ .../forms/attended-not-screened-fields.njk | 111 +++++ .../attended-not-screened-reason.html | 207 +-------- app/views/clinics/close-appointment-row.html | 7 + .../close-attended-not-screened-reason.html | 197 +------- app/views/clinics/close-reschedule.html | 80 ++-- app/views/clinics/close.html | 342 +++----------- app/views/clinics/show.html | 2 +- 18 files changed, 693 insertions(+), 992 deletions(-) create mode 100644 app/assets/javascript/close-clinic.js create mode 100644 app/assets/sass/components/_clinic-appointments-table.scss create mode 100644 app/views/_includes/close-clinic-appointment-row.njk create mode 100644 app/views/_includes/forms/attended-not-screened-fields.njk create mode 100644 app/views/clinics/close-appointment-row.html diff --git a/app/assets/javascript/close-clinic.js b/app/assets/javascript/close-clinic.js new file mode 100644 index 00000000..dc913743 --- /dev/null +++ b/app/assets/javascript/close-clinic.js @@ -0,0 +1,129 @@ +// Close clinic page - progressively enhances the outcome action links so +// status changes happen without a full page reload. Without JS every link +// still works as a normal navigation. Changed rows are re-rendered +// server-side (the appointment-row fragment route) and swapped in place, so +// tags and action links can't drift from the server's rendering. + +document.addEventListener('DOMContentLoaded', () => { + const container = document.getElementById('js-close-clinic-content') + if (!container) return + + const clinicId = container.dataset.clinicId + const fetchOptions = { headers: { 'X-Requested-With': 'XMLHttpRequest' } } + + // Counts in the card headings and inset text aren't updated in place - + // this link invites a refresh instead + const showRefreshLink = () => { + const link = container.querySelector('.js-refresh-link') + if (link) link.hidden = false + } + + const rowFor = (appointmentId) => + container.querySelector(`tr[data-appointment-id="${appointmentId}"]`) + + // Replace a row with its server-rendered replacement + const swapRow = (row, html) => { + const template = document.createElement('template') + template.innerHTML = html.trim() + const newRow = template.content.querySelector('tr[data-appointment-id]') + if (!newRow || newRow.dataset.appointmentId !== row.dataset.appointmentId) { + throw new Error('Response was not the expected row') + } + row.replaceWith(newRow) + } + + // Re-fetch one row and swap it in place + const refreshRow = (row) => { + const showActions = row.closest('table')?.dataset.showActions || 'false' + const url = `/clinics/${clinicId}/close/appointment-row/${row.dataset.appointmentId}?showActions=${showActions}` + return fetch(url, fetchOptions) + .then((response) => { + if (!response.ok) throw new Error('Failed to refresh row') + return response.text() + }) + .then((html) => swapRow(row, html)) + } + + // Single outcome change - the response is the re-rendered row + const handleActionClick = (link) => { + const row = link.closest('tr') + fetch(link.href, fetchOptions) + .then((response) => { + if (!response.ok) throw new Error('Request failed') + return response.text() + }) + .then((html) => { + swapRow(row, html) + showRefreshLink() + }) + .catch(() => { + window.location.href = link.href + }) + } + + // Bulk outcome change - refresh each affected row, then swap the button + // and its undo message over + const handleBulkClick = (link) => { + const bulkContainer = link.closest('.js-bulk-action-container') + const isUndo = Boolean(link.closest('.js-bulk-undo-message')) + + fetch(link.href, fetchOptions) + .then((response) => { + if (!response.ok) throw new Error('Request failed') + return response.json() + }) + .then((result) => { + const rows = result.appointmentIds.map(rowFor).filter(Boolean) + return Promise.all(rows.map(refreshRow)).then(() => result) + }) + .then((result) => { + bulkContainer.querySelector('.nhsuk-button').hidden = !isUndo + const undoMessage = bulkContainer.querySelector('.js-bulk-undo-message') + undoMessage.hidden = isUndo + if (!isUndo) { + undoMessage.querySelector('.js-bulk-count').textContent = + result.count === 1 ? '1 participant' : `${result.count} participants` + } + showRefreshLink() + }) + .catch(() => { + window.location.href = link.href + }) + } + + container.addEventListener('click', (event) => { + const actionLink = event.target.closest('.js-close-clinic-action') + if (actionLink) { + event.preventDefault() + handleActionClick(actionLink) + return + } + + const bulkLink = event.target.closest('.js-bulk-action') + if (bulkLink) { + event.preventDefault() + handleBulkClick(bulkLink) + return + } + + // Details links open in a modal (attributes added by the openInModal + // filter). Take over from the global handler in modal.js so the row can + // be refreshed in place when the modal form saves. + const modalLink = event.target.closest('[data-load-modal-url]') + if (modalLink) { + event.preventDefault() + event.stopPropagation() + const appointmentId = modalLink.closest('tr')?.dataset.appointmentId + window.openModal(modalLink.dataset.modalId || 'app-form-modal', { + loadUrl: modalLink.dataset.loadModalUrl, + onSuccess: () => { + const row = rowFor(appointmentId) + if (!row) return window.location.reload() + refreshRow(row) + .then(showRefreshLink) + .catch(() => window.location.reload()) + } + }) + } + }) +}) diff --git a/app/assets/javascript/modal.js b/app/assets/javascript/modal.js index 355df9b4..ebec212a 100644 --- a/app/assets/javascript/modal.js +++ b/app/assets/javascript/modal.js @@ -717,10 +717,12 @@ class AppModal { this.close() window.location.href = finalUrl } else { - // Flow complete — close and refresh + // Flow complete — close and refresh. Capture the callback before + // close(), which resets it. + const onSuccess = this._onSuccessCallback this.close() - if (this._onSuccessCallback) { - this._onSuccessCallback() + if (onSuccess) { + onSuccess() } else { window.location.reload() } diff --git a/app/assets/sass/_app-styles.scss b/app/assets/sass/_app-styles.scss index 7063c4eb..27f3532c 100644 --- a/app/assets/sass/_app-styles.scss +++ b/app/assets/sass/_app-styles.scss @@ -14,6 +14,7 @@ @forward "components/secondary-navigation-overrides"; @forward "components/count"; @forward "components/card"; +@forward "components/clinic-appointments-table"; @forward "components/forward-link"; @forward "components/status"; @forward "components/status-bar"; diff --git a/app/assets/sass/components/_clinic-appointments-table.scss b/app/assets/sass/components/_clinic-appointments-table.scss new file mode 100644 index 00000000..bdc08f72 --- /dev/null +++ b/app/assets/sass/components/_clinic-appointments-table.scss @@ -0,0 +1,25 @@ +// app/assets/sass/components/_clinic-appointments-table.scss +// Appointment tables on the clinic pages (clinic view and close clinic) + +.app-clinic-appointments-table { + table-layout: fixed; + width: 100%; + + .app-clinic-appointments-table__time-column { + width: 22%; + } + + .app-clinic-appointments-table__status-column { + width: 25%; + } + + .app-clinic-appointments-table__actions-column { + width: 30%; + } +} + +// Flush variant - sits at the bottom of a card, which provides the spacing, +// so drop the final row border +.app-clinic-appointments-table--flush .nhsuk-table__body tr:last-child .nhsuk-table__cell { + border-bottom: 0; +} diff --git a/app/assets/sass/components/_compact.scss b/app/assets/sass/components/_compact.scss index 4f3c3866..53a58dc5 100644 --- a/app/assets/sass/components/_compact.scss +++ b/app/assets/sass/components/_compact.scss @@ -423,15 +423,3 @@ } } -.app-clinic-appointments-table.nhsuk-u-margin-bottom-0 tbody tr:last-child td { - border-bottom: 0; -} - -.app-clinic-appointments-table { - table-layout: fixed; - width: 100%; - - .app-clinic-appointments-table__time-column { - width: 22%; - } -} diff --git a/app/lib/generators/appointment-generator.js b/app/lib/generators/appointment-generator.js index 40659771..0d312325 100644 --- a/app/lib/generators/appointment-generator.js +++ b/app/lib/generators/appointment-generator.js @@ -6,6 +6,7 @@ const weighted = require('weighted') const dayjs = require('dayjs') const config = require('../../config') const { STATUS_GROUPS, isCompleted } = require('../utils/status') +const { getStoppedReasons } = require('../utils/appointment-data') const { generateMammogramImages } = require('./mammogram-generator') const { generateMedicalInformation @@ -395,15 +396,13 @@ const generateAppointment = ({ endedAt: actualEndTime.toISOString() } - appointment.appointmentStopped = { - stoppedReason: [faker.helpers.arrayElement([ - 'Consent withdrawn', - 'Physical health issue', - 'Pain during screening', - 'Technical issues at clinic', - 'No qualified mammographer available' - ])], - needsReschedule: faker.helpers.arrayElement(['no-invite', 'no-invite', 'yes']) + // Most stopped appointments have their reasons recorded already; leave + // some without so the close clinic flow's 'add details' state is seeded + if (Math.random() < 0.7) { + appointment.appointmentStopped = { + stoppedReason: [faker.helpers.arrayElement(getStoppedReasons()).value], + needsReschedule: faker.helpers.arrayElement(['no-invite', 'no-invite', 'yes']) + } } } diff --git a/app/lib/utils/appointment-data.js b/app/lib/utils/appointment-data.js index 027b17ad..f4108a3e 100644 --- a/app/lib/utils/appointment-data.js +++ b/app/lib/utils/appointment-data.js @@ -149,9 +149,30 @@ const saveTempAppointmentToAppointment = (data) => { return updatedAppointment } +/** + * Reasons an appointment can be stopped (attended not screened), each with + * the form field that holds its optional details. Defined once so the reason + * forms and the seed generator stay in step. "Other reason" is handled + * separately by the form as its details are required rather than optional. + * + * @returns {Array} List of { value, detailsField } reason options + */ +const getStoppedReasons = () => [ + { value: 'Failed identity check', detailsField: 'failedIdentityDetails' }, + { value: 'Pain during screening', detailsField: 'painDetails' }, + { value: 'Has a symptomatic appointment', detailsField: 'symptomaticDetails' }, + { value: 'Consent withdrawn', detailsField: 'consentDetails' }, + { value: 'Physical health issue', detailsField: 'physicalHealthDetails' }, + { value: 'Mental health issue', detailsField: 'mentalHealthDetails' }, + { value: 'Language difficulties', detailsField: 'languageDetails' }, + { value: 'No qualified mammographer available', detailsField: 'mammographerDetails' }, + { value: 'Technical issues at clinic', detailsField: 'technicalDetails' } +] + module.exports = { getAppointment, getAppointmentData, + getStoppedReasons, updateAppointment, updateAppointmentData, saveTempAppointmentToAppointment diff --git a/app/lib/utils/clinics.js b/app/lib/utils/clinics.js index b6407298..6b2c9de3 100644 --- a/app/lib/utils/clinics.js +++ b/app/lib/utils/clinics.js @@ -138,11 +138,35 @@ const getFilteredClinics = (clinics, filter = 'all') => { } } +/** + * Find and update a clinic in session data + * + * @param {object} data - Session data + * @param {string} clinicId - Clinic ID + * @param {object} updates - Fields to merge into the clinic + * @returns {object | null} Updated clinic or null if not found + */ +const updateClinic = (data, clinicId, updates) => { + const clinicIndex = data.clinics.findIndex((c) => c.id === clinicId) + if (clinicIndex === -1) return null + + // Update in the attached array (same-request reads) and record the change + // in data._changes (persistence - the attached array is rebuilt from the + // shared data store on every request; see middleware in app/routes.js) + const updatedClinic = { ...data.clinics[clinicIndex], ...updates } + data.clinics[clinicIndex] = updatedClinic + if (data._changes?.clinics) { + data._changes.clinics[clinicId] = updatedClinic + } + return updatedClinic +} + module.exports = { getClinic, getTodaysClinics, getFilteredClinics, getClinicAppointments, formatTimeSlot, - getClinicHours + getClinicHours, + updateClinic } diff --git a/app/lib/utils/status.js b/app/lib/utils/status.js index 545efc8f..ec1aa301 100644 --- a/app/lib/utils/status.js +++ b/app/lib/utils/status.js @@ -210,7 +210,7 @@ const STATUS_TAGS = { complete: { label: 'Screened', colour: 'green' }, partially_screened: { label: 'Partially screened', colour: 'orange' }, did_not_attend: { label: 'Did not attend', colour: 'red' }, - attended_not_screened: { label: 'Attended not screened', colour: 'red' }, + attended_not_screened: { label: 'Attended not screened', colour: 'orange' }, cancelled: { label: 'Cancelled', colour: 'red' }, rescheduled: { label: 'Reschedule requested', colour: 'red' } }, @@ -402,6 +402,16 @@ const hasSymptoms = (appointment) => { ) } +/** + * Check if an attended-not-screened appointment has its reasons recorded + * + * @param {object} appointment - Appointment object to check + * @returns {boolean} Whether stopped reasons have been recorded + */ +const hasStoppedDetails = (appointment) => { + return Boolean(appointment?.appointmentStopped?.stoppedReason?.length) +} + module.exports = { hasNotStarted, isCompleted, @@ -418,6 +428,7 @@ module.exports = { isSpecialAppointment, hasAppointmentNote, hasSymptoms, + hasStoppedDetails, // Export groups and display vocabularies for testing/reference STATUS_GROUPS, STATUS_TAGS diff --git a/app/routes/clinics.js b/app/routes/clinics.js index a12c8ec5..b2686ca6 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -4,18 +4,20 @@ const dayjs = require('dayjs') const { getClinic, getFilteredClinics, - getClinicAppointments + getClinicAppointments, + updateClinic } = require('../lib/utils/clinics') -const { filterAppointmentsByStatus } = require('../lib/utils/status') const { - getReturnUrl, - urlWithReferrer, - appendReferrer, - modalBreakout -} = require('../lib/utils/referrers') -const { getParticipant, getFullName } = require('../lib/utils/participants') + filterAppointmentsByStatus, + isInProgress, + isFinal, + hasStoppedDetails +} = require('../lib/utils/status') +const { getReturnUrl, modalBreakout } = require('../lib/utils/referrers') +const { getParticipant } = require('../lib/utils/participants') const { updateAppointmentStatus } = require('../lib/utils/appointment-status') const { getAppointment, updateAppointmentData } = require('../lib/utils/appointment-data') +const { pluralise } = require('../lib/utils/strings') /** * Get clinic and its related data from id @@ -56,6 +58,47 @@ function getClinicData(data, clinicId) { } } +// Status changes available from the close clinic page, keyed by the status +// being applied. Marking a final status records the appointment as resolved +// by this close flow, so that undoing 'all' only reverts appointments changed +// here - not ones that were already resolved before the flow started. +const CLOSE_STATUS_ACTIONS = { + attended_not_screened: { from: 'checked_in', resolves: true }, + did_not_attend: { from: 'scheduled', resolves: true }, + checked_in: { from: 'attended_not_screened', resolves: false }, + scheduled: { from: 'did_not_attend', resolves: false } +} + +/** + * Appointments resolved during this session's close flow, per clinic + */ +const getCloseResolvedIds = (data, clinicId) => { + return data.closeClinicResolvedIds?.[clinicId] || [] +} + +/** + * Record (or forget) appointments as resolved by the close flow + */ +const trackCloseResolvedIds = (data, clinicId, appointmentIds, resolves) => { + const existing = getCloseResolvedIds(data, clinicId) + const updated = resolves + ? [...new Set([...existing, ...appointmentIds])] + : existing.filter((id) => !appointmentIds.includes(id)) + + data.closeClinicResolvedIds = { + ...data.closeClinicResolvedIds, + [clinicId]: updated + } +} + +/** + * Attended not screened but no reasons recorded yet - still needs action + * before the clinic can close + */ +const needsStoppedDetails = (appointment) => { + return appointment.status === 'attended_not_screened' && !hasStoppedDetails(appointment) +} + module.exports = (router) => { // Set clinics to active in nav for all urls starting with /clinics router.use('/clinics', (req, res, next) => { @@ -165,204 +208,135 @@ module.exports = (router) => { res.redirect(returnUrl) }) - // Close clinic page - router.get('/clinics/:id/close', (req, res) => { - const clinicData = getClinicData(req.session.data, req.params.id) - - if (!clinicData) { + // Close clinic flow - resolve the clinic once for every close page + router.use('/clinics/:clinicId/close', (req, res, next) => { + const clinic = getClinic(req.session.data, req.params.clinicId) + if (!clinic) { return res.redirect('/clinics') } + res.locals.clinic = clinic + res.locals.clinicId = clinic.id + next() + }) - const resolvedKey = `closeClinicResolved_${req.params.id}` - - // Group by status so similar statuses appear together - const statusOrder = ["in_progress", "paused", "checked_in", "scheduled", "attended_not_screened", "did_not_attend", "complete", "partially_screened", "cancelled", "rescheduled"] - const sortedAppointments = [...clinicData.appointments].sort((a, b) => - statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status) + // Resolve the appointment and participant for close routes acting on one + const loadCloseAppointment = (req, res, next) => { + const { clinicId, appointmentId } = req.params + const data = req.session.data + const appointment = data.appointments.find( + (a) => a.id === appointmentId && a.clinicId === clinicId ) + if (!appointment) { + return res.redirect(`/clinics/${clinicId}/close`) + } + res.locals.appointment = appointment + res.locals.participant = getParticipant(data, appointment.participantId) + next() + } + // Close clinic page + router.get('/clinics/:clinicId/close', (req, res) => { + const { appointments, unit } = getClinicData(req.session.data, req.params.clinicId) + + // Attended not screened only counts as an outcome once reasons are + // recorded - until then it stays in the 'needs an outcome' group res.render('clinics/close', { - clinicId: req.params.id, - clinic: clinicData.clinic, - allAppointments: sortedAppointments + unit, + appointmentCount: appointments.length, + needsOutcomeCount: appointments.filter((a) => !isFinal(a) || needsStoppedDetails(a)).length, + inProgressAppointments: appointments.filter((a) => isInProgress(a)), + checkedInAppointments: [ + ...appointments.filter((a) => a.status === 'checked_in'), + ...appointments.filter((a) => needsStoppedDetails(a)) + ], + scheduledAppointments: appointments.filter((a) => a.status === 'scheduled'), + outcomeRecordedAppointments: appointments.filter((a) => isFinal(a) && !needsStoppedDetails(a)) }) }) - // Mark appointment as attended not screened from close clinic page - router.get('/clinics/:id/close/attended-not-screened/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params - updateAppointmentStatus(req.session.data, appointmentId, 'attended_not_screened') - const resolvedKey = `closeClinicResolved_${id}` - if (!req.session[resolvedKey]) req.session[resolvedKey] = [] - if (!req.session[resolvedKey].includes(appointmentId)) req.session[resolvedKey].push(appointmentId) - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success' }) + // Change one appointment's outcome from the close page. GET so the actions + // work as plain links without JS, mirroring the check-in route above. + // Fetch requests get the re-rendered row so the page can update in place. + router.get('/clinics/:clinicId/close/set-status/:appointmentId/:status', loadCloseAppointment, (req, res) => { + const { clinicId, appointmentId, status } = req.params + const action = CLOSE_STATUS_ACTIONS[status] + if (!action) { + return res.redirect(`/clinics/${clinicId}/close`) } - res.redirect(`/clinics/${id}/close`) - }) - // Undo attended not screened - router.get('/clinics/:id/close/undo-attended-not-screened/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params - updateAppointmentStatus(req.session.data, appointmentId, 'checked_in') - const resolvedKey = `closeClinicResolved_${id}` - if (req.session[resolvedKey]) req.session[resolvedKey] = req.session[resolvedKey].filter((i) => i !== appointmentId) - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success' }) - } - res.redirect(`/clinics/${id}/close`) - }) + const data = req.session.data + updateAppointmentStatus(data, appointmentId, status) + trackCloseResolvedIds(data, clinicId, [appointmentId], action.resolves) - // Mark appointment as did not attend from close clinic page - router.get('/clinics/:id/close/did-not-attend/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params - updateAppointmentStatus(req.session.data, appointmentId, 'did_not_attend') - const resolvedKey = `closeClinicResolved_${id}` - if (!req.session[resolvedKey]) req.session[resolvedKey] = [] - if (!req.session[resolvedKey].includes(appointmentId)) req.session[resolvedKey].push(appointmentId) - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success' }) + if (req.xhr) { + return res.render('clinics/close-appointment-row', { + appointment: getAppointment(data, appointmentId), + showActions: true + }) } - res.redirect(`/clinics/${id}/close`) + res.redirect(`/clinics/${clinicId}/close`) }) - // Undo did not attend - router.get('/clinics/:id/close/undo-did-not-attend/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params - updateAppointmentStatus(req.session.data, appointmentId, 'scheduled') - const resolvedKey = `closeClinicResolved_${id}` - if (req.session[resolvedKey]) req.session[resolvedKey] = req.session[resolvedKey].filter((i) => i !== appointmentId) - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success' }) + // Bulk version - applies the change to every appointment in the source + // status. Undoing (back to a non-final status) only touches appointments + // resolved by this flow. + router.get('/clinics/:clinicId/close/set-status-all/:status', (req, res) => { + const { clinicId, status } = req.params + const action = CLOSE_STATUS_ACTIONS[status] + if (!action) { + return res.redirect(`/clinics/${clinicId}/close`) } - res.redirect(`/clinics/${id}/close`) - }) - // Bulk mark all checked-in as attended not screened - router.get('/clinics/:id/close/attended-not-screened-all', (req, res) => { - const { id } = req.params const data = req.session.data - const appointments = data.appointments.filter( - (a) => a.clinicId === id && a.status === 'checked_in' + const resolvedIds = getCloseResolvedIds(data, clinicId) + const appointments = data.appointments.filter((a) => + a.clinicId === clinicId && + a.status === action.from && + (action.resolves || resolvedIds.includes(a.id)) ) - const resolvedKey = `closeClinicResolved_${id}` - if (!req.session[resolvedKey]) req.session[resolvedKey] = [] - appointments.forEach((a) => { - updateAppointmentStatus(data, a.id, 'attended_not_screened') - if (!req.session[resolvedKey].includes(a.id)) req.session[resolvedKey].push(a.id) - }) - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success', count: appointments.length }) - } - res.redirect(`/clinics/${id}/close`) - }) - // Bulk undo attended not screened (revert to checked_in) - router.get('/clinics/:id/close/undo-attended-not-screened-all', (req, res) => { - const { id } = req.params - const data = req.session.data - const resolvedKey = `closeClinicResolved_${id}` - const resolvedIds = req.session[resolvedKey] || [] - const appointments = data.appointments.filter( - (a) => a.clinicId === id && a.status === 'attended_not_screened' && resolvedIds.includes(a.id) - ) - appointments.forEach((a) => { - updateAppointmentStatus(data, a.id, 'checked_in') - }) - if (req.session[resolvedKey]) { - req.session[resolvedKey] = req.session[resolvedKey].filter( - (i) => !appointments.find((a) => a.id === i) - ) - } - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success', count: appointments.length }) - } - res.redirect(`/clinics/${id}/close`) - }) + appointments.forEach((a) => updateAppointmentStatus(data, a.id, status)) + trackCloseResolvedIds(data, clinicId, appointments.map((a) => a.id), action.resolves) - // Bulk mark all remaining as did not attend - router.get('/clinics/:id/close/did-not-attend-all', (req, res) => { - const { id } = req.params - const data = req.session.data - const appointments = data.appointments.filter( - (a) => a.clinicId === id && a.status === 'scheduled' - ) - const resolvedKey = `closeClinicResolved_${id}` - if (!req.session[resolvedKey]) req.session[resolvedKey] = [] - appointments.forEach((a) => { - updateAppointmentStatus(data, a.id, 'did_not_attend') - if (!req.session[resolvedKey].includes(a.id)) req.session[resolvedKey].push(a.id) - }) - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success', count: appointments.length }) + if (req.xhr) { + return res.json({ + count: appointments.length, + appointmentIds: appointments.map((a) => a.id) + }) } - res.redirect(`/clinics/${id}/close`) + res.redirect(`/clinics/${clinicId}/close`) }) - // Bulk undo did not attend (revert to scheduled) - router.get('/clinics/:id/close/undo-did-not-attend-all', (req, res) => { - const { id } = req.params - const data = req.session.data - const resolvedKey = `closeClinicResolved_${id}` - const resolvedIds = req.session[resolvedKey] || [] - const appointments = data.appointments.filter( - (a) => a.clinicId === id && a.status === 'did_not_attend' && resolvedIds.includes(a.id) - ) - appointments.forEach((a) => { - updateAppointmentStatus(data, a.id, 'scheduled') + // Re-render a single appointment row - fetched by close-clinic.js after a + // modal form saves, so the row can update without a page reload + router.get('/clinics/:clinicId/close/appointment-row/:appointmentId', loadCloseAppointment, (req, res) => { + res.render('clinics/close-appointment-row', { + showActions: req.query.showActions === 'true' }) - if (req.session[resolvedKey]) { - req.session[resolvedKey] = req.session[resolvedKey].filter( - (i) => !appointments.find((a) => a.id === i) - ) - } - if (req.headers.accept?.includes('application/json')) { - return res.json({ status: 'success', count: appointments.length }) - } - res.redirect(`/clinics/${id}/close`) }) // Attended-not-screened reason page (opens in modal from close page) - router.get('/clinics/:id/close/reason/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params + router.get('/clinics/:clinicId/close/reason/:appointmentId', loadCloseAppointment, (req, res) => { const data = req.session.data - const appointment = data.appointments.find((a) => a.id === appointmentId) - if (!appointment) { - return res.redirect(`/clinics/${id}/close`) + // Seed the form from the saved appointment - but not when re-rendering + // after a validation error, which must keep the user's answers. By this + // point the locals middleware has moved any flash into res.locals.flash. + const hasValidationErrors = Boolean(res.locals.flash?.error?.length) + if (!hasValidationErrors) { + data.closeReasonForm = structuredClone(res.locals.appointment.appointmentStopped || {}) + res.locals.data.closeReasonForm = data.closeReasonForm } - const participant = getParticipant(data, appointment.participantId) - const clinic = getClinic(data, id) - - // Pre-populate form with existing data, syncing both session and locals - const formData = appointment.appointmentStopped - ? { ...appointment.appointmentStopped } - : {} - data.closeReasonForm = formData - res.locals.data.closeReasonForm = formData - - res.render('clinics/close-attended-not-screened-reason', { - clinicId: id, - clinic, - appointment, - participant - }) + res.render('clinics/close-attended-not-screened-reason') }) - router.post('/clinics/:id/close/reason/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params + router.post('/clinics/:clinicId/close/reason/:appointmentId', loadCloseAppointment, (req, res) => { + const { clinicId, appointmentId } = req.params const data = req.session.data - const appointment = data.appointments.find((a) => a.id === appointmentId) - if (!appointment) { - return res.redirect(`/clinics/${id}/close`) - } - const formData = data.closeReasonForm || {} - const stoppedReason = formData.stoppedReason - const needsReschedule = formData.needsReschedule - const otherDetails = formData.otherDetails + const { stoppedReason, needsReschedule, otherDetails } = formData const hasOtherReasonButNoDetails = stoppedReason?.includes('Other reason') && !otherDetails @@ -389,145 +363,100 @@ module.exports = (router) => { href: '#needsReschedule' }) } - return res.redirect(`/clinics/${id}/close/reason/${appointmentId}`) + return res.redirect(`/clinics/${clinicId}/close/reason/${appointmentId}`) } - // Save the reason data to the appointment via updateAppointmentData (not direct mutation) + // Save the whole form rather than maintaining a field list here updateAppointmentData(data, appointmentId, { - appointmentStopped: { - stoppedReason, - needsReschedule, - otherDetails: formData.otherDetails, - failedIdentityDetails: formData.failedIdentityDetails, - painDetails: formData.painDetails, - symptomaticDetails: formData.symptomaticDetails, - consentDetails: formData.consentDetails, - physicalHealthDetails: formData.physicalHealthDetails, - mentalHealthDetails: formData.mentalHealthDetails, - languageDetails: formData.languageDetails, - mammographerDetails: formData.mammographerDetails, - technicalDetails: formData.technicalDetails, - optOutDetails: formData.optOutDetails - } + appointmentStopped: { ...formData } }) delete data.closeReasonForm // If reschedule requested, go to reschedule step if (needsReschedule === 'yes') { - return res.redirect(`/clinics/${id}/close/reschedule/${appointmentId}`) + return res.redirect(`/clinics/${clinicId}/close/reschedule/${appointmentId}`) } - // In modal context, close without page reload - if (req.query._modal === '1' || req.body?._modal === '1') { + // In modal context reply with an empty success, so the modal closes and + // the page updates the row in place rather than reloading + if (res.locals.parentLayout) { return res.send('') } - res.redirect(`/clinics/${id}/close`) + res.redirect(`/clinics/${clinicId}/close`) }) // Reschedule step (follows reason page when reschedule selected) - router.get('/clinics/:id/close/reschedule/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params + router.get('/clinics/:clinicId/close/reschedule/:appointmentId', loadCloseAppointment, (req, res) => { const data = req.session.data - const appointment = data.appointments.find((a) => a.id === appointmentId) - if (!appointment) { - return res.redirect(`/clinics/${id}/close`) + // Seed from the saved appointment unless re-rendering a validation error + // (the locals middleware has already moved any flash into res.locals.flash) + const hasValidationErrors = Boolean(res.locals.flash?.error?.length) + if (!hasValidationErrors) { + data.closeRescheduleForm = structuredClone(res.locals.appointment.reschedule || {}) + res.locals.data.closeRescheduleForm = data.closeRescheduleForm } - const participant = getParticipant(data, appointment.participantId) - const clinic = getClinic(data, id) - - // Pre-populate form with existing data, syncing both session and locals - const rescheduleFormData = appointment.reschedule - ? { ...appointment.reschedule } - : {} - data.closeRescheduleForm = rescheduleFormData - res.locals.data.closeRescheduleForm = rescheduleFormData - - res.render('clinics/close-reschedule', { - clinicId: id, - clinic, - appointment, - participant - }) + res.render('clinics/close-reschedule') }) - router.post('/clinics/:id/close/reschedule/:appointmentId', (req, res) => { - const { id, appointmentId } = req.params + router.post('/clinics/:clinicId/close/reschedule/:appointmentId', loadCloseAppointment, (req, res) => { + const { clinicId, appointmentId } = req.params const data = req.session.data - const appointment = data.appointments.find((a) => a.id === appointmentId) - if (!appointment) { - return res.redirect(`/clinics/${id}/close`) - } - const formData = data.closeRescheduleForm || {} - const timing = formData.timing - if (!timing) { + if (!formData.timing) { req.flash('error', { text: 'Select when the appointment should be rescheduled', name: 'closeRescheduleForm[timing]', href: '#timing' }) - return res.redirect(`/clinics/${id}/close/reschedule/${appointmentId}`) + return res.redirect(`/clinics/${clinicId}/close/reschedule/${appointmentId}`) } updateAppointmentData(data, appointmentId, { - reschedule: { - timing, - note: formData.note - } + reschedule: { ...formData } }) updateAppointmentStatus(data, appointmentId, 'rescheduled') delete data.closeRescheduleForm - res.redirect(modalBreakout(`/clinics/${id}/close`)) + res.redirect(modalBreakout(`/clinics/${clinicId}/close`)) }) // Confirm and close clinic - router.post('/clinics/:id/close', (req, res) => { - const { id } = req.params + router.post('/clinics/:clinicId/close', (req, res) => { + const { clinicId } = req.params const data = req.session.data - // Check all appointments have a final outcome - const finalStatuses = ['complete', 'partially_screened', 'did_not_attend', 'attended_not_screened', 'cancelled', 'rescheduled'] - const clinicAppointments = data.appointments.filter((a) => a.clinicId === id) - const unresolved = clinicAppointments.filter((a) => !finalStatuses.includes(a.status)) + const clinicAppointments = data.appointments.filter((a) => a.clinicId === clinicId) + // Every appointment needs a final outcome before the clinic can close + const unresolved = clinicAppointments.filter((a) => !isFinal(a)) if (unresolved.length > 0) { req.flash('error', [{ - text: `${unresolved.length} participant${unresolved.length === 1 ? '' : 's'} still need${unresolved.length === 1 ? 's' : ''} an outcome recorded before the clinic can be closed` + text: `An outcome still needs to be recorded for ${unresolved.length} ${pluralise('participant', unresolved.length)} before the clinic can be closed` }]) - return res.redirect(`/clinics/${id}/close`) + return res.redirect(`/clinics/${clinicId}/close`) } - // Check attended-not-screened appointments have details recorded - const ansNeedsDetails = clinicAppointments.filter( - (a) => a.status === 'attended_not_screened' && !a.appointmentStopped?.stoppedReason?.length - ) - - if (ansNeedsDetails.length > 0) { + // Attended-not-screened appointments also need their reasons recorded + const missingDetails = clinicAppointments.filter((a) => needsStoppedDetails(a)) + if (missingDetails.length > 0) { req.flash('error', [{ - text: `${ansNeedsDetails.length} participant${ansNeedsDetails.length === 1 ? '' : 's'} marked as attended not screened still need${ansNeedsDetails.length === 1 ? 's' : ''} details added` + text: `Details still need to be added for ${missingDetails.length} ${pluralise('participant', missingDetails.length)} marked as attended not screened` }]) - return res.redirect(`/clinics/${id}/close`) + return res.redirect(`/clinics/${clinicId}/close`) } - const clinicIndex = data.clinics.findIndex((c) => c.id === id) - - if (clinicIndex !== -1) { - const updatedClinic = { ...data.clinics[clinicIndex], status: 'closed' } - data.clinics[clinicIndex] = updatedClinic - if (data._changes?.clinics) { - data._changes.clinics[id] = updatedClinic - } + const updatedClinic = updateClinic(data, clinicId, { status: 'closed' }) + if (updatedClinic) { req.flash('success', `Clinic ${updatedClinic.clinicCode} closed`) } - // Clean up resolved tracking - delete req.session[`closeClinicResolved_${id}`] + // This clinic's close flow is finished - drop its resolved tracking + delete data.closeClinicResolvedIds?.[clinicId] res.redirect('/clinics/completed') }) diff --git a/app/views/_includes/close-clinic-appointment-row.njk b/app/views/_includes/close-clinic-appointment-row.njk new file mode 100644 index 00000000..60fe9ea4 --- /dev/null +++ b/app/views/_includes/close-clinic-appointment-row.njk @@ -0,0 +1,54 @@ +{# app/views/_includes/close-clinic-appointment-row.njk #} +{# + A single appointment row on the close clinic page. Also rendered on its own + by the appointment-row fragment route so close-clinic.js can swap a row in + place after a status change - keep it self-contained. + + appointment - appointment record + participant - participant the appointment belongs to + clinicId - id of the clinic being closed + showActions - whether to render the outcome action links + + Import with context so openInModal can read the modal forms setting. +#} + +{%- from '_components/link/macro.njk' import appLink %} + +{% macro closeClinicAppointmentRow(appointment, participant, clinicId, showActions) %} + {% set detailsLink = { + text: "Manage details" if appointment | hasStoppedDetails else "Add details", + href: "/clinics/" + clinicId + "/close/reason/" + appointment.id + } %} + + {{ appointment.statusHistory[0].timestamp | formatTimeString }} + +

{{ participant | getFullName }}

+

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+

View appointment

+ + + {{ appointment.status | toTag({ vocabulary: "appointment" }) }} + {% if appointment.status == "rescheduled" and appointment | hasStoppedDetails %} + {{ "attended_not_screened" | toTag({ vocabulary: "appointment" }) }} + {% endif %} + + + {% if showActions %} + {% if appointment.status == "in_progress" or appointment.status == "paused" %} + Go to appointment + {% elseif appointment.status == "checked_in" %} + Mark as attended not screened + {% elseif appointment.status == "scheduled" %} + Mark as did not attend + {% elseif appointment.status == "attended_not_screened" %} + Undo +
{{ appLink(detailsLink | openInModal) }} + {% elseif appointment.status == "did_not_attend" %} + Undo + {% endif %} + {% elseif appointment | hasStoppedDetails %} + {{ appLink(detailsLink | openInModal) }} + {% endif %} + + +{% endmacro %} diff --git a/app/views/_includes/forms/attended-not-screened-fields.njk b/app/views/_includes/forms/attended-not-screened-fields.njk new file mode 100644 index 00000000..a2af12c0 --- /dev/null +++ b/app/views/_includes/forms/attended-not-screened-fields.njk @@ -0,0 +1,111 @@ +{# app/views/_includes/forms/attended-not-screened-fields.njk #} +{# + Shared fields for recording why an appointment was stopped (attended not + screened) - used by the in-appointment page and the close clinic flow, + which store their answers under different form names. + + namePrefix - prefix for field names, eg "appointment[appointmentStopped]" or "closeReasonForm" + values - object holding the current answers, eg appointment.appointmentStopped + participant - participant the appointment belongs to + + Import with context so populateErrors can read flash errors. +#} + +{%- from 'checkboxes/macro.njk' import checkboxes %} +{%- from 'input/macro.njk' import input %} +{%- from 'radios/macro.njk' import radios %} +{%- from 'textarea/macro.njk' import textarea %} + +{% macro attendedNotScreenedFields(namePrefix, values, participant) %} + + {% set values = values or {} %} + + {% set stoppedReasonItems = [] %} + {% for reason in getStoppedReasons() %} + {% set stoppedReasonItems = stoppedReasonItems | push({ + value: reason.value, + text: reason.value, + conditional: { + html: input({ + name: namePrefix + "[" + reason.detailsField + "]", + label: { text: "Provide details (optional)" }, + value: values[reason.detailsField], + autocomplete: "off" + }) + } + }) %} + {% endfor %} + + {% set stoppedReasonItems = stoppedReasonItems | push({ divider: "or" }) %} + + {% set stoppedReasonItems = stoppedReasonItems | push({ + value: "Other reason", + text: "Other reason", + conditional: { + html: textarea({ + name: namePrefix + "[otherDetails]", + label: { text: "Provide details" }, + rows: 5, + value: values.otherDetails, + autocomplete: "off" + }) + } + }) %} + + {{ checkboxes({ + name: namePrefix + "[stoppedReason]", + values: values.stoppedReason, + fieldset: { + legend: { + text: "Why has this appointment been stopped?", + size: "m", + isPageHeading: false + }, + hint: { + text: "Select all that apply" + } + }, + items: stoppedReasonItems + } | populateErrors) }} + + {{ radios({ + name: namePrefix + "[needsReschedule]", + value: values.needsReschedule, + fieldset: { + legend: { + text: "Should the appointment be rescheduled?", + size: "m", + isPageHeading: false + } + }, + items: [ + { + value: "yes", + text: "Yes" + }, + { + value: "no-invite", + text: "No, invite to next routine appointment", + hint: { + text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" + } + }, + { + value: "no-opt-out", + text: "No, request opt out", + hint: { + text: "They will receive information explaining their options" + }, + conditional: { + html: input({ + name: namePrefix + "[optOutDetails]", + label: { text: "Provide details (optional)" }, + value: values.optOutDetails, + autocomplete: "off" + }) + } + } + ] + } | populateErrors) }} + +{% endmacro %} diff --git a/app/views/appointments/attended-not-screened-reason.html b/app/views/appointments/attended-not-screened-reason.html index 01a41c1a..c9b710ee 100644 --- a/app/views/appointments/attended-not-screened-reason.html +++ b/app/views/appointments/attended-not-screened-reason.html @@ -2,6 +2,8 @@ {% extends 'layout-appointment.html' %} +{% from "_includes/forms/attended-not-screened-fields.njk" import attendedNotScreenedFields with context %} + {% set pageHeading = "Appointment cannot proceed" %} {% set formAction = './attended-not-screened-answer' %} @@ -26,213 +28,12 @@

href: './previous-mammograms/add' | urlWithReferrer(currentUrl) } | openInModal) }} if the appointment is being stopped because the participant has recently had one

{% endset %} - + {{ insetText({ html: insetHtml }) }} - {{ checkboxes({ - name: "appointment[appointmentStopped][stoppedReason]", - values: appointment.appointmentStopped.stoppedReason, - fieldset: { - legend: { - text: "Why has this appointment been stopped?", - size: "m", - isPageHeading: false - }, - hint: { - text: "Select all that apply" - } - }, - items: [ - { - value: "Failed identity check", - text: "Failed identity check", - conditional: { - html: input({ - name: "appointment[appointmentStopped][failedIdentityDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.failedIdentityDetails, - autocomplete: "off" - }) - } - }, - { - value: "Pain during screening", - text: "Pain during screening", - conditional: { - html: input({ - name: "appointment[appointmentStopped][painDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.painDetails, - autocomplete: "off" - }) - } - }, - { - value: "Has a symptomatic appointment", - text: "Has a symptomatic appointment", - conditional: { - html: input({ - name: "appointment[appointmentStopped][symptomaticDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.symptomaticDetails, - autocomplete: "off" - }) - } - }, - { - value: "Consent withdrawn", - text: "Consent withdrawn", - conditional: { - html: input({ - name: "appointment[appointmentStopped][consentDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.consentDetails, - autocomplete: "off" - }) - } - }, - { - value: "Physical health issue", - text: "Physical health issue", - conditional: { - html: input({ - name: "appointment[appointmentStopped][physicalHealthDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.physicalHealthDetails, - autocomplete: "off" - }) - } - }, - { - value: "Mental health issue", - text: "Mental health issue", - conditional: { - html: input({ - name: "appointment[appointmentStopped][mentalHealthDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.mentalHealthDetails, - autocomplete: "off" - }) - } - }, - { - value: "Language difficulties", - text: "Language difficulties", - conditional: { - html: input({ - name: "appointment[appointmentStopped][languageDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.languageDetails, - autocomplete: "off" - }) - } - }, - { - value: "No qualified mammographer available", - text: "No qualified mammographer available", - conditional: { - html: input({ - name: "appointment[appointmentStopped][mammographerDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.mammographerDetails, - autocomplete: "off" - }) - } - }, - { - value: "Technical issues at clinic", - text: "Technical issues at clinic", - conditional: { - html: input({ - name: "appointment[appointmentStopped][technicalDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.technicalDetails, - autocomplete: "off" - }) - } - }, - { - divider: "or" - }, - { - value: "Other reason", - text: "Other reason", - conditional: { - html: textarea({ - name: "appointment[appointmentStopped][otherDetails]", - label: { - text: "Provide details" - }, - rows: 5, - value: appointment.appointmentStopped.otherDetails, - autocomplete: "off" - }) - } - } - ] - } | populateErrors) }} - - {{ radios({ - name: "appointment[appointmentStopped][needsReschedule]", - value: appointment.appointmentStopped.needsReschedule, - fieldset: { - legend: { - text: "Should the appointment be rescheduled?", - size: "m", - isPageHeading: false - } - }, - items: [ - { - value: "yes", - text: "Yes" - }, - { - value: "no-invite", - text: "No, invite to next routine appointment", - hint: { - text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" - } - }, - { - value: "no-opt-out", - text: "No, request opt out", - hint: { - text: "They will receive information explaining their options" - }, - conditional: { - html: input({ - name: "appointment[appointmentStopped][optOutDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.optOutDetails, - autocomplete: "off" - }) - } - } - ] - } | populateErrors) }} + {{ attendedNotScreenedFields("appointment[appointmentStopped]", appointment.appointmentStopped, participant) }}
{{ button({ diff --git a/app/views/clinics/close-appointment-row.html b/app/views/clinics/close-appointment-row.html new file mode 100644 index 00000000..eca11c10 --- /dev/null +++ b/app/views/clinics/close-appointment-row.html @@ -0,0 +1,7 @@ +{# app/views/clinics/close-appointment-row.html #} +{# Bare fragment - a single table row fetched by close-clinic.js to update + the close clinic page in place after a status change #} + +{% from "_includes/close-clinic-appointment-row.njk" import closeClinicAppointmentRow with context %} + +{{ closeClinicAppointmentRow(appointment, participant, clinicId, showActions) }} diff --git a/app/views/clinics/close-attended-not-screened-reason.html b/app/views/clinics/close-attended-not-screened-reason.html index 802cce17..34e6821c 100644 --- a/app/views/clinics/close-attended-not-screened-reason.html +++ b/app/views/clinics/close-attended-not-screened-reason.html @@ -2,6 +2,8 @@ {% extends parentLayout or 'layout-app.html' %} +{% from "_includes/forms/attended-not-screened-fields.njk" import attendedNotScreenedFields with context %} + {% set pageHeading = "Why was this appointment stopped?" %} {% set formAction = "/clinics/" + clinicId + "/close/reason/" + appointment.id %} @@ -19,195 +21,12 @@

{{ pageHeading }}

-
- - {{ checkboxes({ - name: "closeReasonForm[stoppedReason]", - values: data.closeReasonForm.stoppedReason, - fieldset: { - legend: { - text: "Why has this appointment been stopped?", - size: "m", - isPageHeading: false - }, - hint: { - text: "Select all that apply" - } - }, - items: [ - { - value: "Failed identity check", - text: "Failed identity check", - conditional: { - html: input({ - name: "closeReasonForm[failedIdentityDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.failedIdentityDetails, - autocomplete: "off" - }) - } - }, - { - value: "Pain during screening", - text: "Pain during screening", - conditional: { - html: input({ - name: "closeReasonForm[painDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.painDetails, - autocomplete: "off" - }) - } - }, - { - value: "Has a symptomatic appointment", - text: "Has a symptomatic appointment", - conditional: { - html: input({ - name: "closeReasonForm[symptomaticDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.symptomaticDetails, - autocomplete: "off" - }) - } - }, - { - value: "Consent withdrawn", - text: "Consent withdrawn", - conditional: { - html: input({ - name: "closeReasonForm[consentDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.consentDetails, - autocomplete: "off" - }) - } - }, - { - value: "Physical health issue", - text: "Physical health issue", - conditional: { - html: input({ - name: "closeReasonForm[physicalHealthDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.physicalHealthDetails, - autocomplete: "off" - }) - } - }, - { - value: "Mental health issue", - text: "Mental health issue", - conditional: { - html: input({ - name: "closeReasonForm[mentalHealthDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.mentalHealthDetails, - autocomplete: "off" - }) - } - }, - { - value: "Language difficulties", - text: "Language difficulties", - conditional: { - html: input({ - name: "closeReasonForm[languageDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.languageDetails, - autocomplete: "off" - }) - } - }, - { - value: "No qualified mammographer available", - text: "No qualified mammographer available", - conditional: { - html: input({ - name: "closeReasonForm[mammographerDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.mammographerDetails, - autocomplete: "off" - }) - } - }, - { - value: "Technical issues at clinic", - text: "Technical issues at clinic", - conditional: { - html: input({ - name: "closeReasonForm[technicalDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.technicalDetails, - autocomplete: "off" - }) - } - }, - { - divider: "or" - }, - { - value: "Other reason", - text: "Other reason", - conditional: { - html: textarea({ - name: "closeReasonForm[otherDetails]", - label: { text: "Provide details" }, - rows: 5, - value: data.closeReasonForm.otherDetails, - autocomplete: "off" - }) - } - } - ] - } | populateErrors) }} - - {{ radios({ - name: "closeReasonForm[needsReschedule]", - value: data.closeReasonForm.needsReschedule, - fieldset: { - legend: { - text: "Should the appointment be rescheduled?", - size: "m", - isPageHeading: false - } - }, - items: [ - { - value: "yes", - text: "Yes" - }, - { - value: "no-invite", - text: "No, invite to next routine appointment", - hint: { - text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" - } - }, - { - value: "no-opt-out", - text: "No, request opt out", - hint: { - text: "They will receive information explaining their options" - }, - conditional: { - html: input({ - name: "closeReasonForm[optOutDetails]", - label: { text: "Provide details (optional)" }, - value: data.closeReasonForm.optOutDetails, - autocomplete: "off" - }) - } - } - ] - } | populateErrors) }} - -
- {{ button({ - text: "Continue" - }) }} -
+ {{ attendedNotScreenedFields("closeReasonForm", data.closeReasonForm, participant) }} -
+
+ {{ button({ + text: "Continue" + }) }} +
{% endblock %} diff --git a/app/views/clinics/close-reschedule.html b/app/views/clinics/close-reschedule.html index 8b7b7b32..ce56320e 100644 --- a/app/views/clinics/close-reschedule.html +++ b/app/views/clinics/close-reschedule.html @@ -19,50 +19,46 @@

{{ pageHeading }}

-
- - {{ radios({ - name: "closeRescheduleForm[timing]", - value: data.closeRescheduleForm.timing, - fieldset: { - legend: { - text: "When should the appointment be?", - size: "m", - isPageHeading: false - } - }, - items: [ - { - value: "within-6-weeks", - text: "Within the next 6 weeks" - }, - { - value: "more-than-6-weeks", - text: "More than 6 weeks away" - } - ] - } | populateErrors) }} - - {{ textarea({ - name: "closeRescheduleForm[note]", - label: { - text: "Note for rescheduling (optional)", - size: "m" + {{ radios({ + name: "closeRescheduleForm[timing]", + value: data.closeRescheduleForm.timing, + fieldset: { + legend: { + text: "When should the appointment be?", + size: "m", + isPageHeading: false + } + }, + items: [ + { + value: "within-6-weeks", + text: "Within the next 6 weeks" }, - hint: { - text: "Include any other relevant information" - }, - rows: 5, - value: data.closeRescheduleForm.note, - autocomplete: "off" - }) }} + { + value: "more-than-6-weeks", + text: "More than 6 weeks away" + } + ] + } | populateErrors) }} -
- {{ button({ - text: "Continue" - }) }} -
+ {{ textarea({ + name: "closeRescheduleForm[note]", + label: { + text: "Note for rescheduling (optional)", + size: "m" + }, + hint: { + text: "Include any other relevant information" + }, + rows: 5, + value: data.closeRescheduleForm.note, + autocomplete: "off" + }) }} -
+
+ {{ button({ + text: "Continue" + }) }} +
{% endblock %} diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html index c960efaa..234d699f 100644 --- a/app/views/clinics/close.html +++ b/app/views/clinics/close.html @@ -1,6 +1,9 @@ {# app/views/clinics/close.html #} {% extends 'layout-app.html' %} + +{% from "_includes/close-clinic-appointment-row.njk" import closeClinicAppointmentRow with context %} + {% set pageHeading = "Close clinic " ~ clinic.clinicCode %} {% set gridColumn = "nhsuk-grid-column-full" %} @@ -11,8 +14,6 @@ {% block pageContent %} - {% set unit = data.breastScreeningUnits | findById(clinic.breastScreeningUnitId) %} -

{{ unit.name }} {{ pageHeading }} @@ -20,152 +21,88 @@

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

- {# ANS with details is a final outcome; without details still needs action #} - {% set ansWithoutDetails = [] %} - {% for appointment in allAppointments %} - {% if appointment.status == "attended_not_screened" and not (appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length) %} - {% set ansWithoutDetails = ansWithoutDetails | push(appointment) %} - {% endif %} - {% endfor %} - - {% set needsOutcomeAppointments = allAppointments | removeWhere("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} - {% set needsOutcomeCount = needsOutcomeAppointments | length + ansWithoutDetails | length %} - -
- - {{ insetText({ - html: "

Record an appointment outcome for every participant to close this clinic.

There were " + allAppointments | length + " total participants in this clinic, and " + needsOutcomeCount + " still need a final outcome assigned.

" - }) }} - - {# Macro for appointment table rows #} - {% macro appointmentRow(appointment, clinicId, showActions) %} - {% set participant = data.participants | findById(appointment.participantId) %} - - {{ appointment.statusHistory[0].timestamp | formatTimeString }} - -

{{ participant | getFullName }}

-

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

-

View appointment

- - - {{ appointment.status | toTag({ vocabulary: "appointment" }) }} - {% if appointment.status == "rescheduled" and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} - {{ "attended_not_screened" | toTag({ vocabulary: "appointment" }) }} - {% endif %} - - - {% if showActions %} - {% if appointment.status == "in_progress" or appointment.status == "paused" %} - Go to appointment - {% elseif appointment.status == "checked_in" %} - Mark as attended not screened - {% elseif appointment.status == "scheduled" %} - Mark as did not attend - {% elseif appointment.status == "attended_not_screened" %} - Undo - {% if appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} -
Manage details - {% else %} -
Add details - {% endif %} - {% elseif appointment.status == "did_not_attend" %} - Undo - {% endif %} - {% endif %} - {% if not showActions and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} - Manage details - {% endif %} - - - {% endmacro %} - - {# Macro for a status group table #} + {# Table of appointments in one status group #} {% macro statusGroupTable(appointments, clinicId, showActions) %} - +
- - + + {% for appointment in appointments %} - {{ appointmentRow(appointment, clinicId, showActions) }} + {{ closeClinicAppointmentRow(appointment, appointment.participant, clinicId, showActions) }} {% endfor %}
Time DetailsStatus{{ "Actions" if showActions }}Status{{ "Actions" if showActions }}
{% endmacro %} - {% if needsOutcomeCount %} - {% set needsOutcomeHtml %} - {# In progress #} - {% set inProgressAppointments = allAppointments | where("status", ["in_progress", "paused"]) %} - {% if inProgressAppointments | length %} -

In progress

-

Complete or end these appointments to close the clinic.

- {{ statusGroupTable(inProgressAppointments, clinicId, true) }} - {% endif %} + {# Bulk action button - close-clinic.js swaps it for the undo message once used #} + {% macro bulkActionControl(clinicId, markStatus, undoStatus, buttonText, markedLabel) %} +

+ {{ buttonText }} + +

+ {% endmacro %} - {# Checked in, not screened (includes ANS without details) #} - {% set checkedInAppointments = allAppointments | where("status", "checked_in") %} - {% set checkedInAndAns = [] %} - {% for appointment in checkedInAppointments %} - {% set checkedInAndAns = checkedInAndAns | push(appointment) %} - {% endfor %} - {% for appointment in ansWithoutDetails %} - {% set checkedInAndAns = checkedInAndAns | push(appointment) %} - {% endfor %} - {% if checkedInAndAns | length %} -

Checked in, not screened

-

- Mark all as attended not screened -

- {{ statusGroupTable(checkedInAndAns, clinicId, true) }} - {% endif %} +
- {# Did not check in #} - {% set scheduledAppointments = allAppointments | where("status", "scheduled") %} - {% if scheduledAppointments | length %} -

Did not check in

-

- Mark all as did not attend -

- {{ statusGroupTable(scheduledAppointments, clinicId, true) }} - {% endif %} + {% set introHtml %} +

Record an appointment outcome for every participant to close this clinic.

+

There were {{ appointmentCount }} total participants in this clinic, and {{ needsOutcomeCount }} still need a final outcome assigned.

{% endset %} - {{ card({ - heading: "Needs an outcome (" + needsOutcomeCount + ")", - headingLevel: "2", - feature: true, - descriptionHtml: needsOutcomeHtml + {{ insetText({ + html: introHtml }) }} - {% endif %} - {# Outcome recorded — includes ANS with details #} - {% set outcomeRecordedAppointments = allAppointments | where("status", ["complete", "partially_screened", "did_not_attend", "cancelled", "rescheduled"]) %} - {% for appointment in allAppointments %} - {% if appointment.status == "attended_not_screened" and appointment.appointmentStopped and appointment.appointmentStopped.stoppedReason | length %} - {% set outcomeRecordedAppointments = outcomeRecordedAppointments | push(appointment) %} - {% endif %} - {% endfor %} + {% if needsOutcomeCount %} + {% set needsOutcomeHtml %} + {% if inProgressAppointments | length %} +

In progress

+

Complete or end these appointments to close the clinic.

+ {{ statusGroupTable(inProgressAppointments, clinicId, true) }} + {% endif %} + + {# Checked in, not screened - includes attended not screened appointments still needing details #} + {% if checkedInAppointments | length %} +

Checked in, not screened

+ {{ bulkActionControl(clinicId, "attended_not_screened", "checked_in", "Mark all as attended not screened", "attended not screened") }} + {{ statusGroupTable(checkedInAppointments, clinicId, true) }} + {% endif %} - {% set outcomeRecordedHtml %} -

- {% if outcomeRecordedAppointments | length %} - {{ statusGroupTable(outcomeRecordedAppointments, clinicId, false) }} + {% if scheduledAppointments | length %} +

Did not check in

+ {{ bulkActionControl(clinicId, "did_not_attend", "scheduled", "Mark all as did not attend", "did not attend") }} + {{ statusGroupTable(scheduledAppointments, clinicId, true) }} + {% endif %} + {% endset %} + + {{ card({ + heading: "Needs an outcome (" + needsOutcomeCount + ")", + headingLevel: "2", + feature: true, + descriptionHtml: needsOutcomeHtml + }) }} {% endif %} - {% endset %} - {{ card({ - heading: "Outcome recorded (" + outcomeRecordedAppointments | length + ")", - headingLevel: "2", - feature: true, - classes: "app-card--feature-green", - descriptionHtml: outcomeRecordedHtml - }) }} + {% set outcomeRecordedHtml %} +

+ {% if outcomeRecordedAppointments | length %} + {{ statusGroupTable(outcomeRecordedAppointments, clinicId, false) }} + {% endif %} + {% endset %} + + {{ card({ + heading: "Outcome recorded (" + outcomeRecordedAppointments | length + ")", + headingLevel: "2", + feature: true, + classes: "app-card--feature-green", + descriptionHtml: outcomeRecordedHtml + }) }}
@@ -178,158 +115,5 @@

Did not check in

{% endblock %} {% block pageScripts %} - + {% endblock %} diff --git a/app/views/clinics/show.html b/app/views/clinics/show.html index a4d2cb82..1c50a703 100644 --- a/app/views/clinics/show.html +++ b/app/views/clinics/show.html @@ -35,7 +35,7 @@

{{ clinic.status | toTag({ vocabulary: "clinic" }) }}

- View clinic report + View clinic report

{% if clinic.status !== "closed" and clinic.status !== "scheduled" %}

From fbe3e7a5d85a3be4332c7060b12863583d728aef Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 14 Aug 2026 15:15:10 +0100 Subject: [PATCH 17/19] Convert stopped-reason fields to an include, finish reschedule in place - attended-not-screened fields become a context include, matching the repo's include convention - reschedule step now closes the modal and updates the row in place, matching the no-reschedule path --- app/routes/clinics.js | 10 +- .../close-clinic-appointment-row.njk | 2 + .../forms/attended-not-screened-fields.njk | 167 +++++++++--------- .../attended-not-screened-reason.html | 6 +- .../close-attended-not-screened-reason.html | 6 +- 5 files changed, 95 insertions(+), 96 deletions(-) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index b2686ca6..a20bc086 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -13,7 +13,7 @@ const { isFinal, hasStoppedDetails } = require('../lib/utils/status') -const { getReturnUrl, modalBreakout } = require('../lib/utils/referrers') +const { getReturnUrl } = require('../lib/utils/referrers') const { getParticipant } = require('../lib/utils/participants') const { updateAppointmentStatus } = require('../lib/utils/appointment-status') const { getAppointment, updateAppointmentData } = require('../lib/utils/appointment-data') @@ -422,7 +422,13 @@ module.exports = (router) => { updateAppointmentStatus(data, appointmentId, 'rescheduled') delete data.closeRescheduleForm - res.redirect(modalBreakout(`/clinics/${clinicId}/close`)) + + // In modal context reply with an empty success, so the modal closes and + // the page updates the row in place rather than reloading + if (res.locals.parentLayout) { + return res.send('') + } + res.redirect(`/clinics/${clinicId}/close`) }) // Confirm and close clinic diff --git a/app/views/_includes/close-clinic-appointment-row.njk b/app/views/_includes/close-clinic-appointment-row.njk index 60fe9ea4..8157adb1 100644 --- a/app/views/_includes/close-clinic-appointment-row.njk +++ b/app/views/_includes/close-clinic-appointment-row.njk @@ -45,6 +45,8 @@
{{ appLink(detailsLink | openInModal) }} {% elseif appointment.status == "did_not_attend" %} Undo + {% elseif appointment.status == "rescheduled" and appointment | hasStoppedDetails %} + {{ appLink(detailsLink | openInModal) }} {% endif %} {% elseif appointment | hasStoppedDetails %} {{ appLink(detailsLink | openInModal) }} diff --git a/app/views/_includes/forms/attended-not-screened-fields.njk b/app/views/_includes/forms/attended-not-screened-fields.njk index a2af12c0..048d24f6 100644 --- a/app/views/_includes/forms/attended-not-screened-fields.njk +++ b/app/views/_includes/forms/attended-not-screened-fields.njk @@ -4,108 +4,99 @@ screened) - used by the in-appointment page and the close clinic flow, which store their answers under different form names. - namePrefix - prefix for field names, eg "appointment[appointmentStopped]" or "closeReasonForm" - values - object holding the current answers, eg appointment.appointmentStopped - participant - participant the appointment belongs to + Set before including: + stoppedFieldsNamePrefix - prefix for field names, eg "appointment[appointmentStopped]" or "closeReasonForm" + stoppedFieldsValues - object holding the current answers, eg appointment.appointmentStopped - Import with context so populateErrors can read flash errors. + Also expects participant in context for the reschedule hint. #} -{%- from 'checkboxes/macro.njk' import checkboxes %} -{%- from 'input/macro.njk' import input %} -{%- from 'radios/macro.njk' import radios %} -{%- from 'textarea/macro.njk' import textarea %} - -{% macro attendedNotScreenedFields(namePrefix, values, participant) %} - - {% set values = values or {} %} - - {% set stoppedReasonItems = [] %} - {% for reason in getStoppedReasons() %} - {% set stoppedReasonItems = stoppedReasonItems | push({ - value: reason.value, - text: reason.value, - conditional: { - html: input({ - name: namePrefix + "[" + reason.detailsField + "]", - label: { text: "Provide details (optional)" }, - value: values[reason.detailsField], - autocomplete: "off" - }) - } - }) %} - {% endfor %} - - {% set stoppedReasonItems = stoppedReasonItems | push({ divider: "or" }) %} +{% set stoppedFieldsValues = stoppedFieldsValues or {} %} +{% set stoppedReasonItems = [] %} +{% for reason in getStoppedReasons() %} {% set stoppedReasonItems = stoppedReasonItems | push({ - value: "Other reason", - text: "Other reason", + value: reason.value, + text: reason.value, conditional: { - html: textarea({ - name: namePrefix + "[otherDetails]", - label: { text: "Provide details" }, - rows: 5, - value: values.otherDetails, + html: input({ + name: stoppedFieldsNamePrefix + "[" + reason.detailsField + "]", + label: { text: "Provide details (optional)" }, + value: stoppedFieldsValues[reason.detailsField], autocomplete: "off" }) } }) %} +{% endfor %} - {{ checkboxes({ - name: namePrefix + "[stoppedReason]", - values: values.stoppedReason, - fieldset: { - legend: { - text: "Why has this appointment been stopped?", - size: "m", - isPageHeading: false - }, - hint: { - text: "Select all that apply" - } +{% set stoppedReasonItems = stoppedReasonItems | push({ divider: "or" }) %} + +{% set stoppedReasonItems = stoppedReasonItems | push({ + value: "Other reason", + text: "Other reason", + conditional: { + html: textarea({ + name: stoppedFieldsNamePrefix + "[otherDetails]", + label: { text: "Provide details" }, + rows: 5, + value: stoppedFieldsValues.otherDetails, + autocomplete: "off" + }) + } +}) %} + +{{ checkboxes({ + name: stoppedFieldsNamePrefix + "[stoppedReason]", + values: stoppedFieldsValues.stoppedReason, + fieldset: { + legend: { + text: "Why has this appointment been stopped?", + size: "m", + isPageHeading: false }, - items: stoppedReasonItems - } | populateErrors) }} + hint: { + text: "Select all that apply" + } + }, + items: stoppedReasonItems +} | populateErrors) }} - {{ radios({ - name: namePrefix + "[needsReschedule]", - value: values.needsReschedule, - fieldset: { - legend: { - text: "Should the appointment be rescheduled?", - size: "m", - isPageHeading: false +{{ radios({ + name: stoppedFieldsNamePrefix + "[needsReschedule]", + value: stoppedFieldsValues.needsReschedule, + fieldset: { + legend: { + text: "Should the appointment be rescheduled?", + size: "m", + isPageHeading: false + } + }, + items: [ + { + value: "yes", + text: "Yes" + }, + { + value: "no-invite", + text: "No, invite to next routine appointment", + hint: { + text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" } }, - items: [ - { - value: "yes", - text: "Yes" - }, - { - value: "no-invite", - text: "No, invite to next routine appointment", - hint: { - text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" - } + { + value: "no-opt-out", + text: "No, request opt out", + hint: { + text: "They will receive information explaining their options" }, - { - value: "no-opt-out", - text: "No, request opt out", - hint: { - text: "They will receive information explaining their options" - }, - conditional: { - html: input({ - name: namePrefix + "[optOutDetails]", - label: { text: "Provide details (optional)" }, - value: values.optOutDetails, - autocomplete: "off" - }) - } + conditional: { + html: input({ + name: stoppedFieldsNamePrefix + "[optOutDetails]", + label: { text: "Provide details (optional)" }, + value: stoppedFieldsValues.optOutDetails, + autocomplete: "off" + }) } - ] - } | populateErrors) }} - -{% endmacro %} + } + ] +} | populateErrors) }} diff --git a/app/views/appointments/attended-not-screened-reason.html b/app/views/appointments/attended-not-screened-reason.html index c9b710ee..78763b40 100644 --- a/app/views/appointments/attended-not-screened-reason.html +++ b/app/views/appointments/attended-not-screened-reason.html @@ -2,8 +2,6 @@ {% extends 'layout-appointment.html' %} -{% from "_includes/forms/attended-not-screened-fields.njk" import attendedNotScreenedFields with context %} - {% set pageHeading = "Appointment cannot proceed" %} {% set formAction = './attended-not-screened-answer' %} @@ -33,7 +31,9 @@

html: insetHtml }) }} - {{ attendedNotScreenedFields("appointment[appointmentStopped]", appointment.appointmentStopped, participant) }} + {% set stoppedFieldsNamePrefix = "appointment[appointmentStopped]" %} + {% set stoppedFieldsValues = appointment.appointmentStopped %} + {% include "_includes/forms/attended-not-screened-fields.njk" %}
{{ button({ diff --git a/app/views/clinics/close-attended-not-screened-reason.html b/app/views/clinics/close-attended-not-screened-reason.html index 34e6821c..b726dcbb 100644 --- a/app/views/clinics/close-attended-not-screened-reason.html +++ b/app/views/clinics/close-attended-not-screened-reason.html @@ -2,8 +2,6 @@ {% extends parentLayout or 'layout-app.html' %} -{% from "_includes/forms/attended-not-screened-fields.njk" import attendedNotScreenedFields with context %} - {% set pageHeading = "Why was this appointment stopped?" %} {% set formAction = "/clinics/" + clinicId + "/close/reason/" + appointment.id %} @@ -21,7 +19,9 @@

{{ pageHeading }}

- {{ attendedNotScreenedFields("closeReasonForm", data.closeReasonForm, participant) }} + {% set stoppedFieldsNamePrefix = "closeReasonForm" %} + {% set stoppedFieldsValues = data.closeReasonForm %} + {% include "_includes/forms/attended-not-screened-fields.njk" %}
{{ button({ From 19065ed3ad3699ee3a8bc49575e249a58f0de75d Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 14 Aug 2026 15:18:38 +0100 Subject: [PATCH 18/19] Seeded attended-not-screened appointments always have details again --- app/lib/generators/appointment-generator.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/lib/generators/appointment-generator.js b/app/lib/generators/appointment-generator.js index 0d312325..fa9ba751 100644 --- a/app/lib/generators/appointment-generator.js +++ b/app/lib/generators/appointment-generator.js @@ -396,13 +396,11 @@ const generateAppointment = ({ endedAt: actualEndTime.toISOString() } - // Most stopped appointments have their reasons recorded already; leave - // some without so the close clinic flow's 'add details' state is seeded - if (Math.random() < 0.7) { - appointment.appointmentStopped = { - stoppedReason: [faker.helpers.arrayElement(getStoppedReasons()).value], - needsReschedule: faker.helpers.arrayElement(['no-invite', 'no-invite', 'yes']) - } + // Stopping an appointment in the appointment flow requires reasons, so + // seeded attended-not-screened appointments always have them + appointment.appointmentStopped = { + stoppedReason: [faker.helpers.arrayElement(getStoppedReasons()).value], + needsReschedule: faker.helpers.arrayElement(['no-invite', 'no-invite', 'yes']) } } From 0181d4ed2c55b030c39b16db2caa5a5352d0092f Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Fri, 14 Aug 2026 16:02:22 +0100 Subject: [PATCH 19/19] Add shared fragment-actions module for in-place status updates - fragment-actions.js: links and forms marked data-fragment-action are fetched, and the server's re-rendered data-fragment-id element is swapped in place; falls back to normal navigation without JS or on error - priors dashboard: row extracted to a macro rendered by a fragment route; deletes the inline script that duplicated status vocab and forms - clinic page check-in: row extracted to a macro; check-in route returns the re-rendered row and main.js swaps it instead of hand-patching tags - close clinic page moved onto the same convention --- app/assets/javascript/close-clinic.js | 67 +---- app/assets/javascript/fragment-actions.js | 85 ++++++ app/assets/javascript/main.js | 92 ++----- app/routes/clinics.js | 18 +- app/routes/reading.js | 24 +- .../_includes/clinic-appointment-row.njk | 115 ++++++++ .../close-clinic-appointment-row.njk | 10 +- .../_includes/reading/prior-mammogram-row.njk | 104 +++++++ app/views/_includes/scripts.html | 1 + app/views/clinics/clinic-appointment-row.html | 7 + app/views/clinics/show.html | 199 +------------- app/views/reading/prior-mammogram-row.html | 7 + app/views/reading/priors.html | 260 +----------------- 13 files changed, 386 insertions(+), 603 deletions(-) create mode 100644 app/assets/javascript/fragment-actions.js create mode 100644 app/views/_includes/clinic-appointment-row.njk create mode 100644 app/views/_includes/reading/prior-mammogram-row.njk create mode 100644 app/views/clinics/clinic-appointment-row.html create mode 100644 app/views/reading/prior-mammogram-row.html diff --git a/app/assets/javascript/close-clinic.js b/app/assets/javascript/close-clinic.js index dc913743..7345f448 100644 --- a/app/assets/javascript/close-clinic.js +++ b/app/assets/javascript/close-clinic.js @@ -1,8 +1,10 @@ -// Close clinic page - progressively enhances the outcome action links so -// status changes happen without a full page reload. Without JS every link -// still works as a normal navigation. Changed rows are re-rendered -// server-side (the appointment-row fragment route) and swapped in place, so -// tags and action links can't drift from the server's rendering. +// Close clinic page - page-specific enhancements on top of +// fragment-actions.js, which already handles the single outcome links +// (marked data-fragment-action in the row macro). This file adds the parts +// with wider effects: bulk actions, revealing the refresh hint when counts +// go stale, and refreshing a row after its details modal saves. + +import { refreshFragment } from './fragment-actions.js' document.addEventListener('DOMContentLoaded', () => { const container = document.getElementById('js-close-clinic-content') @@ -18,47 +20,17 @@ document.addEventListener('DOMContentLoaded', () => { if (link) link.hidden = false } - const rowFor = (appointmentId) => - container.querySelector(`tr[data-appointment-id="${appointmentId}"]`) + // Any swapped row means the page counts may be stale + container.addEventListener('fragment:swapped', showRefreshLink) - // Replace a row with its server-rendered replacement - const swapRow = (row, html) => { - const template = document.createElement('template') - template.innerHTML = html.trim() - const newRow = template.content.querySelector('tr[data-appointment-id]') - if (!newRow || newRow.dataset.appointmentId !== row.dataset.appointmentId) { - throw new Error('Response was not the expected row') - } - row.replaceWith(newRow) - } + const rowFor = (appointmentId) => + container.querySelector(`tr[data-fragment-id="${appointmentId}"]`) // Re-fetch one row and swap it in place const refreshRow = (row) => { const showActions = row.closest('table')?.dataset.showActions || 'false' - const url = `/clinics/${clinicId}/close/appointment-row/${row.dataset.appointmentId}?showActions=${showActions}` - return fetch(url, fetchOptions) - .then((response) => { - if (!response.ok) throw new Error('Failed to refresh row') - return response.text() - }) - .then((html) => swapRow(row, html)) - } - - // Single outcome change - the response is the re-rendered row - const handleActionClick = (link) => { - const row = link.closest('tr') - fetch(link.href, fetchOptions) - .then((response) => { - if (!response.ok) throw new Error('Request failed') - return response.text() - }) - .then((html) => { - swapRow(row, html) - showRefreshLink() - }) - .catch(() => { - window.location.href = link.href - }) + const url = `/clinics/${clinicId}/close/appointment-row/${row.dataset.fragmentId}?showActions=${showActions}` + return refreshFragment(row, url) } // Bulk outcome change - refresh each affected row, then swap the button @@ -92,13 +64,6 @@ document.addEventListener('DOMContentLoaded', () => { } container.addEventListener('click', (event) => { - const actionLink = event.target.closest('.js-close-clinic-action') - if (actionLink) { - event.preventDefault() - handleActionClick(actionLink) - return - } - const bulkLink = event.target.closest('.js-bulk-action') if (bulkLink) { event.preventDefault() @@ -113,15 +78,13 @@ document.addEventListener('DOMContentLoaded', () => { if (modalLink) { event.preventDefault() event.stopPropagation() - const appointmentId = modalLink.closest('tr')?.dataset.appointmentId + const appointmentId = modalLink.closest('tr')?.dataset.fragmentId window.openModal(modalLink.dataset.modalId || 'app-form-modal', { loadUrl: modalLink.dataset.loadModalUrl, onSuccess: () => { const row = rowFor(appointmentId) if (!row) return window.location.reload() - refreshRow(row) - .then(showRefreshLink) - .catch(() => window.location.reload()) + refreshRow(row).catch(() => window.location.reload()) } }) } diff --git a/app/assets/javascript/fragment-actions.js b/app/assets/javascript/fragment-actions.js new file mode 100644 index 00000000..645b75b1 --- /dev/null +++ b/app/assets/javascript/fragment-actions.js @@ -0,0 +1,85 @@ +// app/assets/javascript/fragment-actions.js +// +// Progressive enhancement for in-place status updates. Mark a link or form +// with data-fragment-action and wrap the markup it changes in an element +// with a unique data-fragment-id. The action is sent with fetch, and the +// server responds with a re-rendered copy of that element (built from the +// same Nunjucks macro the page used), which is swapped in place - so tags, +// labels and action links can't drift from the server's rendering. Routes +// detect these requests with req.xhr and render the fragment view instead +// of redirecting. Without JS, or on any failure, the link or form falls +// back to a normal navigation. +// +// A bubbling fragment:swapped event fires on each replacement element, for +// pages that need to react (eg revealing a 'refresh to update counts' hint). + +const fetchOptions = { headers: { 'X-Requested-With': 'XMLHttpRequest' } } + +// Swap target for the fragment contained in html, verifying the ids match +// so an unexpected response (eg a redirect to a full page) never gets +// injected into the table +export const swapFragment = (target, html) => { + const template = document.createElement('template') + template.innerHTML = html.trim() + const replacement = template.content.querySelector('[data-fragment-id]') + if (!replacement || replacement.dataset.fragmentId !== target.dataset.fragmentId) { + throw new Error('Response was not the expected fragment') + } + target.replaceWith(replacement) + replacement.dispatchEvent( + new CustomEvent('fragment:swapped', { + bubbles: true, + detail: { fragment: replacement } + }) + ) + return replacement +} + +// Fetch a fragment URL and swap the response into target +export const refreshFragment = (target, url) => + fetch(url, fetchOptions) + .then((response) => { + if (!response.ok) throw new Error('Failed to fetch fragment') + return response.text() + }) + .then((html) => swapFragment(target, html)) + +// GET actions: +document.addEventListener('click', (event) => { + const link = event.target.closest('a[data-fragment-action]') + if (!link) return + const target = link.closest('[data-fragment-id]') + if (!target) return + + event.preventDefault() + fetch(link.href, fetchOptions) + .then((response) => { + if (!response.ok) throw new Error('Request failed') + return response.text() + }) + .then((html) => swapFragment(target, html)) + .catch(() => { + window.location.href = link.href + }) +}) + +// POST actions:
+document.addEventListener('submit', (event) => { + const form = event.target.closest('form[data-fragment-action]') + if (!form) return + const target = form.closest('[data-fragment-id]') + if (!target) return + + event.preventDefault() + fetch(form.action, { + method: (form.method || 'POST').toUpperCase(), + body: new URLSearchParams(new FormData(form)), + headers: fetchOptions.headers + }) + .then((response) => { + if (!response.ok) throw new Error('Request failed') + return response.text() + }) + .then((html) => swapFragment(target, html)) + .catch(() => form.submit()) +}) diff --git a/app/assets/javascript/main.js b/app/assets/javascript/main.js index bfe28e13..b820f8a0 100644 --- a/app/assets/javascript/main.js +++ b/app/assets/javascript/main.js @@ -2,92 +2,48 @@ // ES6 or Vanilla JavaScript +import { swapFragment } from './fragment-actions.js' + document.addEventListener('DOMContentLoaded', () => { - // Inline check in without requiring page reload + // Inline check-in without a page reload. The server responds with the + // re-rendered appointment row (see the check-in route), which replaces the + // old one. Handled here rather than by fragment-actions.js directly because + // the trigger can be a button inside the confirm-identity modal, which + // lives inside the row being replaced. const checkInLinks = document.querySelectorAll('.js-check-in-link') checkInLinks.forEach((link) => { link.addEventListener('click', async (e) => { e.preventDefault() - const link = e.currentTarget - const clinicId = link.dataset.clinicId - const appointmentId = link.dataset.appointmentId - const statusTagId = link.dataset.statusTagId + const { clinicId, appointmentId } = e.currentTarget.dataset + const url = `/clinics/${clinicId}/check-in/${appointmentId}` try { - const response = await fetch( - `/clinics/${clinicId}/check-in/${appointmentId}`, - { - method: 'GET', - headers: { - Accept: 'application/json' - } - } - ) - + const response = await fetch(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) if (!response.ok) { throw new Error('Failed to check in participant') } + const html = await response.text() - // Update the status tag if we have an ID - if (statusTagId) { - const statusTag = document.getElementById(statusTagId) - if (statusTag) { - // Update the existing tag's text and classes - statusTag.textContent = 'Checked in' - statusTag.className = 'nhsuk-tag app-nowrap' - } - } - - // Show the start appointment link by removing the hidden class - const appointmentRow = document.getElementById(`appointment-row-${appointmentId}`) - if (appointmentRow) { - const startAppointmentLink = appointmentRow.querySelector( - '.js-start-appointment-link' - ) - if (startAppointmentLink) { - startAppointmentLink.classList.remove('app-display-none') - } - - // Set focus on the row for accessibility - appointmentRow.setAttribute('tabindex', '-1') - appointmentRow.focus() - } - - // Remove the check-in link - // Check if this is a modal button or a direct link - const isModalButton = link.closest('.app-modal') - - if (isModalButton) { - // For modal buttons, find the original check-in link on the main page - // Look for a link that opens the modal for this specific appointment - const modalId = `check-in-modal-${appointmentId}` - const originalCheckInLink = document.querySelector( - `a[onclick*="openModal('${modalId}')"]` - ) - - if (originalCheckInLink) { - const checkInParagraph = originalCheckInLink.closest('p') - if (checkInParagraph) { - checkInParagraph.remove() - } - } - } else { - // For direct links, remove the paragraph containing the link - const checkInParagraph = link.closest('p') - if (checkInParagraph) { - checkInParagraph.remove() - } - } - - // Close any open modal (for modal-based check-ins) + // Close the confirm-identity modal before the swap - its markup + // lives inside the row that's about to be replaced const openModal = document.querySelector('.app-modal:not([hidden])') if (openModal && window.closeModal) { window.closeModal(openModal.id) } + + const row = document.querySelector(`tr[data-fragment-id="${appointmentId}"]`) + if (!row) throw new Error('Appointment row not found') + const newRow = swapFragment(row, html) + + // Set focus on the row for accessibility + newRow.setAttribute('tabindex', '-1') + newRow.focus() } catch (error) { console.error('Error checking in participant:', error) - window.location.href = link.href + window.location.href = url } }) }) diff --git a/app/routes/clinics.js b/app/routes/clinics.js index a20bc086..38051912 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -170,9 +170,6 @@ module.exports = (router) => { ) if (appointmentIndex === -1) { - if (req.headers.accept?.includes('application/json')) { - return res.status(404).json({ error: 'Appointment not found' }) - } return res.redirect(`/clinics/${clinicId}/${currentFilter}`) } @@ -181,9 +178,6 @@ module.exports = (router) => { // Only allow check-in if currently scheduled if (appointment.status !== 'scheduled') { - if (req.headers.accept?.includes('application/json')) { - return res.status(400).json({ error: 'Appointment cannot be checked in' }) - } return res.redirect(`/clinics/${clinicId}/${currentFilter}`) } @@ -193,11 +187,13 @@ module.exports = (router) => { // Save back to session req.session.data = data - // If this was an AJAX request, send JSON response - if (req.headers.accept?.includes('application/json')) { - return res.json({ - status: 'success', - appointment: data.appointments[appointmentIndex] + // Fetch requests get the re-rendered row so the page can update in place + if (req.xhr) { + const updatedAppointment = data.appointments[appointmentIndex] + return res.render('clinics/clinic-appointment-row', { + appointment: updatedAppointment, + participant: getParticipant(data, updatedAppointment.participantId), + clinicId }) } diff --git a/app/routes/reading.js b/app/routes/reading.js index c70be246..5aefc7ce 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -214,17 +214,11 @@ module.exports = (router) => { // Only accept known request statuses - the value comes straight from // the request body if (!PRIOR_REQUEST_STATUSES.includes(newStatus)) { - if (req.headers.accept?.includes('application/json')) { - return res.status(400).json({ error: 'Unknown request status' }) - } return res.redirect('/reading/priors') } const appointment = getAppointment(data, appointmentId) if (!appointment || !appointment.previousMammograms) { - if (req.headers.accept?.includes('application/json')) { - return res.status(404).json({ error: 'Appointment not found' }) - } return res.redirect('/reading/priors') } @@ -232,9 +226,6 @@ module.exports = (router) => { (m) => m.id === mammogramId ) if (!mammogram) { - if (req.headers.accept?.includes('application/json')) { - return res.status(404).json({ error: 'Mammogram not found' }) - } return res.redirect('/reading/priors') } @@ -263,14 +254,13 @@ module.exports = (router) => { }) // Saves to the appointment and mirrors into data.appointment if it matches - updateAppointmentData(data, appointmentId, { previousMammograms }) - - // If this was a fetch request, send JSON response for in-place update - if (req.headers.accept?.includes('application/json')) { - return res.json({ - status: 'success', - newStatus, - mammogramId + const updatedAppointment = updateAppointmentData(data, appointmentId, { previousMammograms }) + + // Fetch requests get the re-rendered row so the page can update in place + if (req.xhr) { + return res.render('reading/prior-mammogram-row', { + appointment: updatedAppointment, + mammogram: updatedAppointment.previousMammograms.find((m) => m.id === mammogramId) }) } diff --git a/app/views/_includes/clinic-appointment-row.njk b/app/views/_includes/clinic-appointment-row.njk new file mode 100644 index 00000000..2396a3e8 --- /dev/null +++ b/app/views/_includes/clinic-appointment-row.njk @@ -0,0 +1,115 @@ +{# app/views/_includes/clinic-appointment-row.njk #} +{# + A single appointment row on the clinic page appointment list. Also rendered + on its own by the check-in route so main.js can swap the row in place after + checking in - keep it self-contained. + + appointment - appointment record + participant - participant the appointment belongs to + clinicId - id of the clinic being viewed + + Import with context - the row reads settings and the current user from data. +#} + +{%- from 'tag/macro.njk' import tag -%} +{%- from '_components/check-in/macro.njk' import appCheckIn %} + +{% macro clinicAppointmentRow(appointment, participant, clinicId) %} + {% set appointmentBaseUrl = "/clinics/" + clinicId + "/appointments/" + appointment.id %} + {% set statusTagId = "status-tag-" + appointment.id %} + + + + {# Appointment time #} + {{ appointment.statusHistory[0].timestamp | formatTimeString }} +
+ {% if appointment | isSpecialAppointment %} + {{ tag({ + text: "Special appointment", + colour: "yellow", + classes: "nhsuk-u-margin-top-2 app-nowrap" + })}} + {% endif %} + {% if appointment | hasAppointmentNote %} + {{ tag({ + text: "Appointment note", + colour: "yellow", + classes: "nhsuk-u-margin-top-2 app-nowrap" + })}} + {% endif %} + + + {# Appointment details - Name and NHS number #} + +

+ {{ participant | getFullName }} +

+

+ NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }} +

+ + + {# Date of birth #} + {{ participant.demographicInformation.dateOfBirth | formatDate }}
+ ({{ participant.demographicInformation.dateOfBirth | formatRelativeDate(true) + }}) + + + {# Appointment status and view appointment link #} + + {{ appointment.status | toTag({ id: statusTagId, vocabulary: "appointment" }) }} + + {% if appointment | isInProgressNotPaused %} + with {{ + appointment.sessionDetails.startedBy | getUsername({ + format: 'short', + identifyCurrentUser: true + }) }} + + {% elseif (appointment | isFinal) %} + {% if appointment.sessionDetails.startedBy %} + by {{ appointment.sessionDetails.startedBy + | getUsername({ + format: 'short', + identifyCurrentUser: true + }) }} + {% endif %} + {% endif %} + +

+ View appointment {{- ((" for " + (participant | getShortName)) | + asVisuallyHiddenText) | safe }} +

+ + + {# Actions - check-in or start appointment #} + + {% if appointment.status == 'scheduled' %} + {{ appCheckIn({ + clinicId: clinicId, + appointment: appointment, + participant: participant, + confirmIdentityOnCheckIn: true if data.settings.appointment.confirmIdentityOnCheckIn == 'true' else false, + statusTagId: statusTagId, + currentUser: currentUser + }) }} + {% endif %} + + {% if (currentUser | isClinician) and (appointment | isActive) %} + {% if appointment | hasNotStarted %} + {# Start appointment link gets rendered hidden if the appointment is not checked in - js can later remove this + class to make the link visible #} + + Start appointment + + + {% elseif appointment | isPaused or appointment | isInProgress %} + + Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} + + {% endif %} + {% endif %} + + +{% endmacro %} diff --git a/app/views/_includes/close-clinic-appointment-row.njk b/app/views/_includes/close-clinic-appointment-row.njk index 8157adb1..bb495058 100644 --- a/app/views/_includes/close-clinic-appointment-row.njk +++ b/app/views/_includes/close-clinic-appointment-row.njk @@ -19,7 +19,7 @@ text: "Manage details" if appointment | hasStoppedDetails else "Add details", href: "/clinics/" + clinicId + "/close/reason/" + appointment.id } %} - + {{ appointment.statusHistory[0].timestamp | formatTimeString }}

{{ participant | getFullName }}

@@ -37,14 +37,14 @@ {% if appointment.status == "in_progress" or appointment.status == "paused" %} Go to appointment {% elseif appointment.status == "checked_in" %} - Mark as attended not screened + Mark as attended not screened {% elseif appointment.status == "scheduled" %} - Mark as did not attend + Mark as did not attend {% elseif appointment.status == "attended_not_screened" %} - Undo + Undo
{{ appLink(detailsLink | openInModal) }} {% elseif appointment.status == "did_not_attend" %} - Undo + Undo {% elseif appointment.status == "rescheduled" and appointment | hasStoppedDetails %} {{ appLink(detailsLink | openInModal) }} {% endif %} diff --git a/app/views/_includes/reading/prior-mammogram-row.njk b/app/views/_includes/reading/prior-mammogram-row.njk new file mode 100644 index 00000000..03ce01be --- /dev/null +++ b/app/views/_includes/reading/prior-mammogram-row.njk @@ -0,0 +1,104 @@ +{# app/views/_includes/reading/prior-mammogram-row.njk #} +{# + A single prior mammogram row on the priors management page. Also rendered + on its own by the prior-mammogram-row fragment route so fragment-actions.js + can swap a row in place after a status change - keep it self-contained. + + appointment - the screening appointment the prior belongs to + mammogram - the prior mammogram record (one entry of previousMammograms) + + Import with context - the row reads participants, reading thresholds and + the current user from data. +#} + +{% macro priorMammogramRow(appointment, mammogram) %} + + {# Participant name - look up from session data #} + {% set thisParticipant = data | getParticipant(appointment.participantId) %} + + + {{ thisParticipant | getFullName }} + + + + {# Screening date #} + + {% set daysSinceScreening = appointment.timing.startTime | daysSince %} + {% if daysSinceScreening >= data.config.reading.urgentThreshold %} + {{ "Urgent" | toTag }}
+ {% elseif daysSinceScreening >= data.config.reading.priorityThreshold %} + {{ "Due soon" | toTag }}
+ {% endif %} + {{ appointment.timing.startTime | formatDate }}
+ + {{ appointment.timing.startTime | formatRelativeDate }} + + + + {# Mammogram location and date, then the request story - who + requested or actioned it, when, and the reader's reason #} + + {% if mammogram.requestStatus == "pending" %} + {# Requested by a reader, so the request date is theirs #} + {% set changedLabel = "Requested" %} + {% set changedDate = mammogram.requestedDate %} + {% set changedBy = mammogram.requestedBy %} + {% elseif mammogram.requestStatus != "not_requested" %} + {# Actioned by admin staff #} + {% set changedLabel = "Requested" if mammogram.requestStatus == "requested" else "Updated" %} + {% set changedDate = mammogram.statusChangedDate %} + {% set changedBy = mammogram.statusChangedBy %} + {% endif %} + +

{{ mammogram | summarisePriorMammogram }}

+ + {% if changedDate or mammogram.requestReason %} +

+ {% if changedDate %} + + {{- changedLabel }} + {%- if changedBy %} by {{ changedBy | getUsername({ format: "short", identifyCurrentUser: true }) }}{% endif %} + on {{ changedDate | formatDate("D MMMM YYYY") -}} + + {% endif %} + {% if mammogram.requestReason %} + {% if changedDate %}
{% endif %} + Reason: {{ mammogram.requestReason }} + {% endif %} +

+ {% endif %} + + + {# Status #} + + {{ mammogram.requestStatus | toTag({ vocabulary: "priorsRequest" }) }} + + + {# Actions - each a one-click form, submitted in place by fragment-actions.js #} + + {% set availableActions = [] %} + {% if mammogram.requestStatus == "not_requested" or mammogram.requestStatus == "pending" %} + {% set availableActions = [ + { status: "requested", label: "Mark as requested" }, + { status: "not_available", label: "Not available" }, + { status: "not_needed", label: "Not needed" } + ] %} + {% elseif mammogram.requestStatus == "requested" %} + {% set availableActions = [ + { status: "received", label: "Mark as received" }, + { status: "not_available", label: "Not available" }, + { status: "not_needed", label: "Not needed" } + ] %} + {% endif %} + + {% for action in availableActions %} + + + + + +
+ {% endfor %} + + +{% endmacro %} diff --git a/app/views/_includes/scripts.html b/app/views/_includes/scripts.html index 78986800..bec76280 100755 --- a/app/views/_includes/scripts.html +++ b/app/views/_includes/scripts.html @@ -5,6 +5,7 @@ + diff --git a/app/views/clinics/clinic-appointment-row.html b/app/views/clinics/clinic-appointment-row.html new file mode 100644 index 00000000..b8a053ee --- /dev/null +++ b/app/views/clinics/clinic-appointment-row.html @@ -0,0 +1,7 @@ +{# app/views/clinics/clinic-appointment-row.html #} +{# Bare fragment - a single clinic appointment list row, fetched by main.js + to update the clinic page in place after checking in #} + +{% from "_includes/clinic-appointment-row.njk" import clinicAppointmentRow with context %} + +{{ clinicAppointmentRow(appointment, participant, clinicId) }} diff --git a/app/views/clinics/show.html b/app/views/clinics/show.html index 1c50a703..e4f2806c 100644 --- a/app/views/clinics/show.html +++ b/app/views/clinics/show.html @@ -1,4 +1,7 @@ {% extends 'layout-app.html' %} + +{% from "_includes/clinic-appointment-row.njk" import clinicAppointmentRow with context %} + {% set pageHeading = "Screening clinic - " ~ clinic.clinicCode %} {% set gridColumn = "nhsuk-grid-column-full" %} @@ -126,203 +129,11 @@

{% for appointment in filteredAppointments %} - {% set participant = data.participants | findById(appointment.participantId) %} - {% set appointmentBaseUrl = "/clinics/" + clinicId + "/appointments/" + appointment.id %} - {% set statusTagId = "status-tag-" + appointment.id %} - - - - {# Appointment time #} - {{ appointment.statusHistory[0].timestamp | formatTimeString }} -
- {% if appointment | isSpecialAppointment %} - {{ tag({ - text: "Special appointment", - colour: "yellow", - classes: "nhsuk-u-margin-top-2 app-nowrap" - })}} - {% endif %} - {% if appointment | hasAppointmentNote %} - {{ tag({ - text: "Appointment note", - colour: "yellow", - classes: "nhsuk-u-margin-top-2 app-nowrap" - })}} - {% endif %} - {# Symptoms tag used for testing to verify data is working #} - {# {% if appointment | hasSymptoms %} - {{ tag({ - text: "Has symptoms", - colour: "yellow", - classes: "nhsuk-u-margin-top-2 app-nowrap" - })}} - {% endif %} #} - - - {# Appointment details - Name and NHS number #} - - - {% set participantHref = ("/participants/" ~ participant.id) | urlWithReferrer(currentUrl) %} - -

- {# #} - {# - {{ participant | getFullName }} - #} - {{ participant | getFullName }} -

-

- {# DOB: {{ participant.demographicInformation.dateOfBirth | formatDate }} ({{ - participant.demographicInformation.dateOfBirth | formatRelativeDate(true) }}) -
#} - NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }} - {#
- SX Number: {{ participant.sxNumber }} #} -

- - - - {# Date of birth #} - {{ participant.demographicInformation.dateOfBirth | formatDate }}
- ({{ participant.demographicInformation.dateOfBirth | formatRelativeDate(true) - }}) - - - {# Appointment status and view appointment link #} - - {# Status tag with check-in link #} - {# {{ appointmentStatus({ - clinicId: clinicId, - appointment: appointment, - participant: participant, - participantUrl: "/participants/" + participant.id | urlWithReferrer(currentUrl), - appointmentUrl: "/clinics/" + clinicId + "/appointments/" + appointment.id, - referrerChain: currentUrl + "#appointment-row-" + appointment.id, - confirmIdentityOnCheckIn: true if data.settings.appointment.confirmIdentityOnCheckIn == 'true' else false, - showAppointmentLink: true - })}} #} - - {{ appointment.status | toTag({ id: statusTagId, vocabulary: "appointment" }) }} - - {% if appointment | isInProgressNotPaused %} - with {{ - appointment.sessionDetails.startedBy | getUsername({ - format: 'short', - identifyCurrentUser: true - }) }} - - {% elseif (appointment | isFinal) %} - {% if appointment.sessionDetails.startedBy %} - by {{ appointment.sessionDetails.startedBy - | getUsername({ - format: 'short', - identifyCurrentUser: true - }) }} - {% endif %} - {% endif %} - -

- View appointment {{- ((" for " + (participant | getShortName)) | - asVisuallyHiddenText) | safe }} -

- - - {# {% set viewAppointmentLink %} - View appointment {{- ((" for " + (participant | getShortName)) | - asVisuallyHiddenText) | safe }} - {% endset %} #} - - {# Scheduled appointments have dynamic check-in links #} - - {# {% if appointment.status != 'scheduled' %} -

- {% if currentUser | isClinician %} - - {% if appointment | isFinal %} - {{ viewAppointmentLink | safe }} - {% elseif appointment | isInProgress %} - - {% if appointment | startedByCurrentUser %} - - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% else %} - {{ viewAppointmentLink | safe }} - {% endif %} - - {% else %} - - Start appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% endif %} - - {% else %} - {{ viewAppointmentLink | safe }} - {% endif %} -

- - - {% endif %} #} - - - - {# Actions - check-in or start appointment #} - - {% if appointment.status == 'scheduled' %} - {{ appCheckIn({ - clinicId: clinicId, - appointment: appointment, - participant: participant, - confirmIdentityOnCheckIn: true if data.settings.appointment.confirmIdentityOnCheckIn == 'true' else false, - statusTagId: statusTagId, - currentUser: currentUser - }) }} - - {% endif %} - - {% if (currentUser | isClinician) and (appointment | isActive) %} - {% if appointment | hasNotStarted %} - {# Start appointment link gets rendered hidden if the appointment is not checked in - js can later remove this - class to make the link visible #} - - Start appointment - - - {% elseif appointment | isPaused or appointment | isInProgress %} - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - - {% elseif (appointment | isPaused) and (appointment | startedByCurrentUser) %} - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% endif %} - - {% endif %} - - {# {% if not (appointment | isFinal) and (currentUser | isClinician) %} -

- {% if appointment | hasNotStarted %} - - Start appointment - - {% elseif appointment | startedByCurrentUser %} - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% endif %} -

- {% endif %} #} - - - + {{ clinicAppointmentRow(appointment, data.participants | findById(appointment.participantId), clinicId) }} {% endfor %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/views/reading/prior-mammogram-row.html b/app/views/reading/prior-mammogram-row.html new file mode 100644 index 00000000..a3070e01 --- /dev/null +++ b/app/views/reading/prior-mammogram-row.html @@ -0,0 +1,7 @@ +{# app/views/reading/prior-mammogram-row.html #} +{# Bare fragment - a single priors table row, fetched by fragment-actions.js + to update the priors page in place after a status change #} + +{% from "_includes/reading/prior-mammogram-row.njk" import priorMammogramRow with context %} + +{{ priorMammogramRow(appointment, mammogram) }} diff --git a/app/views/reading/priors.html b/app/views/reading/priors.html index fa344132..27c3fdbd 100644 --- a/app/views/reading/priors.html +++ b/app/views/reading/priors.html @@ -2,6 +2,8 @@ {% extends 'layout-app.html' %} +{% from "_includes/reading/prior-mammogram-row.njk" import priorMammogramRow with context %} + {% set pageHeading = "Prior mammograms" %} {% set gridColumn = "nhsuk-grid-column-full" %} @@ -89,7 +91,7 @@

{{ pageHeading }}

No prior mammograms in this view.

{% else %} - +
@@ -101,264 +103,10 @@

{{ pageHeading }}

{% for row in displayRows %} - {% set thisAppointment = row.appointment %} - {% set mammogram = row.mammogram %} - - {# Participant name - look up from session data #} - {% set thisParticipant = data | getParticipant(thisAppointment.participantId) %} - - - {# Screening date #} - - - {# Mammogram location and date, then the request story - who - requested or actioned it, when, and the reader's reason #} - - - {# Status #} - - - {# Actions #} - - + {{ priorMammogramRow(row.appointment, row.mammogram) }} {% endfor %}
Participant
- - {{ thisParticipant | getFullName }} - - - {% set daysSinceScreening = thisAppointment.timing.startTime | daysSince %} - {% if daysSinceScreening >= data.config.reading.urgentThreshold %} - {{ "Urgent" | toTag }}
- {% elseif daysSinceScreening >= data.config.reading.priorityThreshold %} - {{ "Due soon" | toTag }}
- {% endif %} - {{ thisAppointment.timing.startTime | formatDate }}
- - {{ thisAppointment.timing.startTime | formatRelativeDate }} - -
- {% if mammogram.requestStatus == "pending" %} - {# Requested by a reader, so the request date is theirs #} - {% set changedLabel = "Requested" %} - {% set changedDate = mammogram.requestedDate %} - {% set changedBy = mammogram.requestedBy %} - {% elseif mammogram.requestStatus != "not_requested" %} - {# Actioned by admin staff #} - {% set changedLabel = "Requested" if mammogram.requestStatus == "requested" else "Updated" %} - {% set changedDate = mammogram.statusChangedDate %} - {% set changedBy = mammogram.statusChangedBy %} - {% endif %} - -

{{ mammogram | summarisePriorMammogram }}

- - {% if changedDate or mammogram.requestReason %} -

- {% if changedDate %} - - {{- changedLabel }} - {%- if changedBy %} by {{ changedBy | getUsername({ format: "short", identifyCurrentUser: true }) }}{% endif %} - on {{ changedDate | formatDate("D MMMM YYYY") -}} - - {% endif %} - {% if mammogram.requestReason %} - {% if changedDate %}
{% endif %} - Reason: {{ mammogram.requestReason }} - {% endif %} -

- {% endif %} -
- {{ mammogram.requestStatus | toTag({ vocabulary: "priorsRequest" }) }} - - {% if mammogram.requestStatus == "not_requested" or mammogram.requestStatus == "pending" %} - {# Can mark as requested (sent to IEP), not available, or not needed #} -
- - - - -
-
- - - - -
-
- - - - -
- {% elseif mammogram.requestStatus == "requested" %} - {# Can mark as received, not available, or not needed #} -
- - - - -
-
- - - - -
-
- - - - -
- {% endif %} -
{% endif %} {% endblock %} - -{% block pageScripts %} - -{% endblock %}