diff --git a/app/controllers/patient.js b/app/controllers/patient.js index 46fadb1c4..861f6c581 100644 --- a/app/controllers/patient.js +++ b/app/controllers/patient.js @@ -66,7 +66,8 @@ export const patientController = { }, readAll(request, response, next) { - const { option, programme_id, q, yearGroup } = request.query + const { invitedToClinic, option, programme_id, q, yearGroup } = + request.query const { data } = request.session const programmes = Programme.findAll(data) @@ -120,6 +121,19 @@ export const patientController = { ) } + // Filter by programme clinic invitations + if (programme_id && invitedToClinic === 'true') { + results = results.filter( + (patient) => patient.programmes[programme_id]?.invitedToClinic + ) + } else if (invitedToClinic === 'true') { + results = results.filter((patient) => + Object.values(patient.programmes).some( + (programme) => programme.invitedToClinic + ) + ) + } + // Filter by status if (filters.report && filters.report !== 'none') { const ids = programme_ids || programmes.map((programme) => programme.id) @@ -196,6 +210,7 @@ export const patientController = { })) // Clean up session data + delete data.invitedToClinic delete data.option delete data.patientConsent delete data.patientDeferred @@ -233,6 +248,7 @@ export const patientController = { // Checkboxes for (const key of [ + 'invitedToClinic', 'option', 'patientConsent', 'patientDeferred', diff --git a/app/controllers/school.js b/app/controllers/school.js index 30db7549c..012954dc9 100644 --- a/app/controllers/school.js +++ b/app/controllers/school.js @@ -18,7 +18,10 @@ export const schoolController = { }, readAll(request, response, next) { - response.locals.schools = School.findAll(request.session.data) + // Combine children with no known school with home-schooled children) + response.locals.schools = School.findAll(request.session.data).filter( + (school) => school.id !== '888888' + ) next() }, @@ -93,17 +96,13 @@ export const schoolController = { }, readPatients(request, response, next) { - const { school_id } = request.params - const { option, programme_id, q, yearGroup } = request.query + const { invitedToClinic, option, programme_id, q, yearGroup } = + request.query const { data } = request.session const { school } = response.locals - const patients = Patient.findAll(data).filter( - (patient) => patient.school_id === school_id - ) - // Sort - let results = _.sortBy(patients, 'lastName') + let results = _.sortBy(school.patients, 'lastName') // Query if (q) { @@ -147,6 +146,19 @@ export const schoolController = { ) } + // Filter by programme clinic invitations + if (programme_id && invitedToClinic === 'true') { + results = results.filter( + (patient) => patient.programmes[programme_id]?.invitedToClinic + ) + } else if (invitedToClinic === 'true') { + results = results.filter((patient) => + Object.values(patient.programmes).some( + (programme) => programme.invitedToClinic + ) + ) + } + // Filter by status if (filters.report && filters.report !== 'none') { const ids = @@ -207,7 +219,7 @@ export const schoolController = { // Results response.locals.school = school - response.locals.patients = patients + response.locals.patients = school.patients response.locals.results = getResults(results, request.query) response.locals.pages = getPagination(results, request.query) @@ -225,6 +237,7 @@ export const schoolController = { })) // Clean up session data + delete data.invitedToClinic delete data.option delete data.patientConsent delete data.patientDeferred @@ -254,6 +267,7 @@ export const schoolController = { // Checkboxes for (const key of [ + 'invitedToClinic', 'option', 'patientConsent', 'patientDeferred', @@ -452,5 +466,36 @@ export const schoolController = { request.flash('success', __(`school.delete.success`)) response.redirect(referrer) + }, + + inviteToClinic(request, response) { + const { school_id } = request.params + const { data } = request.session + const { __mf } = response.locals + + const school = School.findOne(school_id, data) + + // Find patients to invite to clinic + const patientSessionsForClinic = school.patients.map( + (patient) => patient.uuid + ) + + // Invite parents to book into a clinic + for (const patient of school.patients) { + const clinicProgramme_ids = request.body.clinicProgramme_ids.filter( + (item) => item !== '_unchecked' + ) + + Patient.update(patient.uuid, { clinicProgramme_ids }, data) + } + + request.flash( + 'success', + __mf(`school.inviteToClinic.success`, { + count: patientSessionsForClinic.length + }) + ) + + response.redirect(school.uri) } } diff --git a/app/controllers/session.js b/app/controllers/session.js index 4ff876592..b6f5f4c74 100644 --- a/app/controllers/session.js +++ b/app/controllers/session.js @@ -685,11 +685,11 @@ export const sessionController = { response.redirect(session.uri) }, - close(request, response) { + inviteToClinic(request, response) { const { account } = request.app.locals const { session_id } = request.params const { data } = request.session - const { __ } = response.locals + const { __mf } = response.locals // Update session as closed const session = Session.update(session_id, { closed: true }, data) @@ -701,11 +701,13 @@ export const sessionController = { programme_ids.some((id) => session.programme_ids.includes(id)) ) + // Find patients to invite to clinic + const patientSessionsForClinic = session.patientSessionsForClinic.map( + (patient) => patient.uuid + ) + // Move patients to clinic if (clinic) { - const patientSessionsForClinic = session.patientSessionsForClinic.map( - (patient) => patient.uuid - ) for (const patientSession of patientSessionsForClinic) { const patient = Patient.findOne(patientSession.patient_uuid, data) patientSession.removeFromSession({ @@ -716,7 +718,12 @@ export const sessionController = { } } - request.flash('success', __(`session.close.success`, { session })) + request.flash( + 'success', + __mf(`session.inviteToClinic.success`, { + count: patientSessionsForClinic.length + }) + ) response.redirect(session.uri) } diff --git a/app/datasets/schools.js b/app/datasets/schools.js index b84f769c8..ce3673ee6 100644 --- a/app/datasets/schools.js +++ b/app/datasets/schools.js @@ -12,6 +12,7 @@ export default { id: '888888', urn: '888888', name: 'Unknown school', + yearGroups: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], team_id: '001', presetNames }, @@ -19,6 +20,7 @@ export default { id: '999999', urn: '999999', name: 'Home-schooled', + yearGroups: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], team_id: '001', presetNames }, diff --git a/app/locales/en.js b/app/locales/en.js index a81ec1274..b6f56a7a1 100644 --- a/app/locales/en.js +++ b/app/locales/en.js @@ -1083,6 +1083,9 @@ export const en = { programmes: { label: 'Vaccination programmes' }, + clinicProgramme_ids: { + label: 'Clinic invitations' + }, status: { label: 'Status' } @@ -1535,6 +1538,25 @@ export const en = { yes: 'Yes, I want to add this school', no: 'No, I want to add a different school' }, + inviteToClinic: { + title: 'Invite parents to book a clinic appointment', + label: 'Send clinic invitations', + count: + '{count, plural, =0 {No children} one {1 child} other {# children}} are due a vaccination for at least 1 programme. They have not been invited to a clinic yet.', + description: + 'You can now send clinic booking invitations to their parents.', + programme: 'Which programmes do you want to send invitations for?', + flu: '{count, plural, =0 {No children} one {1 child} other {# children}} have not had a flu vaccination', + hpv: '{count, plural, =0 {No children have} one {1 child has} other {# children have}} not had an HPV vaccination', + menacwy: + '{count, plural, =0 {No children have} one {1 child has} other {# children have}} not had an MenACWY vaccination', + mmr: '{count, plural, =0 {No children have} one {1 child has} other {# children have}} not had an MMR vaccination', + 'td-ipv': + '{count, plural, =0 {No children have} one {1 child has} other {# children have}} not had a Td/IPV vaccination', + confirm: 'Send clinic invitations', + success: + '{count, plural, =0 {No children} one {1 child} other {# children}} invited to the clinic' + }, patients: { label: 'Children', title: 'Children' @@ -1892,26 +1914,16 @@ export const en = { lastName: 'Last name' } }, - close: { - title: 'Close session', + inviteToClinic: { + title: 'Invite parents to book a clinic appointment', + label: 'Send clinic invitations', + count: + '{count, plural, =0 {No children} one {1 child} other {# children}} were not vaccinated at this school and have not already been invited to a clinic.', description: - 'All sessions for this school have been completed.\n\nWhen you close this session, the following children will be invited to community clinics:', - confirm: 'Close session', - success: '{{session.name}} has been closed' - }, - closingSummary: { - noConsentRequest: { - count: - '{count, plural, =0 {No children} one {1 child} other {# children}} whose parents did not receive a consent request' - }, - noConsentResponse: { - count: - '{count, plural, =0 {No children} one {1 child} other {# children}} whose parents did not give a consent response' - }, - couldNotVaccinate: { - count: - '{count, plural, =0 {No children} one {1 child} other {# children}} who could not be vaccinated' - } + 'You can send invitations to their parents to book an appointment to have their children vaccinated at a clinic.\n\nThe next clinic is on %s.', + confirm: 'Send clinic invitations', + success: + '{count, plural, =0 {No children} one {1 child} other {# children}} invited to the clinic' } }, texts: { diff --git a/app/models/patient-programme.js b/app/models/patient-programme.js index f753dabb8..d4411be83 100644 --- a/app/models/patient-programme.js +++ b/app/models/patient-programme.js @@ -25,12 +25,14 @@ import { * @param {object} options - Options * @param {object} [context] - Global context * @property {object} [context] - Global context + * @property {boolean} [invitedToClinic] - Invited to clinic * @property {string} patient_uuid - Patient UUID * @property {string} programme_id - Programme ID */ export class PatientProgramme { constructor(options, context) { this.context = context + this.invitedToClinic = options?.invitedToClinic this.patient_uuid = options?.patient_uuid this.programme_id = options?.programme_id } diff --git a/app/models/patient.js b/app/models/patient.js index 93d745e6a..b0d0b3738 100644 --- a/app/models/patient.js +++ b/app/models/patient.js @@ -11,6 +11,7 @@ import { Parent, PatientProgramme, PatientSession, + Programme, Reply, Vaccination } from '../models.js' @@ -45,6 +46,7 @@ import { * @property {Patient} [pendingChanges] - Pending changes to record values * @property {import('../enums.js').ArchiveRecordReason} [archiveReason] - Archival reason * @property {string} [archiveReasonOther] - Other archival reason + * @property {Array} events - Events * @property {Array} [reply_uuids] - Reply IDs * @property {Array} [patientSession_uuids] - Patient session IDs @@ -71,6 +73,7 @@ export class Patient extends Child { this.archiveReasonOther = options?.archiveReasonOther this.pendingChanges = options?.pendingChanges || {} + this.clinicProgramme_ids = options?.clinicProgramme_ids || [] this.events = options?.events || [] this.reply_uuids = options?.reply_uuids || [] this.patientSession_uuids = options?.patientSession_uuids || [] @@ -304,13 +307,20 @@ export class Patient extends Child { for (const programme of Object.values(programmesData).filter( (programme) => !programme.hidden )) { - programmes[programme.id] = new PatientProgramme( + const patientProgramme = new PatientProgramme( { patient_uuid: this.uuid, programme_id: programme.id }, this.context ) + + // Patient invited to clinic if invitation needed and invitation sent + patientProgramme.invitedToClinic = + patientProgramme.inviteToSession && + this.clinicProgramme_ids.includes(programme.id) + + programmes[programme.id] = patientProgramme } return programmes @@ -449,7 +459,10 @@ export class Patient extends Child { archiveReason: formatOther(this.archiveReasonOther, this.archiveReason), lastReminderDate: this.lastReminderDate ? `Last reminder sent on ${this.lastReminderDate}` - : 'No reminders sent' + : 'No reminders sent', + clinicProgramme_ids: this.clinicProgramme_ids + .map((id) => Programme.findOne(id, this.context).nameTag) + .join(' ') } } diff --git a/app/models/school.js b/app/models/school.js index 5ec7e7f25..857be0307 100644 --- a/app/models/school.js +++ b/app/models/school.js @@ -31,6 +31,11 @@ export class School extends Location { this.site = options?.site this.phase = options?.phase this.yearGroups = options?.yearGroups || [] + this.homeOrUnknown = ['888888', '999999'].includes(this.urn) + + if (this.homeOrUnknown) { + this.name = 'No known school (including home-schooled children)' + } } /** @@ -62,6 +67,13 @@ export class School extends Location { */ get patients() { if (this.context?.patients && this.id) { + // Combine children with no known school with home-schooled children) + if (this.homeOrUnknown) { + return Object.values(this.context?.patients) + .filter(({ school_id }) => ['888888', '999999'].includes(school_id)) + .map((patient) => new Patient(patient, this.context)) + } + return Object.values(this.context?.patients) .filter(({ school_id }) => school_id === this.id) .map((patient) => new Patient(patient, this.context)) @@ -79,6 +91,18 @@ export class School extends Location { return this.patients.filter((patient) => patient.hasMissingNhsNumber) } + /** + * Get school pupils to invite to a (clinic) session + * + * @param {string} programmeId - Programme ID + * @returns {Array} Patient records + */ + patientsToInviteToSession(programmeId) { + return this.patients.filter( + (patient) => patient.programmes[programmeId].inviteToSession + ) + } + /** * Get sessions run at this school * diff --git a/app/models/session.js b/app/models/session.js index 94d72ef26..5202e7edd 100644 --- a/app/models/session.js +++ b/app/models/session.js @@ -641,31 +641,23 @@ export class Session { } /** - * Get closing summary + * Get patient sessions that can be moved to a clinic session * - * @returns {object} Closing summary + * @returns {Array} Patient sessions */ - get closingSummary() { - return { - noConsentRequest: this.patients.filter( - ({ consent }) => consent === ConsentOutcome.NotDelivered - ), - noConsentResponse: this.patients.filter( - ({ consent }) => consent === ConsentOutcome.NoResponse - ), - couldNotVaccinate: this.patients.filter( - ({ report }) => report !== PatientStatus.Vaccinated - ) - } + get patientSessionsForClinic() { + return this.patients.filter(({ report }) => report === PatientStatus.Due) } /** - * Get patient sessions that can be moved to a clinic session + * Get next available clinic session * - * @returns {Array} Patient sessions + * @returns {Session} Session */ - get patientSessionsForClinic() { - return this.patients.filter(({ report }) => report === PatientStatus.Due) + get nextProgrammeClinic() { + return Session.findAll(this.context).find( + (session) => session.type === SessionType.Clinic + ) } /** diff --git a/app/routes/school.js b/app/routes/school.js index 1a8c552d8..db68a7ca2 100644 --- a/app/routes/school.js +++ b/app/routes/school.js @@ -32,6 +32,8 @@ router.get('/:school_id/sessions', school.readSessions) router.all('/:school_id', school.readPatients) router.post('/:school_id', school.filterPatients) +router.post('/:school_id/invite-to-clinic', school.inviteToClinic) + router.get('/:school_id{/:view}', school.show) export const schoolRoutes = router diff --git a/app/routes/session.js b/app/routes/session.js index f9c4344c1..1f65d929a 100644 --- a/app/routes/session.js +++ b/app/routes/session.js @@ -23,7 +23,7 @@ router.all('/:session_id/edit/:view', session.readForm('edit')) router.get('/:session_id/edit/:view', session.showForm) router.post('/:session_id/edit/:view', session.updateForm) -router.post('/:session_id/close', session.close) +router.post('/:session_id/invite-to-clinic', session.inviteToClinic) router.post('/:session_id/instructions', session.giveInstructions) router.post('/:session_id/offline', session.downloadFile) router.post('/:session_id/reminders', session.sendReminders) diff --git a/app/views/_macros/patient-search.njk b/app/views/_macros/patient-search.njk index cac3dd347..2c90e2da4 100644 --- a/app/views/_macros/patient-search.njk +++ b/app/views/_macros/patient-search.njk @@ -62,6 +62,21 @@ decorate: "programme_id" }) if programmeItems }} + {{ checkboxes({ + classes: "nhsuk-checkboxes--small", + fieldset: { + legend: { + classes: "nhsuk-fieldset__legend--s", + text: __("patient.clinicProgramme_ids.label") + } + }, + items: [{ + text: 'Invited to clinic', + value: 'true' + }], + decorate: "invitedToClinic" + }) }} + {% for name, enums in params.checkboxFilters %} {{ checkboxes({ classes: "nhsuk-checkboxes--small", diff --git a/app/views/patient/list.njk b/app/views/patient/list.njk index d2bb39b1d..57e09e2db 100644 --- a/app/views/patient/list.njk +++ b/app/views/patient/list.njk @@ -74,6 +74,7 @@ value: patient.school.name | highlightQuery(data.q) }, yearGroup: {}, + clinicProgramme_ids: {}, report: { label: __("patientSession.report.label"), value: reportStatusHtml diff --git a/app/views/school/invite-to-clinic.njk b/app/views/school/invite-to-clinic.njk new file mode 100644 index 000000000..bba541840 --- /dev/null +++ b/app/views/school/invite-to-clinic.njk @@ -0,0 +1,68 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("school.inviteToClinic.title") %} +{% set confirmButtonText = __("school.inviteToClinic.confirm") %} + +{% block form %} + {{ appHeading({ + caption: school.name, + title: title + }) }} + + {{ __mf("school.inviteToClinic.count", { + count: school.patients.length + }) | nhsukMarkdown }} + + {{ __("school.inviteToClinic.description", "1 June 2026") | nhsukMarkdown }} + + {{ checkboxes({ + fieldset: { + legend: { + text: __("school.inviteToClinic.programme"), + size: "m" + } + }, + items: [{ + text: ProgrammeType.Flu, + value: "flu", + hint: { + text: __mf("school.inviteToClinic.flu", { + count: school.patientsToInviteToSession('flu').length + }) + } + } if school.patientsToInviteToSession('flu').length, { + text: ProgrammeType.HPV, + value: "hpv", + hint: { + text: __mf("school.inviteToClinic.hpv", { + count: school.patientsToInviteToSession('hpv').length + }) + } + } if school.patientsToInviteToSession('hpv').length, { + text: ProgrammeType.MenACWY, + value: "menacwy", + hint: { + text: __mf("school.inviteToClinic.menacwy", { + count: school.patientsToInviteToSession('menacwy').length + }) + } + } if school.patientsToInviteToSession('menacwy').length, { + text: ProgrammeType.MMR, + value: "mmr", + hint: { + text: __mf("school.inviteToClinic.mmr", { + count: school.patientsToInviteToSession('mmr').length + }) + } + } if school.patientsToInviteToSession('mmr').length, { + text: ProgrammeType.TdIPV, + value: "td-ipv", + hint: { + text: __mf("school.inviteToClinic.td-ipv", { + count: school.patientsToInviteToSession('td-ipv').length + }) + } + } if school.patientsToInviteToSession('td-ipv').length], + decorate: "clinicProgramme_ids" + }) }} +{% endblock %} diff --git a/app/views/school/show.njk b/app/views/school/show.njk index a30ab075e..676ef88ac 100644 --- a/app/views/school/show.njk +++ b/app/views/school/show.njk @@ -32,7 +32,11 @@ classes: "nhsuk-button--secondary", href: "/uploads/new?type=" + UploadType.School + "&school_id=" + school.id, text: __("session.upload-class-list.title") - }) if school.id != "888888" and school.id != "999999" }} + } if not school.homeOrUnknown else { + classes: "nhsuk-button--secondary", + href: school.uri + "/invite-to-clinic", + text: __("session.inviteToClinic.label") + }) }}
@@ -88,6 +92,7 @@ nhsn: {}, dob: {}, yearGroup: {}, + clinicProgramme_ids: {}, report: { label: __("patientSession.report.label"), value: reportStatusHtml diff --git a/app/views/session/close.njk b/app/views/session/close.njk deleted file mode 100644 index e4d233c4b..000000000 --- a/app/views/session/close.njk +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "_layouts/form.njk" %} - -{% set title = __("session.close.title") %} -{% set confirmButtonText = __("session.close.confirm") %} - -{% block form %} - {{ appHeading({ - caption: session.location.name, - title: title - }) }} - - {{ __("session.close.description") | nhsukMarkdown }} - -
    -{% for outcome, patients in session.closingSummary %} - {% if patients.length > 0 %} -
  • - {{ __mf("session.closingSummary." + outcome + ".count", { - count: patients.length - }) }} -
  • - {% endif %} -{% endfor %} -
