diff --git a/app/filters/nunjucks.js b/app/filters/nunjucks.js index 28780fa9b..11b74e2c3 100644 --- a/app/filters/nunjucks.js +++ b/app/filters/nunjucks.js @@ -110,7 +110,7 @@ const getUsername = function (userId, options = {}) { const user = users.find((u) => u.id === userId) if (!user) return userId - // Format options: full (default), short (initial + surname), initial (just initials) + // Format options: full (default), short (initial + surname), initial (just initials), reversed (SURNAME, First) const format = options.format || 'full' let formattedName @@ -121,6 +121,9 @@ const getUsername = function (userId, options = {}) { case 'initial': formattedName = `${user.firstName.charAt(0)}${user.lastName.charAt(0)}` break + case 'reversed': + formattedName = `${user.lastName.toUpperCase()}, ${user.firstName}` + break case 'full': default: formattedName = `${user.firstName} ${user.lastName}` diff --git a/app/lib/utils/roles-and-permissions.js b/app/lib/utils/roles-and-permissions.js index 367d5065e..6345af477 100644 --- a/app/lib/utils/roles-and-permissions.js +++ b/app/lib/utils/roles-and-permissions.js @@ -1,6 +1,7 @@ // app/lib/utils/roles-and-permissions.js const { isMedicalHistoryItemRemoved } = require('./medical-information') +const { getUsername } = require('../../filters/nunjucks') // Implanted devices that need a mammographer with implant imaging training. // The prototype treats every device type as needing it - narrow this list if @@ -79,17 +80,9 @@ const hasAnyPermission = (user, permissions) => { return permissions.some((permission) => hasPermission(user, permission)) } -/** - * Check whether an appointment needs a user with implant imaging training - * - * Breast implants and implanted medical devices both need it, but only while - * they are still in place - once removed, any clinician can image the - * participant. - * - * @param {object} appointment - Appointment object - * @returns {boolean} Whether implant imaging training is needed - */ -const requiresImplantImaging = (appointment) => { +// Breakdown of what on an appointment needs implant imaging training. Kept +// unexported so it doesn't become a Nunjucks filter. +const getImplantImagingNeeds = (appointment) => { const medicalHistory = appointment?.medicalInformation?.medicalHistory || {} const hasActiveBreastImplants = ( @@ -104,9 +97,77 @@ const requiresImplantImaging = (appointment) => { } ) + return { hasActiveBreastImplants, hasActiveDevice } +} + +/** + * Check whether an appointment needs a user with implant imaging training + * + * Breast implants and implanted medical devices both need it, but only while + * they are still in place - once removed, any clinician can image the + * participant. + * + * @param {object} appointment - Appointment object + * @returns {boolean} Whether implant imaging training is needed + */ +const requiresImplantImaging = (appointment) => { + const { hasActiveBreastImplants, hasActiveDevice } = + getImplantImagingNeeds(appointment) + return hasActiveBreastImplants || hasActiveDevice } +/** + * Describe why an appointment needs a mammographer with implant imaging + * training, for use in page content + * + * @param {object} appointment - Appointment object + * @returns {string} Reason text, or '' when implant imaging is not needed + */ +const getImplantImagingReason = (appointment) => { + const { hasActiveBreastImplants, hasActiveDevice } = + getImplantImagingNeeds(appointment) + + if (hasActiveBreastImplants && hasActiveDevice) { + return 'breast implants and an implanted medical device' + } + if (hasActiveBreastImplants) return 'breast implants' + if (hasActiveDevice) return 'an implanted medical device' + return '' +} + +/** + * Check whether an authorised mammographer other than the current user has + * been nominated to take the images for an appointment + * + * @param {object} appointment - Appointment object + * @returns {boolean} Whether an authorised mammographer has been nominated + */ +const hasNominatedAuthorisedMammographer = (appointment) => { + return Boolean( + appointment?.authorisedMammographerId || + appointment?.authorisedMammographerOtherName + ) +} + +/** + * Get the display name of the authorised mammographer nominated for an + * appointment + * + * Returns '' when nobody has been nominated, so callers can decide their own + * fallback. + * + * @param {object} appointment - Appointment object + * @param {object} [options] - Display options, as accepted by getUsername + * @returns {string} Nominated authorised mammographer's name, or '' + */ +const getAuthorisedMammographerName = function (appointment, options = {}) { + if (appointment?.authorisedMammographerId) { + return getUsername.call(this, appointment.authorisedMammographerId, options) + } + return appointment?.authorisedMammographerOtherName || '' +} + /** * Check whether a user is able to screen a given appointment * @@ -208,6 +269,9 @@ module.exports = { hasPermission, hasAnyPermission, requiresImplantImaging, + getImplantImagingReason, + hasNominatedAuthorisedMammographer, + getAuthorisedMammographerName, canUserScreenAppointment, isClinician, isAdministrative, diff --git a/app/routes/appointments/lifecycle.js b/app/routes/appointments/lifecycle.js index d22896382..dae3a227e 100644 --- a/app/routes/appointments/lifecycle.js +++ b/app/routes/appointments/lifecycle.js @@ -337,6 +337,94 @@ module.exports = (router) => { } ) + // Change the authorised mammographer from the in-page modal, then return to + // the page it was opened from with a success banner if it actually changed + router.post( + '/clinics/:clinicId/appointments/:appointmentId/change-authorised-mammographer-answer', + (req, res) => { + const { clinicId, appointmentId } = req.params + const data = req.session.data + const appointmentUrl = `/clinics/${clinicId}/appointments/${appointmentId}` + + const authorisedMammographerTemp = + data.appointment?.authorisedMammographerTemp || {} + const selectedUserId = authorisedMammographerTemp.userId + const otherName = (authorisedMammographerTemp.otherName || '') + .toString() + .trim() + + const errors = [] + if (!selectedUserId) { + errors.push({ + text: 'Select who is taking the images', + name: 'appointment[authorisedMammographerTemp][userId]', + href: '#authorisedMammographerUserId' + }) + } else if (selectedUserId === 'other' && !otherName) { + errors.push({ + text: "Enter the mammographer's full name", + name: 'appointment[authorisedMammographerTemp][otherName]', + href: '#authorisedMammographerOtherName' + }) + } + + if (errors.length) { + errors.forEach((error) => req.flash('error', error)) + // Back to the modal page - _modal is threaded by the modal middleware, + // the referrer chain has to be carried by hand + return res.redirect( + urlWithReferrer( + `${appointmentUrl}/change-authorised-mammographer`, + req.query.referrerChain + ) + ) + } + + const previousAuthorisedMammographerId = + data.appointment?.authorisedMammographerId + const previousAuthorisedMammographerOtherName = + data.appointment?.authorisedMammographerOtherName + + if (selectedUserId === 'other') { + data.appointment.authorisedMammographerId = null + data.appointment.authorisedMammographerOtherName = otherName + } else { + // Nominating yourself is the same as no nomination at all + data.appointment.authorisedMammographerId = + selectedUserId === data.currentUser?.id ? null : selectedUserId + data.appointment.authorisedMammographerOtherName = null + } + + // Clear the transient modal fields so they don't leak into other forms + delete data.appointment.authorisedMammographerTemp + + const hasChanged = + data.appointment.authorisedMammographerId !== + (previousAuthorisedMammographerId || null) || + data.appointment.authorisedMammographerOtherName !== + (previousAuthorisedMammographerOtherName || null) + + if (hasChanged) { + const selectedUser = (data.users || []).find( + (user) => user.id === selectedUserId + ) + const newAuthorisedMammographerName = selectedUser + ? `${selectedUser.lastName.toUpperCase()}, ${selectedUser.firstName}` + : otherName + req.flash( + 'success', + `Authorised mammographer updated to ${newAuthorisedMammographerName}` + ) + } + + const returnUrl = getReturnUrl( + `${appointmentUrl}/check-information`, + req.query.referrerChain + ) + res.redirect(modalBreakout(returnUrl)) + } + ) + // Appointment within clinic context router.get('/clinics/:clinicId/appointments/:appointmentId', (req, res) => { const { clinicId, appointmentId } = req.params diff --git a/app/routes/clinics.js b/app/routes/clinics.js index 380519127..4751d62f2 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -482,6 +482,9 @@ module.exports = (router) => { } const clinicData = getClinicData(req.session.data, req.params.id) + if (!clinicData) { + return res.redirect('/clinics') + } let remainingCount = filterAppointmentsByStatus( clinicData.appointments, 'remaining' diff --git a/app/views/_includes/authorised-mammographer.njk b/app/views/_includes/authorised-mammographer.njk new file mode 100644 index 000000000..6e2d4b918 --- /dev/null +++ b/app/views/_includes/authorised-mammographer.njk @@ -0,0 +1,56 @@ +{# app/views/_includes/authorised-mammographer.njk #} + +{# + The authorised mammographer for an in-progress appointment, alongside the + button that continues the workflow. + + Where the current user cannot image this participant themselves and nobody + else has been nominated, the workflow is blocked until an authorised + mammographer with implant imaging training is chosen. + + Set continueButtonText before including. +#} + +{% set authorisedMammographerChangeHref = (appointmentUrl + "/change-authorised-mammographer") | urlWithReferrer(referrerChain | appendReferrer(currentUrl)) %} + +{% set needsNominatedAuthorisedMammographer = appointment.workflowStatus['review-medical-information'] != 'completed' + and not (currentUser | canUserScreenAppointment(appointment)) + and not (appointment | hasNominatedAuthorisedMammographer) %} + +{% if needsNominatedAuthorisedMammographer %} + + {% set insetTextHtml %} +

Due to {{ appointment | getImplantImagingReason }} you do not have the relevant permissions to take all the mammograms required for this appointment. Change the mammographer to someone authorised to take these images or {{ appLink({ text: "exit the appointment", href: (appointmentUrl + "/exit-appointment") | urlWithReferrer(referrerChain) }) | trim | safe }}.

+ {% endset %} + + {{ insetText({ + html: insetTextHtml + }) }} + +
+
+
+ {{ button({ + text: "Change mammographer", + href: authorisedMammographerChangeHref, + classes: "nhsuk-u-margin-bottom-0" + } | openInModal) }} +
+
+
+ +{% else %} + + {% set authorisedMammographerName = (appointment | getAuthorisedMammographerName({ format: "reversed" })) or (currentUser.id | getUsername({ format: "reversed" })) %} + +
+
+ {{ button({ + text: continueButtonText, + classes: "nhsuk-u-margin-bottom-0 js-complete-all-sections" + }) }} +

Authorised mammographer: {{ authorisedMammographerName }} ({{ appLink({ text: "change", href: authorisedMammographerChangeHref } | openInModal) | trim | safe }})

+
+
+ +{% endif %} diff --git a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk index 0364ae4ca..15afd0cba 100644 --- a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk +++ b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk @@ -137,7 +137,7 @@ }) %} {% endif %} -{# Machine room #} +{# Mammogram location #} {% if appointment.mammogramData.machineRoom %} {# Check if this is a mobile clinic #} {% set isMobileClinic = (clinic.location.type == 'mobile_unit') %} @@ -148,14 +148,14 @@ { href: "./images-room-selection" | urlWithReferrer(referrerChain, scrollTo), text: "Change", - visuallyHiddenText: "machine room" + visuallyHiddenText: "mammogram location" } ] %} {% endif %} {% set summaryRows = summaryRows | push({ key: { - text: "Machine room" + text: "Mammogram location" }, value: { text: appointment.mammogramData.machineRoom @@ -166,6 +166,42 @@ }) %} {% endif %} +{# Authorised mammographer - whoever was nominated to take the images, falling + back to whoever ran the appointment #} +{% set authorisedMammographerName = appointment | getAuthorisedMammographerName({ format: "reversed" }) %} +{% if not authorisedMammographerName %} + {% if appointment.sessionDetails.endedBy %} + {% set authorisedMammographerName = appointment.sessionDetails.endedBy | getUsername({ format: "reversed" }) %} + {% else %} + {% set authorisedMammographerName = appointment.sessionDetails.startedBy | getUsername({ format: "reversed" }) %} + {% endif %} +{% endif %} + +{% if authorisedMammographerName %} + {% set authorisedMammographerActions = [] %} + {% if allowEdits and authorisedMammographerChangeHref %} + {% set authorisedMammographerActions = [ + ({ + href: authorisedMammographerChangeHref, + text: "Change", + visuallyHiddenText: "authorised mammographer" + } | openInModal) + ] %} + {% endif %} + + {% set summaryRows = summaryRows | push({ + key: { + text: "Authorised mammographer" + }, + value: { + text: authorisedMammographerName + }, + actions: { + items: authorisedMammographerActions + } + }) %} +{% endif %} + {# Combined views taken (showing all view information in one row) #} {# Sort views by side and standard order before displaying #} {% set sortedViews = [] %} diff --git a/app/views/appointments/appointment.html b/app/views/appointments/appointment.html index 04c54ae7a..29a39c0df 100644 --- a/app/views/appointments/appointment.html +++ b/app/views/appointments/appointment.html @@ -229,11 +229,11 @@ }, { key: { - text: "Screened by" + text: "Run by" }, value: { - html: (appointment.sessionDetails.endedBy | getUsername) + - ((" and " + (appointment.sessionDetails.authors | last).userId | getUsername) + html: (appointment.sessionDetails.endedBy | getUsername({ format: "reversed" })) + + ((" and " + ((appointment.sessionDetails.authors | last).userId | getUsername({ format: "reversed" }))) if appointment.sessionDetails.authors and appointment.sessionDetails.authors.length > 0 and (appointment.sessionDetails.authors | last).userId != appointment.sessionDetails.endedBy else "") @@ -242,6 +242,17 @@ items: [] } } if appointment.status == "complete" and appointment.sessionDetails.endedBy else {}, + { + key: { + text: "Authorised mammographer" + }, + value: { + html: (appointment | getAuthorisedMammographerName({ format: "reversed" })) or (appointment.sessionDetails.endedBy | getUsername({ format: "reversed" })) or (appointment.sessionDetails.startedBy | getUsername({ format: "reversed" })) + }, + actions: { + items: [] + } + } if appointment.status == "complete" and appointment.sessionDetails.endedBy else {}, { key: { text: "Special appointment" diff --git a/app/views/appointments/change-authorised-mammographer.html b/app/views/appointments/change-authorised-mammographer.html new file mode 100644 index 000000000..b3c8b03f0 --- /dev/null +++ b/app/views/appointments/change-authorised-mammographer.html @@ -0,0 +1,100 @@ +{# app/views/appointments/change-authorised-mammographer.html #} + +{% extends parentLayout or '_templates/layout-appointment.html' %} + +{# Wording differs depending on whether images have already been taken #} +{% set imagesTaken = appointment.workflowStatus['take-images'] == 'completed' %} + +{% set pageHeading = "Change mammographer" %} +{% set formAction = "./change-authorised-mammographer-answer" | urlWithReferrer(referrerChain) %} + +{% set legendText = "Who took the images for this appointment?" if imagesTaken else "Who is taking images for this appointment?" %} + +{# Where Save and Cancel return to - the page the modal was opened from #} +{% set returnPageHref = (appointmentUrl + "/check-information") | getReturnUrl(referrerChain) %} + +{% block pageContent %} + +

{{ pageHeading }}

+ + {% if not imagesTaken %} + {{ insetText({ + text: "Ensure the person selected is also logged into the mammogram machine before images are taken" + }) }} + {% endif %} + + {# Current user sits at the top, followed by every other clinician able to + screen this appointment - only those with implant imaging training when + the participant has implants or a device #} + {% set authorisedMammographerItems = [ + { + value: data.currentUser.id, + text: data.currentUser.id | getUsername({ format: "reversed", identifyCurrentUser: true }) + } + ] %} + + {% for user in data.users | sort(false, false, "lastName") %} + {% if user.id != data.currentUser.id and (user | canUserScreenAppointment(appointment)) %} + {% set authorisedMammographerItems = authorisedMammographerItems | push({ + value: user.id, + text: user.id | getUsername({ format: "reversed" }) + }) %} + {% endif %} + {% endfor %} + + {% set otherNameHtml %} + {{ input({ + id: "authorisedMammographerOtherName", + name: "appointment[authorisedMammographerTemp][otherName]", + value: appointment.authorisedMammographerTemp.otherName or appointment.authorisedMammographerOtherName, + label: { + text: "Enter the mammographer's full name" + }, + autocomplete: "off" + } | populateErrors) }} + {% endset %} + + {% set authorisedMammographerItems = authorisedMammographerItems | push({ divider: "or" }) %} + {% set authorisedMammographerItems = authorisedMammographerItems | push({ + value: "other", + text: "Other", + conditional: { + html: otherNameHtml + } + }) %} + + {# Nominating yourself is stored as no nomination, so default to the current user #} + {% set selectedAuthorisedMammographer = data.currentUser.id %} + {% if appointment.authorisedMammographerOtherName %} + {% set selectedAuthorisedMammographer = "other" %} + {% endif %} + {% if appointment.authorisedMammographerId %} + {% set selectedAuthorisedMammographer = appointment.authorisedMammographerId %} + {% endif %} + {% if appointment.authorisedMammographerTemp.userId %} + {% set selectedAuthorisedMammographer = appointment.authorisedMammographerTemp.userId %} + {% endif %} + + {{ radios({ + idPrefix: "authorisedMammographerUserId", + name: "appointment[authorisedMammographerTemp][userId]", + value: selectedAuthorisedMammographer, + fieldset: { + legend: { + text: legendText + } + }, + items: authorisedMammographerItems + } | populateErrors) }} + +
+ {{ button({ + text: "Save" + }) }} + {{ appLink({ text: "Cancel", href: returnPageHref, attributes: { "data-modal-action": "close" } }) | trim | safe }} + {% if not imagesTaken %} + {{ appLink({ text: "Exit appointment", href: (appointmentUrl + "/exit-appointment") | urlWithReferrer(referrerChain) }) | trim | safe }} + {% endif %} +
+ +{% endblock %} diff --git a/app/views/appointments/check-information.html b/app/views/appointments/check-information.html index b7462ff7a..9cc3eccc8 100644 --- a/app/views/appointments/check-information.html +++ b/app/views/appointments/check-information.html @@ -22,6 +22,7 @@ {% set activeTab = 'review' %} {% set showReviewAfterImagingReminder = appointment.workflowStatus['review-breast-features-after-imaging'] == 'yes' %} +{% set authorisedMammographerChangeHref = (appointmentUrl + "/change-authorised-mammographer") | urlWithReferrer(referrerChain | appendReferrer(currentUrl)) %} diff --git a/app/views/appointments/images-automatic.html b/app/views/appointments/images-automatic.html index 7f17a9522..34f1a4db9 100644 --- a/app/views/appointments/images-automatic.html +++ b/app/views/appointments/images-automatic.html @@ -50,6 +50,15 @@

{{ pageHeading }}

+ {# The authorised mammographer defaults to the logged in user, unless an alternative has been nominated #} + {% set authorisedMammographerName = (appointment | getAuthorisedMammographerName({ format: "reversed" })) or (currentUser.id | getUsername({ format: "reversed" })) %} + {% set authorisedMammographerChangeHref = (appointmentUrl + "/change-authorised-mammographer") | urlWithReferrer(referrerChain | appendReferrer(currentUrl)) %} + +

+ Authorised mammographer: {{ authorisedMammographerName }} + ({{ appLink({ text: "change", href: authorisedMammographerChangeHref } | openInModal) | trim | safe }}) +

+

Observations during mammogram

diff --git a/app/views/appointments/images-manual.html b/app/views/appointments/images-manual.html index 742612618..37dd76ea0 100644 --- a/app/views/appointments/images-manual.html +++ b/app/views/appointments/images-manual.html @@ -18,6 +18,9 @@

{{ pageHeading }}

{% set mammogramSource = appointment.mammogramDataTemp or appointment.mammogramData %} + {# The authorised mammographer defaults to the logged in user, unless an alternative has been nominated #} + {% set authorisedMammographerName = (appointment | getAuthorisedMammographerName({ format: "reversed" })) or (currentUser.id | getUsername({ format: "reversed" })) %} + {# Determine current room name #} {% set currentRoomName = "" %} {% set isMobileClinic = (clinic.location.type == 'mobile_unit') %} @@ -38,10 +41,15 @@

{{ pageHeading }}

Mammogram location: {{ currentRoomName }}. {% if not isMobileClinic %} -
Change room or machine details + (change) {% endif %}

+

+ Authorised mammographer: {{ authorisedMammographerName }} + ({{ appLink({ text: "change", href: (appointmentUrl + "/change-authorised-mammographer") | urlWithReferrer(referrerChain | appendReferrer(currentUrl)) } | openInModal) | trim | safe }}) +

+ {{ appHiddenInput({ name: "appointment[mammogramDataTemp][machineRoom]", value: currentRoomName @@ -148,7 +156,7 @@

Manually add participant details

name: "appointment[mammogramDataTemp][isStandardSet]", fieldset: { legend: { - text: "Have you taken a standard set of images?", + text: "Were a standard set of images taken?", size: "m", isPageHeading: false } diff --git a/app/views/appointments/review-medical-information.html b/app/views/appointments/review-medical-information.html index b61b10a50..e49d75f16 100644 --- a/app/views/appointments/review-medical-information.html +++ b/app/views/appointments/review-medical-information.html @@ -29,26 +29,20 @@

value: appointment.workflowStatus['review-breast-features-after-imaging'] }) }} -
-
- {{ button({ - text: "Complete all and continue" if appointment.workflowStatus['review-medical-information'] != 'completed' else "Next section", - classes: "nhsuk-u-margin-bottom-0 nhsuk-button js-complete-all-sections" - }) }} -
-
+ {% set continueButtonText = "Complete all and continue" if appointment.workflowStatus['review-medical-information'] != 'completed' else "Next section" %} + {% include "_includes/authorised-mammographer.njk" %}
{% include "_includes/medical-information/index.njk" %}
- {{ button({ - text: "Complete all and continue", - classes: "js-complete-all-sections" - }) }} + {% set continueButtonText = "Complete all and continue" %} + {% include "_includes/authorised-mammographer.njk" %} - {% include "screening-cannot-proceed-link.njk" %} +
+ {% include "screening-cannot-proceed-link.njk" %} +
{% endblock %} diff --git a/app/views/choose-user.html b/app/views/choose-user.html index 81cee64f5..270b3eb67 100644 --- a/app/views/choose-user.html +++ b/app/views/choose-user.html @@ -32,6 +32,15 @@

{{ role | sentenceCase -}} {% if not loop.last %}, {% endif %} {% endfor %} + {% if user.permissions | length %} +
+ Permissions: + {% for permission in user.permissions %} + {{ permission | kebabCase | formatWords("-") | sentenceCase -}} + {% if not loop.last %}, {% endif %} + {% endfor %} + + {% endif %}