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:
+document.addEventListener('submit', (event) => { + const form = event.target.closest('form[data-fragment-action]') + if (!form) return + const target = form.closest('[data-fragment-id]') + if (!target) return + + event.preventDefault() + fetch(form.action, { + method: (form.method || 'POST').toUpperCase(), + body: new URLSearchParams(new FormData(form)), + headers: fetchOptions.headers + }) + .then((response) => { + if (!response.ok) throw new Error('Request failed') + return response.text() + }) + .then((html) => swapFragment(target, html)) + .catch(() => form.submit()) +}) diff --git a/app/assets/javascript/main.js b/app/assets/javascript/main.js index bfe28e13..b820f8a0 100644 --- a/app/assets/javascript/main.js +++ b/app/assets/javascript/main.js @@ -2,92 +2,48 @@ // ES6 or Vanilla JavaScript +import { swapFragment } from './fragment-actions.js' + document.addEventListener('DOMContentLoaded', () => { - // Inline check in without requiring page reload + // Inline check-in without a page reload. The server responds with the + // re-rendered appointment row (see the check-in route), which replaces the + // old one. Handled here rather than by fragment-actions.js directly because + // the trigger can be a button inside the confirm-identity modal, which + // lives inside the row being replaced. const checkInLinks = document.querySelectorAll('.js-check-in-link') checkInLinks.forEach((link) => { link.addEventListener('click', async (e) => { e.preventDefault() - const link = e.currentTarget - const clinicId = link.dataset.clinicId - const appointmentId = link.dataset.appointmentId - const statusTagId = link.dataset.statusTagId + const { clinicId, appointmentId } = e.currentTarget.dataset + const url = `/clinics/${clinicId}/check-in/${appointmentId}` try { - const response = await fetch( - `/clinics/${clinicId}/check-in/${appointmentId}`, - { - method: 'GET', - headers: { - Accept: 'application/json' - } - } - ) - + const response = await fetch(url, { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) if (!response.ok) { throw new Error('Failed to check in participant') } + const html = await response.text() - // Update the status tag if we have an ID - if (statusTagId) { - const statusTag = document.getElementById(statusTagId) - if (statusTag) { - // Update the existing tag's text and classes - statusTag.textContent = 'Checked in' - statusTag.className = 'nhsuk-tag app-nowrap' - } - } - - // Show the start appointment link by removing the hidden class - const appointmentRow = document.getElementById(`appointment-row-${appointmentId}`) - if (appointmentRow) { - const startAppointmentLink = appointmentRow.querySelector( - '.js-start-appointment-link' - ) - if (startAppointmentLink) { - startAppointmentLink.classList.remove('app-display-none') - } - - // Set focus on the row for accessibility - appointmentRow.setAttribute('tabindex', '-1') - appointmentRow.focus() - } - - // Remove the check-in link - // Check if this is a modal button or a direct link - const isModalButton = link.closest('.app-modal') - - if (isModalButton) { - // For modal buttons, find the original check-in link on the main page - // Look for a link that opens the modal for this specific appointment - const modalId = `check-in-modal-${appointmentId}` - const originalCheckInLink = document.querySelector( - `a[onclick*="openModal('${modalId}')"]` - ) - - if (originalCheckInLink) { - const checkInParagraph = originalCheckInLink.closest('p') - if (checkInParagraph) { - checkInParagraph.remove() - } - } - } else { - // For direct links, remove the paragraph containing the link - const checkInParagraph = link.closest('p') - if (checkInParagraph) { - checkInParagraph.remove() - } - } - - // Close any open modal (for modal-based check-ins) + // Close the confirm-identity modal before the swap - its markup + // lives inside the row that's about to be replaced const openModal = document.querySelector('.app-modal:not([hidden])') if (openModal && window.closeModal) { window.closeModal(openModal.id) } + + const row = document.querySelector(`tr[data-fragment-id="${appointmentId}"]`) + if (!row) throw new Error('Appointment row not found') + const newRow = swapFragment(row, html) + + // Set focus on the row for accessibility + newRow.setAttribute('tabindex', '-1') + newRow.focus() } catch (error) { console.error('Error checking in participant:', error) - window.location.href = link.href + window.location.href = url } }) }) diff --git a/app/assets/javascript/modal.js b/app/assets/javascript/modal.js index 355df9b4..ebec212a 100644 --- a/app/assets/javascript/modal.js +++ b/app/assets/javascript/modal.js @@ -717,10 +717,12 @@ class AppModal { this.close() window.location.href = finalUrl } else { - // Flow complete — close and refresh + // Flow complete — close and refresh. Capture the callback before + // close(), which resets it. + const onSuccess = this._onSuccessCallback this.close() - if (this._onSuccessCallback) { - this._onSuccessCallback() + if (onSuccess) { + onSuccess() } else { window.location.reload() } diff --git a/app/assets/sass/_app-styles.scss b/app/assets/sass/_app-styles.scss index 7063c4eb..27f3532c 100644 --- a/app/assets/sass/_app-styles.scss +++ b/app/assets/sass/_app-styles.scss @@ -14,6 +14,7 @@ @forward "components/secondary-navigation-overrides"; @forward "components/count"; @forward "components/card"; +@forward "components/clinic-appointments-table"; @forward "components/forward-link"; @forward "components/status"; @forward "components/status-bar"; diff --git a/app/assets/sass/components/_card.scss b/app/assets/sass/components/_card.scss index 9837245c..3a39888b 100644 --- a/app/assets/sass/components/_card.scss +++ b/app/assets/sass/components/_card.scss @@ -8,3 +8,13 @@ background-color: nhsuk-colour("grey-1"); color: nhsuk-colour("white"); } + +.nhsuk-card--feature.app-card--feature-orange .nhsuk-card__heading { + background-color: nhsuk-colour("orange"); + color: nhsuk-colour("white"); +} + +.nhsuk-card--feature.app-card--feature-green .nhsuk-card__heading { + background-color: nhsuk-colour("green"); + color: nhsuk-colour("white"); +} diff --git a/app/assets/sass/components/_clinic-appointments-table.scss b/app/assets/sass/components/_clinic-appointments-table.scss new file mode 100644 index 00000000..bdc08f72 --- /dev/null +++ b/app/assets/sass/components/_clinic-appointments-table.scss @@ -0,0 +1,25 @@ +// app/assets/sass/components/_clinic-appointments-table.scss +// Appointment tables on the clinic pages (clinic view and close clinic) + +.app-clinic-appointments-table { + table-layout: fixed; + width: 100%; + + .app-clinic-appointments-table__time-column { + width: 22%; + } + + .app-clinic-appointments-table__status-column { + width: 25%; + } + + .app-clinic-appointments-table__actions-column { + width: 30%; + } +} + +// Flush variant - sits at the bottom of a card, which provides the spacing, +// so drop the final row border +.app-clinic-appointments-table--flush .nhsuk-table__body tr:last-child .nhsuk-table__cell { + border-bottom: 0; +} diff --git a/app/assets/sass/components/_compact.scss b/app/assets/sass/components/_compact.scss index 9d13e658..53a58dc5 100644 --- a/app/assets/sass/components/_compact.scss +++ b/app/assets/sass/components/_compact.scss @@ -273,12 +273,6 @@ } .app-clinic-appointments-table { - table-layout: fixed; - width: 100%; - - .app-clinic-appointments-table__time-column { - width: 22%; - } .app-clinic-appointments-table__time-column .nhsuk-tag { display: inline-block; @@ -428,3 +422,4 @@ @include nhsuk-font($size: 22, $weight: bold); } } + diff --git a/app/lib/generators/appointment-generator.js b/app/lib/generators/appointment-generator.js index 2ee2c108..fa9ba751 100644 --- a/app/lib/generators/appointment-generator.js +++ b/app/lib/generators/appointment-generator.js @@ -6,6 +6,7 @@ const weighted = require('weighted') const dayjs = require('dayjs') const config = require('../../config') const { STATUS_GROUPS, isCompleted } = require('../utils/status') +const { getStoppedReasons } = require('../utils/appointment-data') const { generateMammogramImages } = require('./mammogram-generator') const { generateMedicalInformation @@ -394,6 +395,13 @@ const generateAppointment = ({ startedBy: randomUser.id, endedAt: actualEndTime.toISOString() } + + // Stopping an appointment in the appointment flow requires reasons, so + // seeded attended-not-screened appointments always have them + appointment.appointmentStopped = { + stoppedReason: [faker.helpers.arrayElement(getStoppedReasons()).value], + needsReschedule: faker.helpers.arrayElement(['no-invite', 'no-invite', 'yes']) + } } // Select image set for appointments with mammogram data diff --git a/app/lib/utils/appointment-data.js b/app/lib/utils/appointment-data.js index 027b17ad..f4108a3e 100644 --- a/app/lib/utils/appointment-data.js +++ b/app/lib/utils/appointment-data.js @@ -149,9 +149,30 @@ const saveTempAppointmentToAppointment = (data) => { return updatedAppointment } +/** + * Reasons an appointment can be stopped (attended not screened), each with + * the form field that holds its optional details. Defined once so the reason + * forms and the seed generator stay in step. "Other reason" is handled + * separately by the form as its details are required rather than optional. + * + * @returns {Array} List of { value, detailsField } reason options + */ +const getStoppedReasons = () => [ + { value: 'Failed identity check', detailsField: 'failedIdentityDetails' }, + { value: 'Pain during screening', detailsField: 'painDetails' }, + { value: 'Has a symptomatic appointment', detailsField: 'symptomaticDetails' }, + { value: 'Consent withdrawn', detailsField: 'consentDetails' }, + { value: 'Physical health issue', detailsField: 'physicalHealthDetails' }, + { value: 'Mental health issue', detailsField: 'mentalHealthDetails' }, + { value: 'Language difficulties', detailsField: 'languageDetails' }, + { value: 'No qualified mammographer available', detailsField: 'mammographerDetails' }, + { value: 'Technical issues at clinic', detailsField: 'technicalDetails' } +] + module.exports = { getAppointment, getAppointmentData, + getStoppedReasons, updateAppointment, updateAppointmentData, saveTempAppointmentToAppointment diff --git a/app/lib/utils/clinics.js b/app/lib/utils/clinics.js index 9c97d9c4..6b2c9de3 100644 --- a/app/lib/utils/clinics.js +++ b/app/lib/utils/clinics.js @@ -114,7 +114,7 @@ const getFilteredClinics = (clinics, filter = 'all') => { switch (filter) { case 'today': return recentClinics.filter((clinic) => - dayjs(clinic.date).isSame(today, 'day') + dayjs(clinic.date).isSame(today, 'day') && clinic.status !== 'closed' ) case 'upcoming': @@ -124,7 +124,10 @@ const getFilteredClinics = (clinics, filter = 'all') => { case 'completed': return recentClinics - .filter((clinic) => dayjs(clinic.date).isBefore(today, 'day')) + .filter((clinic) => + dayjs(clinic.date).isBefore(today, 'day') || + (dayjs(clinic.date).isSame(today, 'day') && clinic.status === 'closed') + ) .sort((a, b) => new Date(b.date) - new Date(a.date)) // Most recent first case 'all': @@ -135,11 +138,35 @@ const getFilteredClinics = (clinics, filter = 'all') => { } } +/** + * Find and update a clinic in session data + * + * @param {object} data - Session data + * @param {string} clinicId - Clinic ID + * @param {object} updates - Fields to merge into the clinic + * @returns {object | null} Updated clinic or null if not found + */ +const updateClinic = (data, clinicId, updates) => { + const clinicIndex = data.clinics.findIndex((c) => c.id === clinicId) + if (clinicIndex === -1) return null + + // Update in the attached array (same-request reads) and record the change + // in data._changes (persistence - the attached array is rebuilt from the + // shared data store on every request; see middleware in app/routes.js) + const updatedClinic = { ...data.clinics[clinicIndex], ...updates } + data.clinics[clinicIndex] = updatedClinic + if (data._changes?.clinics) { + data._changes.clinics[clinicId] = updatedClinic + } + return updatedClinic +} + module.exports = { getClinic, getTodaysClinics, getFilteredClinics, getClinicAppointments, formatTimeSlot, - getClinicHours + getClinicHours, + updateClinic } diff --git a/app/lib/utils/status.js b/app/lib/utils/status.js index db120a17..ec1aa301 100644 --- a/app/lib/utils/status.js +++ b/app/lib/utils/status.js @@ -402,6 +402,16 @@ const hasSymptoms = (appointment) => { ) } +/** + * Check if an attended-not-screened appointment has its reasons recorded + * + * @param {object} appointment - Appointment object to check + * @returns {boolean} Whether stopped reasons have been recorded + */ +const hasStoppedDetails = (appointment) => { + return Boolean(appointment?.appointmentStopped?.stoppedReason?.length) +} + module.exports = { hasNotStarted, isCompleted, @@ -418,6 +428,7 @@ module.exports = { isSpecialAppointment, hasAppointmentNote, hasSymptoms, + hasStoppedDetails, // Export groups and display vocabularies for testing/reference STATUS_GROUPS, STATUS_TAGS diff --git a/app/routes/clinics.js b/app/routes/clinics.js index faad4200..38051912 100644 --- a/app/routes/clinics.js +++ b/app/routes/clinics.js @@ -4,16 +4,20 @@ const dayjs = require('dayjs') const { getClinic, getFilteredClinics, - getClinicAppointments + getClinicAppointments, + updateClinic } = require('../lib/utils/clinics') -const { filterAppointmentsByStatus } = require('../lib/utils/status') const { - getReturnUrl, - urlWithReferrer, - appendReferrer -} = require('../lib/utils/referrers') + filterAppointmentsByStatus, + isInProgress, + isFinal, + hasStoppedDetails +} = require('../lib/utils/status') +const { getReturnUrl } = require('../lib/utils/referrers') const { getParticipant } = require('../lib/utils/participants') const { updateAppointmentStatus } = require('../lib/utils/appointment-status') +const { getAppointment, updateAppointmentData } = require('../lib/utils/appointment-data') +const { pluralise } = require('../lib/utils/strings') /** * Get clinic and its related data from id @@ -54,6 +58,47 @@ function getClinicData(data, clinicId) { } } +// Status changes available from the close clinic page, keyed by the status +// being applied. Marking a final status records the appointment as resolved +// by this close flow, so that undoing 'all' only reverts appointments changed +// here - not ones that were already resolved before the flow started. +const CLOSE_STATUS_ACTIONS = { + attended_not_screened: { from: 'checked_in', resolves: true }, + did_not_attend: { from: 'scheduled', resolves: true }, + checked_in: { from: 'attended_not_screened', resolves: false }, + scheduled: { from: 'did_not_attend', resolves: false } +} + +/** + * Appointments resolved during this session's close flow, per clinic + */ +const getCloseResolvedIds = (data, clinicId) => { + return data.closeClinicResolvedIds?.[clinicId] || [] +} + +/** + * Record (or forget) appointments as resolved by the close flow + */ +const trackCloseResolvedIds = (data, clinicId, appointmentIds, resolves) => { + const existing = getCloseResolvedIds(data, clinicId) + const updated = resolves + ? [...new Set([...existing, ...appointmentIds])] + : existing.filter((id) => !appointmentIds.includes(id)) + + data.closeClinicResolvedIds = { + ...data.closeClinicResolvedIds, + [clinicId]: updated + } +} + +/** + * Attended not screened but no reasons recorded yet - still needs action + * before the clinic can close + */ +const needsStoppedDetails = (appointment) => { + return appointment.status === 'attended_not_screened' && !hasStoppedDetails(appointment) +} + module.exports = (router) => { // Set clinics to active in nav for all urls starting with /clinics router.use('/clinics', (req, res, next) => { @@ -125,9 +170,6 @@ module.exports = (router) => { ) if (appointmentIndex === -1) { - if (req.headers.accept?.includes('application/json')) { - return res.status(404).json({ error: 'Appointment not found' }) - } return res.redirect(`/clinics/${clinicId}/${currentFilter}`) } @@ -136,9 +178,6 @@ module.exports = (router) => { // Only allow check-in if currently scheduled if (appointment.status !== 'scheduled') { - if (req.headers.accept?.includes('application/json')) { - return res.status(400).json({ error: 'Appointment cannot be checked in' }) - } return res.redirect(`/clinics/${clinicId}/${currentFilter}`) } @@ -148,11 +187,13 @@ module.exports = (router) => { // Save back to session req.session.data = data - // If this was an AJAX request, send JSON response - if (req.headers.accept?.includes('application/json')) { - return res.json({ - status: 'success', - appointment: data.appointments[appointmentIndex] + // Fetch requests get the re-rendered row so the page can update in place + if (req.xhr) { + const updatedAppointment = data.appointments[appointmentIndex] + return res.render('clinics/clinic-appointment-row', { + appointment: updatedAppointment, + participant: getParticipant(data, updatedAppointment.participantId), + clinicId }) } @@ -163,6 +204,265 @@ module.exports = (router) => { res.redirect(returnUrl) }) + // Close clinic flow - resolve the clinic once for every close page + router.use('/clinics/:clinicId/close', (req, res, next) => { + const clinic = getClinic(req.session.data, req.params.clinicId) + if (!clinic) { + return res.redirect('/clinics') + } + res.locals.clinic = clinic + res.locals.clinicId = clinic.id + next() + }) + + // Resolve the appointment and participant for close routes acting on one + const loadCloseAppointment = (req, res, next) => { + const { clinicId, appointmentId } = req.params + const data = req.session.data + const appointment = data.appointments.find( + (a) => a.id === appointmentId && a.clinicId === clinicId + ) + if (!appointment) { + return res.redirect(`/clinics/${clinicId}/close`) + } + res.locals.appointment = appointment + res.locals.participant = getParticipant(data, appointment.participantId) + next() + } + + // Close clinic page + router.get('/clinics/:clinicId/close', (req, res) => { + const { appointments, unit } = getClinicData(req.session.data, req.params.clinicId) + + // Attended not screened only counts as an outcome once reasons are + // recorded - until then it stays in the 'needs an outcome' group + res.render('clinics/close', { + unit, + appointmentCount: appointments.length, + needsOutcomeCount: appointments.filter((a) => !isFinal(a) || needsStoppedDetails(a)).length, + inProgressAppointments: appointments.filter((a) => isInProgress(a)), + checkedInAppointments: [ + ...appointments.filter((a) => a.status === 'checked_in'), + ...appointments.filter((a) => needsStoppedDetails(a)) + ], + scheduledAppointments: appointments.filter((a) => a.status === 'scheduled'), + outcomeRecordedAppointments: appointments.filter((a) => isFinal(a) && !needsStoppedDetails(a)) + }) + }) + + // Change one appointment's outcome from the close page. GET so the actions + // work as plain links without JS, mirroring the check-in route above. + // Fetch requests get the re-rendered row so the page can update in place. + router.get('/clinics/:clinicId/close/set-status/:appointmentId/:status', loadCloseAppointment, (req, res) => { + const { clinicId, appointmentId, status } = req.params + const action = CLOSE_STATUS_ACTIONS[status] + if (!action) { + return res.redirect(`/clinics/${clinicId}/close`) + } + + const data = req.session.data + updateAppointmentStatus(data, appointmentId, status) + trackCloseResolvedIds(data, clinicId, [appointmentId], action.resolves) + + if (req.xhr) { + return res.render('clinics/close-appointment-row', { + appointment: getAppointment(data, appointmentId), + showActions: true + }) + } + res.redirect(`/clinics/${clinicId}/close`) + }) + + // Bulk version - applies the change to every appointment in the source + // status. Undoing (back to a non-final status) only touches appointments + // resolved by this flow. + router.get('/clinics/:clinicId/close/set-status-all/:status', (req, res) => { + const { clinicId, status } = req.params + const action = CLOSE_STATUS_ACTIONS[status] + if (!action) { + return res.redirect(`/clinics/${clinicId}/close`) + } + + const data = req.session.data + const resolvedIds = getCloseResolvedIds(data, clinicId) + const appointments = data.appointments.filter((a) => + a.clinicId === clinicId && + a.status === action.from && + (action.resolves || resolvedIds.includes(a.id)) + ) + + appointments.forEach((a) => updateAppointmentStatus(data, a.id, status)) + trackCloseResolvedIds(data, clinicId, appointments.map((a) => a.id), action.resolves) + + if (req.xhr) { + return res.json({ + count: appointments.length, + appointmentIds: appointments.map((a) => a.id) + }) + } + res.redirect(`/clinics/${clinicId}/close`) + }) + + // Re-render a single appointment row - fetched by close-clinic.js after a + // modal form saves, so the row can update without a page reload + router.get('/clinics/:clinicId/close/appointment-row/:appointmentId', loadCloseAppointment, (req, res) => { + res.render('clinics/close-appointment-row', { + showActions: req.query.showActions === 'true' + }) + }) + + // Attended-not-screened reason page (opens in modal from close page) + router.get('/clinics/:clinicId/close/reason/:appointmentId', loadCloseAppointment, (req, res) => { + const data = req.session.data + + // Seed the form from the saved appointment - but not when re-rendering + // after a validation error, which must keep the user's answers. By this + // point the locals middleware has moved any flash into res.locals.flash. + const hasValidationErrors = Boolean(res.locals.flash?.error?.length) + if (!hasValidationErrors) { + data.closeReasonForm = structuredClone(res.locals.appointment.appointmentStopped || {}) + res.locals.data.closeReasonForm = data.closeReasonForm + } + + res.render('clinics/close-attended-not-screened-reason') + }) + + router.post('/clinics/:clinicId/close/reason/:appointmentId', loadCloseAppointment, (req, res) => { + const { clinicId, appointmentId } = req.params + const data = req.session.data + + const formData = data.closeReasonForm || {} + const { stoppedReason, needsReschedule, otherDetails } = formData + const hasOtherReasonButNoDetails = + stoppedReason?.includes('Other reason') && !otherDetails + + // Validation + if (!stoppedReason || !needsReschedule || hasOtherReasonButNoDetails) { + if (!stoppedReason) { + req.flash('error', { + text: 'Select why this appointment has been stopped', + name: 'closeReasonForm[stoppedReason]', + href: '#stoppedReason' + }) + } + if (hasOtherReasonButNoDetails) { + req.flash('error', { + text: 'Provide details about the other reason', + name: 'closeReasonForm[otherDetails]', + href: '#otherDetails' + }) + } + if (!needsReschedule) { + req.flash('error', { + text: 'Select whether the appointment should be rescheduled', + name: 'closeReasonForm[needsReschedule]', + href: '#needsReschedule' + }) + } + return res.redirect(`/clinics/${clinicId}/close/reason/${appointmentId}`) + } + + // Save the whole form rather than maintaining a field list here + updateAppointmentData(data, appointmentId, { + appointmentStopped: { ...formData } + }) + + delete data.closeReasonForm + + // If reschedule requested, go to reschedule step + if (needsReschedule === 'yes') { + return res.redirect(`/clinics/${clinicId}/close/reschedule/${appointmentId}`) + } + + // In modal context reply with an empty success, so the modal closes and + // the page updates the row in place rather than reloading + if (res.locals.parentLayout) { + return res.send('') + } + res.redirect(`/clinics/${clinicId}/close`) + }) + + // Reschedule step (follows reason page when reschedule selected) + router.get('/clinics/:clinicId/close/reschedule/:appointmentId', loadCloseAppointment, (req, res) => { + const data = req.session.data + + // Seed from the saved appointment unless re-rendering a validation error + // (the locals middleware has already moved any flash into res.locals.flash) + const hasValidationErrors = Boolean(res.locals.flash?.error?.length) + if (!hasValidationErrors) { + data.closeRescheduleForm = structuredClone(res.locals.appointment.reschedule || {}) + res.locals.data.closeRescheduleForm = data.closeRescheduleForm + } + + res.render('clinics/close-reschedule') + }) + + router.post('/clinics/:clinicId/close/reschedule/:appointmentId', loadCloseAppointment, (req, res) => { + const { clinicId, appointmentId } = req.params + const data = req.session.data + + const formData = data.closeRescheduleForm || {} + + if (!formData.timing) { + req.flash('error', { + text: 'Select when the appointment should be rescheduled', + name: 'closeRescheduleForm[timing]', + href: '#timing' + }) + return res.redirect(`/clinics/${clinicId}/close/reschedule/${appointmentId}`) + } + + updateAppointmentData(data, appointmentId, { + reschedule: { ...formData } + }) + updateAppointmentStatus(data, appointmentId, 'rescheduled') + + delete data.closeRescheduleForm + + // In modal context reply with an empty success, so the modal closes and + // the page updates the row in place rather than reloading + if (res.locals.parentLayout) { + return res.send('') + } + res.redirect(`/clinics/${clinicId}/close`) + }) + + // Confirm and close clinic + router.post('/clinics/:clinicId/close', (req, res) => { + const { clinicId } = req.params + const data = req.session.data + + const clinicAppointments = data.appointments.filter((a) => a.clinicId === clinicId) + + // Every appointment needs a final outcome before the clinic can close + const unresolved = clinicAppointments.filter((a) => !isFinal(a)) + if (unresolved.length > 0) { + req.flash('error', [{ + text: `An outcome still needs to be recorded for ${unresolved.length} ${pluralise('participant', unresolved.length)} before the clinic can be closed` + }]) + return res.redirect(`/clinics/${clinicId}/close`) + } + + // Attended-not-screened appointments also need their reasons recorded + const missingDetails = clinicAppointments.filter((a) => needsStoppedDetails(a)) + if (missingDetails.length > 0) { + req.flash('error', [{ + text: `Details still need to be added for ${missingDetails.length} ${pluralise('participant', missingDetails.length)} marked as attended not screened` + }]) + return res.redirect(`/clinics/${clinicId}/close`) + } + + const updatedClinic = updateClinic(data, clinicId, { status: 'closed' }) + if (updatedClinic) { + req.flash('success', `Clinic ${updatedClinic.clinicCode} closed`) + } + + // This clinic's close flow is finished - drop its resolved tracking + delete data.closeClinicResolvedIds?.[clinicId] + + res.redirect('/clinics/completed') + }) + // Single clinic view const VALID_FILTERS = [ 'remaining', diff --git a/app/routes/reading.js b/app/routes/reading.js index c70be246..5aefc7ce 100644 --- a/app/routes/reading.js +++ b/app/routes/reading.js @@ -214,17 +214,11 @@ module.exports = (router) => { // Only accept known request statuses - the value comes straight from // the request body if (!PRIOR_REQUEST_STATUSES.includes(newStatus)) { - if (req.headers.accept?.includes('application/json')) { - return res.status(400).json({ error: 'Unknown request status' }) - } return res.redirect('/reading/priors') } const appointment = getAppointment(data, appointmentId) if (!appointment || !appointment.previousMammograms) { - if (req.headers.accept?.includes('application/json')) { - return res.status(404).json({ error: 'Appointment not found' }) - } return res.redirect('/reading/priors') } @@ -232,9 +226,6 @@ module.exports = (router) => { (m) => m.id === mammogramId ) if (!mammogram) { - if (req.headers.accept?.includes('application/json')) { - return res.status(404).json({ error: 'Mammogram not found' }) - } return res.redirect('/reading/priors') } @@ -263,14 +254,13 @@ module.exports = (router) => { }) // Saves to the appointment and mirrors into data.appointment if it matches - updateAppointmentData(data, appointmentId, { previousMammograms }) - - // If this was a fetch request, send JSON response for in-place update - if (req.headers.accept?.includes('application/json')) { - return res.json({ - status: 'success', - newStatus, - mammogramId + const updatedAppointment = updateAppointmentData(data, appointmentId, { previousMammograms }) + + // Fetch requests get the re-rendered row so the page can update in place + if (req.xhr) { + return res.render('reading/prior-mammogram-row', { + appointment: updatedAppointment, + mammogram: updatedAppointment.previousMammograms.find((m) => m.id === mammogramId) }) } diff --git a/app/views/_includes/clinic-appointment-row.njk b/app/views/_includes/clinic-appointment-row.njk new file mode 100644 index 00000000..2396a3e8 --- /dev/null +++ b/app/views/_includes/clinic-appointment-row.njk @@ -0,0 +1,115 @@ +{# app/views/_includes/clinic-appointment-row.njk #} +{# + A single appointment row on the clinic page appointment list. Also rendered + on its own by the check-in route so main.js can swap the row in place after + checking in - keep it self-contained. + + appointment - appointment record + participant - participant the appointment belongs to + clinicId - id of the clinic being viewed + + Import with context - the row reads settings and the current user from data. +#} + +{%- from 'tag/macro.njk' import tag -%} +{%- from '_components/check-in/macro.njk' import appCheckIn %} + +{% macro clinicAppointmentRow(appointment, participant, clinicId) %} + {% set appointmentBaseUrl = "/clinics/" + clinicId + "/appointments/" + appointment.id %} + {% set statusTagId = "status-tag-" + appointment.id %} + + + + {# Appointment time #} + {{ appointment.statusHistory[0].timestamp | formatTimeString }} +
+ {% if appointment | isSpecialAppointment %} + {{ tag({ + text: "Special appointment", + colour: "yellow", + classes: "nhsuk-u-margin-top-2 app-nowrap" + })}} + {% endif %} + {% if appointment | hasAppointmentNote %} + {{ tag({ + text: "Appointment note", + colour: "yellow", + classes: "nhsuk-u-margin-top-2 app-nowrap" + })}} + {% endif %} + + + {# Appointment details - Name and NHS number #} + +

+ {{ participant | getFullName }} +

+

+ NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }} +