-{% endblock %} diff --git a/app/views/session/invite-to-clinic.njk b/app/views/session/invite-to-clinic.njk new file mode 100644 index 000000000..0e908fad6 --- /dev/null +++ b/app/views/session/invite-to-clinic.njk @@ -0,0 +1,17 @@ +{% extends "_layouts/form.njk" %} + +{% set title = __("session.inviteToClinic.title") %} +{% set confirmButtonText = __("session.inviteToClinic.confirm") %} + +{% block form %} + {{ appHeading({ + caption: session.location.name, + title: title + }) }} + + {{ __mf("session.inviteToClinic.count", { + count: session.patientSessionsForClinic.length + }) | nhsukMarkdown }} + + {{ __("session.inviteToClinic.description", session.nextProgrammeClinic.formatted.date) | nhsukMarkdown }} +{% endblock %} diff --git a/app/views/session/show.njk b/app/views/session/show.njk index 2df9117ec..317a6c9b4 100644 --- a/app/views/session/show.njk +++ b/app/views/session/show.njk @@ -48,8 +48,8 @@ }, { classes: "nhsuk-button--secondary", - text: __("session.close.title"), - href: session.uri + "/close" + text: __("session.inviteToClinic.label"), + href: session.uri + "/invite-to-clinic" } if session.isCompleted ], links: [{