diff --git a/app/assets/javascript/retry-worklist-connection.js b/app/assets/javascript/retry-worklist-connection.js new file mode 100644 index 00000000..f4f103f0 --- /dev/null +++ b/app/assets/javascript/retry-worklist-connection.js @@ -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) + }) +})() diff --git a/app/assets/sass/_utils.scss b/app/assets/sass/_utils.scss index 00cef5bc..caf09aba 100644 --- a/app/assets/sass/_utils.scss +++ b/app/assets/sass/_utils.scss @@ -17,3 +17,8 @@ .app-display-none { display: none; } + +.app-code-font { + font-family: $nhsuk-code-font; + word-spacing: -0.5ch; +} diff --git a/app/assets/sass/components/_status-bar.scss b/app/assets/sass/components/_status-bar.scss index 480c19c8..292e2345 100644 --- a/app/assets/sass/components/_status-bar.scss +++ b/app/assets/sass/components/_status-bar.scss @@ -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; @@ -45,6 +49,10 @@ opacity: 0.9; } +.app-status-bar__worklist-status { + margin-left: 4px; +} + .app-status-bar__viewer-link { margin-left: auto; } diff --git a/app/lib/generators/clinic-generator.js b/app/lib/generators/clinic-generator.js index d80efc5f..b9f1b3d8 100644 --- a/app/lib/generators/clinic-generator.js +++ b/app/lib/generators/clinic-generator.js @@ -183,6 +183,7 @@ const generateClinic = ( clinicCode: generateClinicCode(), date: clinicDate.format('YYYY-MM-DD'), breastScreeningUnitId: breastScreeningUnit.id, + bsuAbbreviation: breastScreeningUnit.abbreviation, locationType: location.type, clinicType, riskLevels, diff --git a/app/lib/generators/event-generator.js b/app/lib/generators/event-generator.js index 1b6fa167..3867dc49 100644 --- a/app/lib/generators/event-generator.js +++ b/app/lib/generators/event-generator.js @@ -143,10 +143,19 @@ const generateEvent = ({ eventStatus = 'event_in_progress' } + // Generate accession number for this appointment - format: ABCYYYYMMDD##### + // ABC = BSU abbreviation, YYYYMMDD = clinic date, ##### = random 5-digit sequence + const accessionNumber = [ + clinic.bsuAbbreviation || 'BSU', + dayjs(clinic.date).format('YYYYMMDD'), + faker.number.int({ min: 10000, max: 99999 }) + ].join('') + const eventBase = { id: id || generateId(), participantId: participant.id, clinicId: clinic.id, + accessionNumber, slotId: slot.id, type: clinic.clinicType, timing: { @@ -271,6 +280,7 @@ const generateEvent = ({ // Add mammogram images for completed events event.mammogramData = generateMammogramImages({ startTime: actualStartTime, + accessionNumber: eventBase.accessionNumber, isSeedData: true, config: participant.config, scenarioWeights: seedDataProfile?.mammogram?.scenarioWeights, @@ -347,6 +357,7 @@ const generateEvent = ({ if (event.workflowStatus?.['take-images'] === 'completed') { event.mammogramData = generateMammogramImages({ startTime: dayjs(event.sessionDetails.startedAt), + accessionNumber: event.accessionNumber, isSeedData: true, config: participant.config, scenarioWeights: seedDataProfile?.mammogram?.scenarioWeights, diff --git a/app/lib/generators/mammogram-generator.js b/app/lib/generators/mammogram-generator.js index b0318d16..a9f19626 100644 --- a/app/lib/generators/mammogram-generator.js +++ b/app/lib/generators/mammogram-generator.js @@ -145,6 +145,7 @@ const generateViewImages = ({ * * @param {object} [options] - Generation options * @param {Date|string} [options.startTime] - Starting timestamp (defaults to now) + * @param {string} [options.accessionNumber] - Accession number for this study (from the event) * @param {boolean} [options.isSeedData] - Whether generating seed data * @param {object} [options.config] - Optional configuration for specific scenarios * @param {string} [options.config.scenario] - Force a specific scenario ('standard', 'extraImages', 'technicalRepeat', 'incomplete', 'incompleteImperfect') @@ -155,13 +156,15 @@ const generateViewImages = ({ */ const generateMammogramImages = ({ startTime = new Date(), + accessionNumber = null, isSeedData = false, config = {}, scenarioWeights = null, imperfectChanceForTechnicalOrIncomplete = 0.15, notesForReaderChanceWithoutImperfect = 0.05 } = {}) => { - const accessionBase = faker.number + // Use the provided accession number as base, or fall back to a random number + const accessionBase = accessionNumber || faker.number .int({ min: 100000000, max: 999999999 }) .toString() let currentIndex = 1 @@ -359,7 +362,6 @@ const generateMammogramImages = ({ } return { - accessionBase, views, ...incompleteMammographyData, ...imperfectData, diff --git a/app/lib/utils/strings.js b/app/lib/utils/strings.js index 01e2d329..5e58181c 100644 --- a/app/lib/utils/strings.js +++ b/app/lib/utils/strings.js @@ -356,6 +356,29 @@ const formatNhsNumber = (input) => { // formatNhsNumber(4857773456) // returns '485 777 3456' // formatNhsNumber('485 777 3456') // returns '485 777 3456' +/** + * Format an accession number for display with spaces (ABC YYYYMMDD ##### format) + * + * @param {string} input - Raw accession number, e.g. 'KOX2026052712345' + * @returns {string} Formatted accession number, e.g. 'KOX 20260527 12345' + * @example + * formatAccessionNumber('KOX2026052712345') // 'KOX 20260527 12345' + */ +const formatAccessionNumber = (input) => { + if (!input) return '' + const str = input.toString().replace(/\s/g, '') + + // Expect 3 letters + 8 digits (date) + remaining digits + if (!/^[A-Z]{3}\d{13,}$/.test(str)) { + return input + } + + const bsu = str.slice(0, 3) + const date = str.slice(3, 11) + const sequence = str.slice(11) + return `${bsu} ${date} ${sequence}` +} + /** * Make a word plural based on a count * @@ -394,6 +417,7 @@ const formatMammogramViewCode = (code) => { module.exports = { addIndefiniteArticle, + formatAccessionNumber, formatCurrency, formatCurrencyForCsv, formatNhsNumber, diff --git a/app/routes/events.js b/app/routes/events.js index 1903f4e5..5dbb5ecb 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -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: `

${participantName} is now on the worklist

+

Image information will be sent automatically from the mammogram machine

` + }) + 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: '

Manual image mode enabled

' + }) + + 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 && @@ -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) { @@ -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') }) diff --git a/app/routes/settings.js b/app/routes/settings.js index 4ea599dd..e78fd8c2 100644 --- a/app/routes/settings.js +++ b/app/routes/settings.js @@ -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') diff --git a/app/views/_components/status-bar/template.njk b/app/views/_components/status-bar/template.njk index b31f0a1a..b771bb61 100644 --- a/app/views/_components/status-bar/template.njk +++ b/app/views/_components/status-bar/template.njk @@ -6,7 +6,7 @@
{% for row in params.rows %} -
+
{% for item in row.items %}
{% if item.key and item.value %} diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index b454a757..a9380294 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -4,7 +4,7 @@ {% set appointmentRowItems = [] %} {# Date and time #} -{% set appointmentHtml %} +{% set appointmentDateTimeHtml %} {{ clinic.date | formatDate }} at {{ event.timing.startTime | formatTimeString }} {% if event | isSpecialAppointment %} {{ tag({ @@ -13,21 +13,36 @@ classes: "nhsuk-u-margin-left-1" })}} {% endif %} +{% endset %} +{% set appointmentRowItems = appointmentRowItems | push({ + key: 'Appt:', + value: appointmentDateTimeHtml +}) %} + +{# Worklist status #} +{% set worklistStatusHtml %} {% if data.settings.screening.addedToWorklist == 'false' %} {{ appIcon("cross", { classes: "app-header-status__icon" }) }} - Not added to worklist (Retry) + {% if data.settings.screening.manualImageCollection == 'true' and data.settings.screening.manualImageModeEnabledByUser == 'true' %} + Worklist issue, manual mode enabled + {% else %} + Not on worklist (Retry) + {% endif %} {% else %} {{ appIcon("tick", { classes: "app-header-status__icon" }) }} - Added to worklist + On worklist {% endif %} {% endset %} + +{# Worklist accession number with inline worklist status #} +{% set accessionNumberFormatted = event.accessionNumber | formatAccessionNumber %} {% set appointmentRowItems = appointmentRowItems | push({ - key: "Appointment:", - value: appointmentHtml + key: 'Accn:', + value: '' + accessionNumberFormatted + '' + worklistStatusHtml + '' }) %} {# Appointment type #} @@ -86,7 +101,7 @@ {# NHS Number #} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: participant.medicalInformation.nhsNumber | formatNhsNumber + value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber | replace(' ', ' ')) + '' }) %} {{ appStatusBar({ diff --git a/app/views/_includes/images/image-troubleshooting.njk b/app/views/_includes/images/image-troubleshooting.njk index 562b8cbd..2db4334e 100644 --- a/app/views/_includes/images/image-troubleshooting.njk +++ b/app/views/_includes/images/image-troubleshooting.njk @@ -19,7 +19,7 @@

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

-

If the issue is not resolved, enable manual image mode.

+

If the issue is not resolved, enable manual image mode.

Images details will be reconciled after the appointment.

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

Enable manual image mode.

+

Enable manual image mode.

Images details will be reconciled after the appointment.

@@ -41,7 +41,7 @@

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

-

If the issue is not resolved, enable manual image mode.

+

If the issue is not resolved, enable manual image mode.

Images details will be reconciled after the appointment.

diff --git a/app/views/events/appointment.html b/app/views/events/appointment.html index 565df501..8a16138e 100644 --- a/app/views/events/appointment.html +++ b/app/views/events/appointment.html @@ -252,6 +252,17 @@ } | openInModal ] if event.status != "event_complete" else [] } + }, + { + key: { + text: "Accession number" + }, + value: { + html: '' + (event.accessionNumber | formatAccessionNumber) + '' + }, + actions: { + items: [] + } } ] } | handleSummaryListMissingInformation) }} diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index c7351ec6..ccada7ff 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -4,7 +4,7 @@ {% set hideBackLink = true %} {% set activeWorkflowStep = 'take-images' %} -{% set pageHeading = "Record images taken" %} +{% set pageHeading = "Manually record image information" %} {% set gridColumn = "nhsuk-grid-column-two-thirds" %} @@ -79,6 +79,58 @@

{{ pageHeading }}

] }) }} #} + {# Show unscheduled-appointment setup details whenever the appointment is + being handled in manual image collection mode. The reason-for-switching + input is only shown when the user manually switched mid-appointment via + the troubleshooting link on the automatic images page (failover). #} + {% set isManualFailover = event.mammogramDataTemp.isManualFailover %} + {% set troubleshootingIssue = event.mammogramDataTemp.troubleshootingIssue %} + {% set hasTroubleshootingIssue = troubleshootingIssue in ['worklist-participant', 'wrong-image-count', 'incorrect-image-labels'] %} + {% set showWorklistMatchingContent = troubleshootingIssue == 'worklist-participant' %} + {% set showDefaultManualContent = data.settings.screening.manualImageCollection == 'true' or isManualFailover %} + + {% if (hasTroubleshootingIssue and showWorklistMatchingContent) or (not hasTroubleshootingIssue and showDefaultManualContent) %} +

Manually add participant details

+

Set up an unscheduled appointment using the following information so mammograms can be assigned correctly:

+ {{ summaryList({ + rows: [ + { + key: { text: "Accession number" }, + value: { html: 'KOX 20260527 A1246' } + }, + { + key: { text: "NHS number" }, + value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber | replace(' ', ' ')) + '' } + }, + { + key: { text: "Full name" }, + value: { text: participant | getFullName } + }, + { + key: { text: "Date of birth" }, + value: { text: participant.demographicInformation.dateOfBirth | formatDate } + } + ] + }) }} + + {% if isManualFailover %} + {% call details({ + summaryText: "Add reason for switching to manual" + }) %} + {{ textarea({ + label: { + text: "Reason for switching to manual", + classes: "nhsuk-label--s" + }, + id: "manualImageModeReason", + name: "event[mammogramDataTemp][manualImageModeReason]", + rows: 3, + value: mammogramSource.manualImageModeReason + }) }} + {% endcall %} + {% endif %} + {% endif %} + {{ radios({ idPrefix: "imagingComplete", name: "event[mammogramDataTemp][isStandardSet]", diff --git a/app/views/events/retry-worklist-connection.html b/app/views/events/retry-worklist-connection.html new file mode 100644 index 00000000..8f8c6ad5 --- /dev/null +++ b/app/views/events/retry-worklist-connection.html @@ -0,0 +1,52 @@ +{# app/views/events/retry-worklist-connection.html #} + +{% extends 'layout-appointment.html' %} + +{% set hideBackLink = true %} +{% set activeWorkflowStep = 'take-images' %} +{% set activeTab = 'images' %} + +{% set pageHeading = (participant | getFullName) + " has not been added to the worklist" %} + +{% set gridColumn = "nhsuk-grid-column-two-thirds" %} + +{% block pageContent %} + + {{ backLink({ + href: "javascript:history.back();", + text: "Back", + classes: "nhsuk-u-margin-top-0 nhsuk-u-margin-bottom-4" + }) }} + +

{{ pageHeading }}

+ +

Due to a connection issue, image information for this appointment cannot be transferred automatically from the mammogram machine.

+ +
+
+ {{ button({ + text: "Retry connection", + attributes: { + formaction: "./retry-worklist-connection", + "data-retry-connection-button": "" + } + }) }} + + {{ button({ + text: "Switch to manual image mode", + classes: "nhsuk-button--secondary", + attributes: { + formaction: "./switch-to-manual-image-mode" + } + }) }} +
+
+ + {% set lastRetryAt = data.settings.screening.worklistLastRetryAt %} + {% if lastRetryAt %} +

Connection failed on retry. Last retry attempt at {{ lastRetryAt | formatTime('HH:mm:ss') }}

+ {% endif %} + + + +{% endblock %}