Skip to content
54 changes: 54 additions & 0 deletions app/assets/javascript/retry-worklist-connection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// app/assets/javascript/retry-worklist-connection.js
//
// Adds a brief "Attempting to reconnect" transient state to the Retry
// connection button so the user sees feedback before the page reloads.

(function () {
const RECONNECT_DELAY_MS = 1500
const button = document.querySelector('[data-retry-connection-button]')
if (!button) return

const form = button.form
if (!form) return

const originalText = button.textContent

// Reset the button on initial load and when restored from bfcache, so it
// doesn't get stuck in the "Attempting to reconnect" disabled state after
// the post-redirect page load.
const resetButton = function () {
button.disabled = false
button.textContent = originalText
}
resetButton()
window.addEventListener('pageshow', resetButton)

let isSubmitting = false

form.addEventListener('submit', function (event) {
// Only intercept when the user clicked the Retry button (not the
// secondary "Switch to manual" button, which uses its own formaction).
if (event.submitter !== button) return

// After the simulated reconnect delay we re-submit programmatically;
// let that submission through without re-intercepting it.
if (isSubmitting) return

event.preventDefault()

button.disabled = true
button.textContent = 'Attempting to reconnect'

window.setTimeout(function () {
isSubmitting = true
// Re-enable so the value is submitted, then submit using the
// button's formaction.
button.disabled = false
if (typeof form.requestSubmit === 'function') {
form.requestSubmit(button)
} else {
form.submit()
}
}, RECONNECT_DELAY_MS)
})
})()
4 changes: 4 additions & 0 deletions app/assets/sass/_utils.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@
.app-display-none {
display: none;
}

.app-code-font {
font-family: $nhsuk-code-font;
}
8 changes: 8 additions & 0 deletions app/assets/sass/components/_status-bar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
align-items: center;
}

.app-status-bar__row--compact {
gap: 10px;
}

.app-status-bar__row + .app-status-bar__row {
margin-top: 8px;
padding-top: 8px;
Expand All @@ -45,6 +49,10 @@
opacity: 0.9;
}

.app-status-bar__worklist-status {
margin-left: 4px;
}

.app-status-bar__viewer-link {
margin-left: auto;
}
Expand Down
144 changes: 142 additions & 2 deletions app/routes/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -2228,18 +2228,141 @@ module.exports = (router) => {
}
)

// Worklist connection retry routes

// Helper: only allow same-origin app paths as return URLs.
const safeReturnUrl = (url) => {
if (typeof url !== 'string') return null
if (!url.startsWith('/')) return null
if (url.startsWith('//')) return null
return url
}

// GET the retry page - capture where the user came from so we can send them
// back after a successful reconnect.
router.get('/clinics/:clinicId/events/:eventId/retry-worklist-connection', (req, res) => {
const data = req.session.data

const fromQuery = safeReturnUrl(req.query.returnUrl)
if (fromQuery) {
data.worklistRetryReturnUrl = fromQuery
}

res.render('events/retry-worklist-connection')
})

// Handle "Retry connection" button.
// The first attempt always fails (updates the "last retry attempt" time).
// The second (and any subsequent) attempt succeeds: marks the appointment as
// added to the worklist, flashes a success message, and returns the user to
// wherever they clicked Retry from.
router.post('/clinics/:clinicId/events/:eventId/retry-worklist-connection', (req, res) => {
const { clinicId, eventId } = req.params
const data = req.session.data

data.settings = data.settings || {}
data.settings.screening = data.settings.screening || {}

const attempts = (data.worklistRetryAttempts || 0) + 1

if (attempts >= 2) {
// Success: connection restored.
data.settings.screening.addedToWorklist = 'true'
delete data.settings.screening.worklistLastRetryAt
delete data.worklistRetryAttempts

const returnUrl =
safeReturnUrl(data.worklistRetryReturnUrl) ||
`/clinics/${clinicId}/events/${eventId}/take-images`
delete data.worklistRetryReturnUrl

const participantName = getFullName(data.participant)
req.flash('success', {
html: `<p class="nhsuk-notification-banner__heading">${participantName} is now on the worklist</p>
<p>Image information will be sent automatically from the mammogram machine</p>`
})
return res.redirect(returnUrl)
}

// Failed attempt.
data.worklistRetryAttempts = attempts
data.settings.screening.worklistLastRetryAt = new Date().toISOString()

res.redirect(`/clinics/${clinicId}/events/${eventId}/retry-worklist-connection`)
})

