From ecfd65fac25030183e138c671500bf7484953835 Mon Sep 17 00:00:00 2001 From: rivalee Date: Mon, 3 Aug 2026 15:04:07 +0100 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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) {