diff --git a/app/assets/javascript/close-clinic.js b/app/assets/javascript/close-clinic.js
new file mode 100644
index 00000000..7345f448
--- /dev/null
+++ b/app/assets/javascript/close-clinic.js
@@ -0,0 +1,92 @@
+// Close clinic page - page-specific enhancements on top of
+// fragment-actions.js, which already handles the single outcome links
+// (marked data-fragment-action in the row macro). This file adds the parts
+// with wider effects: bulk actions, revealing the refresh hint when counts
+// go stale, and refreshing a row after its details modal saves.
+
+import { refreshFragment } from './fragment-actions.js'
+
+document.addEventListener('DOMContentLoaded', () => {
+ const container = document.getElementById('js-close-clinic-content')
+ if (!container) return
+
+ const clinicId = container.dataset.clinicId
+ const fetchOptions = { headers: { 'X-Requested-With': 'XMLHttpRequest' } }
+
+ // Counts in the card headings and inset text aren't updated in place -
+ // this link invites a refresh instead
+ const showRefreshLink = () => {
+ const link = container.querySelector('.js-refresh-link')
+ if (link) link.hidden = false
+ }
+
+ // Any swapped row means the page counts may be stale
+ container.addEventListener('fragment:swapped', showRefreshLink)
+
+ const rowFor = (appointmentId) =>
+ container.querySelector(`tr[data-fragment-id="${appointmentId}"]`)
+
+ // Re-fetch one row and swap it in place
+ const refreshRow = (row) => {
+ const showActions = row.closest('table')?.dataset.showActions || 'false'
+ const url = `/clinics/${clinicId}/close/appointment-row/${row.dataset.fragmentId}?showActions=${showActions}`
+ return refreshFragment(row, url)
+ }
+
+ // Bulk outcome change - refresh each affected row, then swap the button
+ // and its undo message over
+ const handleBulkClick = (link) => {
+ const bulkContainer = link.closest('.js-bulk-action-container')
+ const isUndo = Boolean(link.closest('.js-bulk-undo-message'))
+
+ fetch(link.href, fetchOptions)
+ .then((response) => {
+ if (!response.ok) throw new Error('Request failed')
+ return response.json()
+ })
+ .then((result) => {
+ const rows = result.appointmentIds.map(rowFor).filter(Boolean)
+ return Promise.all(rows.map(refreshRow)).then(() => result)
+ })
+ .then((result) => {
+ bulkContainer.querySelector('.nhsuk-button').hidden = !isUndo
+ const undoMessage = bulkContainer.querySelector('.js-bulk-undo-message')
+ undoMessage.hidden = isUndo
+ if (!isUndo) {
+ undoMessage.querySelector('.js-bulk-count').textContent =
+ result.count === 1 ? '1 participant' : `${result.count} participants`
+ }
+ showRefreshLink()
+ })
+ .catch(() => {
+ window.location.href = link.href
+ })
+ }
+
+ container.addEventListener('click', (event) => {
+ const bulkLink = event.target.closest('.js-bulk-action')
+ if (bulkLink) {
+ event.preventDefault()
+ handleBulkClick(bulkLink)
+ return
+ }
+
+ // Details links open in a modal (attributes added by the openInModal
+ // filter). Take over from the global handler in modal.js so the row can
+ // be refreshed in place when the modal form saves.
+ const modalLink = event.target.closest('[data-load-modal-url]')
+ if (modalLink) {
+ event.preventDefault()
+ event.stopPropagation()
+ const appointmentId = modalLink.closest('tr')?.dataset.fragmentId
+ window.openModal(modalLink.dataset.modalId || 'app-form-modal', {
+ loadUrl: modalLink.dataset.loadModalUrl,
+ onSuccess: () => {
+ const row = rowFor(appointmentId)
+ if (!row) return window.location.reload()
+ refreshRow(row).catch(() => window.location.reload())
+ }
+ })
+ }
+ })
+})
diff --git a/app/assets/javascript/fragment-actions.js b/app/assets/javascript/fragment-actions.js
new file mode 100644
index 00000000..645b75b1
--- /dev/null
+++ b/app/assets/javascript/fragment-actions.js
@@ -0,0 +1,85 @@
+// app/assets/javascript/fragment-actions.js
+//
+// Progressive enhancement for in-place status updates. Mark a link or form
+// with data-fragment-action and wrap the markup it changes in an element
+// with a unique data-fragment-id. The action is sent with fetch, and the
+// server responds with a re-rendered copy of that element (built from the
+// same Nunjucks macro the page used), which is swapped in place - so tags,
+// labels and action links can't drift from the server's rendering. Routes
+// detect these requests with req.xhr and render the fragment view instead
+// of redirecting. Without JS, or on any failure, the link or form falls
+// back to a normal navigation.
+//
+// A bubbling fragment:swapped event fires on each replacement element, for
+// pages that need to react (eg revealing a 'refresh to update counts' hint).
+
+const fetchOptions = { headers: { 'X-Requested-With': 'XMLHttpRequest' } }
+
+// Swap target for the fragment contained in html, verifying the ids match
+// so an unexpected response (eg a redirect to a full page) never gets
+// injected into the table
+export const swapFragment = (target, html) => {
+ const template = document.createElement('template')
+ template.innerHTML = html.trim()
+ const replacement = template.content.querySelector('[data-fragment-id]')
+ if (!replacement || replacement.dataset.fragmentId !== target.dataset.fragmentId) {
+ throw new Error('Response was not the expected fragment')
+ }
+ target.replaceWith(replacement)
+ replacement.dispatchEvent(
+ new CustomEvent('fragment:swapped', {
+ bubbles: true,
+ detail: { fragment: replacement }
+ })
+ )
+ return replacement
+}
+
+// Fetch a fragment URL and swap the response into target
+export const refreshFragment = (target, url) =>
+ fetch(url, fetchOptions)
+ .then((response) => {
+ if (!response.ok) throw new Error('Failed to fetch fragment')
+ return response.text()
+ })
+ .then((html) => swapFragment(target, html))
+
+// GET actions:
+document.addEventListener('click', (event) => {
+ const link = event.target.closest('a[data-fragment-action]')
+ if (!link) return
+ const target = link.closest('[data-fragment-id]')
+ if (!target) return
+
+ event.preventDefault()
+ fetch(link.href, fetchOptions)
+ .then((response) => {
+ if (!response.ok) throw new Error('Request failed')
+ return response.text()
+ })
+ .then((html) => swapFragment(target, html))
+ .catch(() => {
+ window.location.href = link.href
+ })
+})
+
+// POST actions:
{{ button({
diff --git a/app/views/clinics/clinic-appointment-row.html b/app/views/clinics/clinic-appointment-row.html
new file mode 100644
index 00000000..b8a053ee
--- /dev/null
+++ b/app/views/clinics/clinic-appointment-row.html
@@ -0,0 +1,7 @@
+{# app/views/clinics/clinic-appointment-row.html #}
+{# Bare fragment - a single clinic appointment list row, fetched by main.js
+ to update the clinic page in place after checking in #}
+
+{% from "_includes/clinic-appointment-row.njk" import clinicAppointmentRow with context %}
+
+{{ clinicAppointmentRow(appointment, participant, clinicId) }}
diff --git a/app/views/clinics/close-appointment-row.html b/app/views/clinics/close-appointment-row.html
new file mode 100644
index 00000000..eca11c10
--- /dev/null
+++ b/app/views/clinics/close-appointment-row.html
@@ -0,0 +1,7 @@
+{# app/views/clinics/close-appointment-row.html #}
+{# Bare fragment - a single table row fetched by close-clinic.js to update
+ the close clinic page in place after a status change #}
+
+{% from "_includes/close-clinic-appointment-row.njk" import closeClinicAppointmentRow with context %}
+
+{{ closeClinicAppointmentRow(appointment, participant, clinicId, showActions) }}
diff --git a/app/views/clinics/close-attended-not-screened-reason.html b/app/views/clinics/close-attended-not-screened-reason.html
new file mode 100644
index 00000000..b726dcbb
--- /dev/null
+++ b/app/views/clinics/close-attended-not-screened-reason.html
@@ -0,0 +1,32 @@
+{# app/views/clinics/close-attended-not-screened-reason.html #}
+
+{% extends parentLayout or 'layout-app.html' %}
+
+{% set pageHeading = "Why was this appointment stopped?" %}
+{% set formAction = "/clinics/" + clinicId + "/close/reason/" + appointment.id %}
+
+{% set back = {
+ href: "/clinics/" + clinicId + "/close",
+ text: "Back to close clinic"
+} %}
+
+{% block pageContent %}
+
+
{% endif %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/app/views/reading/prior-mammogram-row.html b/app/views/reading/prior-mammogram-row.html
new file mode 100644
index 00000000..a3070e01
--- /dev/null
+++ b/app/views/reading/prior-mammogram-row.html
@@ -0,0 +1,7 @@
+{# app/views/reading/prior-mammogram-row.html #}
+{# Bare fragment - a single priors table row, fetched by fragment-actions.js
+ to update the priors page in place after a status change #}
+
+{% from "_includes/reading/prior-mammogram-row.njk" import priorMammogramRow with context %}
+
+{{ priorMammogramRow(appointment, mammogram) }}
diff --git a/app/views/reading/priors.html b/app/views/reading/priors.html
index fa344132..27c3fdbd 100644
--- a/app/views/reading/priors.html
+++ b/app/views/reading/priors.html
@@ -2,6 +2,8 @@
{% extends 'layout-app.html' %}
+{% from "_includes/reading/prior-mammogram-row.njk" import priorMammogramRow with context %}
+
{% set pageHeading = "Prior mammograms" %}
{% set gridColumn = "nhsuk-grid-column-full" %}
@@ -89,7 +91,7 @@
{{ pageHeading }}
No prior mammograms in this view.
{% else %}
-
+
Participant
@@ -101,264 +103,10 @@
{{ pageHeading }}
{% for row in displayRows %}
- {% set thisAppointment = row.appointment %}
- {% set mammogram = row.mammogram %}
-
- {# Participant name - look up from session data #}
- {% set thisParticipant = data | getParticipant(thisAppointment.participantId) %}
-
- {% if mammogram.requestStatus == "not_requested" or mammogram.requestStatus == "pending" %}
- {# Can mark as requested (sent to IEP), not available, or not needed #}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% elseif mammogram.requestStatus == "requested" %}
- {# Can mark as received, not available, or not needed #}
-