+ + + {# Date of birth #} + {{ participant.demographicInformation.dateOfBirth | formatDate }}
+ ({{ participant.demographicInformation.dateOfBirth | formatRelativeDate(true) + }}) + + + {# Appointment status and view appointment link #} + + {{ appointment.status | toTag({ id: statusTagId, vocabulary: "appointment" }) }} + + {% if appointment | isInProgressNotPaused %} + with {{ + appointment.sessionDetails.startedBy | getUsername({ + format: 'short', + identifyCurrentUser: true + }) }} + + {% elseif (appointment | isFinal) %} + {% if appointment.sessionDetails.startedBy %} + by {{ appointment.sessionDetails.startedBy + | getUsername({ + format: 'short', + identifyCurrentUser: true + }) }} + {% endif %} + {% endif %} + +

+ View appointment {{- ((" for " + (participant | getShortName)) | + asVisuallyHiddenText) | safe }} +

+ + + {# Actions - check-in or start appointment #} + + {% if appointment.status == 'scheduled' %} + {{ appCheckIn({ + clinicId: clinicId, + appointment: appointment, + participant: participant, + confirmIdentityOnCheckIn: true if data.settings.appointment.confirmIdentityOnCheckIn == 'true' else false, + statusTagId: statusTagId, + currentUser: currentUser + }) }} + {% endif %} + + {% if (currentUser | isClinician) and (appointment | isActive) %} + {% if appointment | hasNotStarted %} + {# Start appointment link gets rendered hidden if the appointment is not checked in - js can later remove this + class to make the link visible #} + + Start appointment + + + {% elseif appointment | isPaused or appointment | isInProgress %} + + Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} + + {% endif %} + {% endif %} + + +{% endmacro %} diff --git a/app/views/_includes/close-clinic-appointment-row.njk b/app/views/_includes/close-clinic-appointment-row.njk new file mode 100644 index 00000000..bb495058 --- /dev/null +++ b/app/views/_includes/close-clinic-appointment-row.njk @@ -0,0 +1,56 @@ +{# app/views/_includes/close-clinic-appointment-row.njk #} +{# + A single appointment row on the close clinic page. Also rendered on its own + by the appointment-row fragment route so close-clinic.js can swap a row in + place after a status change - keep it self-contained. + + appointment - appointment record + participant - participant the appointment belongs to + clinicId - id of the clinic being closed + showActions - whether to render the outcome action links + + Import with context so openInModal can read the modal forms setting. +#} + +{%- from '_components/link/macro.njk' import appLink %} + +{% macro closeClinicAppointmentRow(appointment, participant, clinicId, showActions) %} + {% set detailsLink = { + text: "Manage details" if appointment | hasStoppedDetails else "Add details", + href: "/clinics/" + clinicId + "/close/reason/" + appointment.id + } %} + + {{ appointment.statusHistory[0].timestamp | formatTimeString }} + +

{{ participant | getFullName }}

+

NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }}

+

View appointment

+ + + {{ appointment.status | toTag({ vocabulary: "appointment" }) }} + {% if appointment.status == "rescheduled" and appointment | hasStoppedDetails %} + {{ "attended_not_screened" | toTag({ vocabulary: "appointment" }) }} + {% endif %} + + + {% if showActions %} + {% if appointment.status == "in_progress" or appointment.status == "paused" %} + Go to appointment + {% elseif appointment.status == "checked_in" %} + Mark as attended not screened + {% elseif appointment.status == "scheduled" %} + Mark as did not attend + {% elseif appointment.status == "attended_not_screened" %} + Undo +
{{ appLink(detailsLink | openInModal) }} + {% elseif appointment.status == "did_not_attend" %} + Undo + {% elseif appointment.status == "rescheduled" and appointment | hasStoppedDetails %} + {{ appLink(detailsLink | openInModal) }} + {% endif %} + {% elseif appointment | hasStoppedDetails %} + {{ appLink(detailsLink | openInModal) }} + {% endif %} + + +{% endmacro %} diff --git a/app/views/_includes/forms/attended-not-screened-fields.njk b/app/views/_includes/forms/attended-not-screened-fields.njk new file mode 100644 index 00000000..048d24f6 --- /dev/null +++ b/app/views/_includes/forms/attended-not-screened-fields.njk @@ -0,0 +1,102 @@ +{# app/views/_includes/forms/attended-not-screened-fields.njk #} +{# + Shared fields for recording why an appointment was stopped (attended not + screened) - used by the in-appointment page and the close clinic flow, + which store their answers under different form names. + + Set before including: + stoppedFieldsNamePrefix - prefix for field names, eg "appointment[appointmentStopped]" or "closeReasonForm" + stoppedFieldsValues - object holding the current answers, eg appointment.appointmentStopped + + Also expects participant in context for the reschedule hint. +#} + +{% set stoppedFieldsValues = stoppedFieldsValues or {} %} + +{% set stoppedReasonItems = [] %} +{% for reason in getStoppedReasons() %} + {% set stoppedReasonItems = stoppedReasonItems | push({ + value: reason.value, + text: reason.value, + conditional: { + html: input({ + name: stoppedFieldsNamePrefix + "[" + reason.detailsField + "]", + label: { text: "Provide details (optional)" }, + value: stoppedFieldsValues[reason.detailsField], + autocomplete: "off" + }) + } + }) %} +{% endfor %} + +{% set stoppedReasonItems = stoppedReasonItems | push({ divider: "or" }) %} + +{% set stoppedReasonItems = stoppedReasonItems | push({ + value: "Other reason", + text: "Other reason", + conditional: { + html: textarea({ + name: stoppedFieldsNamePrefix + "[otherDetails]", + label: { text: "Provide details" }, + rows: 5, + value: stoppedFieldsValues.otherDetails, + autocomplete: "off" + }) + } +}) %} + +{{ checkboxes({ + name: stoppedFieldsNamePrefix + "[stoppedReason]", + values: stoppedFieldsValues.stoppedReason, + fieldset: { + legend: { + text: "Why has this appointment been stopped?", + size: "m", + isPageHeading: false + }, + hint: { + text: "Select all that apply" + } + }, + items: stoppedReasonItems +} | populateErrors) }} + +{{ radios({ + name: stoppedFieldsNamePrefix + "[needsReschedule]", + value: stoppedFieldsValues.needsReschedule, + fieldset: { + legend: { + text: "Should the appointment be rescheduled?", + size: "m", + isPageHeading: false + } + }, + items: [ + { + value: "yes", + text: "Yes" + }, + { + value: "no-invite", + text: "No, invite to next routine appointment", + hint: { + text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" + } + }, + { + value: "no-opt-out", + text: "No, request opt out", + hint: { + text: "They will receive information explaining their options" + }, + conditional: { + html: input({ + name: stoppedFieldsNamePrefix + "[optOutDetails]", + label: { text: "Provide details (optional)" }, + value: stoppedFieldsValues.optOutDetails, + autocomplete: "off" + }) + } + } + ] +} | populateErrors) }} diff --git a/app/views/_includes/reading/prior-mammogram-row.njk b/app/views/_includes/reading/prior-mammogram-row.njk new file mode 100644 index 00000000..03ce01be --- /dev/null +++ b/app/views/_includes/reading/prior-mammogram-row.njk @@ -0,0 +1,104 @@ +{# app/views/_includes/reading/prior-mammogram-row.njk #} +{# + A single prior mammogram row on the priors management page. Also rendered + on its own by the prior-mammogram-row fragment route so fragment-actions.js + can swap a row in place after a status change - keep it self-contained. + + appointment - the screening appointment the prior belongs to + mammogram - the prior mammogram record (one entry of previousMammograms) + + Import with context - the row reads participants, reading thresholds and + the current user from data. +#} + +{% macro priorMammogramRow(appointment, mammogram) %} + + {# Participant name - look up from session data #} + {% set thisParticipant = data | getParticipant(appointment.participantId) %} + + + {{ thisParticipant | getFullName }} + + + + {# Screening date #} + + {% set daysSinceScreening = appointment.timing.startTime | daysSince %} + {% if daysSinceScreening >= data.config.reading.urgentThreshold %} + {{ "Urgent" | toTag }}
+ {% elseif daysSinceScreening >= data.config.reading.priorityThreshold %} + {{ "Due soon" | toTag }}
+ {% endif %} + {{ appointment.timing.startTime | formatDate }}
+ + {{ appointment.timing.startTime | formatRelativeDate }} + + + + {# Mammogram location and date, then the request story - who + requested or actioned it, when, and the reader's reason #} + + {% if mammogram.requestStatus == "pending" %} + {# Requested by a reader, so the request date is theirs #} + {% set changedLabel = "Requested" %} + {% set changedDate = mammogram.requestedDate %} + {% set changedBy = mammogram.requestedBy %} + {% elseif mammogram.requestStatus != "not_requested" %} + {# Actioned by admin staff #} + {% set changedLabel = "Requested" if mammogram.requestStatus == "requested" else "Updated" %} + {% set changedDate = mammogram.statusChangedDate %} + {% set changedBy = mammogram.statusChangedBy %} + {% endif %} + +

{{ mammogram | summarisePriorMammogram }}

+ + {% if changedDate or mammogram.requestReason %} +

+ {% if changedDate %} + + {{- changedLabel }} + {%- if changedBy %} by {{ changedBy | getUsername({ format: "short", identifyCurrentUser: true }) }}{% endif %} + on {{ changedDate | formatDate("D MMMM YYYY") -}} + + {% endif %} + {% if mammogram.requestReason %} + {% if changedDate %}
{% endif %} + Reason: {{ mammogram.requestReason }} + {% endif %} +

+ {% endif %} + + + {# Status #} + + {{ mammogram.requestStatus | toTag({ vocabulary: "priorsRequest" }) }} + + + {# Actions - each a one-click form, submitted in place by fragment-actions.js #} + + {% set availableActions = [] %} + {% if mammogram.requestStatus == "not_requested" or mammogram.requestStatus == "pending" %} + {% set availableActions = [ + { status: "requested", label: "Mark as requested" }, + { status: "not_available", label: "Not available" }, + { status: "not_needed", label: "Not needed" } + ] %} + {% elseif mammogram.requestStatus == "requested" %} + {% set availableActions = [ + { status: "received", label: "Mark as received" }, + { status: "not_available", label: "Not available" }, + { status: "not_needed", label: "Not needed" } + ] %} + {% endif %} + + {% for action in availableActions %} + + + + + +
+ {% endfor %} + + +{% endmacro %} diff --git a/app/views/_includes/scripts.html b/app/views/_includes/scripts.html index 78986800..bec76280 100755 --- a/app/views/_includes/scripts.html +++ b/app/views/_includes/scripts.html @@ -5,6 +5,7 @@ + diff --git a/app/views/appointments/attended-not-screened-reason.html b/app/views/appointments/attended-not-screened-reason.html index 01a41c1a..78763b40 100644 --- a/app/views/appointments/attended-not-screened-reason.html +++ b/app/views/appointments/attended-not-screened-reason.html @@ -26,213 +26,14 @@

href: './previous-mammograms/add' | urlWithReferrer(currentUrl) } | openInModal) }} if the appointment is being stopped because the participant has recently had one

