From 5e0bcce79b2cbcbf3c8cdb6f69b857903a02594a Mon Sep 17 00:00:00 2001 From: Bob Meredith Date: Fri, 20 Dec 2024 13:30:45 +0000 Subject: [PATCH] APS-1677 AP occupancy view --- server/controllers/manage/index.ts | 4 + .../apOccupancyViewController.test.ts | 113 ++++++++++++++++++ .../premises/apOccupancyViewController.ts | 64 ++++++++++ server/paths/manage.ts | 4 + server/routes/manage.test.ts | 3 + server/routes/manage.ts | 4 + server/utils/premises/occupancy.test.ts | 51 ++++++++ server/utils/premises/occupancy.ts | 67 +++++++++++ server/utils/premises/premisesActions.ts | 5 + .../manage/premises/occupancy/_calendar.njk | 12 ++ .../views/manage/premises/occupancy/view.njk | 96 +++++++++++++++ .../partials/_occupancyCalendar.njk | 59 +++------ server/views/partials/_calendar.njk | 25 ++++ 13 files changed, 467 insertions(+), 40 deletions(-) create mode 100644 server/controllers/manage/premises/apOccupancyViewController.test.ts create mode 100644 server/controllers/manage/premises/apOccupancyViewController.ts create mode 100644 server/utils/premises/occupancy.test.ts create mode 100644 server/utils/premises/occupancy.ts create mode 100644 server/views/manage/premises/occupancy/_calendar.njk create mode 100644 server/views/manage/premises/occupancy/view.njk create mode 100644 server/views/partials/_calendar.njk 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..fc0faac240 --- /dev/null +++ b/server/controllers/manage/premises/apOccupancyViewController.test.ts @@ -0,0 +1,113 @@ +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({ + 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..076706ded4 --- /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', { + 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..baa468bede 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,9 @@ export default function routes(controllers: Controllers, router: Router, service allowedPermissions: ['cas1_space_booking_withdraw'], }) + // Occupancy + get(paths.premises.occupancy.view.pattern, apOccupancyViewController.view()) + // Bookings get(paths.bookings.show.pattern, bookingsController.show(), { auditEvent: 'SHOW_BOOKING', 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..58f3012009 --- /dev/null +++ b/server/utils/premises/occupancy.ts @@ -0,0 +1,67 @@ +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 + + let status: CalendarDayStatus = availability >= 0 ? 'available' : 'overbooked' + if (availability === 0) status = 'full' + + 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.ts b/server/utils/premises/premisesActions.ts index 2e9338f5b9..ee55fd163b 100644 --- a/server/utils/premises/premisesActions.ts +++ b/server/utils/premises/premisesActions.ts @@ -19,6 +19,11 @@ export const premisesActions = (user: UserDetails, premises: Premises) => { classes: 'govuk-button--secondary', href: paths.outOfServiceBeds.premisesIndex({ premisesId: premises.id, temporality: 'current' }), }) + 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..4e05730e84 --- /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 + " - " + premises.name %} +{% set mainClasses = "app-container govuk-body" %} + +{% block beforeContent %} + {{ govukBackLink({ + text: "Back", + href: backLink + }) }} +{% endblock %} + +{% set titleHtml %} + +{% endset %} + +{% block content %} + {{ showErrorSummary(errorSummary) }} + +

View spaces in {{ premises.name }}

+ +
+
+

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 %}