// Handle "Switch to manual image mode" button - enable manual mode, return
// the user to where they clicked Retry from, and flash a success banner
// explaining what they need to do on the mammogram machine.
router.post('/clinics/:clinicId/events/:eventId/switch-to-manual-image-mode', (req, res) => {
const { clinicId, eventId } = req.params
const data = req.session.data

data.settings = data.settings || {}
data.settings.screening = data.settings.screening || {}
data.settings.screening.manualImageCollection = 'true'
data.settings.screening.manualImageModeEnabledByUser = 'true'

const returnUrl =
safeReturnUrl(data.worklistRetryReturnUrl) ||
`/clinics/${clinicId}/events/${eventId}/take-images`

delete data.worklistRetryAttempts
delete data.worklistRetryReturnUrl
delete data.settings.screening.worklistLastRetryAt

// Clear any failover flag from a prior automatic→manual switch so we
// don't incorrectly show the "Reason for switching" input here (the
// reason is implicit when arriving via the retry-connection flow).
if (data.event?.mammogramDataTemp) {
delete data.event.mammogramDataTemp.isManualFailover
}

req.flash('success', {
title: 'Success',
html: '<p class="nhsuk-notification-banner__heading">Manual image mode enabled</p>'
})

res.redirect(returnUrl)
})

// Manual imaging routes

