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