diff --git a/app/assets/sass/components/_reading.scss b/app/assets/sass/components/_reading.scss index 660fbc95..f2cefacd 100644 --- a/app/assets/sass/components/_reading.scss +++ b/app/assets/sass/components/_reading.scss @@ -11,8 +11,133 @@ padding: 10px; } +// Mammogram thumbnail grid for reading pages +.app-mammogram-thumbnails { + display: inline-grid; + grid-template-columns: auto auto; + gap: 8px; +} + +.app-mammogram-thumbnail--right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.app-mammogram-thumbnail--left { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +// Individual image wrapper (contains image + label) +.app-mammogram-thumbnail__image-wrapper { + position: relative; + // Fixed dimensions for consistent sizing + width: 120px; + height: 150px; + background-color: #000; + overflow: hidden; + // Use flexbox for image alignment + display: flex; + align-items: flex-start; +} + +// Right breast images align right (inwards), left breast align left (inwards) +.app-mammogram-thumbnail--right .app-mammogram-thumbnail__image-wrapper { + justify-content: flex-end; +} + +.app-mammogram-thumbnail--left .app-mammogram-thumbnail__image-wrapper { + justify-content: flex-start; +} + +.app-mammogram-thumbnail__image { + max-width: 100%; + max-height: 100%; + display: block; + cursor: default; +} + +// Slight smoothing for diagram thumbnails to reduce moiré +.app-mammogram-thumbnail__image--diagram { + filter: blur(0.5px); +} + +.app-mammogram-thumbnail__label { + position: absolute; + top: 4px; + background-color: nhsuk-colour("blue"); + color: #fff; + padding: 2px 6px; + font-size: 11px; + font-weight: 600; + z-index: 1; +} + +// Labels align to outer edge - right breast labels on left, left breast labels on right +.app-mammogram-thumbnail--right .app-mammogram-thumbnail__label { + left: 4px; +} + +.app-mammogram-thumbnail--left .app-mammogram-thumbnail__label { + right: 4px; +} + +// Missing image placeholder +.app-mammogram-thumbnail__missing { + width: 100%; + height: 100%; + border: 2px dashed nhsuk-colour("grey-2"); + background-color: nhsuk-colour("grey-5"); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.app-mammogram-thumbnail__missing-text { + font-size: 12px; + color: nhsuk-colour("grey-2"); +} + +// Zoom overlay for thumbnails +.app-mammogram-zoom { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.95); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + cursor: default; +} + +.app-mammogram-zoom__image { + max-width: 95%; + max-height: 95%; + object-fit: contain; +} + +.app-mammogram-zoom__label { + position: absolute; + top: 16px; + left: 16px; + background-color: nhsuk-colour("blue"); + color: #fff; + padding: 8px 16px; + font-size: 14px; + font-weight: 600; +} + // Todo: should this be merged with app-status-bar.scss? // Todo: otherwise refctor so classes aren't created with & + .app-reading-status { background-color: nhsuk-shade(nhsuk-colour("blue"), 40%); color: $nhsuk-reverse-text-colour; diff --git a/app/config.js b/app/config.js index 4f7bbc59..09bafec7 100644 --- a/app/config.js +++ b/app/config.js @@ -45,6 +45,9 @@ module.exports = { urgentThreshold: 10, // 10 days and over priorityThreshold: 7, // 7 days and over mammogramImageSource: 'diagrams', // 'diagrams' or 'real' + // View order for mammogram display: 'cc-first' or 'mlo-first' + // Right breast views always on left, left breast views on right + mammogramViewOrder: 'cc-first', // Distribution of image set tags (must sum to 1.0) // These are the base weights - they get adjusted based on event context // (symptoms, imperfect images, etc.) diff --git a/app/lib/utils/reading.js b/app/lib/utils/reading.js index 6ca77880..2d4401c3 100644 --- a/app/lib/utils/reading.js +++ b/app/lib/utils/reading.js @@ -1096,6 +1096,42 @@ const createReadingBatch = (data, options) => { event?.medicalInformation.symptoms?.length > 0 ) } + + // 4. Apply complex case filter if specified + if (filters.complexOnly) { + const isComplexCase = (event) => { + const hasSymptoms = + event?.medicalInformation?.symptoms && + event?.medicalInformation.symptoms?.length > 0 + + const hasAdditionalImages = + event?.mammogramData?.metadata?.hasAdditionalImages + + const isImperfect = + event?.mammogramData?.isImperfectButBestPossible?.includes && + event.mammogramData.isImperfectButBestPossible.includes('yes') + + const isIncomplete = + event?.mammogramData?.isIncompleteMammography?.includes && + event.mammogramData.isIncompleteMammography.includes('yes') + + const hasImplants = + event?.medicalInformation?.medicalHistory + ?.breastImplantsAugmentation && + event.medicalInformation.medicalHistory.breastImplantsAugmentation + .length > 0 + + return ( + hasSymptoms || + hasAdditionalImages || + isImperfect || + isIncomplete || + hasImplants + ) + } + + events = events.filter((event) => isComplexCase(event)) + } } // Apply read type filters diff --git a/app/lib/utils/summary-list.js b/app/lib/utils/summary-list.js index 71c9fceb..5ad55484 100644 --- a/app/lib/utils/summary-list.js +++ b/app/lib/utils/summary-list.js @@ -96,6 +96,60 @@ const handleSummaryListMissingInformation = ( return processRow(input) } +/** + * Add no-border class to the last summary list row + * + * Usefule where the summary list is within a card and you want to remove the bottom border + * + * @param {object|array} input - Summary list object or rows array + * @returns {object|array} Updated summary list or rows array + */ +const removeLastRowBorder = (input) => { + if (!input) return input + + const isArrayInput = Array.isArray(input) + const rows = isArrayInput ? input : input.rows + + if (!Array.isArray(rows) || rows.length === 0) return input + + let lastRowIndex = -1 + + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index] + + if (row && typeof row === 'object' && row.key) { + lastRowIndex = index + break + } + } + + if (lastRowIndex === -1) return input + + const updatedRows = rows.map((row, index) => { + if (index !== lastRowIndex) return row + + const className = 'nhsuk-summary-list__row--no-border' + const existingClasses = row.classes || '' + const hasClass = existingClasses.split(' ').includes(className) + const classes = hasClass + ? existingClasses + : `${existingClasses} ${className}`.trim() + + return { + ...row, + classes + } + }) + + if (isArrayInput) return updatedRows + + return { + ...input, + rows: updatedRows + } +} + module.exports = { - handleSummaryListMissingInformation + handleSummaryListMissingInformation, + removeLastRowBorder } diff --git a/app/routes/reading.js b/app/routes/reading.js index eea793ac..795f57ce 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -149,6 +149,10 @@ module.exports = (router) => { filters.includeAwaitingPriors = true } + if (queryFilters.includes('complexOnly')) { + filters.complexOnly = true + } + // Create the batch try { const batch = createReadingBatch(data, { diff --git a/app/views/_includes/additional-image-details.njk b/app/views/_includes/additional-image-details.njk index f94bd114..55d94cb9 100644 --- a/app/views/_includes/additional-image-details.njk +++ b/app/views/_includes/additional-image-details.njk @@ -53,9 +53,6 @@ value: "Technical issues", text: "Technical issues" }, - { - divider: "or" - }, { value: "Other", text: "Other" diff --git a/app/views/_includes/reading/image-warnings.njk b/app/views/_includes/reading/image-warnings.njk index c03e831b..132f74f1 100644 --- a/app/views/_includes/reading/image-warnings.njk +++ b/app/views/_includes/reading/image-warnings.njk @@ -91,7 +91,7 @@
{{ insetText({ html: warningsHtml, - classes: "nhsuk-u-margin-top-6" + classes: "" }) }}
diff --git a/app/views/_includes/summary-lists/medical-info-summary.njk b/app/views/_includes/summary-lists/medical-info-summary.njk index bbd2408f..27193b84 100644 --- a/app/views/_includes/summary-lists/medical-info-summary.njk +++ b/app/views/_includes/summary-lists/medical-info-summary.njk @@ -214,26 +214,10 @@ }) %} {% endif %} -{# Final summary list rows #} -{# Rebuild rows so we can add a no-border class to the final row #} -{% set rowsForSummaryList = [] %} -{% for row in rows %} - {% if loop.last %} - {% set rowsForSummaryList = rowsForSummaryList | push({ - key: row.key, - value: row.value, - actions: row.actions, - classes: "nhsuk-summary-list__row--no-border" - }) %} - {% else %} - {% set rowsForSummaryList = rowsForSummaryList | push(row) %} - {% endif %} -{% endfor %} - -{% if rowsForSummaryList | length == 0 %} +{% if rows | length == 0 %}

No medical information recorded.

{% else %} {{ summaryList({ - rows: rowsForSummaryList + rows: rows | removeLastRowBorder }) }} {% endif %} diff --git a/app/views/_includes/summary-lists/read-summary.njk b/app/views/_includes/summary-lists/read-summary.njk index 9584c259..3593f52e 100644 --- a/app/views/_includes/summary-lists/read-summary.njk +++ b/app/views/_includes/summary-lists/read-summary.njk @@ -215,5 +215,5 @@ {% endif %} {{ summaryList({ - rows: rows + rows: rows | removeLastRowBorder }) }} diff --git a/app/views/_includes/symptomsWarningCard.njk b/app/views/_includes/symptomsWarningCard.njk index 156bf683..1acd90d5 100644 --- a/app/views/_includes/symptomsWarningCard.njk +++ b/app/views/_includes/symptomsWarningCard.njk @@ -8,6 +8,7 @@ {{ warningCallout({ heading: 'Significant symptoms reported', - html: warningCalloutHtml + html: warningCalloutHtml, + classes: "nhsuk-u-margin-top-3 nhsuk-u-margin-bottom-5" }) }} {% endif %} diff --git a/app/views/events/images-manual-details.html b/app/views/events/images-manual-details.html index b09135eb..c5751c2f 100644 --- a/app/views/events/images-manual-details.html +++ b/app/views/events/images-manual-details.html @@ -58,33 +58,63 @@ {{ (viewsQuestionText + " for the") | asVisuallyHiddenText }} Right breast - {{ appStepperInput({ - label: { - text: "RMLO" - }, - _hint: { - text: "Mediolateral oblique view" - }, - name: "event[mammogramDataTemp][viewsRightBreastMLOCount]", - value: viewsRightBreastMLOCount | default("1"), - id: "views-right-breast-mlo-count", - min: 0, - max: 9 - }) }} - - {{ appStepperInput({ - label: { - text: "RCC" - }, - _hint: { - text: "Craniocaudal view" - }, - name: "event[mammogramDataTemp][viewsRightBreastCCCount]", - value: viewsRightBreastCCCount | default("1"), - id: "views-right-breast-cc-count", - min: 0, - max: 9 - }) }} + {% if data.config.reading.mammogramViewOrder == 'mlo-first' %} + {{ appStepperInput({ + label: { + text: "RMLO" + }, + _hint: { + text: "Mediolateral oblique view" + }, + name: "event[mammogramDataTemp][viewsRightBreastMLOCount]", + value: viewsRightBreastMLOCount | default("1"), + id: "views-right-breast-mlo-count", + min: 0, + max: 9 + }) }} + + {{ appStepperInput({ + label: { + text: "RCC" + }, + _hint: { + text: "Craniocaudal view" + }, + name: "event[mammogramDataTemp][viewsRightBreastCCCount]", + value: viewsRightBreastCCCount | default("1"), + id: "views-right-breast-cc-count", + min: 0, + max: 9 + }) }} + {% else %} + {{ appStepperInput({ + label: { + text: "RCC" + }, + _hint: { + text: "Craniocaudal view" + }, + name: "event[mammogramDataTemp][viewsRightBreastCCCount]", + value: viewsRightBreastCCCount | default("1"), + id: "views-right-breast-cc-count", + min: 0, + max: 9 + }) }} + + {{ appStepperInput({ + label: { + text: "RMLO" + }, + _hint: { + text: "Mediolateral oblique view" + }, + name: "event[mammogramDataTemp][viewsRightBreastMLOCount]", + value: viewsRightBreastMLOCount | default("1"), + id: "views-right-breast-mlo-count", + min: 0, + max: 9 + }) }} + {% endif %} {{ appStepperInput({ label: { @@ -108,33 +138,63 @@ {{ (viewsQuestionText + " for the") | asVisuallyHiddenText }} Left breast - {{ appStepperInput({ - label: { - text: "LMLO" - }, - _hint: { - text: "Mediolateral oblique view" - }, - name: "event[mammogramDataTemp][viewsLeftBreastMLOCount]", - value: viewsLeftBreastMLOCount | default("1"), - id: "views-left-breast-mlo-count", - min: 0, - max: 9 - }) }} - - {{ appStepperInput({ - label: { - text: "LCC" - }, - _hint: { - text: "Craniocaudal view" - }, - name: "event[mammogramDataTemp][viewsLeftBreastCCCount]", - value: viewsLeftBreastCCCount | default("1"), - id: "views-left-breast-cc-count", - min: 0, - max: 9 - }) }} + {% if data.config.reading.mammogramViewOrder == 'mlo-first' %} + {{ appStepperInput({ + label: { + text: "LMLO" + }, + _hint: { + text: "Mediolateral oblique view" + }, + name: "event[mammogramDataTemp][viewsLeftBreastMLOCount]", + value: viewsLeftBreastMLOCount | default("1"), + id: "views-left-breast-mlo-count", + min: 0, + max: 9 + }) }} + + {{ appStepperInput({ + label: { + text: "LCC" + }, + _hint: { + text: "Craniocaudal view" + }, + name: "event[mammogramDataTemp][viewsLeftBreastCCCount]", + value: viewsLeftBreastCCCount | default("1"), + id: "views-left-breast-cc-count", + min: 0, + max: 9 + }) }} + {% else %} + {{ appStepperInput({ + label: { + text: "LCC" + }, + _hint: { + text: "Craniocaudal view" + }, + name: "event[mammogramDataTemp][viewsLeftBreastCCCount]", + value: viewsLeftBreastCCCount | default("1"), + id: "views-left-breast-cc-count", + min: 0, + max: 9 + }) }} + + {{ appStepperInput({ + label: { + text: "LMLO" + }, + _hint: { + text: "Mediolateral oblique view" + }, + name: "event[mammogramDataTemp][viewsLeftBreastMLOCount]", + value: viewsLeftBreastMLOCount | default("1"), + id: "views-left-breast-mlo-count", + min: 0, + max: 9 + }) }} + {% endif %} {{ appStepperInput({ label: { diff --git a/app/views/reading/create-custom-batch.html b/app/views/reading/create-custom-batch.html index 817e3fcc..b24af7c4 100644 --- a/app/views/reading/create-custom-batch.html +++ b/app/views/reading/create-custom-batch.html @@ -89,13 +89,12 @@

{{ pageHeading }}

}, items: [ { - value: "hasSymptoms", - text: "Only cases with symptoms" - }, - { - value: "includeAwaitingPriors", - text: "Include awaiting priors cases" - } if false + value: "complexOnly", + text: "Only complex cases", + hint: { + text: "Symptoms, additional images, incomplete or imperfect images, or implants" + } + } ] }) }} {% endif %} diff --git a/app/views/reading/workflow/opinion.html b/app/views/reading/workflow/opinion.html index 9b0637fd..834472a5 100644 --- a/app/views/reading/workflow/opinion.html +++ b/app/views/reading/workflow/opinion.html @@ -3,7 +3,7 @@ {% extends 'layout-reading.html' %} -{% set pageHeading = "Review images" %} +{% set pageHeading = participant | getFullName %} {% set gridColumn = "none" %} {% set showWorkflowNav = true %} @@ -28,14 +28,36 @@ {% set opinionHeading = "Update review" %} {% endif %} + {# Get mammogram images for thumbnails #} + {% set mammogramImageSource = data.config.reading.mammogramImageSource or "diagrams" %} + {% set mammogramImages = getImagesForEvent(eventId, mammogramImageSource, { event: event }) %} + + {# View order from config - determines if CC or MLO comes first #} + {# Right breast views on left, left breast views on right #} + {% if data.config.reading.mammogramViewOrder == 'mlo-first' %} + {% set viewOrder = [ + { key: 'rmlo', label: 'RMLO', side: 'right' }, + { key: 'lmlo', label: 'LMLO', side: 'left' }, + { key: 'rcc', label: 'RCC', side: 'right' }, + { key: 'lcc', label: 'LCC', side: 'left' } + ] %} + {% else %} + {% set viewOrder = [ + { key: 'rcc', label: 'RCC', side: 'right' }, + { key: 'lcc', label: 'LCC', side: 'left' }, + { key: 'rmlo', label: 'RMLO', side: 'right' }, + { key: 'lmlo', label: 'LMLO', side: 'left' } + ] %} + {% endif %} + {# Page header with status tags #}
- {{ participant | getFullName }} + {{ opinionHeading }}

- {{ opinionHeading }} + {{ participant | getFullName }}

@@ -99,20 +121,87 @@

- {# Opinion form #} + + + {# Opinion form with thumbnails #} {% set questionText = "What is your opinion of these images?" %} + {# Count total images #} + {% set imageCount = 0 %} + {% for view in viewOrder %} + {% set allPaths = mammogramImages.allPaths[view.key] if mammogramImages and mammogramImages.allPaths else null %} + {% if allPaths %} + {% if allPaths is string %} + {% set imageCount = imageCount + 1 %} + {% else %} + {% set imageCount = imageCount + allPaths | length %} + {% endif %} + {% endif %} + {% endfor %} + +
+ {# Opinion form data attributes are used to manage the first-load lockout #} {# Todo: do we need a form here? is the parent layout's form not enough? #} - {# Hidden field to track previous result for change detection #} + {# Hidden field to track previous result for opinion change detection #} - {% if hasExistingOpinion %} - {# Show radios when updating an existing or in-progress result #} -
-
+
+ + {# Left column: Mammogram thumbnails #} +
+

Image thumbnails ({{ imageCount }})

+
+ {% for view in viewOrder %} +
+ {% set allPaths = mammogramImages.allPaths[view.key] if mammogramImages and mammogramImages.allPaths else null %} + + {% if allPaths %} + {# Handle both single path (string) and multiple paths (array) #} + {% if allPaths is string %} +
+ {{ view.label }} + {{ view.label }} view +
+ {% else %} + {# Multiple images - each with its own wrapper and label #} + {% for imagePath in allPaths %} +
+ {{ view.label }} + {{ view.label }} view ({{ loop.index }} of {{ loop.length }}) +
+ {% endfor %} + {% endif %} + {% else %} + {# Missing image placeholder #} +
+ {{ view.label }} +
+ No image +
+
+ {% endif %} +
+ {% endfor %} +
+
+ + {# Right column: Opinion buttons/radios #} +
+ {% if hasExistingOpinion %} + {# Show radios when updating an existing or in-progress result #} {{ radios({ name: "imageReadingTemp[result]", fieldset: { @@ -148,20 +237,12 @@

{{ button({ text: "Update opinion" }) }} -

-
- {% else %} - {# Show buttons for initial assessment #} -
-
-

{{ questionText }}

-
-
+ {% else %} + {# Show buttons for initial assessment - vertically stacked #} +

{{ questionText }}

-
-
-
+
{% if hasSymptoms %} {{ button({ text: "Normal, and add details", @@ -177,26 +258,24 @@

{{ questionText }}

classes: "app-button-full-width nhsuk-u-margin-bottom-3" }) }} -

+

{% endif %}
-
-
-
+ +
{{ button({ text: "Technical recall", value: "technical_recall", name: "imageReadingTemp[result]", - classes: "nhsuk-button--secondary app-button-full-width" + classes: "nhsuk-button--secondary app-button-full-width nhsuk-u-margin-bottom-3" }) }}
-
-
-
+ +
{{ button({ text: "Recall for assessment", value: "recall_for_assessment", @@ -204,19 +283,23 @@

{{ questionText }}

classes: "nhsuk-button--warning app-button-full-width" }) }}
-
+ + {% endif %}
+
- {% endif %} + - {% include "_includes/reading/image-warnings.njk" %} - + {# Image notes - moved above opinion section #} + {% include "_includes/reading/image-warnings.njk" %} + {# Medical summary #} {# Only show rows that have data #} {% set showOnlyPopulated = true %} {# No edits allowed in image reading #} + {% set allowEdits = false %} {% set medicalSummaryHtml %} @@ -231,3 +314,45 @@

{{ questionText }}

}) }} {% endblock %} + +{% block pageScripts %} + +{% endblock %} diff --git a/app/views/reading/workflow/technical-recall.html b/app/views/reading/workflow/technical-recall.html index ab18c9c6..db3e892f 100644 --- a/app/views/reading/workflow/technical-recall.html +++ b/app/views/reading/workflow/technical-recall.html @@ -26,8 +26,12 @@

{{ pageHeading }}

- {# Standard views - could be dynamic based on participant/event in future #} - {% set views = ['RMLO', 'LMLO', 'RCC', 'LCC'] %} + {# Standard views - order matches config setting #} + {% if data.config.reading.mammogramViewOrder == 'mlo-first' %} + {% set views = ['RMLO', 'LMLO', 'RCC', 'LCC'] %} + {% else %} + {% set views = ['RCC', 'LCC', 'RMLO', 'LMLO'] %} + {% endif %} {% set recallReasons = [ 'Breast positioning',