diff --git a/app/assets/sass/_overrides.scss b/app/assets/sass/_overrides.scss index 546b6b084..589aa38ec 100644 --- a/app/assets/sass/_overrides.scss +++ b/app/assets/sass/_overrides.scss @@ -5,13 +5,17 @@ } } +.nhsuk-width-container { + max-width: 1100px; +} + .app-pre-footer-banner { border-top: 1px solid #b1b4b6; } .app-pre-footer-banner .govuk-phase-banner__content { display: flex; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: baseline; @include nhsuk-font(16); margin-top: 24px; diff --git a/app/assets/sass/components/_related-nav.scss b/app/assets/sass/components/_related-nav.scss index 77deba5eb..bf6dc5d16 100755 --- a/app/assets/sass/components/_related-nav.scss +++ b/app/assets/sass/components/_related-nav.scss @@ -23,3 +23,4 @@ list-style: none; padding-left: 0; } + diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss index 974636a5c..7f86571e7 100755 --- a/app/assets/sass/main.scss +++ b/app/assets/sass/main.scss @@ -1,5 +1,6 @@ // Import NHS.UK frontend library @import "nhsuk-frontend/dist/nhsuk"; +@import "../../../node_modules/@x-govuk/govuk-prototype-components/src/x-govuk"; // Local override classes @import 'overrides'; @@ -149,3 +150,184 @@ border-radius: 3px; .nhsuk-checkboxes__item[hidden] { display: none; } + +.x-govuk-sub-navigation__section-item--current { + background-color: transparent; +} + +.app-pharmacy-back-link { + margin-bottom: 24px; +} + +.app-pharmacy-page-title { + margin-bottom: nhsuk-spacing(6); +} + +@include nhsuk-media-query($from: tablet) { + .app-pharmacy-back-link { + margin-bottom: 48px; + } +} + +.app-section-navigation { + margin-bottom: nhsuk-spacing(4); +} + +.app-section-navigation__list { + @include nhsuk-font(19); + + display: flex; + flex-flow: row nowrap; + gap: nhsuk-spacing(1) nhsuk-spacing(2); + list-style: none; + margin: 0; + padding: 0; + box-shadow: inset 0 -1px $nhsuk-border-colour; + overflow-x: auto; +} + +.app-section-navigation__item { + margin: 0; +} + +.app-section-navigation__link { + @include nhsuk-link-style-default; + @include nhsuk-link-style-no-visited-state; + + display: block; + padding: nhsuk-spacing(2) 2px; + text-decoration: none; + white-space: nowrap; +} + +.app-section-navigation__link[aria-current="page"] { + box-shadow: inset 0 -4px $nhsuk-link-colour; +} + +.app-section-navigation__link[aria-current="page"]:focus { + box-shadow: inset 0 -4px $nhsuk-text-colour; +} + +.app-section-navigation__current { + font-weight: 700; +} + +.app-section-navigation--vertical .app-section-navigation__list { + flex-flow: column; + gap: 0; + box-shadow: none; + overflow: visible; +} + +.app-section-navigation--vertical .app-section-navigation__item { + border-left: 4px solid transparent; +} + +.app-section-navigation--vertical .app-section-navigation__item--current { + border-left-color: $nhsuk-link-colour; +} + +.app-section-navigation--vertical .app-section-navigation__link { + padding: nhsuk-spacing(1) nhsuk-spacing(2) nhsuk-spacing(1) calc(#{nhsuk-spacing(2)} + 4px); +} + +.app-section-navigation--vertical .app-section-navigation__link[aria-current="page"] { + box-shadow: none; +} + +.app-section-navigation--vertical .app-section-navigation__link[aria-current="page"]:focus { + box-shadow: inset 0 -4px $nhsuk-text-colour; +} + +.app-pharmacy-users-table-responsive { + container-name: pharmacy-users-table; + container-type: inline-size; + max-width: 100%; +} + +.app-pharmacy-users-table { + width: 100%; +} + +.app-pharmacy-users-table .app-pharmacy-users-vaccinator { + white-space: nowrap; + width: 1%; +} + +.app-pharmacy-users-actions a { + display: block; +} + +.app-pharmacy-users-actions a + a { + margin-top: nhsuk-spacing(1); +} + +@media (max-width: 1200px) { + .app-pharmacy-layout-nav, + .app-pharmacy-layout-content { + float: none; + width: 100%; + } + + .app-pharmacy-layout-nav { + margin-bottom: nhsuk-spacing(4); + } +} + +@container pharmacy-users-table (max-width: 700px) { + .app-pharmacy-users-table { + min-width: 0; + } + + .app-pharmacy-users-table thead { + display: none; + } + + .app-pharmacy-users-table, + .app-pharmacy-users-table tbody, + .app-pharmacy-users-table tr, + .app-pharmacy-users-table td { + display: block; + width: 100%; + } + + .app-pharmacy-users-table tr { + box-sizing: border-box; + border-bottom: 1px solid $nhsuk-border-colour; + margin-bottom: nhsuk-spacing(3); + padding-bottom: nhsuk-spacing(2); + } + + .app-pharmacy-users-table td { + border-bottom: 0; + min-width: 0; + padding: nhsuk-spacing(1) 0; + } + + .app-pharmacy-users-table td::before { + content: attr(data-label); + display: block; + font-weight: 700; + margin-bottom: 2px; + } + + .app-pharmacy-users-actions a { + display: inline; + margin-right: nhsuk-spacing(3); + } + + .app-pharmacy-users-actions a + a { + margin-top: 0; + } +} + +@include nhsuk-media-query($from: tablet) { + .app-pharmacy-users-table { + min-width: 760px; + } + + .app-section-navigation--vertical { + margin-right: -#{nhsuk-spacing(2)}; + margin-left: -#{nhsuk-spacing(2)}; + } +} diff --git a/app/components/secondary-navigation/_secondary-navigation.scss b/app/components/secondary-navigation/_secondary-navigation.scss index f89d0389c..5c623916f 100644 --- a/app/components/secondary-navigation/_secondary-navigation.scss +++ b/app/components/secondary-navigation/_secondary-navigation.scss @@ -62,3 +62,32 @@ padding-bottom: nhsuk-spacing(4) - $_current-link-border-width; } } + + .app-secondary-navigation--side { + border-bottom: 0; + border-left: 4px solid $nhsuk-border-colour; + margin-bottom: nhsuk-spacing(6); + padding-left: nhsuk-spacing(3); + } + + .app-secondary-navigation--side .app-secondary-navigation__list { + @include nhsuk-font(19); + + display: block; + margin-bottom: 0; + } + + .app-secondary-navigation--side .app-secondary-navigation__list-item { + margin: 0 0 nhsuk-spacing(2); + padding: 0; + } + + .app-secondary-navigation--side .app-secondary-navigation__list-item:not(:last-child) { + margin-right: 0; + } + + .app-secondary-navigation--side .app-secondary-navigation__list-item--current { + border-left-width: $_current-link-border-width; + margin-left: ((nhsuk-spacing(2) + $_current-link-border-width) * -1); + padding-left: nhsuk-spacing(2); + } diff --git a/app/components/x-govuk-secondary-navigation/macro.njk b/app/components/x-govuk-secondary-navigation/macro.njk new file mode 100644 index 000000000..d2b639cc8 --- /dev/null +++ b/app/components/x-govuk-secondary-navigation/macro.njk @@ -0,0 +1,3 @@ +{% macro xGovukSecondaryNavigation(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/app/components/x-govuk-secondary-navigation/template.njk b/app/components/x-govuk-secondary-navigation/template.njk new file mode 100644 index 000000000..dd4fc4f6b --- /dev/null +++ b/app/components/x-govuk-secondary-navigation/template.njk @@ -0,0 +1,16 @@ +{% from "../../../node_modules/nhsuk-frontend/dist/nhsuk/macros/attributes.njk" import nhsukAttributes %} + + diff --git a/app/components/x-govuk-sub-navigation/macro.njk b/app/components/x-govuk-sub-navigation/macro.njk new file mode 100644 index 000000000..d35b43004 --- /dev/null +++ b/app/components/x-govuk-sub-navigation/macro.njk @@ -0,0 +1,3 @@ +{% macro xGovukSubNavigation(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/app/components/x-govuk-sub-navigation/template.njk b/app/components/x-govuk-sub-navigation/template.njk new file mode 100644 index 000000000..7fa95c7d8 --- /dev/null +++ b/app/components/x-govuk-sub-navigation/template.njk @@ -0,0 +1,33 @@ +{% from "../../../node_modules/nhsuk-frontend/dist/nhsuk/macros/attributes.njk" import nhsukAttributes -%} + + diff --git a/app/data/organisations.js b/app/data/organisations.js index bfe3b7bd9..2412317c4 100644 --- a/app/data/organisations.js +++ b/app/data/organisations.js @@ -186,9 +186,9 @@ module.exports = [ }, { id: "RVL", - name: "Barnet and chase farm Hospitals NHS Trust", + name: "Barnet and Chase Farm Hospitals NHS Trust", address: { - line1: "Barnet General hospital", + line1: "Barnet General Hospital", town: "Barnet", postcode: "EN5 3DJ" }, @@ -222,7 +222,7 @@ module.exports = [ }, { id: "RRP", - name: "Barnet, enfield and haringey mental Health NHS Trust", + name: "Barnet, Enfield and Haringey Mental Health NHS Trust", address: { line1: "Trust headquarters block b2", town: "London", @@ -240,7 +240,7 @@ module.exports = [ }, { id: "RCN", - name: "Barnsley Community and priority services NHS Trust", + name: "Barnsley Community and Priority Services NHS Trust", address: { line1: "Kendray hospital", town: "Barnsley", @@ -7694,7 +7694,7 @@ module.exports = [ id: "RDC", name: "Wellhouse NHS Trust", address: { - line1: "Barnet hospital", + line1: "Barnet Hospital", town: "Barnet", postcode: "EN5 3DJ" }, diff --git a/app/routes/pharmacies.js b/app/routes/pharmacies.js index 20638aa45..aadf3db10 100644 --- a/app/routes/pharmacies.js +++ b/app/routes/pharmacies.js @@ -16,6 +16,7 @@ const hasVaccinationRecords = (data, organisationId) => { } const scenarioCompanyIds = ['P0191N', 'P15951'] +const allowedPharmacyVaccineNames = ['flu', 'COVID-19', 'MenB'] const isoDaysAgo = (daysAgo) => { const date = new Date() @@ -846,7 +847,7 @@ module.exports = router => { req.session.data.permissionLevel = '' req.session.data.vaccinator = '' - res.redirect(`/pharmacies/${organisation.id}?added=true&addedUserId=${addedUserId}&tab=${existingUser ? 'active' : 'invited'}`) + res.redirect(`/pharmacies/${organisation.id}?section=users&added=true&addedUserId=${addedUserId}&tab=${existingUser ? 'active' : 'invited'}`) }) @@ -905,7 +906,7 @@ module.exports = router => { return res.redirect(`/pharmacies/users/${user.id}?deactivatedFromPharmacyId=${pharmacy.id}`) } - res.redirect(`/pharmacies/${pharmacy.id}?tab=deactivated&deactivatedUserId=${user.id}&deactivatedFromPharmacyId=${pharmacy.id}`) + res.redirect(`/pharmacies/${pharmacy.id}?section=users&tab=deactivated&deactivatedUserId=${user.id}&deactivatedFromPharmacyId=${pharmacy.id}`) }) @@ -934,7 +935,7 @@ module.exports = router => { } if (!user) { - return res.redirect(`/pharmacies/${pharmacyId}?tab=invited`) + return res.redirect(`/pharmacies/${pharmacyId}?section=users&tab=invited`) } res.render('pharmacies/users/resend-invite', { @@ -958,12 +959,12 @@ module.exports = router => { const role = (user.organisations || []).find((item) => item.id === pharmacyId) if (!role) { - return res.redirect(`/pharmacies/${pharmacyId}?tab=invited`) + return res.redirect(`/pharmacies/${pharmacyId}?section=users&tab=invited`) } role.inviteSent = new Date().toISOString() - res.redirect(`/pharmacies/${pharmacyId}?tab=invited`) + res.redirect(`/pharmacies/${pharmacyId}?section=users&tab=invited`) }) router.get('/pharmacies/:pharmacyId/users/:userId/reactivate', (req, res) => { @@ -992,13 +993,13 @@ module.exports = router => { } if (!user) { - return res.redirect(`/pharmacies/${pharmacyId}?tab=deactivated`) + return res.redirect(`/pharmacies/${pharmacyId}?section=users&tab=deactivated`) } const role = (user.organisations || []).find((item) => item.id === pharmacyId) if (!role) { - return res.redirect(`/pharmacies/${pharmacyId}?tab=deactivated`) + return res.redirect(`/pharmacies/${pharmacyId}?section=users&tab=deactivated`) } role.status = 'Active' @@ -1006,7 +1007,7 @@ module.exports = router => { user.lastLogIn = new Date().toISOString().split('T')[0] } - res.redirect(`/pharmacies/${pharmacyId}?tab=active&reactivatedUserId=${userId}&reactivatedFromPharmacyId=${pharmacyId}`) + res.redirect(`/pharmacies/${pharmacyId}?section=users&tab=active&reactivatedUserId=${userId}&reactivatedFromPharmacyId=${pharmacyId}`) }) router.get('/pharmacies/users/:userId/deactivate-from-all-pharmacies', (req, res) => { @@ -1236,6 +1237,78 @@ module.exports = router => { } }) + router.get('/pharmacies/:id/edit-vaccines', (req, res) => { + const data = req.session.data + const { id } = req.params + const organisation = data.organisations.find((org) => org.id === id) + + if (!organisation) { + return res.redirect('/pharmacies') + } + + const enabledVaccineNames = (organisation.vaccines || []) + .filter((vaccine) => vaccine.status === 'enabled') + .filter((vaccine) => allowedPharmacyVaccineNames.includes(vaccine.name)) + .map((vaccine) => vaccine.name) + + const availableVaccines = (data.vaccines || []) + .filter((vaccine) => allowedPharmacyVaccineNames.includes(vaccine.name)) + .filter((vaccine) => !enabledVaccineNames.includes(vaccine.name)) + + res.render('pharmacies/edit-vaccines', { + organisation, + allVaccines: availableVaccines + }) + }) + + router.post('/pharmacies/:id/update-vaccines', (req, res) => { + const data = req.session.data + const { id } = req.params + const organisation = data.organisations.find((org) => org.id === id) + + if (!organisation) { + return res.redirect('/pharmacies') + } + + const selectedVaccinesRaw = req.body.vaccinesEnabled + const selectedVaccines = Array.isArray(selectedVaccinesRaw) + ? selectedVaccinesRaw + : (selectedVaccinesRaw ? [selectedVaccinesRaw] : []) + + organisation.vaccines ||= [] + let vaccinesUpdatedCount = 0 + + for (const vaccineName of selectedVaccines) { + if (!allowedPharmacyVaccineNames.includes(vaccineName)) { + continue + } + + const existingVaccine = organisation.vaccines.find((vaccine) => vaccine.name === vaccineName) + + if (existingVaccine) { + if (existingVaccine.status !== 'enabled') { + vaccinesUpdatedCount += 1 + } + existingVaccine.status = 'enabled' + } else { + vaccinesUpdatedCount += 1 + organisation.vaccines.push({ + name: vaccineName, + status: 'enabled' + }) + } + } + + return res.redirect(`/pharmacies/${id}?section=vaccines&vaccinesUpdated=true&vaccinesUpdatedCount=${vaccinesUpdatedCount}`) + }) + + router.get('/pharmacies/:id/remove-vaccine', (req, res) => { + const { id } = req.params + + // Group admins can no longer remove vaccines from pharmacies. + return res.redirect(`/pharmacies/${id}?section=vaccines`) + }) + router.get('/pharmacies/:id', async (req, res) => { const data = req.session.data @@ -1247,7 +1320,10 @@ module.exports = router => { const deactivatedFromPharmacyId = req.query.deactivatedFromPharmacyId const reactivatedUserId = req.query.reactivatedUserId const reactivatedFromPharmacyId = req.query.reactivatedFromPharmacyId + const vaccinesUpdated = req.query.vaccinesUpdated + const vaccinesUpdatedCount = Number.parseInt(req.query.vaccinesUpdatedCount, 10) || 0 const tab = (req.query.tab || 'active').toLowerCase() + const section = (req.query.section || 'overview').toLowerCase() const organisation = data.organisations.find((organisation) => organisation.id === id) @@ -1270,6 +1346,13 @@ module.exports = router => { const deactivatedUser = data.users.find((user) => user.id === deactivatedUserId) const reactivatedUser = data.users.find((user) => user.id === reactivatedUserId) const canDeletePharmacy = !hasVaccinationRecords(data, id) + const enabledAllowedVaccineNames = (organisation.vaccines || []) + .filter((vaccine) => vaccine.status === 'enabled') + .filter((vaccine) => allowedPharmacyVaccineNames.includes(vaccine.name)) + .map((vaccine) => vaccine.name) + const hasAvailableVaccinesToAdd = allowedPharmacyVaccineNames.some((vaccineName) => { + return !enabledAllowedVaccineNames.includes(vaccineName) + }) const userOrganisationPermissions = {} @@ -1314,6 +1397,8 @@ module.exports = router => { const validTabs = ['invited', 'active', 'deactivated'] const currentTab = validTabs.includes(tab) ? tab : 'active' + const validSections = ['overview', 'users', 'vaccines', 'action'] + const currentPageSection = validSections.includes(section) ? section : 'overview' const usersForTab = usersByStatus[currentTab] res.render('pharmacies/pharmacy', { @@ -1329,7 +1414,11 @@ module.exports = router => { deactivatedFromPharmacyId, reactivatedUser, reactivatedFromPharmacyId, - canDeletePharmacy + vaccinesUpdated, + vaccinesUpdatedCount, + canDeletePharmacy, + currentPageSection, + hasAvailableVaccinesToAdd }) }) diff --git a/app/routes/regions.js b/app/routes/regions.js index 6fdefc3d0..984106047 100644 --- a/app/routes/regions.js +++ b/app/routes/regions.js @@ -164,24 +164,111 @@ module.exports = router => { router.get('/regions/organisations/:id', (req, res) => { const data = req.session.data const id = req.params.id + const section = (req.query.section || 'overview').toLowerCase() + const tab = (req.query.tab || 'active').toLowerCase() + const removedVaccine = req.query.removedVaccine + const vaccinesAdded = req.query.vaccinesAdded + const vaccinesAddedCount = Number.parseInt(req.query.vaccinesAddedCount, 10) || 0 const organisation = data.organisations.find((org) => org.id === id) if (!organisation) { res.redirect('/regions/'); return } const users = data.users.filter((user) => (user.organisations || []).find((organisation) => organisation.id === id)) + const usersByStatus = { + invited: [], + active: [], + deactivated: [] + } + + for (const user of users) { + const userOrganisationSettings = (user.organisations || []).find((userOrganisation) => userOrganisation.id === id) + const status = (userOrganisationSettings && userOrganisationSettings.status || 'Active').toLowerCase() + + if (status === 'invited') { + usersByStatus.invited.push(user) + } else if (status === 'deactivated') { + usersByStatus.deactivated.push(user) + } else { + usersByStatus.active.push(user) + } + } + + const validTabs = ['invited', 'active', 'deactivated'] + const currentTab = validTabs.includes(tab) ? tab : 'active' + const validSections = ['overview', 'users', 'vaccines', 'action'] + const currentPageSection = validSections.includes(section) ? section : 'overview' + const usersForTab = usersByStatus[currentTab] const vaccines = organisation.vaccines || [] const vaccinesEnabled = vaccines.filter((vaccine) => vaccine.status === "enabled") + const canAddVaccines = vaccinesEnabled.length < (data.vaccines || []).length const messages = (res.locals.currentOrganisation.inbox || []).filter((message) => message.fromOrganisationId === id) res.render('regions/organisation', { organisation, - users, + users: usersForTab, + usersByStatus, + currentTab, + currentPageSection, vaccinesEnabled, + canAddVaccines, + removedVaccine, + vaccinesAdded, + vaccinesAddedCount, messages }) }) + router.get('/regions/organisations/:id/remove-vaccine', (req, res) => { + const data = req.session.data + const id = req.params.id + const vaccineName = req.query.vaccineName + const organisation = data.organisations.find((org) => org.id === id) + if (!organisation) { res.redirect('/regions/'); return } + + if (!vaccineName) { + res.redirect(`/regions/organisations/${id}?section=vaccines`) + return + } + + const existingEnabledVaccine = (organisation.vaccines || []).find((vaccine) => vaccine.name === vaccineName && vaccine.status === 'enabled') + + if (!existingEnabledVaccine) { + res.redirect(`/regions/organisations/${id}?section=vaccines`) + return + } + + res.render('regions/remove-vaccine', { + organisation, + vaccineName + }) + }) + + router.post('/regions/organisations/:id/remove-vaccine', (req, res) => { + const data = req.session.data + const id = req.params.id + const vaccineName = req.body.vaccineName + const organisation = data.organisations.find((org) => org.id === id) + if (!organisation) { res.redirect('/regions/'); return } + + if (!vaccineName) { + res.redirect(`/regions/organisations/${id}?section=vaccines`) + return + } + + organisation.vaccines ||= [] + + const existingVaccine = organisation.vaccines.find((vaccine) => vaccine.name === vaccineName && vaccine.status === 'enabled') + + if (existingVaccine) { + existingVaccine.status = 'disabled' + res.redirect(`/regions/organisations/${id}?section=vaccines&removedVaccine=${encodeURIComponent(vaccineName)}`) + return + } + + res.redirect(`/regions/organisations/${id}?section=vaccines`) + }) + // Viewing the page to set vaccines per organisation router.get('/regions/organisations/:id/add-vaccines', (req, res) => { const data = req.session.data @@ -209,17 +296,25 @@ module.exports = router => { const organisation = data.organisations.find((org) => org.id === id) if (!organisation) { res.redirect('/regions/'); return } - const vaccinesToAdd = data.vaccinesToAdd + const vaccinesToAddRaw = data.vaccinesToAdd + const vaccinesToAdd = Array.isArray(vaccinesToAddRaw) + ? vaccinesToAddRaw + : (vaccinesToAddRaw ? [vaccinesToAddRaw] : []) const vaccines = organisation.vaccines || [] + let vaccinesAddedCount = 0 for (let vaccineToAdd of vaccinesToAdd) { const existingVaccine = vaccines.find((vaccine) => vaccine.name === vaccineToAdd) if (existingVaccine) { + if (existingVaccine.status !== 'enabled') { + vaccinesAddedCount += 1 + } existingVaccine.status = "enabled" } else { + vaccinesAddedCount += 1 vaccines.push({ name: vaccineToAdd, @@ -229,7 +324,7 @@ module.exports = router => { } - res.redirect(`/regions/organisations/${id}`) + res.redirect(`/regions/organisations/${id}?section=vaccines&vaccinesAdded=true&vaccinesAddedCount=${vaccinesAddedCount}`) }) diff --git a/app/views/errors/vaccine-disabled.html b/app/views/errors/vaccine-disabled.html new file mode 100644 index 000000000..39ac17049 --- /dev/null +++ b/app/views/errors/vaccine-disabled.html @@ -0,0 +1,22 @@ +{% extends 'layout.html' %} + +{% set pageName = "There is a problem saving this vaccination" %} + +{% block content %} +
You cannot save this vaccination because a regional lead has removed your access to [vaccine name] vaccines.
+ +If you have already vaccinated the patient, record it on paper and contact your regional lead.
+ +Go back to the Check and confirm page to see details of the vaccination.
+ + + +Make sure an administrator has added batches before you start recording. +
+ {% endset %} + + {{ notificationBanner({ + html: notificationHtml + }) }} + + + {% if (errorList | length) > 0 %} + {{ errorSummary({ + titleText: "There is a problem", + errorList: errorList + }) }} + {% endif %} + + + +If you still need access to [vaccine name] vaccinations, contact your regional lead.
+ {% endset %} + + {{ notificationBanner({ + html: notificationHtml + }) }} + + + {% if (errorList | length) > 0 %} + {{ errorSummary({ + titleText: "There is a problem", + errorList: errorList + }) }} + {% endif %} + + + +