diff --git a/integration_tests/pages/manage/index.ts b/integration_tests/pages/manage/index.ts index a76d4cafe2..0a368f61c6 100644 --- a/integration_tests/pages/manage/index.ts +++ b/integration_tests/pages/manage/index.ts @@ -10,6 +10,7 @@ import NewDateChangePage from './booking/dateChanges/new' import BookingShowPage from './booking/show' import DepartureDateChangeConfirmationPage from './booking/dateChanges/confirmation' import DepartureDateChangePage from './booking/dateChanges/create' +import OccupancyViewPage from './occupancyView' export { CancellationCreatePage, @@ -22,4 +23,5 @@ export { WithdrawConfirmPage, UnableToMatchPage, PremisesListPage, + OccupancyViewPage, } diff --git a/integration_tests/pages/manage/occupancyView.ts b/integration_tests/pages/manage/occupancyView.ts new file mode 100644 index 0000000000..c7995163e6 --- /dev/null +++ b/integration_tests/pages/manage/occupancyView.ts @@ -0,0 +1,50 @@ +import type { Cas1PremiseCapacity, Cas1PremisesSummary } from '@approved-premises/api' +import Page from '../page' +import { DateFormats, daysToWeeksAndDays } from '../../../server/utils/dateUtils' +import paths from '../../../server/paths/manage' + +export default class OccupancyViewPage extends Page { + constructor(private pageTitle: string) { + super(pageTitle) + } + + static visit(premises: Cas1PremisesSummary): OccupancyViewPage { + cy.visit(paths.premises.occupancy.view({ premisesId: premises.id })) + return new OccupancyViewPage(`View spaces in ${premises.name}`) + } + + static visitUnauthorised(premises: Cas1PremisesSummary): OccupancyViewPage { + cy.visit(paths.premises.occupancy.view({ premisesId: premises.id }), { + failOnStatusCode: false, + }) + return new OccupancyViewPage(`Authorisation Error`) + } + + shouldShowCalendarHeading(startDate: string, durationDays: number): void { + const calendarTitle = `Showing ${DateFormats.formatDuration(daysToWeeksAndDays(String(durationDays)))} from ${DateFormats.isoDateToUIDate(startDate, { format: 'short' })}` + cy.contains(calendarTitle) + } + + shouldShowCalendar(premisesCapacity: Cas1PremiseCapacity): void { + cy.get('#calendar-key').within(() => { + cy.contains('Available') + cy.contains('Full') + cy.contains('Overbooked') + }) + cy.get('#calendar').find('li').should('have.length', premisesCapacity.capacity.length) + cy.get('#calendar') + .find('li') + .each((day, index) => { + const dayCapacity = premisesCapacity.capacity[index] + const expectedClass = { '-1': 'govuk-tag--red', '0': 'govuk-tag--yellow', '1': '' }[ + String(Math.sign(dayCapacity.availableBedCount - dayCapacity.bookingCount)) + ] + cy.wrap(day).within(() => { + cy.contains(`${dayCapacity.bookingCount} booked`) + cy.contains(`${dayCapacity.availableBedCount - dayCapacity.bookingCount} available`) + cy.contains(DateFormats.isoDateToUIDate(dayCapacity.date, { format: 'longNoYear' })) + }) + if (expectedClass) cy.wrap(day).should('have.class', expectedClass) + }) + } +} diff --git a/integration_tests/pages/page.ts b/integration_tests/pages/page.ts index 3b9cf44739..9b37fbd633 100644 --- a/integration_tests/pages/page.ts +++ b/integration_tests/pages/page.ts @@ -192,6 +192,11 @@ export default abstract class Page { cy.get('[role="menuitem"]').contains(actionLabel).click() } + actionShouldNotExist(actionLabel: string): void { + cy.get('.moj-button-menu > button').contains('Actions').click() + cy.get('[role="menuitem"]').contains(actionLabel).should('not.exist') + } + actionMenuShouldNotExist(): void { cy.get('.moj-button-menu > button').should('not.exist') } diff --git a/integration_tests/tests/manage/occupancyView.cy.ts b/integration_tests/tests/manage/occupancyView.cy.ts new file mode 100644 index 0000000000..1bbac11eaf --- /dev/null +++ b/integration_tests/tests/manage/occupancyView.cy.ts @@ -0,0 +1,161 @@ +import { addDays } from 'date-fns' +import { + cas1PremiseCapacityFactory, + cas1PremisesSummaryFactory, + cas1SpaceBookingSummaryFactory, + staffMemberFactory, +} from '../../../server/testutils/factories' + +import { OccupancyViewPage, PremisesShowPage } from '../../pages/manage' + +import { signIn } from '../signIn' +import { DateFormats } from '../../../server/utils/dateUtils' +import Page from '../../pages/page' + +context('Premises occupancy', () => { + describe('show', () => { + const startDateObj = new Date() + const endDateObj = new Date(startDateObj) + endDateObj.setDate(endDateObj.getDate() + 84) + + const startDate = DateFormats.dateObjToIsoDate(startDateObj) + const endDate = DateFormats.dateObjToIsoDate(endDateObj) + const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate, endDate }) + + const premises = premisesCapacity.premise + const placements = cas1SpaceBookingSummaryFactory.buildList(30) + const keyworkers = staffMemberFactory.keyworker().buildList(5) + + beforeEach(() => { + cy.task('reset') + + // Given there is a premises in the database + cy.task('stubSinglePremises', premises) + cy.task('stubPremisesStaffMembers', { premisesId: premises.id, staffMembers: keyworkers }) + + // And it has a list of upcoming placements + cy.task('stubSpaceBookingSummaryList', { premisesId: premises.id, placements, residency: 'upcoming' }) + cy.task('stubPremiseCapacity', { premisesId: premises.id, startDate, endDate, premiseCapacity: premisesCapacity }) + }) + + 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 next 12 weeks if navigated from premises page', () => { + // When I visit premises details page + const page = PremisesShowPage.visit(premises) + + // And I click the action link view spaces + page.clickAction('View spaces') + // Then I should navigate to the occupancy view + const occPage = Page.verifyOnPage(OccupancyViewPage, `View spaces in ${premises.name}`) + occPage.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) + occPage.shouldShowCalendar(premisesCapacity) + }) + + it('should allow the user to change the calendar duration', () => { + const endDate26 = DateFormats.dateObjToIsoDate(addDays(startDateObj, 7 * 26)) + cy.task('stubPremiseCapacity', { + premisesId: premises.id, + startDate, + endDate: endDate26, + premiseCapacity: premisesCapacity, + }) + // When I visit the occupancy view page + const page = OccupancyViewPage.visit(premises) + // Then I should be shown the default period + page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) + // When I select a different duration + page.getSelectInputByIdAndSelectAnEntry('durationDays', '26 weeks') + // and click submit + page.clickSubmit() + // Then the duration should change + page.shouldShowCalendarHeading(startDate, 26 * 7) + // and the new duration should be selected + page.shouldHaveSelectText('durationDays', '26 weeks') + }) + + it('should allow the user to change the start date', () => { + const newStartDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5)) + const newEndDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5 + 12 * 7)) + + cy.task('stubPremiseCapacity', { + premisesId: premises.id, + startDate: newStartDate, + endDate: newEndDate, + premiseCapacity: premisesCapacity, + }) + // When I visit the occupancy view page + const page = OccupancyViewPage.visit(premises) + // Then I should be shown the default period + page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) + // When I select a different start date + page.clearAndCompleteDateInputs('startDate', newStartDate) + // and click submit + page.clickSubmit() + // Then the start date should change + page.shouldShowCalendarHeading(newStartDate, 12 * 7) + // and the new duration should be selected + page.shouldHaveSelectText('durationDays', '12 weeks') + // and the start date should be populated + page.dateInputsShouldContainDate('startDate', newStartDate) + }) + + it('should validate the start date', () => { + const newStartDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5)) + const newEndDate = DateFormats.dateObjToIsoDate(addDays(startDateObj, 5 + 12 * 7)) + const badStartDate = '2023-02-29' + + cy.task('stubPremiseCapacity', { + premisesId: premises.id, + startDate: newStartDate, + endDate: newEndDate, + premiseCapacity: premisesCapacity, + }) + // When I visit the occupancy view page + const page = OccupancyViewPage.visit(premises) + // Then I should be shown the default period + page.shouldShowCalendarHeading(startDate, DateFormats.differenceInDays(endDateObj, startDateObj).number) + // When I select a bad start date and submit + page.clearAndCompleteDateInputs('startDate', badStartDate) + page.clickSubmit() + // Then an error should be shown + page.shouldShowErrorMessagesForFields(['startDate'], { + startDate: 'Enter a valid date', + }) + // And the bad date I entered should be populated + page.dateInputsShouldContainDate('startDate', badStartDate) + // And the calendar should be blank + cy.get('.calendar').should('not.exist') + // When I select a different start date and click submit + page.clearAndCompleteDateInputs('startDate', newStartDate) + page.clickSubmit() + // Then the new start date should be shown + page.shouldHaveSelectText('durationDays', '12 weeks') + page.dateInputsShouldContainDate('startDate', newStartDate) + }) + + it('should not be available in the premises actions menu if the premises does not support space bookings', () => { + // Given that I am looking at a premises that does not support space bookings + const nonSpaceBookingPremises = cas1PremisesSummaryFactory.build({ supportsSpaceBookings: false }) + cy.task('stubSinglePremises', nonSpaceBookingPremises) + // When I visit premises details page + const page = PremisesShowPage.visit(nonSpaceBookingPremises) + // Then the view spaces action should not be shown + page.actionShouldNotExist('View spaces') + }) + }) + 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 + OccupancyViewPage.visitUnauthorised(premises) + }) + }) + }) +}) diff --git a/integration_tests/tests/manage/premises.cy.ts b/integration_tests/tests/manage/premises.cy.ts index 9c4d9e5ad2..c30d695280 100644 --- a/integration_tests/tests/manage/premises.cy.ts +++ b/integration_tests/tests/manage/premises.cy.ts @@ -225,7 +225,7 @@ context('Premises', () => { describe('without placement list view permission', () => { beforeEach(() => { // Given I am logged in as a user without placement list view permission - signIn(['future_manager']) + signIn(['future_manager'], ['cas1_premises_view']) }) it('should not show the placements section', () => { diff --git a/server/controllers/manage/index.ts b/server/controllers/manage/index.ts index 5fdfe1f18d..a5bfb261aa 100644 --- a/server/controllers/manage/index.ts +++ b/server/controllers/manage/index.ts @@ -7,6 +7,7 @@ import DateChangesController from './dateChangesController' import type { Services } from '../../services' import PremisesController from './premises/premisesController' +import ApOccupancyViewController from './premises/apOccupancyViewController' import PlacementController from './placementController' import BedsController from './premises/bedsController' import OutOfServiceBedsController from './outOfServiceBedsController' @@ -45,6 +46,7 @@ export const controllers = (services: Services) => { const nonArrivalsController = new NonArrivalsController(services.premisesService, services.placementService) const keyworkerController = new KeyworkerController(services.premisesService, services.placementService) const departuresController = new DeparturesController(services.premisesService, services.placementService) + const apOccupancyViewController = new ApOccupancyViewController(services.premisesService) return { premisesController, @@ -60,6 +62,7 @@ export const controllers = (services: Services) => { cancellationsController, placementController, keyworkerController, + apOccupancyViewController, } } @@ -75,4 +78,5 @@ export { UpdateOutOfServiceBedsController, BookingsController, BookingExtensionsController, + ApOccupancyViewController, } diff --git a/server/controllers/manage/premises/apOccupancyViewController.test.ts b/server/controllers/manage/premises/apOccupancyViewController.test.ts new file mode 100644 index 0000000000..d34426cc26 --- /dev/null +++ b/server/controllers/manage/premises/apOccupancyViewController.test.ts @@ -0,0 +1,114 @@ +import type { Cas1PremisesSummary } 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, cas1PremisesSummaryFactory } from '../../../testutils/factories' + +import paths from '../../../paths/manage' +import { occupancyCalendar } from '../../../utils/premises/occupancy' +import { DateFormats } from '../../../utils/dateUtils' + +describe('AP occupancyViewController', () => { + const token = 'TEST_TOKEN' + const premisesId = 'some-uuid' + + let request: DeepMocked + let response: DeepMocked = createMock({}) + const next: DeepMocked = createMock({}) + + const premisesService = createMock({}) + const occupancyViewController = new ApOccupancyViewController(premisesService) + + beforeEach(() => { + jest.resetAllMocks() + request = createMock({ user: { token }, params: { premisesId }, flash: jest.fn() }) + response = createMock({ locals: { user: { permissions: ['cas1_space_booking_list'] } } }) + + jest.useFakeTimers() + jest.setSystemTime(new Date('2024-01-01')) + }) + + describe('view', () => { + const mockPremises = async (startDate: string = DateFormats.dateObjToIsoDate(new Date())) => { + const premisesSummary: Cas1PremisesSummary = cas1PremisesSummaryFactory.build({ id: premisesId }) + const premisesCapacity = cas1PremiseCapacityFactory.build({ startDate }) + premisesService.getCapacity.mockResolvedValue(premisesCapacity) + premisesService.find.mockResolvedValue(premisesSummary) + + const requestHandler = occupancyViewController.view() + await requestHandler(request, response, next) + + return { + premisesSummary, + premisesCapacity, + } + } + + it('should render the premises occupancy view with default date and duration', async () => { + const startDate = '2024-01-01' + const endDate = '2024-03-25' + const { premisesSummary, premisesCapacity } = await mockPremises(startDate) + + expect(response.render).toHaveBeenCalledWith( + 'manage/premises/occupancy/view', + expect.objectContaining({ + pageHeading: `View spaces in ${premisesSummary.name}`, + calendarHeading: 'Showing 12 weeks from 1 Jan 2024', + premises: premisesSummary, + backLink: paths.premises.show({ premisesId }), + calendar: occupancyCalendar(premisesCapacity.capacity, premisesId), + }), + ) + expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) + expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, startDate, endDate) + }) + + it('should render the premises occupancy view with specified valid date and duration', async () => { + request = createMock({ + user: { token }, + params: { premisesId }, + flash: jest.fn(), + query: { 'startDate-year': '2024', 'startDate-month': '06', 'startDate-day': '20', durationDays: '7' }, + }) + const { premisesSummary, premisesCapacity } = await mockPremises('2024-06-20') + + expect(response.render).toHaveBeenCalledWith( + 'manage/premises/occupancy/view', + expect.objectContaining({ + calendarHeading: 'Showing 1 week from 20 Jun 2024', + premises: premisesSummary, + backLink: paths.premises.show({ premisesId }), + calendar: occupancyCalendar(premisesCapacity.capacity, premisesId), + errorSummary: [], + }), + ) + expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) + expect(premisesService.getCapacity).toHaveBeenCalledWith(token, premisesId, '2024-06-20', '2024-06-27') + }) + + it('should render error if date is invalid', async () => { + request = createMock({ + user: { token }, + params: { premisesId }, + flash: jest.fn(), + query: { 'startDate-year': '2023', 'startDate-month': '02', 'startDate-day': '29', durationDays: '7' }, + }) + const { premisesSummary } = await mockPremises('2024-06-20') + + expect(response.render).toHaveBeenCalledWith( + 'manage/premises/occupancy/view', + expect.objectContaining({ + premises: premisesSummary, + backLink: paths.premises.show({ premisesId }), + calendar: [], + errorSummary: [{ text: 'Enter a valid date', href: '#startDate' }], + }), + ) + expect(premisesService.find).toHaveBeenCalledWith(token, premisesId) + expect(premisesService.getCapacity).not.toHaveBeenCalled() + }) + }) +}) diff --git a/server/controllers/manage/premises/apOccupancyViewController.ts b/server/controllers/manage/premises/apOccupancyViewController.ts new file mode 100644 index 0000000000..6a4b259212 --- /dev/null +++ b/server/controllers/manage/premises/apOccupancyViewController.ts @@ -0,0 +1,64 @@ +import type { Request, RequestHandler, Response } from 'express' + +import { ObjectWithDateParts } from '@approved-premises/ui' +import { PremisesService } from '../../../services' + +import paths from '../../../paths/manage' +import { Calendar, durationSelectOptions, occupancyCalendar } from '../../../utils/premises/occupancy' +import { DateFormats, dateAndTimeInputsAreValidDates, daysToWeeksAndDays } from '../../../utils/dateUtils' +import { placementDates } from '../../../utils/match' +import { fetchErrorsAndUserInput, generateErrorMessages, generateErrorSummary } from '../../../utils/validation' + +export default class ApOccupancyViewController { + constructor(private readonly premisesService: PremisesService) {} + + view(): RequestHandler { + return async (req: Request, res: Response) => { + const { token } = req.user + const { premisesId } = req.params + const { errors, errorSummary } = fetchErrorsAndUserInput(req) + let startDate + if (req.query.durationDays) { + if (dateAndTimeInputsAreValidDates(req.query as ObjectWithDateParts<'startDate'>, 'startDate')) { + startDate = DateFormats.dateAndTimeInputsToIsoString( + req.query as ObjectWithDateParts<'startDate'>, + 'startDate', + ).startDate + } else { + const dateError = { startDate: 'Enter a valid date' } + Object.assign(errors, generateErrorMessages(dateError)) + errorSummary.push(generateErrorSummary(dateError)[0]) + startDate = DateFormats.dateObjToIsoDate(new Date()) + } + } + startDate = startDate || DateFormats.dateObjToIsoDate(new Date()) + const { durationDays = '84', ...startDateParts } = req.query + const premises = await this.premisesService.find(req.user.token, premisesId) + let calendar: Calendar = [] + if (!errorSummary.length) { + const capacityDates = placementDates(String(startDate), String(durationDays)) + const capacity = await this.premisesService.getCapacity( + token, + premisesId, + capacityDates.startDate, + capacityDates.endDate, + ) + calendar = occupancyCalendar(capacity.capacity, premisesId) + } + const calendarHeading = `Showing ${DateFormats.formatDuration(daysToWeeksAndDays(String(durationDays)))} from ${DateFormats.isoDateToUIDate(startDate, { format: 'short' })}` + return res.render('manage/premises/occupancy/view', { + pageHeading: `View spaces in ${premises.name}`, + premises, + calendar, + backLink: paths.premises.show({ premisesId }), + calendarHeading, + ...DateFormats.isoDateToDateInputs(startDate, 'startDate'), + ...startDateParts, + selfPath: paths.premises.occupancy.view({ premisesId }), + durationOptions: durationSelectOptions(String(durationDays)), + errors, + errorSummary, + }) + } + } +} diff --git a/server/paths/manage.ts b/server/paths/manage.ts index 785a642075..6bfab871c1 100644 --- a/server/paths/manage.ts +++ b/server/paths/manage.ts @@ -93,6 +93,10 @@ const paths = { create: placementCancellationsPath.path('create'), }, }, + occupancy: { + view: singlePremisesPath.path('occupancy'), + day: singlePremisesPath.path('occupancy/day'), + }, }, bookings: { diff --git a/server/routes/manage.test.ts b/server/routes/manage.test.ts index b78e9a4ac3..fb55e83ecc 100644 --- a/server/routes/manage.test.ts +++ b/server/routes/manage.test.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { DeepMocked, createMock } from '@golevelup/ts-jest' import { + ApOccupancyViewController, ArrivalsController, BedsController, BookingExtensionsController, @@ -44,6 +45,7 @@ describe('manage routes', () => { const cancellationsController: DeepMocked = createMock({}) const redirectController: DeepMocked = createMock({}) const keyworkerController: DeepMocked = createMock({}) + const apOccupancyViewController: DeepMocked = createMock({}) const controllers: DeepMocked = createMock({ bookingExtensionsController, @@ -60,6 +62,7 @@ describe('manage routes', () => { redirectController, placementController, keyworkerController, + apOccupancyViewController, }) const services: DeepMocked = createMock({}) diff --git a/server/routes/manage.ts b/server/routes/manage.ts index 07f887519e..010b0a1657 100644 --- a/server/routes/manage.ts +++ b/server/routes/manage.ts @@ -26,6 +26,7 @@ export default function routes(controllers: Controllers, router: Router, service cancellationsController, redirectController, keyworkerController, + apOccupancyViewController, } = controllers // Deprecated paths, redirect to v2 equivalent @@ -239,6 +240,12 @@ export default function routes(controllers: Controllers, router: Router, service allowedPermissions: ['cas1_space_booking_withdraw'], }) + // Occupancy + get(paths.premises.occupancy.view.pattern, apOccupancyViewController.view(), { + auditEvent: 'VIEW_OCCUPANCY', + allowedPermissions: ['cas1_premises_view'], + }) + // Bookings get(paths.bookings.show.pattern, bookingsController.show(), { auditEvent: 'SHOW_BOOKING', diff --git a/server/testutils/factories/cas1PremisesSummary.ts b/server/testutils/factories/cas1PremisesSummary.ts index 43a9fec26f..f2335807e2 100644 --- a/server/testutils/factories/cas1PremisesSummary.ts +++ b/server/testutils/factories/cas1PremisesSummary.ts @@ -6,7 +6,7 @@ import { apAreaFactory } from './referenceData' export default Factory.define(() => ({ id: faker.string.uuid(), - name: `${faker.person}`, + name: `${faker.location.direction()} ${faker.location.city()}`, postcode: faker.location.zipCode(), apCode: `${faker.string.alpha(2)}`, bedCount: 50, diff --git a/server/utils/premises/occupancy.test.ts b/server/utils/premises/occupancy.test.ts new file mode 100644 index 0000000000..4bf9626e4e --- /dev/null +++ b/server/utils/premises/occupancy.test.ts @@ -0,0 +1,51 @@ +import type { SelectOption } from '@approved-premises/ui' +import { cas1PremiseCapacityFactory } from '../../testutils/factories' +import { durationSelectOptions, occupancyCalendar } from './occupancy' +import { DateFormats } from '../dateUtils' + +describe('apOccupancy utils', () => { + describe('occupancyCalendar', () => { + it('converts the premises capacity to a calendar', () => { + const capacity = cas1PremiseCapacityFactory.build({ startDate: '2024-12-01', endDate: '2024-12-07' }) + const premisesId = 'test-premises-id' + const calendar = occupancyCalendar(capacity.capacity, premisesId) + calendar.forEach(month => { + expect(month.name).toEqual('December 2024') + month.days.forEach((day, index) => { + const { date, availableBedCount, bookingCount } = capacity.capacity[index] + let expectedStatus = availableBedCount < bookingCount ? 'overbooked' : 'available' + expectedStatus = availableBedCount === bookingCount ? 'full' : expectedStatus + expect(day).toEqual({ + link: `/manage/premises/test-premises-id/occupancy/day?date=${date}`, + availability: availableBedCount - bookingCount, + booked: bookingCount, + name: DateFormats.isoDateToUIDate(date, { format: 'longNoYear' }), + status: expectedStatus, + }) + }) + }) + }) + }) + const durationOptions: Array = [ + { selected: undefined, text: '1 week', value: '7' }, + { selected: undefined, text: '6 weeks', value: '42' }, + { selected: undefined, text: '12 weeks', value: '84' }, + { selected: undefined, text: '26 weeks', value: '182' }, + { selected: undefined, text: '52 weeks', value: '364' }, + ] + + describe('durationSelectOptions', () => { + it('should return the set of duration periods', () => { + expect(durationSelectOptions()).toEqual(durationOptions) + }) + + it('should select the option matching the supplied duration', () => { + expect(durationSelectOptions('26')).toEqual( + durationOptions.map(option => (option.value === '26' ? { ...option, selected: true } : option)), + ) + }) + it('should not select an option if there is no matching value', () => { + expect(durationSelectOptions('27')).toEqual(durationOptions) + }) + }) +}) diff --git a/server/utils/premises/occupancy.ts b/server/utils/premises/occupancy.ts new file mode 100644 index 0000000000..f963703985 --- /dev/null +++ b/server/utils/premises/occupancy.ts @@ -0,0 +1,66 @@ +import type { Cas1PremiseCapacityForDay } from '@approved-premises/api' +import { SelectOption } from '@approved-premises/ui' +import { DateFormats } from '../dateUtils' +import paths from '../../paths/manage' + +type CalendarDayStatus = 'available' | 'full' | 'overbooked' + +type CalendarDay = { + name: string + status: CalendarDayStatus + availability: number + booked: number + criteriaBookableCount?: number + link: string +} +type CalendarMonth = { + name: string + days: Array +} +export type Calendar = Array + +export const occupancyCalendar = (capacity: Array, premisesId: string): Calendar => { + return capacity.reduce((calendar, { availableBedCount, bookingCount, date }) => { + const monthAndYear = DateFormats.isoDateToMonthAndYear(date) + let currentMonth = calendar.find(month => month.name === monthAndYear) + + if (!currentMonth) { + currentMonth = { + name: monthAndYear, + days: [], + } + calendar.push(currentMonth) + } + + const availability = availableBedCount - bookingCount + const statusMap: Record = { '-1': 'overbooked', '0': 'full', '1': 'available' } + const status: CalendarDayStatus = statusMap[String(Math.sign(availability))] + + const calendarDay: CalendarDay = { + name: DateFormats.isoDateToUIDate(date, { format: 'longNoYear' }), + status, + availability, + booked: bookingCount, + link: `${paths.premises.occupancy.day({ premisesId })}?date=${date}`, + } + + currentMonth.days.push(calendarDay) + + return calendar + }, []) +} + +const durationOptionsMap: Record = { + '7': '1 week', + '42': '6 weeks', + '84': '12 weeks', + '182': '26 weeks', + '364': '52 weeks', +} + +export const durationSelectOptions = (durationDays?: string): Array => + Object.entries(durationOptionsMap).map(([value, label]) => ({ + value, + text: label, + selected: value === durationDays || undefined, + })) diff --git a/server/utils/premises/premisesActions.test.ts b/server/utils/premises/premisesActions.test.ts index ff46206709..f953e6fbd1 100644 --- a/server/utils/premises/premisesActions.test.ts +++ b/server/utils/premises/premisesActions.test.ts @@ -1,4 +1,4 @@ -import { premisesFactory } from '../../testutils/factories' +import { cas1PremisesSummaryFactory } from '../../testutils/factories' import { premisesActions } from './premisesActions' import paths from '../../paths/manage' import userDetails from '../../testutils/factories/userDetails' @@ -6,7 +6,7 @@ import userDetails from '../../testutils/factories/userDetails' describe('premisesActions', () => { describe('for users with the role "workflow_manager"', () => { const user = userDetails.build({ roles: ['workflow_manager'], permissions: ['cas1_adhoc_booking_create'] }) - const premises = premisesFactory.build() + const premises = cas1PremisesSummaryFactory.build() it('does NOT include the OUT OF SERVICE BEDS action', () => { expect(premisesActions(user, premises)).not.toContainAction({ @@ -27,7 +27,7 @@ describe('premisesActions', () => { describe('for users with the role "future_manager"', () => { const user = userDetails.build({ roles: ['future_manager'] }) - const premises = premisesFactory.build() + const premises = cas1PremisesSummaryFactory.build() it('includes the MANAGE BEDS action', () => { expect(premisesActions(user, premises)).toContainAction({ @@ -48,7 +48,7 @@ describe('premisesActions', () => { describe('for users with no role', () => { const user = userDetails.build({ roles: [] }) - const premises = premisesFactory.build() + const premises = cas1PremisesSummaryFactory.build() it('does NOT include the OUT OF SERVICE BEDS action', () => { expect(premisesActions(user, premises)).not.toContainAction({ diff --git a/server/utils/premises/premisesActions.ts b/server/utils/premises/premisesActions.ts index 2e9338f5b9..242592a703 100644 --- a/server/utils/premises/premisesActions.ts +++ b/server/utils/premises/premisesActions.ts @@ -1,8 +1,8 @@ -import type { Premises } from '@approved-premises/api' +import type { Cas1PremisesSummary } from '@approved-premises/api' import { UserDetails } from '@approved-premises/ui' import paths from '../../paths/manage' -export const premisesActions = (user: UserDetails, premises: Premises) => { +export const premisesActions = (user: UserDetails, premises: Cas1PremisesSummary) => { const actions = [] const premisesBedsPath = paths.premises.beds.index({ premisesId: premises.id }) @@ -21,5 +21,13 @@ export const premisesActions = (user: UserDetails, premises: Premises) => { }) } + if (user.permissions.includes('cas1_premises_view') && premises.supportsSpaceBookings) { + actions.push({ + text: 'View spaces', + classes: 'govuk-button--secondary', + href: paths.premises.occupancy.view({ premisesId: premises.id }), + }) + } + return actions } diff --git a/server/views/manage/premises/occupancy/_calendar.njk b/server/views/manage/premises/occupancy/_calendar.njk new file mode 100644 index 0000000000..82f0d33657 --- /dev/null +++ b/server/views/manage/premises/occupancy/_calendar.njk @@ -0,0 +1,12 @@ +{% extends "../../../partials/_calendar.njk" %} + +{% block dayContent %} +
+ {{ day.availability }} + available +
+
+ {{ day.booked }} + booked +
+{% endblock %} \ No newline at end of file diff --git a/server/views/manage/premises/occupancy/view.njk b/server/views/manage/premises/occupancy/view.njk new file mode 100644 index 0000000000..0430bcb51e --- /dev/null +++ b/server/views/manage/premises/occupancy/view.njk @@ -0,0 +1,96 @@ +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} +{% from "govuk/components/fieldset/macro.njk" import govukFieldset %} +{% from "govuk/components/select/macro.njk" import govukSelect %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/date-input/macro.njk" import govukDateInput %} +{% from "../../../partials/showErrorSummary.njk" import showErrorSummary %} +{% from "./_calendar.njk" import occupancyCalendar %} + +{%- 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 %} + {{ showErrorSummary(errorSummary) }} + {{ premises.name }} +

{{ pageHeading }}

+ +
+
+

Filter

+ +
+ {{ govukDateInput({ + id: "startDate", + namePrefix: "startDate", + fieldset: { + legend: { + text: "Start date", + classes: "govuk-fieldset__legend--s" + } + }, + items: dateFieldValues('startDate', errors), + errorMessage: errors.startDate + }) }} + + {{ govukSelect({ + id: 'durationDays', + name: 'durationDays', + label: { + text: 'Duration', + classes: 'govuk-label--s' + }, + items: durationOptions + }) }} +
+
+ {{ govukButton({ + text: 'Apply filters', + preventDoubleClick: true + }) }} +
+
+
+ + {% if errorSummary.length === 0 %} +

{{ calendarHeading }}

+
+

Key

+ +
    +
  • + Available +
  • +
  • + Full +
  • +
  • + Overbooked +
  • +
+
+ +
+ {{ occupancyCalendar(calendar) }} +
+ {% endif %} + +{% endblock %} + + diff --git a/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk b/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk index 2e82f62d98..76c3e9d506 100644 --- a/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk +++ b/server/views/match/placementRequests/occupancyView/partials/_occupancyCalendar.njk @@ -1,41 +1,20 @@ -{% macro occupancyCalendar(calendar) %} - -{% endmacro %} +{% block dayContent %} + {% if day.status === 'available' %} +
Available
+ {% else %} + {% if day.criteriaBookableCount is defined %} +
+ {{ day.criteriaBookableCount }} + for + your criteria +
+ {% endif %} +
+ {{ day.bookableCount }} + in + total +
+ {% endif %} +{% endblock %} diff --git a/server/views/partials/_calendar.njk b/server/views/partials/_calendar.njk new file mode 100644 index 0000000000..a58edfa833 --- /dev/null +++ b/server/views/partials/_calendar.njk @@ -0,0 +1,25 @@ +{% macro occupancyCalendar(calendar) %} +
+ {% for month in calendar %} +
+

{{ month.name }}

+ + +
+ {% endfor %} +
+{% endmacro %}