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/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 faad4200..966fc807 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 @@ -163,6 +164,197 @@ 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') + } + + 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) + ) + + res.render('clinics/close', { + clinicId: req.params.id, + clinic: clinicData.clinic, + allAppointments: sortedAppointments + }) + }) + + // 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' }) + } + 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`) + }) + + // 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' }) + } + 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') + 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`) + }) + + // 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 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`) + }) + + // 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 }) + } + 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 + 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) { + 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}`] + + res.redirect('/clinics/completed') + }) + // 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..18c4149b --- /dev/null +++ b/app/views/clinics/close.html @@ -0,0 +1,287 @@ +{# 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) %} + +
{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}
+ + {% set needsOutcomeAppointments = allAppointments | removeWhere("status", ["complete", "partially_screened", "did_not_attend", "attended_not_screened", "cancelled", "rescheduled"]) %} + +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.
" + }) }} + + {# Macro for appointment table rows #} + {% macro appointmentRow(appointment, clinicId, showActions) %} + {% set participant = data.participants | findById(appointment.participantId) %} +{{ participant | getFullName }}
+NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}
+ +| Time | +Details | +Status | +{{ "Actions" if showActions }} | +
|---|
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") %} + {% if checkedInAppointments | length %} ++ Mark all as attended not screened +
+ {{ statusGroupTable(checkedInAppointments, clinicId, true) }} + {% endif %} + + {# Did not check in #} + {% set scheduledAppointments = allAppointments | where("status", "scheduled") %} + {% if scheduledAppointments | length %} +| Location | +Clinic name and location | Date and time | Clinic type | Participants | @@ -70,14 +70,14 @@
|---|---|---|---|---|
|
- {% 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 e4a22690..a4d2cb82 100644 --- a/app/views/clinics/show.html +++ b/app/views/clinics/show.html @@ -34,9 +34,14 @@
{{ clinic.status | toTag({ vocabulary: "clinic" }) }}
-
+ + {% if clinic.status !== "closed" and clinic.status !== "scheduled" %} + + {% endif %} |