{% endset %} - + {{ insetText({ html: insetHtml }) }} - {{ checkboxes({ - name: "appointment[appointmentStopped][stoppedReason]", - values: appointment.appointmentStopped.stoppedReason, - fieldset: { - legend: { - text: "Why has this appointment been stopped?", - size: "m", - isPageHeading: false - }, - hint: { - text: "Select all that apply" - } - }, - items: [ - { - value: "Failed identity check", - text: "Failed identity check", - conditional: { - html: input({ - name: "appointment[appointmentStopped][failedIdentityDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.failedIdentityDetails, - autocomplete: "off" - }) - } - }, - { - value: "Pain during screening", - text: "Pain during screening", - conditional: { - html: input({ - name: "appointment[appointmentStopped][painDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.painDetails, - autocomplete: "off" - }) - } - }, - { - value: "Has a symptomatic appointment", - text: "Has a symptomatic appointment", - conditional: { - html: input({ - name: "appointment[appointmentStopped][symptomaticDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.symptomaticDetails, - autocomplete: "off" - }) - } - }, - { - value: "Consent withdrawn", - text: "Consent withdrawn", - conditional: { - html: input({ - name: "appointment[appointmentStopped][consentDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.consentDetails, - autocomplete: "off" - }) - } - }, - { - value: "Physical health issue", - text: "Physical health issue", - conditional: { - html: input({ - name: "appointment[appointmentStopped][physicalHealthDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.physicalHealthDetails, - autocomplete: "off" - }) - } - }, - { - value: "Mental health issue", - text: "Mental health issue", - conditional: { - html: input({ - name: "appointment[appointmentStopped][mentalHealthDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.mentalHealthDetails, - autocomplete: "off" - }) - } - }, - { - value: "Language difficulties", - text: "Language difficulties", - conditional: { - html: input({ - name: "appointment[appointmentStopped][languageDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.languageDetails, - autocomplete: "off" - }) - } - }, - { - value: "No qualified mammographer available", - text: "No qualified mammographer available", - conditional: { - html: input({ - name: "appointment[appointmentStopped][mammographerDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.mammographerDetails, - autocomplete: "off" - }) - } - }, - { - value: "Technical issues at clinic", - text: "Technical issues at clinic", - conditional: { - html: input({ - name: "appointment[appointmentStopped][technicalDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.technicalDetails, - autocomplete: "off" - }) - } - }, - { - divider: "or" - }, - { - value: "Other reason", - text: "Other reason", - conditional: { - html: textarea({ - name: "appointment[appointmentStopped][otherDetails]", - label: { - text: "Provide details" - }, - rows: 5, - value: appointment.appointmentStopped.otherDetails, - autocomplete: "off" - }) - } - } - ] - } | populateErrors) }} - - {{ radios({ - name: "appointment[appointmentStopped][needsReschedule]", - value: appointment.appointmentStopped.needsReschedule, - fieldset: { - legend: { - text: "Should the appointment be rescheduled?", - size: "m", - isPageHeading: false - } - }, - items: [ - { - value: "yes", - text: "Yes" - }, - { - value: "no-invite", - text: "No, invite to next routine appointment", - hint: { - text: "If eligible, " + (participant | getFullName) + " will be invited to their next routine appointment" - } - }, - { - value: "no-opt-out", - text: "No, request opt out", - hint: { - text: "They will receive information explaining their options" - }, - conditional: { - html: input({ - name: "appointment[appointmentStopped][optOutDetails]", - label: { - text: "Provide details (optional)" - }, - value: appointment.appointmentStopped.optOutDetails, - autocomplete: "off" - }) - } - } - ] - } | populateErrors) }} + {% set stoppedFieldsNamePrefix = "appointment[appointmentStopped]" %} + {% set stoppedFieldsValues = appointment.appointmentStopped %} + {% include "_includes/forms/attended-not-screened-fields.njk" %}
{{ 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 %} + +

+ + {{ participant | getFullName }} + + {{ pageHeading }} +

+ + {% set stoppedFieldsNamePrefix = "closeReasonForm" %} + {% set stoppedFieldsValues = data.closeReasonForm %} + {% include "_includes/forms/attended-not-screened-fields.njk" %} + +
+ {{ button({ + text: "Continue" + }) }} +
+ +{% endblock %} diff --git a/app/views/clinics/close-reschedule.html b/app/views/clinics/close-reschedule.html new file mode 100644 index 00000000..ce56320e --- /dev/null +++ b/app/views/clinics/close-reschedule.html @@ -0,0 +1,64 @@ +{# app/views/clinics/close-reschedule.html #} + +{% extends parentLayout or 'layout-app.html' %} + +{% set pageHeading = "Request to reschedule this appointment" %} +{% set formAction = "/clinics/" + clinicId + "/close/reschedule/" + appointment.id %} + +{% set back = { + href: "/clinics/" + clinicId + "/close/reason/" + appointment.id, + text: "Back" +} %} + +{% block pageContent %} + +

+ + {{ participant | getFullName }} + + {{ pageHeading }} +

+ + {{ radios({ + name: "closeRescheduleForm[timing]", + value: data.closeRescheduleForm.timing, + fieldset: { + legend: { + text: "When should the appointment be?", + size: "m", + isPageHeading: false + } + }, + items: [ + { + value: "within-6-weeks", + text: "Within the next 6 weeks" + }, + { + value: "more-than-6-weeks", + text: "More than 6 weeks away" + } + ] + } | populateErrors) }} + + {{ textarea({ + name: "closeRescheduleForm[note]", + label: { + text: "Note for rescheduling (optional)", + size: "m" + }, + hint: { + text: "Include any other relevant information" + }, + rows: 5, + value: data.closeRescheduleForm.note, + autocomplete: "off" + }) }} + +
+ {{ button({ + text: "Continue" + }) }} +
+ +{% endblock %} diff --git a/app/views/clinics/close.html b/app/views/clinics/close.html new file mode 100644 index 00000000..234d699f --- /dev/null +++ b/app/views/clinics/close.html @@ -0,0 +1,119 @@ +{# app/views/clinics/close.html #} + +{% extends 'layout-app.html' %} + +{% from "_includes/close-clinic-appointment-row.njk" import closeClinicAppointmentRow with context %} + +{% set pageHeading = "Close clinic " ~ clinic.clinicCode %} +{% set gridColumn = "nhsuk-grid-column-full" %} + +{% set back = { + href: "/clinics/" + clinicId, + text: "Back to clinic" +} %} + +{% block pageContent %} + +

+ {{ unit.name }} + {{ pageHeading }} +

+ +

{{ clinic.sessionTimes | formatTimeRange }} - {{ clinic.date | formatDate }}

+ + {# Table of appointments in one status group #} + {% macro statusGroupTable(appointments, clinicId, showActions) %} + + + + + + + + + + + {% for appointment in appointments %} + {{ closeClinicAppointmentRow(appointment, appointment.participant, clinicId, showActions) }} + {% endfor %} + +
TimeDetailsStatus{{ "Actions" if showActions }}
+ {% endmacro %} + + {# Bulk action button - close-clinic.js swaps it for the undo message once used #} + {% macro bulkActionControl(clinicId, markStatus, undoStatus, buttonText, markedLabel) %} +

+ {{ buttonText }} + +

+ {% endmacro %} + +
+ + {% set introHtml %} +

Record an appointment outcome for every participant to close this clinic.

+

There were {{ appointmentCount }} total participants in this clinic, and {{ needsOutcomeCount }} still need a final outcome assigned.

+ {% endset %} + + {{ insetText({ + html: introHtml + }) }} + + {% if needsOutcomeCount %} + {% set needsOutcomeHtml %} + {% if inProgressAppointments | length %} +

In progress

+

Complete or end these appointments to close the clinic.

+ {{ statusGroupTable(inProgressAppointments, clinicId, true) }} + {% endif %} + + {# Checked in, not screened - includes attended not screened appointments still needing details #} + {% if checkedInAppointments | length %} +

Checked in, not screened

+ {{ bulkActionControl(clinicId, "attended_not_screened", "checked_in", "Mark all as attended not screened", "attended not screened") }} + {{ statusGroupTable(checkedInAppointments, clinicId, true) }} + {% endif %} + + {% if scheduledAppointments | length %} +

Did not check in

+ {{ bulkActionControl(clinicId, "did_not_attend", "scheduled", "Mark all as did not attend", "did not attend") }} + {{ statusGroupTable(scheduledAppointments, clinicId, true) }} + {% endif %} + {% endset %} + + {{ card({ + heading: "Needs an outcome (" + needsOutcomeCount + ")", + headingLevel: "2", + feature: true, + descriptionHtml: needsOutcomeHtml + }) }} + {% endif %} + + {% set outcomeRecordedHtml %} +

+ {% if outcomeRecordedAppointments | length %} + {{ statusGroupTable(outcomeRecordedAppointments, clinicId, false) }} + {% endif %} + {% endset %} + + {{ card({ + heading: "Outcome recorded (" + outcomeRecordedAppointments | length + ")", + headingLevel: "2", + feature: true, + classes: "app-card--feature-green", + descriptionHtml: outcomeRecordedHtml + }) }} + +
+ +
+ {{ button({ + text: "Confirm and close clinic" + }) }} +
+ +{% endblock %} + +{% block pageScripts %} + +{% endblock %} diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html index 7422368c..d5d115ab 100644 --- a/app/views/clinics/index.html +++ b/app/views/clinics/index.html @@ -7,7 +7,7 @@ {% case 'upcoming' %} Upcoming clinics {% case 'completed' %} - Completed clinics + Closed clinics {% default %} All clinics {% endswitch %} @@ -28,7 +28,7 @@

{{pageHeading}}

{% for item in [ { id: 'today', label: 'Today' }, { id: 'upcoming', label: 'Upcoming' }, - { id: 'completed', label: 'Completed' }, + { id: 'completed', label: 'Closed' }, { id: 'all', label: 'All' } ] %} {% set href -%} @@ -54,7 +54,7 @@

{{pageHeading}}

- + @@ -70,14 +70,14 @@

{{pageHeading}}

{% for appointment in filteredAppointments %} - {% set participant = data.participants | findById(appointment.participantId) %} - {% set appointmentBaseUrl = "/clinics/" + clinicId + "/appointments/" + appointment.id %} - {% set statusTagId = "status-tag-" + appointment.id %} - - - - {# Appointment time #} - - - {# Appointment details - Name and NHS number #} - - - {# Date of birth #} - - - {# Appointment status and view appointment link #} - - - {# Actions - check-in or start appointment #} - - + {{ clinicAppointmentRow(appointment, data.participants | findById(appointment.participantId), clinicId) }} {% endfor %}
LocationClinic name and location Date and time Clinic type Participants
- {% if location.type === 'mobile_unit' %} - {{ location.name }} at {{ clinic.siteName }} - {% else %} - {{ location.name }} - {% endif %} -
- ({{ clinic.sessionType | sentenceCase }}) + {{ clinic.clinicCode }} ({{ clinic.sessionType | lower }})
+
+ {% if location.type === 'mobile_unit' %} + {{ (location.name + " at " + clinic.siteName) | asHint }} + {% else %} + {{ location.name | asHint }} + {% endif %}
{{ clinic.date | formatDate | noWrap }}
{{clinic.sessionTimes | formatTimeRange | asHint }} diff --git a/app/views/clinics/show.html b/app/views/clinics/show.html index e4a22690..e4f2806c 100644 --- a/app/views/clinics/show.html +++ b/app/views/clinics/show.html @@ -1,4 +1,7 @@ {% extends 'layout-app.html' %} + +{% from "_includes/clinic-appointment-row.njk" import clinicAppointmentRow with context %} + {% set pageHeading = "Screening clinic - " ~ clinic.clinicCode %} {% set gridColumn = "nhsuk-grid-column-full" %} @@ -34,9 +37,14 @@

{{ clinic.status | toTag({ vocabulary: "clinic" }) }} -

- View clinic report +

+ View clinic report +

+ {% if clinic.status !== "closed" and clinic.status !== "scheduled" %} +

+ Close clinic {{ clinic.clinicCode }}

+ {% endif %}
@@ -121,203 +129,11 @@

{{ appointment.statusHistory[0].timestamp | formatTimeString }} -
- {% if appointment | isSpecialAppointment %} - {{ tag({ - text: "Special appointment", - colour: "yellow", - classes: "nhsuk-u-margin-top-2 app-nowrap" - })}} - {% endif %} - {% if appointment | hasAppointmentNote %} - {{ tag({ - text: "Appointment note", - colour: "yellow", - classes: "nhsuk-u-margin-top-2 app-nowrap" - })}} - {% endif %} - {# Symptoms tag used for testing to verify data is working #} - {# {% if appointment | hasSymptoms %} - {{ tag({ - text: "Has symptoms", - colour: "yellow", - classes: "nhsuk-u-margin-top-2 app-nowrap" - })}} - {% endif %} #} -
- - {% set participantHref = ("/participants/" ~ participant.id) | urlWithReferrer(currentUrl) %} - -

- {# #} - {# - {{ participant | getFullName }} - #} - {{ participant | getFullName }} -

-

- {# DOB: {{ participant.demographicInformation.dateOfBirth | formatDate }} ({{ - participant.demographicInformation.dateOfBirth | formatRelativeDate(true) }}) -
#} - NHS: {{ participant.medicalInformation.nhsNumber | formatNhsNumber }} - {#
- SX Number: {{ participant.sxNumber }} #} -

- -
{{ participant.demographicInformation.dateOfBirth | formatDate }}
- ({{ participant.demographicInformation.dateOfBirth | formatRelativeDate(true) - }}) -
- {# Status tag with check-in link #} - {# {{ appointmentStatus({ - clinicId: clinicId, - appointment: appointment, - participant: participant, - participantUrl: "/participants/" + participant.id | urlWithReferrer(currentUrl), - appointmentUrl: "/clinics/" + clinicId + "/appointments/" + appointment.id, - referrerChain: currentUrl + "#appointment-row-" + appointment.id, - confirmIdentityOnCheckIn: true if data.settings.appointment.confirmIdentityOnCheckIn == 'true' else false, - showAppointmentLink: true - })}} #} - - {{ appointment.status | toTag({ id: statusTagId, vocabulary: "appointment" }) }} - - {% if appointment | isInProgressNotPaused %} - with {{ - appointment.sessionDetails.startedBy | getUsername({ - format: 'short', - identifyCurrentUser: true - }) }} - - {% elseif (appointment | isFinal) %} - {% if appointment.sessionDetails.startedBy %} - by {{ appointment.sessionDetails.startedBy - | getUsername({ - format: 'short', - identifyCurrentUser: true - }) }} - {% endif %} - {% endif %} - -

- View appointment {{- ((" for " + (participant | getShortName)) | - asVisuallyHiddenText) | safe }} -

- - - {# {% set viewAppointmentLink %} - View appointment {{- ((" for " + (participant | getShortName)) | - asVisuallyHiddenText) | safe }} - {% endset %} #} - - {# Scheduled appointments have dynamic check-in links #} - - {# {% if appointment.status != 'scheduled' %} -

- {% if currentUser | isClinician %} - - {% if appointment | isFinal %} - {{ viewAppointmentLink | safe }} - {% elseif appointment | isInProgress %} - - {% if appointment | startedByCurrentUser %} - - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% else %} - {{ viewAppointmentLink | safe }} - {% endif %} - - {% else %} - - Start appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% endif %} - - {% else %} - {{ viewAppointmentLink | safe }} - {% endif %} -

- - - {% endif %} #} - -
- {% if appointment.status == 'scheduled' %} - {{ appCheckIn({ - clinicId: clinicId, - appointment: appointment, - participant: participant, - confirmIdentityOnCheckIn: true if data.settings.appointment.confirmIdentityOnCheckIn == 'true' else false, - statusTagId: statusTagId, - currentUser: currentUser - }) }} - - {% endif %} - - {% if (currentUser | isClinician) and (appointment | isActive) %} - {% if appointment | hasNotStarted %} - {# Start appointment link gets rendered hidden if the appointment is not checked in - js can later remove this - class to make the link visible #} - - Start appointment - - - {% elseif appointment | isPaused or appointment | isInProgress %} - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - - {% elseif (appointment | isPaused) and (appointment | startedByCurrentUser) %} - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% endif %} - - {% endif %} - - {# {% if not (appointment | isFinal) and (currentUser | isClinician) %} -

- {% if appointment | hasNotStarted %} - - Start appointment - - {% elseif appointment | startedByCurrentUser %} - - Resume appointment {{- ((" for " + (participant | getShortName)) | asVisuallyHiddenText) | safe }} - - {% endif %} -

- {% endif %} #} - -
{% 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 %} - +
@@ -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) %} - - - {# Screening date #} - - - {# Mammogram location and date, then the request story - who - requested or actioned it, when, and the reader's reason #} - - - {# Status #} - - - {# Actions #} - - + {{ priorMammogramRow(row.appointment, row.mammogram) }} {% endfor %}
Participant
- - {{ thisParticipant | getFullName }} - - - {% set daysSinceScreening = thisAppointment.timing.startTime | daysSince %} - {% if daysSinceScreening >= data.config.reading.urgentThreshold %} - {{ "Urgent" | toTag }}
- {% elseif daysSinceScreening >= data.config.reading.priorityThreshold %} - {{ "Due soon" | toTag }}
- {% endif %} - {{ thisAppointment.timing.startTime | formatDate }}
- - {{ thisAppointment.timing.startTime | formatRelativeDate }} - -
- {% if mammogram.requestStatus == "pending" %} - {# Requested by a reader, so the request date is theirs #} - {% set changedLabel = "Requested" %} - {% set changedDate = mammogram.requestedDate %} - {% set changedBy = mammogram.requestedBy %} - {% elseif mammogram.requestStatus != "not_requested" %} - {# Actioned by admin staff #} - {% set changedLabel = "Requested" if mammogram.requestStatus == "requested" else "Updated" %} - {% set changedDate = mammogram.statusChangedDate %} - {% set changedBy = mammogram.statusChangedBy %} - {% endif %} - -

{{ mammogram | summarisePriorMammogram }}

- - {% if changedDate or mammogram.requestReason %} -

- {% if changedDate %} - - {{- changedLabel }} - {%- if changedBy %} by {{ changedBy | getUsername({ format: "short", identifyCurrentUser: true }) }}{% endif %} - on {{ changedDate | formatDate("D MMMM YYYY") -}} - - {% endif %} - {% if mammogram.requestReason %} - {% if changedDate %}
{% endif %} - Reason: {{ mammogram.requestReason }} - {% endif %} -

- {% endif %} -
- {{ mammogram.requestStatus | toTag({ vocabulary: "priorsRequest" }) }} - - {% 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 #} -
- - - - -
-
- - - - -
-
- - - - -
- {% endif %} -
{% endif %} {% endblock %} - -{% block pageScripts %} - -{% endblock %}