From 838d3694533bd62e54e941d54a4a670602f49d13 Mon Sep 17 00:00:00 2001 From: Bob Meredith Date: Mon, 13 Jan 2025 08:41:36 +0000 Subject: [PATCH] APS-1678 AP day summary view --- assets/sass/application.sass | 1 + assets/sass/components/_previous-next.scss | 21 +++++ integration_tests/mockApis/premises.ts | 28 ++++++ .../pages/manage/occupancyDayView.ts | 32 +++++++ integration_tests/pages/page.ts | 4 + .../tests/manage/occupancyView.cy.ts | 94 +++++++++++++++++++ .../apOccupancyViewController.test.ts | 45 ++++++++- .../premises/apOccupancyViewController.ts | 27 +++++- server/data/premisesClient.test.ts | 50 +++++++++- server/data/premisesClient.ts | 17 ++++ server/paths/api.ts | 2 + server/paths/manage.ts | 2 +- server/routes/manage.ts | 6 ++ server/services/premisesService.test.ts | 14 +++ server/services/premisesService.ts | 16 ++++ .../factories/cas1PremiseCapacity.ts | 12 ++- .../factories/cas1PremisesDaySummary.ts | 23 +++++ server/testutils/factories/index.ts | 2 + server/utils/match/occupancy.test.ts | 4 +- server/utils/premises/occupancy.test.ts | 91 +++++++++++++++++- server/utils/premises/occupancy.ts | 40 +++++++- .../manage/premises/occupancy/dayView.njk | 49 ++++++++++ 22 files changed, 562 insertions(+), 18 deletions(-) create mode 100644 assets/sass/components/_previous-next.scss create mode 100644 integration_tests/pages/manage/occupancyDayView.ts create mode 100644 server/testutils/factories/cas1PremisesDaySummary.ts create mode 100644 server/views/manage/premises/occupancy/dayView.njk diff --git a/assets/sass/application.sass b/assets/sass/application.sass index 32a051c66..0ab620a73 100755 --- a/assets/sass/application.sass +++ b/assets/sass/application.sass @@ -21,6 +21,7 @@ $path: "/assets/images/" @import './components/moj-identity-bar' @import './components/status-tag' @import './components/notification-banner' +@import './components/previous-next' @import './pages/assessments-index' @import './pages/applications-pages-attach-document' diff --git a/assets/sass/components/_previous-next.scss b/assets/sass/components/_previous-next.scss new file mode 100644 index 000000000..e4a89fd46 --- /dev/null +++ b/assets/sass/components/_previous-next.scss @@ -0,0 +1,21 @@ +.prev-next { + font-weight: bold; + margin-bottom: govuk-spacing(3); + + .prev-next--prev { + margin-right: govuk-spacing(3); + + &::before { + content: "\2190"; + margin-right: govuk-spacing(2); + font-size:30px; + } + } + + .prev-next--next::after { + content: "\2192"; + margin-left: govuk-spacing(2); + + font-size:30px; + } +} \ No newline at end of file diff --git a/integration_tests/mockApis/premises.ts b/integration_tests/mockApis/premises.ts index 3a9c7606c..d4b99438b 100644 --- a/integration_tests/mockApis/premises.ts +++ b/integration_tests/mockApis/premises.ts @@ -2,6 +2,7 @@ import type { Cas1PremiseCapacity, Cas1Premises, Cas1PremisesBasicSummary, + Cas1PremisesDaySummary, ExtendedPremisesSummary, ApprovedPremisesSummary as PremisesSummary, StaffMember, @@ -110,6 +111,32 @@ const stubPremiseCapacity = (args: { }, }) +const stubPremiseDaySummary = (args: { + premisesId: string + date: string + premisesDaySummary: Cas1PremisesDaySummary + sortBy?: string + sortDirection?: string +}) => { + const queryString: string = createQueryString({ + sortBy: args.sortBy || undefined, + sortDirection: args.sortDirection || undefined, + }) + return stubFor({ + request: { + method: 'GET', + url: `${paths.premises.daySummary({ premisesId: args.premisesId, date: args.date })}${queryString ? `?${queryString}` : ''}`, + }, + response: { + status: 200, + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + }, + jsonBody: args.premisesDaySummary, + }, + }) +} + export default { stubAllPremises, stubCas1AllPremises, @@ -117,4 +144,5 @@ export default { stubSinglePremises, stubPremisesStaffMembers, stubPremiseCapacity, + stubPremiseDaySummary, } diff --git a/integration_tests/pages/manage/occupancyDayView.ts b/integration_tests/pages/manage/occupancyDayView.ts new file mode 100644 index 000000000..b1b9734d6 --- /dev/null +++ b/integration_tests/pages/manage/occupancyDayView.ts @@ -0,0 +1,32 @@ +import { Cas1PremisesBasicSummary, Cas1PremisesDaySummary } from '@approved-premises/api' +import Page from '../page' +import { DateFormats } from '../../../server/utils/dateUtils' +import paths from '../../../server/paths/manage' +import { daySummaryRows } from '../../../server/utils/premises/occupancy' + +export default class OccupancyDayViewPage extends Page { + constructor(private pageTitle: string) { + super(pageTitle) + } + + static visit(premises: Cas1PremisesBasicSummary, date: string): OccupancyDayViewPage { + cy.visit(paths.premises.occupancy.day({ premisesId: premises.id, date })) + return new OccupancyDayViewPage(DateFormats.isoDateToUIDate(date)) + } + + shouldShowDaySummaryDetails(premisesDaySummary: Cas1PremisesDaySummary) { + this.shouldContainSummaryListItems(daySummaryRows(premisesDaySummary).rows) + } + + shouldNavigateToDay(linkLabel: string, date: string) { + cy.contains(linkLabel).click() + cy.get('h1').contains(DateFormats.isoDateToUIDate(date)) + } + + static visitUnauthorised(premises: Cas1PremisesBasicSummary, date: string): OccupancyDayViewPage { + cy.visit(paths.premises.occupancy.day({ premisesId: premises.id, date }), { + failOnStatusCode: false, + }) + return new OccupancyDayViewPage(`Authorisation Error`) + } +} diff --git a/integration_tests/pages/page.ts b/integration_tests/pages/page.ts index 7b116b9e8..237fa3879 100644 --- a/integration_tests/pages/page.ts +++ b/integration_tests/pages/page.ts @@ -108,6 +108,10 @@ export default abstract class Page { cy.get('.govuk-notification-banner').contains(copy) } + shouldNotShowBanner(): void { + cy.get('.govuk-notification-banner').should('not.exist') + } + radioByNameAndValueShouldNotExist(name: string, option: string): void { cy.get(`input[name = "${name}"][value = "${option}"]`).should('not.exist') } diff --git a/integration_tests/tests/manage/occupancyView.cy.ts b/integration_tests/tests/manage/occupancyView.cy.ts index 61f4722f2..abb4f20d0 100644 --- a/integration_tests/tests/manage/occupancyView.cy.ts +++ b/integration_tests/tests/manage/occupancyView.cy.ts @@ -1,6 +1,10 @@ import { addDays } from 'date-fns' +import { faker } from '@faker-js/faker' import { cas1PremiseCapacityFactory, + cas1PremiseCapacityForDayFactory, + cas1PremisesBasicSummaryFactory, + cas1PremisesDaySummaryFactory, cas1PremisesFactory, cas1SpaceBookingSummaryFactory, staffMemberFactory, @@ -11,6 +15,8 @@ import { OccupancyViewPage, PremisesShowPage } from '../../pages/manage' import { signIn } from '../signIn' import { DateFormats } from '../../../server/utils/dateUtils' import Page from '../../pages/page' +import { premiseCharacteristicAvailability } from '../../../server/testutils/factories/cas1PremiseCapacity' +import OccupancyDayViewPage from '../../pages/manage/occupancyDayView' context('Premises occupancy', () => { describe('show', () => { @@ -159,3 +165,91 @@ context('Premises occupancy', () => { }) }) }) + +context('Premises day occupancy', () => { + describe('day', () => { + const dateObj = faker.date.soon() + const date = DateFormats.dateObjToIsoDate(dateObj) + const premises = cas1PremisesBasicSummaryFactory.build() + + const getDaySummary = (overBook = false) => { + const characteristicAvailability = overBook + ? [ + premiseCharacteristicAvailability.strictlyOverbooked().build({ characteristic: 'isSingle' }), + premiseCharacteristicAvailability.strictlyOverbooked().build({ characteristic: 'hasEnSuite' }), + ] + : [ + premiseCharacteristicAvailability.available().build({ characteristic: 'isSingle' }), + premiseCharacteristicAvailability.available().build({ characteristic: 'hasEnSuite' }), + ] + + const capacity = cas1PremiseCapacityForDayFactory.available().build({ + characteristicAvailability, + }) + return cas1PremisesDaySummaryFactory.build({ forDate: date, capacity }) + } + + beforeEach(() => { + cy.task('reset') + // Given there is a premises in the database + cy.task('stubSinglePremises', premises) + }) + + describe('with premises view permission', () => { + beforeEach(() => { + // Given I am logged in as a future manager with premises_view permission + signIn(['future_manager'], ['cas1_premises_view']) + }) + + it('should show the day summary if spaces available', () => { + const premisesDaySummary = getDaySummary() + cy.task('stubPremiseDaySummary', { premisesId: premises.id, date, premisesDaySummary }) + // When I visit premises day summary page for a day with no characteristic overbooking + const summaryPage = OccupancyDayViewPage.visit(premises, date) + // I should see the occupancy summary for the day + summaryPage.shouldShowDaySummaryDetails(premisesDaySummary) + // And I should see a warning banner + summaryPage.shouldNotShowBanner() + }) + + it('should show the day summary and warning if overbooked', () => { + const premisesDaySummary = getDaySummary(true) + cy.task('stubPremiseDaySummary', { premisesId: premises.id, date, premisesDaySummary }) + // When I visit premises day summary page for a day with a characteristic overbooking + const summaryPage = OccupancyDayViewPage.visit(premises, date) + // I should see the occupancy summary for the day + summaryPage.shouldShowDaySummaryDetails(premisesDaySummary) + // And I should see a warning banner + summaryPage.shouldShowBanner( + 'This AP is overbooked on spaces with the following criteria: single room, en-suite', + ) + }) + + it('should allow navigation to the next day and back again', () => { + cy.task('stubPremiseDaySummary', { premisesId: premises.id, date, premisesDaySummary: getDaySummary() }) + const nextDate = DateFormats.dateObjToIsoDate(addDays(dateObj, 1)) + const premisesNextDaySummary = cas1PremisesDaySummaryFactory.build({ forDate: nextDate }) + cy.task('stubPremiseDaySummary', { + premisesId: premises.id, + date: nextDate, + premisesDaySummary: premisesNextDaySummary, + }) + // Given I visit premises day summary page + const summaryPage = OccupancyDayViewPage.visit(premises, date) + // When I click on Next day, Then I navigate to the next day + summaryPage.shouldNavigateToDay('Next day', nextDate) + // When I click on Previous day, Then I navigate back to the date I started on + summaryPage.shouldNavigateToDay('Previous day', date) + }) + }) + describe('Without premises view permission', () => { + it('should not be availble if the user lacks premises_view permission', () => { + // Given I am logged in as a future manager without premises_view permission + signIn(['future_manager']) + // When I navigate to the view premises occupancy page + // Then I should see an error + OccupancyDayViewPage.visitUnauthorised(premises, date) + }) + }) + }) +}) diff --git a/server/controllers/manage/premises/apOccupancyViewController.test.ts b/server/controllers/manage/premises/apOccupancyViewController.test.ts index 450a7f070..7f0400b60 100644 --- a/server/controllers/manage/premises/apOccupancyViewController.test.ts +++ b/server/controllers/manage/premises/apOccupancyViewController.test.ts @@ -1,14 +1,18 @@ -import type { Cas1Premises } from '@approved-premises/api' +import type { Cas1Premises, Cas1PremisesDaySummary } from '@approved-premises/api' import type { NextFunction, Request, Response } from 'express' import { DeepMocked, createMock } from '@golevelup/ts-jest' import { PremisesService } from 'server/services' import ApOccupancyViewController from './apOccupancyViewController' -import { cas1PremiseCapacityFactory, cas1PremisesFactory } from '../../../testutils/factories' +import { + cas1PremiseCapacityFactory, + cas1PremisesDaySummaryFactory, + cas1PremisesFactory, +} from '../../../testutils/factories' import paths from '../../../paths/manage' -import { occupancyCalendar } from '../../../utils/premises/occupancy' +import { daySummaryRows, generateDaySummaryText, occupancyCalendar } from '../../../utils/premises/occupancy' import { DateFormats } from '../../../utils/dateUtils' describe('AP occupancyViewController', () => { @@ -111,4 +115,39 @@ describe('AP occupancyViewController', () => { expect(premisesService.getCapacity).not.toHaveBeenCalled() }) }) + describe('dayView', () => { + const mockPremises = async (date: string = DateFormats.dateObjToIsoDate(new Date())) => { + const premisesSummary: Cas1Premises = cas1PremisesFactory.build({ id: premisesId }) + const premisesDaySummary: Cas1PremisesDaySummary = cas1PremisesDaySummaryFactory.build({ forDate: date }) + premisesService.getDaySummary.mockResolvedValue(premisesDaySummary) + premisesService.find.mockResolvedValue(premisesSummary) + request.params.date = date + const requestHandler = occupancyViewController.dayView() + await requestHandler(request, response, next) + return { + premisesSummary, + premisesDaySummary, + } + } + + it('should render the premises day summary', async () => { + const date = '2025-01-01' + const { premisesSummary, premisesDaySummary } = await mockPremises(date) + + expect(response.render).toHaveBeenCalledWith( + 'manage/premises/occupancy/dayView', + expect.objectContaining({ + premises: premisesSummary, + pageHeading: DateFormats.isoDateToUIDate(date), + backLink: paths.premises.occupancy.view({ premisesId }), + previousDayLink: paths.premises.occupancy.day({ premisesId, date: '2024-12-31' }), + nextDayLink: paths.premises.occupancy.day({ premisesId, date: '2025-01-02' }), + daySummaryRows: daySummaryRows(premisesDaySummary), + daySummaryText: generateDaySummaryText(premisesDaySummary), + }), + ) + expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) + expect(premisesService.getDaySummary).toHaveBeenCalledWith({ token, premisesId, date }) + }) + }) }) diff --git a/server/controllers/manage/premises/apOccupancyViewController.ts b/server/controllers/manage/premises/apOccupancyViewController.ts index 6a4b25921..ad57c23aa 100644 --- a/server/controllers/manage/premises/apOccupancyViewController.ts +++ b/server/controllers/manage/premises/apOccupancyViewController.ts @@ -4,7 +4,13 @@ import { ObjectWithDateParts } from '@approved-premises/ui' import { PremisesService } from '../../../services' import paths from '../../../paths/manage' -import { Calendar, durationSelectOptions, occupancyCalendar } from '../../../utils/premises/occupancy' +import { + Calendar, + daySummaryRows, + durationSelectOptions, + generateDaySummaryText, + occupancyCalendar, +} from '../../../utils/premises/occupancy' import { DateFormats, dateAndTimeInputsAreValidDates, daysToWeeksAndDays } from '../../../utils/dateUtils' import { placementDates } from '../../../utils/match' import { fetchErrorsAndUserInput, generateErrorMessages, generateErrorSummary } from '../../../utils/validation' @@ -61,4 +67,23 @@ export default class ApOccupancyViewController { }) } } + + dayView(): RequestHandler { + return async (req: Request, res: Response) => { + const { token } = req.user + const { premisesId, date } = req.params + const premises = await this.premisesService.find(token, premisesId) + const daySummary = await this.premisesService.getDaySummary({ token, premisesId, date }) + + return res.render('manage/premises/occupancy/dayView', { + premises, + pageHeading: daySummary ? DateFormats.isoDateToUIDate(daySummary.forDate) : '', + backLink: paths.premises.occupancy.view({ premisesId }), + previousDayLink: daySummary && paths.premises.occupancy.day({ premisesId, date: daySummary.previousDate }), + nextDayLink: daySummary && paths.premises.occupancy.day({ premisesId, date: daySummary.nextDate }), + daySummaryRows: daySummary && daySummaryRows(daySummary), + daySummaryText: daySummary && generateDaySummaryText(daySummary), + }) + } + } } diff --git a/server/data/premisesClient.test.ts b/server/data/premisesClient.test.ts index f3afe95c3..ece37ee60 100644 --- a/server/data/premisesClient.test.ts +++ b/server/data/premisesClient.test.ts @@ -1,7 +1,9 @@ +import { Cas1SpaceBookingDaySummarySortField, SortDirection } from '@approved-premises/api' import { bedDetailFactory, bedSummaryFactory, cas1PremiseCapacityFactory, + cas1PremisesDaySummaryFactory, premisesFactory, staffMemberFactory, } from '../testutils/factories' @@ -9,11 +11,11 @@ import PremisesClient from './premisesClient' import paths from '../paths/api' import describeClient, { describeCas1NamespaceClient } from '../testutils/describeClient' +const token = 'test-token-1' + describeClient('PremisesClient', provider => { let premisesClient: PremisesClient - const token = 'token-1' - beforeEach(() => { premisesClient = new PremisesClient(token) }) @@ -101,7 +103,7 @@ describeCas1NamespaceClient('PremisesCas1Client', provider => { let premisesClient: PremisesClient beforeEach(() => { - premisesClient = new PremisesClient('token1') + premisesClient = new PremisesClient(token) }) describe('getCapacity', () => { @@ -122,7 +124,7 @@ describeCas1NamespaceClient('PremisesCas1Client', provider => { endDate, }, headers: { - authorization: `Bearer token1`, + authorization: `Bearer ${token}`, }, }, willRespondWith: { @@ -135,4 +137,44 @@ describeCas1NamespaceClient('PremisesCas1Client', provider => { expect(output).toEqual(premiseCapacity) }) }) + + describe('getDaySummary', () => { + it('should return capacity and occupancy data for a given premises for a given day', async () => { + const date = '2025-03-14' + const premises = premisesFactory.build() + const premiseCapacity = cas1PremisesDaySummaryFactory.build() + const sortDirection: SortDirection = 'asc' + const sortBy: Cas1SpaceBookingDaySummarySortField = 'personName' + + await provider.addInteraction({ + state: 'Server is healthy', + uponReceiving: 'A request to get the day summary of a premise', + withRequest: { + method: 'GET', + path: paths.premises.daySummary({ premisesId: premises.id, date }), + query: { + sortDirection, + sortBy, + bookingsCriteriaFilter: 'hasEnSuite', + }, + headers: { + authorization: `Bearer ${token}`, + }, + }, + willRespondWith: { + status: 200, + body: premiseCapacity, + }, + }) + + const output = await premisesClient.getDaySummary({ + premisesId: premises.id, + date, + sortDirection, + sortBy, + bookingsCriteriaFilter: ['hasEnSuite'], + }) + expect(output).toEqual(premiseCapacity) + }) + }) }) diff --git a/server/data/premisesClient.ts b/server/data/premisesClient.ts index fea29a523..22715422b 100644 --- a/server/data/premisesClient.ts +++ b/server/data/premisesClient.ts @@ -4,7 +4,10 @@ import type { Cas1PremiseCapacity, Cas1Premises, Cas1PremisesBasicSummary, + Cas1PremisesDaySummary, Cas1SpaceBooking, + Cas1SpaceBookingCharacteristic, + Cas1SpaceBookingDaySummarySortField, Cas1SpaceBookingSummary, Cas1SpaceBookingSummarySortField, SortDirection, @@ -48,6 +51,20 @@ export default class PremisesClient { })) as Cas1PremiseCapacity } + async getDaySummary(args: { + premisesId: string + date: string + bookingsCriteriaFilter?: Array + sortDirection?: SortDirection + sortBy?: Cas1SpaceBookingDaySummarySortField + }): Promise { + const { premisesId, date, sortDirection, sortBy, bookingsCriteriaFilter } = args + return (await this.restClient.get({ + path: paths.premises.daySummary({ premisesId, date }), + query: { sortDirection, sortBy, bookingsCriteriaFilter: bookingsCriteriaFilter?.join(',') }, + })) as Cas1PremisesDaySummary + } + async getPlacements(args: { premisesId: string status?: string diff --git a/server/paths/api.ts b/server/paths/api.ts index 7df5bead0..513f27d1c 100644 --- a/server/paths/api.ts +++ b/server/paths/api.ts @@ -12,6 +12,7 @@ const cas1OutOfServiceBeds = cas1PremisesSingle.path('out-of-service-beds') const cas1OutOfServiceBedsSingle = cas1OutOfServiceBeds.path(':id') const cas1SpaceBookingSingle = cas1PremisesSingle.path('space-bookings/:placementId') const cas1Capacity = cas1PremisesSingle.path('capacity') +const cas1DaySummary = cas1PremisesSingle.path('day-summary/:date') const cas1SpaceBookings = cas1Namespace.path('placement-requests/:id/space-bookings') @@ -70,6 +71,7 @@ export default { index: premises.path('summary'), indexCas1: cas1Premises.path('summary'), capacity: cas1Capacity, + daySummary: cas1DaySummary, summary: premisesSingle.path('summary'), lostBeds: { create: cas1LostBeds, diff --git a/server/paths/manage.ts b/server/paths/manage.ts index 6bfab871c..7b4659e93 100644 --- a/server/paths/manage.ts +++ b/server/paths/manage.ts @@ -95,7 +95,7 @@ const paths = { }, occupancy: { view: singlePremisesPath.path('occupancy'), - day: singlePremisesPath.path('occupancy/day'), + day: singlePremisesPath.path('occupancy/day/:date'), }, }, diff --git a/server/routes/manage.ts b/server/routes/manage.ts index 010b0a165..a3492f0d2 100644 --- a/server/routes/manage.ts +++ b/server/routes/manage.ts @@ -246,6 +246,12 @@ export default function routes(controllers: Controllers, router: Router, service allowedPermissions: ['cas1_premises_view'], }) + // Occupancy for day + get(paths.premises.occupancy.day.pattern, apOccupancyViewController.dayView(), { + auditEvent: 'VIEW_DAY_SUMMARY', + allowedPermissions: ['cas1_premises_view'], + }) + // Bookings get(paths.bookings.show.pattern, bookingsController.show(), { auditEvent: 'SHOW_BOOKING', diff --git a/server/services/premisesService.test.ts b/server/services/premisesService.test.ts index 28a155264..4734929c6 100644 --- a/server/services/premisesService.test.ts +++ b/server/services/premisesService.test.ts @@ -8,6 +8,7 @@ import { bedSummaryFactory, cas1PremiseCapacityFactory, cas1PremisesBasicSummaryFactory, + cas1PremisesDaySummaryFactory, cas1PremisesFactory, cas1SpaceBookingFactory, cas1SpaceBookingSummaryFactory, @@ -129,6 +130,19 @@ describe('PremisesService', () => { }) }) + describe('getDaySummary', () => { + it('should return the day summary for a premises', async () => { + const date = '2025-05-20' + const daySummary = cas1PremisesDaySummaryFactory.build() + premisesClient.getDaySummary.mockResolvedValue(daySummary) + const result = await service.getDaySummary({ token, premisesId, date }) + + expect(result).toEqual(daySummary) + expect(premisesClientFactory).toHaveBeenCalledWith(token) + expect(premisesClient.getDaySummary).toHaveBeenCalledWith({ premisesId, date }) + }) + }) + describe('find', () => { it('fetches the premises from the client', async () => { const premises = cas1PremisesFactory.build() diff --git a/server/services/premisesService.ts b/server/services/premisesService.ts index 78e83b75e..3db2f4cfc 100644 --- a/server/services/premisesService.ts +++ b/server/services/premisesService.ts @@ -4,6 +4,9 @@ import type { Cas1PremiseCapacity, Cas1Premises, Cas1PremisesBasicSummary, + Cas1PremisesDaySummary, + Cas1SpaceBookingCharacteristic, + Cas1SpaceBookingDaySummarySortField, Cas1SpaceBookingSummarySortField, SortDirection, StaffMember, @@ -44,6 +47,19 @@ export default class PremisesService { return premisesClient.getCapacity(premisesId, startDate, endDate || startDate) } + async getDaySummary(args: { + token: string + premisesId: string + date: string + bookingsCriteriaFilter?: Array + sortBy?: Cas1SpaceBookingDaySummarySortField + sortDirection?: SortDirection + }): Promise { + const { token, ...parameters } = args + const premisesClient = this.premisesClientFactory(token) + return premisesClient.getDaySummary(parameters) + } + async find(token: string, id: string): Promise { const premisesClient = this.premisesClientFactory(token) return premisesClient.find(id) diff --git a/server/testutils/factories/cas1PremiseCapacity.ts b/server/testutils/factories/cas1PremiseCapacity.ts index f7c598264..51ecfff5a 100644 --- a/server/testutils/factories/cas1PremiseCapacity.ts +++ b/server/testutils/factories/cas1PremiseCapacity.ts @@ -87,7 +87,7 @@ class PremisesCharacteristicAvailability extends Factory ({ diff --git a/server/testutils/factories/cas1PremisesDaySummary.ts b/server/testutils/factories/cas1PremisesDaySummary.ts new file mode 100644 index 000000000..1f91efb56 --- /dev/null +++ b/server/testutils/factories/cas1PremisesDaySummary.ts @@ -0,0 +1,23 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker/locale/en_GB' + +import type { Cas1PremisesDaySummary } from '@approved-premises/api' +import { addDays } from 'date-fns' +import { DateFormats } from '../../utils/dateUtils' +import { cas1PremiseCapacityForDayFactory } from './cas1PremiseCapacity' + +export default Factory.define(({ params }) => { + const forDate = params.forDate ? DateFormats.isoToDateObj(params.forDate) : faker.date.anytime() + const capacity = cas1PremiseCapacityForDayFactory.build({ + date: DateFormats.dateObjToIsoDate(forDate), + }) + // const spaceBookings = cas1SpaceBookingDaySummaryFactory.buildList(20) + // TODO: Add the spacebookings and lost beds lists + + return { + forDate: DateFormats.dateObjToIsoDate(forDate), + previousDate: DateFormats.dateObjToIsoDate(addDays(forDate, -1)), + nextDate: DateFormats.dateObjToIsoDate(addDays(forDate, 1)), + capacity, + } as Cas1PremisesDaySummary +}) diff --git a/server/testutils/factories/index.ts b/server/testutils/factories/index.ts index 405c5c9ca..f3f728128 100644 --- a/server/testutils/factories/index.ts +++ b/server/testutils/factories/index.ts @@ -93,6 +93,7 @@ import cas1NewDepartureFactory from './cas1NewDeparture' import cas1SpaceBookingDepartureFactory from './cas1SpaceBookingDeparture' import cas1KeyworkerAllocationFactory from './cas1KeyworkerAllocation' import cas1NewSpaceBookingCancellationFactory from './cas1NewSpaceBookingCancellation' +import cas1PremisesDaySummaryFactory from './cas1PremisesDaySummary' export { acctAlertFactory, @@ -121,6 +122,7 @@ export { cas1PremiseCapacityFactory, cas1PremiseCapacityForDayFactory, cas1PremisesFactory, + cas1PremisesDaySummaryFactory, cas1ReferenceDataFactory, cas1SpaceBookingDatesFactory, cas1SpaceBookingDepartureFactory, diff --git a/server/utils/match/occupancy.test.ts b/server/utils/match/occupancy.test.ts index b7073da3f..4bb359b4e 100644 --- a/server/utils/match/occupancy.test.ts +++ b/server/utils/match/occupancy.test.ts @@ -88,7 +88,7 @@ describe('dayAvailabilityStatus', () => { characteristicAvailability: [ premiseCharacteristicAvailability.available().build({ characteristic: 'isSuitedForSexOffenders' }), premiseCharacteristicAvailability.available().build({ characteristic: 'isSingle' }), - premiseCharacteristicAvailability.overbooked().build({ characteristic: 'hasEnSuite' }), + premiseCharacteristicAvailability.overbookedOrFull().build({ characteristic: 'hasEnSuite' }), ], }) @@ -106,7 +106,7 @@ describe('dayAvailabilityStatus', () => { characteristicAvailability: [ premiseCharacteristicAvailability.available().build({ characteristic: 'isSuitedForSexOffenders' }), premiseCharacteristicAvailability.available().build({ characteristic: 'isSingle' }), - premiseCharacteristicAvailability.overbooked().build({ characteristic: 'hasEnSuite' }), + premiseCharacteristicAvailability.overbookedOrFull().build({ characteristic: 'hasEnSuite' }), ], }) diff --git a/server/utils/premises/occupancy.test.ts b/server/utils/premises/occupancy.test.ts index 4bf9626e4..2a09a1232 100644 --- a/server/utils/premises/occupancy.test.ts +++ b/server/utils/premises/occupancy.test.ts @@ -1,7 +1,14 @@ import type { SelectOption } from '@approved-premises/ui' -import { cas1PremiseCapacityFactory } from '../../testutils/factories' -import { durationSelectOptions, occupancyCalendar } from './occupancy' +import { type Cas1SpaceBookingCharacteristic } from '@approved-premises/api' +import { + cas1PremiseCapacityFactory, + cas1PremiseCapacityForDayFactory, + cas1PremisesDaySummaryFactory, +} from '../../testutils/factories' +import { daySummaryRows, durationSelectOptions, generateDaySummaryText, occupancyCalendar } from './occupancy' import { DateFormats } from '../dateUtils' +import { occupancyCriteriaMap } from '../match/occupancy' +import { premiseCharacteristicAvailability } from '../../testutils/factories/cas1PremiseCapacity' describe('apOccupancy utils', () => { describe('occupancyCalendar', () => { @@ -16,7 +23,7 @@ describe('apOccupancy utils', () => { let expectedStatus = availableBedCount < bookingCount ? 'overbooked' : 'available' expectedStatus = availableBedCount === bookingCount ? 'full' : expectedStatus expect(day).toEqual({ - link: `/manage/premises/test-premises-id/occupancy/day?date=${date}`, + link: `/manage/premises/test-premises-id/occupancy/day/${date}`, availability: availableBedCount - bookingCount, booked: bookingCount, name: DateFormats.isoDateToUIDate(date, { format: 'longNoYear' }), @@ -48,4 +55,82 @@ describe('apOccupancy utils', () => { expect(durationSelectOptions('27')).toEqual(durationOptions) }) }) + + describe('generateDaySummaryText', () => { + const buildDaySummary = (overbookedCharacteristics: Array, overbook = false) => { + const characteristicAvailability = Object.keys(occupancyCriteriaMap).map(characteristic => + overbookedCharacteristics.includes(characteristic as Cas1SpaceBookingCharacteristic) + ? premiseCharacteristicAvailability + .strictlyOverbooked() + .build({ characteristic: characteristic as Cas1SpaceBookingCharacteristic }) + : premiseCharacteristicAvailability + .available() + .build({ characteristic: characteristic as Cas1SpaceBookingCharacteristic }), + ) + const capacityForDay = overbook + ? cas1PremiseCapacityForDayFactory.overbooked().build({ + characteristicAvailability, + }) + : cas1PremiseCapacityForDayFactory.available().build({ + characteristicAvailability, + }) + return cas1PremisesDaySummaryFactory.build({ + capacity: capacityForDay, + }) + } + + it('should generate the text for an premises day with an overbooking on a single characteristic', () => { + const characteristicAvailability = buildDaySummary(['isSingle']) + expect(generateDaySummaryText(characteristicAvailability)).toEqual( + 'This AP is overbooked on spaces with the following criterion: single room.', + ) + }) + + it('should generate the text for an premises day with an overbooking on multiple characteristics', () => { + const characteristicAvailability = buildDaySummary(['isSingle', 'isArsonSuitable', 'isWheelchairDesignated']) + expect(generateDaySummaryText(characteristicAvailability)).toEqual( + 'This AP is overbooked on spaces with the following criteria: wheelchair accessible, single room, designated arson room.', + ) + }) + + it('should generate the text for an premises day with an overall overbooking but no overbooked characteristics', () => { + const characteristicAvailability = buildDaySummary([], true) + expect(generateDaySummaryText(characteristicAvailability)).toEqual('This AP has bookings exceeding its available capacity.') + }) + + it('should generate the text for an premises day with an overall overbooking and overbooked characteristics', () => { + const characteristicAvailability = buildDaySummary(['isSingle', 'isArsonSuitable'], true) + expect(generateDaySummaryText(characteristicAvailability)).toEqual( + 'This AP has bookings exceeding its available capacity and is overbooked on spaces with the following criteria: single room, designated arson room.', + ) + }) + + it('should generate empty text for an premises day with no overbooked characteristics', () => { + const characteristicAvailability = buildDaySummary([]) + expect(generateDaySummaryText(characteristicAvailability)).toEqual('') + }) + }) + + describe('daySummaryRows', () => { + it('should generate a list of day summary rows', () => { + const capacityForDay = cas1PremiseCapacityForDayFactory.build({ + totalBedCount: 20, + availableBedCount: 18, + bookingCount: 6, + }) + const daySummary = cas1PremisesDaySummaryFactory.build({ + capacity: capacityForDay, + }) + const expected = [ + { key: { text: 'Capacity' }, value: { text: '20' } }, + { key: { text: 'Booked spaces' }, value: { text: '6' } }, + { key: { text: 'Out of service beds' }, value: { text: '2' } }, + { key: { text: 'Available spaces' }, value: { text: '12' } }, + ] + + expect(daySummaryRows(daySummary)).toEqual({ + rows: expected, + }) + }) + }) }) diff --git a/server/utils/premises/occupancy.ts b/server/utils/premises/occupancy.ts index f96370398..783489190 100644 --- a/server/utils/premises/occupancy.ts +++ b/server/utils/premises/occupancy.ts @@ -1,7 +1,9 @@ -import type { Cas1PremiseCapacityForDay } from '@approved-premises/api' +import { Cas1PremiseCapacityForDay, Cas1PremisesDaySummary } from '@approved-premises/api' import { SelectOption } from '@approved-premises/ui' import { DateFormats } from '../dateUtils' -import paths from '../../paths/manage' +import { occupancyCriteriaMap } from '../match/occupancy' +import managePaths from '../../paths/manage' +import { summaryListItem } from '../formUtils' type CalendarDayStatus = 'available' | 'full' | 'overbooked' @@ -41,7 +43,7 @@ export const occupancyCalendar = (capacity: Array, pr status, availability, booked: bookingCount, - link: `${paths.premises.occupancy.day({ premisesId })}?date=${date}`, + link: managePaths.premises.occupancy.day({ premisesId, date }), } currentMonth.days.push(calendarDay) @@ -64,3 +66,35 @@ export const durationSelectOptions = (durationDays?: string): Array { + const { + capacity: { characteristicAvailability, availableBedCount, bookingCount }, + } = daySummary + const overbookedCriteria = characteristicAvailability + .map(({ characteristic, availableBedsCount, bookingsCount }) => + bookingsCount > availableBedsCount ? characteristic : undefined, + ) + .filter(Boolean) + const messages: Array = [] + if (bookingCount > availableBedCount) messages.push('has bookings exceeding its available capacity') + if (overbookedCriteria.length) + messages.push( + `is overbooked on spaces with the following ${overbookedCriteria.length > 1 ? 'criteria' : 'criterion'}: ${overbookedCriteria.map(characteristic => occupancyCriteriaMap[characteristic].toLowerCase()).join(', ')}`, + ) + return messages.length ? `This AP ${messages.join(' and ')}.` : '' +} + +export const daySummaryRows = (daySummary: Cas1PremisesDaySummary) => { + const { + capacity: { totalBedCount, bookingCount, availableBedCount }, + } = daySummary + return { + rows: [ + summaryListItem('Capacity', String(totalBedCount)), + summaryListItem('Booked spaces', String(bookingCount)), + summaryListItem('Out of service beds', String(totalBedCount - availableBedCount)), + summaryListItem('Available spaces', String(availableBedCount - bookingCount)), + ], + } +} diff --git a/server/views/manage/premises/occupancy/dayView.njk b/server/views/manage/premises/occupancy/dayView.njk new file mode 100644 index 000000000..22ae4e992 --- /dev/null +++ b/server/views/manage/premises/occupancy/dayView.njk @@ -0,0 +1,49 @@ +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "../../../partials/showErrorSummary.njk" import showErrorSummary %} +{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "../../../partials/showErrorSummary.njk" import showErrorSummary %} + +{%- from "moj/components/identity-bar/macro.njk" import mojIdentityBar -%} + +{% extends "../../../partials/layout.njk" %} + +{% set pageTitle = applicationName + " - " + pageHeading %} +{% set mainClasses = "app-container govuk-body" %} + +{% block beforeContent %} + {{ govukBackLink({ + text: "Back", + href: backLink + }) }} +{% endblock %} + +{% set titleHtml %} + +{% endset %} + +{% block content %} + {{ premises.name }} +

{{ pageHeading }}

+
+ + Previous day + + + Next day + +
+ {% if daySummaryText %} + {{ govukNotificationBanner({ + text: daySummaryText, + classes: 'govuk-notification-banner--full-width-content' + }) }} + {% endif %} + {{ govukSummaryList(daySummaryRows) }} + +{% endblock %} + +