// Handle take-images route - redirect to appropriate page based on state
router.get('/clinics/:clinicId/events/:eventId/take-images', (req, res) => {
// Handle take-images route - redirect to appropriate page based on state.
// Use `all` so the gate applies to the POST from review-medical-information
// as well as direct GET navigation.
router.all('/clinics/:clinicId/events/:eventId/take-images', (req, res) => {
const { clinicId, eventId } = req.params
const data = req.session.data

const isAddedToWorklist =
data.settings?.screening?.addedToWorklist !== 'false'

// When a participant is on the worklist, image transfer should use the
// automatic flow. Clear any stale manual-mode state from prior actions.
if (isAddedToWorklist) {
data.settings = data.settings || {}
data.settings.screening = data.settings.screening || {}
data.settings.screening.manualImageCollection = 'false'
delete data.settings.screening.manualImageModeEnabledByUser
}

const isManualImageCollection =
data.settings?.screening?.manualImageCollection === 'true'
const imagesStageCompleted =
data.event?.workflowStatus?.['take-images'] === 'completed'

// Gate: if the appointment was not added to the worklist and the user
// hasn't yet switched to manual image mode, divert to the retry page
// before letting them into the image-taking step.

if (!isAddedToWorklist && !isManualImageCollection) {
return res.redirect(
`/clinics/${clinicId}/events/${eventId}/retry-worklist-connection?returnUrl=` +
encodeURIComponent(`/clinics/${clinicId}/events/${eventId}/take-images`)
)
}

// If manual flow and images already completed, redirect to details page for editing
if (
isManualImageCollection &&
Expand Down Expand Up @@ -2270,6 +2393,12 @@ module.exports = (router) => {
router.get('/clinics/:clinicId/events/:eventId/images-manual', (req, res) => {
const { clinicId, eventId } = req.params
const data = req.session.data
const validTroubleshootingIssues = [
'worklist-participant',
'wrong-image-count',
'incorrect-image-labels'
]
const troubleshootingIssue = req.query.issue

// If mammogramData exists and is manual entry, prepopulate temp for editing
if (data.event?.mammogramData?.isManualEntry) {
Expand Down Expand Up @@ -2298,6 +2427,17 @@ module.exports = (router) => {
}
}

// Persist troubleshooting issue context when navigating from troubleshooting links
if (validTroubleshootingIssues.includes(troubleshootingIssue)) {
if (!data.event.mammogramDataTemp) {
data.event.mammogramDataTemp = {}
}
data.event.mammogramDataTemp.troubleshootingIssue = troubleshootingIssue
} else if (data.event?.mammogramDataTemp?.troubleshootingIssue) {
// Clear stale issue context for non-troubleshooting entry points
delete data.event.mammogramDataTemp.troubleshootingIssue
}

// Let the dynamic routing handle the actual rendering
res.render('events/images-manual')
})
Expand Down
32 changes: 32 additions & 0 deletions app/routes/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ const getCustomOverridesFromBody = (body = {}, fallbackProfile = {}) => {
}

module.exports = (router) => {
// Keep worklist simulation settings consistent with image collection mode:
// - "Not successful": clear any stale "manual mode enabled by user" marker
// so retry messaging is shown
// - "Successful": return to automatic image collection mode
router.get('/settings', (req, res, next) => {
const addedToWorklistFromQuery =
req.query?.settings?.screening?.addedToWorklist

if (addedToWorklistFromQuery === 'false') {
if (!req.session.data.settings) {
req.session.data.settings = {}
}
if (!req.session.data.settings.screening) {
req.session.data.settings.screening = {}
}

delete req.session.data.settings.screening.manualImageModeEnabledByUser
} else if (addedToWorklistFromQuery === 'true') {
if (!req.session.data.settings) {
req.session.data.settings = {}
}
if (!req.session.data.settings.screening) {
req.session.data.settings.screening = {}
}

req.session.data.settings.screening.manualImageCollection = 'false'
delete req.session.data.settings.screening.manualImageModeEnabledByUser
}

next()
})

// Ensure any POST to settings resolves to a GET render
router.post('/settings', (req, res) => {
return res.redirect(303, '/settings')
Expand Down
2 changes: 1 addition & 1 deletion app/views/_components/status-bar/template.njk
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<div class="app-status-bar {{ classes }}">
<div class="nhsuk-width-container">
{% for row in params.rows %}
<div class="app-status-bar__row">
<div class="app-status-bar__row{% if row.classes %} {{ row.classes }}{% endif %}">
{% for item in row.items %}
<div class="app-status-bar__item">
{% if item.key and item.value %}
Expand Down
26 changes: 20 additions & 6 deletions app/views/_includes/appointment-status-bar.njk
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,43 @@
{% set appointmentRowItems = [] %}

{# Date and time #}
{% set appointmentHtml %}
{% set appointmentDateTimeHtml %}
{{ clinic.date | formatDate }} at {{ event.timing.startTime | formatTimeString }}
{% if event | isSpecialAppointment %}
{{ tag({
text: "Special appointment",
classes: "nhsuk-tag--yellow nhsuk-u-margin-left-1"
})}}
{% endif %}
{% endset %}
{% set appointmentRowItems = appointmentRowItems | push({
key: '<abbr title="Appointment">Appt</abbr>:',
value: appointmentDateTimeHtml
}) %}

{# Worklist status #}
{% set worklistStatusHtml %}
{% if data.settings.screening.addedToWorklist == 'false' %}
<span class="app-header-status app-header-status--fail">
{{ appIcon("cross", { classes: "app-header-status__icon" }) }}
<span>Not added to worklist (<a href="#">Retry</a>)</span>
{% if data.settings.screening.manualImageCollection == 'true' and data.settings.screening.manualImageModeEnabledByUser == 'true' %}
<span>Worklist issue, manual mode enabled</span>
{% else %}
<span>Not on worklist (<a href="/clinics/{{ clinicId }}/events/{{ event.id }}/retry-worklist-connection?returnUrl={{ currentUrl | urlencode }}">Retry</a>)</span>
{% endif %}
</span>
{% else %}
<span class="app-header-status">
{{ appIcon("tick", { classes: "app-header-status__icon" }) }}
<span>Added to worklist</span>
<span>On worklist</span>
</span>
{% endif %}
{% endset %}

{# Worklist accession number with inline worklist status #}
{% set appointmentRowItems = appointmentRowItems | push({
key: "Appointment:",
value: appointmentHtml
key: '<abbr title="Accession number">Accn</abbr>:',
value: '<span class="app-code-font nhsuk-u-nowrap">KOX&#8201;20260527&#8201;A1246</span><span class="app-status-bar__worklist-status">' + worklistStatusHtml + '</span>'
}) %}

{# Appointment type #}
Expand Down Expand Up @@ -84,7 +98,7 @@
{# NHS Number #}
{% set participantRowItems = participantRowItems | push({
key: "NHS:",
value: participant.medicalInformation.nhsNumber | formatNhsNumber
value: '<span class="app-code-font nhsuk-u-nowrap">' + (participant.medicalInformation.nhsNumber | formatNhsNumber | replace(' ', '&#8201;')) + '</span>'
}) %}

{{ appStatusBar({
Expand Down
6 changes: 3 additions & 3 deletions app/views/_includes/images/image-troubleshooting.njk
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

<p>If this needs to be changed, update the mammogram machine and wait a few seconds for the image details to refresh.</p>

<p>If the issue is not resolved, <a href="./images-manual" class="nhsuk-link">enable manual image mode</a>.</p>
<p>If the issue is not resolved, <a href="./images-manual?issue=worklist-participant" class="nhsuk-link">enable manual image mode</a>.</p>

<p>Images details will be reconciled after the appointment.</p>

Expand All @@ -29,7 +29,7 @@
summaryText: "The wrong number of images are displayed"
}) %}

<p><a href="./images-manual" class="nhsuk-link">Enable manual image mode</a>.</p>
<p><a href="./images-manual?issue=wrong-image-count" class="nhsuk-link">Enable manual image mode</a>.</p>

<p>Images details will be reconciled after the appointment.</p>

Expand All @@ -41,7 +41,7 @@

<p>Update image information on the mammogram machine and wait a few seconds for the image details to refresh.</p>

<p>If the issue is not resolved, <a href="./images-manual" class="nhsuk-link">enable manual image mode</a>.</p>
<p>If the issue is not resolved, <a href="./images-manual?issue=incorrect-image-labels" class="nhsuk-link">enable manual image mode</a>.</p>

<p>Images details will be reconciled after the appointment.</p>

Expand Down
Loading