diff --git a/app/assets/javascript/retry-worklist-connection.js b/app/assets/javascript/retry-worklist-connection.js new file mode 100644 index 00000000..925f2f7d --- /dev/null +++ b/app/assets/javascript/retry-worklist-connection.js @@ -0,0 +1,73 @@ +// app/assets/javascript/retry-worklist-connection.js +// +// Handles retry connection simulation entirely client-side. +// First click: shows "Attempting to reconnect" then shows a failure message. +// Second click: shows "Attempting to reconnect" then submits the form (server +// marks worklist as connected and redirects via referrerChain). + +;(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 failureMessage = document.querySelector('[data-retry-failure-message]') + const retryTimeSpan = document.querySelector('[data-retry-time]') + const originalText = button.textContent + let attempts = 0 + + // Reset button state on page load and bfcache restore + const resetButton = () => { + button.disabled = false + button.textContent = originalText + } + resetButton() + window.addEventListener('pageshow', resetButton) + + let isSubmitting = false + + form.addEventListener('submit', (event) => { + // Only intercept the Retry button (not the secondary Switch to manual button) + if (event.submitter !== button) return + + // After a successful retry we re-submit programmatically — let it through + if (isSubmitting) return + + event.preventDefault() + + button.disabled = true + button.textContent = 'Attempting to reconnect' + + setTimeout(() => { + attempts++ + + if (attempts >= 2) { + // Second attempt: simulate success — submit form to server + isSubmitting = true + button.disabled = false + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(button) + } else { + form.submit() + } + return + } + + // First attempt: simulate failure — show message and re-enable button + if (failureMessage && retryTimeSpan) { + const now = new Date() + const timeString = now.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + retryTimeSpan.textContent = timeString + failureMessage.style.display = '' + } + + resetButton() + }, 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..1f5e5bd2 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -2228,18 +2228,99 @@ module.exports = (router) => { } ) + // Worklist connection retry routes + // + // The retry page is rendered as a simple GET (auto-routed or via this route). + // Retry counting is handled entirely client-side — the JS fakes a failed + // first attempt and a successful second attempt, then submits the form. + // + // POST retry-worklist-connection: marks worklist as connected and redirects + // back using the standard referrerChain system. + // + // POST switch-to-manual-image-mode: stores manual mode on the EVENT (not + // globally) and redirects back via referrerChain. + + // Handle successful "Retry connection" — client-side JS only submits after + // simulating a successful reconnect. + router.post( + '/clinics/:clinicId/events/:eventId/retry-worklist-connection', + (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data + + // Mark this event as reconnected (per-event, doesn't change the global setting) + data.event.isOnWorklist = true + + const participantName = getFullName(data.participant) + req.flash('success', { + html: `
+Image information will be sent automatically from the mammogram machine
` + }) + + const returnUrl = getReturnUrl( + `/clinics/${clinicId}/events/${eventId}/take-images`, + req.query.referrerChain + ) + return res.redirect(returnUrl) + } + ) + + // Handle "Switch to manual image mode" — stores override on the event only. + router.post( + '/clinics/:clinicId/events/:eventId/switch-to-manual-image-mode', + (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data + + // Store manual mode on the event itself, not globally + data.event.isManualImageCollection = true + + req.flash('success', { + html: '' + }) + + const returnUrl = getReturnUrl( + `/clinics/${clinicId}/events/${eventId}/take-images`, + req.query.referrerChain + ) + 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' || + data.event?.isOnWorklist === true + + // Manual mode is true if the global setting says so, OR this specific + // event was switched to manual (e.g. via the retry-connection page). const isManualImageCollection = - data.settings?.screening?.manualImageCollection === 'true' + data.settings?.screening?.manualImageCollection === 'true' || + data.event?.isManualImageCollection === 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( + urlWithReferrer( + `/clinics/${clinicId}/events/${eventId}/retry-worklist-connection`, + `/clinics/${clinicId}/events/${eventId}/take-images` + ) + ) + } + // If manual flow and images already completed, redirect to details page for editing if ( isManualImageCollection && @@ -2270,6 +2351,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) { @@ -2283,14 +2370,16 @@ module.exports = (router) => { // Clear any existing temp data for fresh start delete data.event.mammogramDataTemp - // Check if this is a failover from automatic mode - const isManualImageCollection = + // Check if this is a failover from automatic mode (event was switched + // to manual via the retry-connection page, or user navigated here from + // the troubleshooting link on the automatic images page). + const isGlobalManualSetting = data.settings?.screening?.manualImageCollection === 'true' const hadAutomaticData = !!data.event?.mammogramData && !data.event?.mammogramData?.isManualEntry // Set failover flag if switching from automatic to manual - if (!isManualImageCollection || hadAutomaticData) { + if (!isGlobalManualSetting || hadAutomaticData) { if (!data.event.mammogramDataTemp) { data.event.mammogramDataTemp = {} } @@ -2298,6 +2387,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/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 @@