diff --git a/assets/js/establishment-roll.js b/assets/js/establishment-roll.js new file mode 100644 index 0000000..7a874b8 --- /dev/null +++ b/assets/js/establishment-roll.js @@ -0,0 +1,49 @@ +const landingRows = document.querySelectorAll('.establishment-roll__table__landing-row') +const spurRows = document.querySelectorAll('.establishment-roll__table__spur-row') +const wingRows = document.querySelectorAll('.establishment-roll__table__wing-row') +const totalsRow = document.querySelector('#roll-table-totals-row') + +function init() { + ;[...landingRows, ...spurRows].forEach(row => { + row.setAttribute('hidden', 'hidden') + }) + + wingRows.forEach((wingRow, index) => { + const wingId = wingRow.getAttribute('id') + const wingNameCell = wingRow.getElementsByTagName('td')[0] + const wingNameText = wingNameCell.innerText + const childRows = document.querySelectorAll('[data-wing-id="' + wingId + '"]') + const childrenIds = [...childRows].map(row => row.getAttribute('id')) + + const wingLink = document.createElement('a') + wingLink.setAttribute('href', '#') + wingLink.setAttribute('class', 'govuk-details__summary govuk-link--no-visited-state') + wingLink.setAttribute('aria-controls', childrenIds.join(' ')) + wingLink.innerHTML = wingNameText + + wingLink.addEventListener('click', function (event) { + event.preventDefault() + const nextRow = wingRows[index + 1] ? wingRows[index + 1] : totalsRow + + childRows.forEach(row => { + const isOpen = !row.getAttribute('hidden') + + if (isOpen) { + row.setAttribute('hidden', 'hidden') + wingLink.setAttribute('aria-expanded', 'false') + wingRow.classList.remove('open') + nextRow.classList.remove('next-wing-to-open') + } else { + row.removeAttribute('hidden') + wingLink.setAttribute('aria-expanded', 'true') + wingRow.classList.add('open') + nextRow.classList.add('next-wing-to-open') + } + }) + }) + + wingNameCell.replaceChildren(wingLink) + }) +} + +init() diff --git a/assets/scss/pages/_establishment-roll.scss b/assets/scss/pages/_establishment-roll.scss index 0a4748c..fc2073c 100644 --- a/assets/scss/pages/_establishment-roll.scss +++ b/assets/scss/pages/_establishment-roll.scss @@ -23,6 +23,7 @@ } &__table { + border-collapse: separate; tbody { tr:last-child { * { @@ -30,5 +31,80 @@ } } } + + &__wing-row { + td { + padding: 13px 51px 7px 0; + } + + td:first-of-type { + a[aria-expanded='true']:before { + display: block; + width: 0; + height: 0; + -webkit-clip-path: polygon(0 0, 50% 100%, 100% 0); + clip-path: polygon(0 0, 50% 100%, 100% 0); + border-color: transparent; + border-style: solid; + border-width: 12.124px 7px 0; + border-top-color: inherit; + } + } + &.open { + td { + border-bottom: none; + } + } + + + } + + &__landing-row { + font-size: 16px; + + td:first-of-type { + display: block; + margin-left: 46px; + border-collapse: separate; + + a { + color: govuk-colour('blue'); + } + } + + &.last-in-group { + td { + border-bottom: none; + margin-bottom: 10px; + } + } + + } + + &__spur-row { + font-size: 16px; + + td:first-of-type { + display: block; + margin-left: 30px; + border-collapse: separate; + font-weight: bold; + padding-left: 15px; + font-size: 19px; + padding-top: 8px; + } + + td { + background-color: govuk-colour('light-grey'); + border-bottom: none; + padding-top: 10px; + } + } + + tr.next-wing-to-open { + td { + border-top: 1px solid govuk-colour('mid-grey'); + } + } } } \ No newline at end of file diff --git a/integration_tests/e2e/establishmentRoll.cy.ts b/integration_tests/e2e/establishmentRoll.cy.ts index dec63c2..6a3b0a5 100644 --- a/integration_tests/e2e/establishmentRoll.cy.ts +++ b/integration_tests/e2e/establishmentRoll.cy.ts @@ -1,6 +1,7 @@ import Page from '../pages/page' import EstablishmentRollPage from '../pages/EstablishmentRoll' import { Role } from '../../server/enums/role' +import { assignedRollCountWithSpursMock } from '../../server/mocks/rollCountMock' context('Establishment Roll Page', () => { beforeEach(() => { @@ -11,7 +12,7 @@ context('Establishment Roll Page', () => { { caseloadFunction: '', caseLoadId: 'LEI', currentlyActive: true, description: 'Leeds (HMP)', type: '' }, ], }) - cy.task('stubRollCount') + cy.task('stubRollCount', { payload: assignedRollCountWithSpursMock, query: '?wingOnly=false' }) cy.task('stubRollCountUnassigned') cy.task('stubMovements') cy.task('stubEnrouteRollCount') @@ -28,8 +29,8 @@ context('Establishment Roll Page', () => { context('Outage Banner', () => { it('should display todays stats', () => { const page = Page.verifyOnPage(EstablishmentRollPage) - page.todaysStats().unlockRoll().should('contain.text', '1015') - page.todaysStats().currentPopulation().should('contain.text', '1023') + page.todaysStats().unlockRoll().should('contain.text', '1815') + page.todaysStats().currentPopulation().should('contain.text', '1823') page.todaysStats().arrivedToday().should('contain.text', '17') page.todaysStats().inReception().should('contain.text', '23') page.todaysStats().stillToArrive().should('contain.text', '1') @@ -37,29 +38,55 @@ context('Establishment Roll Page', () => { page.todaysStats().noCellAllocated().should('contain.text', '31') }) - it('should display a table row for each assignedRollCount', () => { + it('should display a table row for each wing level assignedRollCount', () => { const page = Page.verifyOnPage(EstablishmentRollPage) - page.assignedRollCountFirstRow().should('have.length', 6) + page.assignedRollCountRows().should('have.length', 6) - page.assignedRollCountFirstRow().first().find('td').eq(0).should('contain.text', 'A') - page.assignedRollCountFirstRow().first().find('td').eq(1).should('contain.text', '76') - page.assignedRollCountFirstRow().first().find('td').eq(2).should('contain.text', '900') - page.assignedRollCountFirstRow().first().find('td').eq(3).should('contain.text', '5') - page.assignedRollCountFirstRow().first().find('td').eq(4).should('contain.text', '60') - page.assignedRollCountFirstRow().first().find('td').eq(5).should('contain.text', '-16') - page.assignedRollCountFirstRow().first().find('td').eq(6).should('contain.text', '0') + page.assignedRollCountRows().first().find('td').eq(0).should('contain.text', 'A') + page.assignedRollCountRows().first().find('td').eq(1).should('contain.text', '76') + page.assignedRollCountRows().first().find('td').eq(2).should('contain.text', '900') + page.assignedRollCountRows().first().find('td').eq(3).should('contain.text', '5') + page.assignedRollCountRows().first().find('td').eq(4).should('contain.text', '60') + page.assignedRollCountRows().first().find('td').eq(5).should('contain.text', '-16') + page.assignedRollCountRows().first().find('td').eq(6).should('contain.text', '0') }) it('should display a table row for totals', () => { const page = Page.verifyOnPage(EstablishmentRollPage) - page.assignedRollCountFirstRow().last().find('td').eq(0).should('contain.text', 'Totals') - page.assignedRollCountFirstRow().last().find('td').eq(1).should('contain.text', '332') - page.assignedRollCountFirstRow().last().find('td').eq(2).should('contain.text', '1000') - page.assignedRollCountFirstRow().last().find('td').eq(3).should('contain.text', '5') - page.assignedRollCountFirstRow().last().find('td').eq(4).should('contain.text', '312') - page.assignedRollCountFirstRow().last().find('td').eq(5).should('contain.text', '-20') - page.assignedRollCountFirstRow().last().find('td').eq(6).should('contain.text', '0') + page.assignedRollCountRows().last().find('td').eq(0).should('contain.text', 'Totals') + page.assignedRollCountRows().last().find('td').eq(1).should('contain.text', '152') + page.assignedRollCountRows().last().find('td').eq(2).should('contain.text', '1800') + page.assignedRollCountRows().last().find('td').eq(3).should('contain.text', '10') + page.assignedRollCountRows().last().find('td').eq(4).should('contain.text', '120') + page.assignedRollCountRows().last().find('td').eq(5).should('contain.text', '-32') + page.assignedRollCountRows().last().find('td').eq(6).should('contain.text', '0') + }) + + it('should reveal spurs and landings when click on link', () => { + const page = Page.verifyOnPage(EstablishmentRollPage) + + page.assignedRollCountRows().eq(0).find('td').eq(0).should('contain.text', 'A').should('be.visible') + page.assignedRollCountRows().eq(1).find('td').eq(0).should('contain.text', 'Spur A1').should('not.be.visible') + page.assignedRollCountRows().eq(2).find('td').eq(0).should('contain.text', 'Landing A1X').should('not.be.visible') + page.assignedRollCountRows().eq(3).find('td').eq(0).should('contain.text', 'B').should('be.visible') + page.assignedRollCountRows().eq(4).find('td').eq(0).should('contain.text', 'LANDING BY').should('not.be.visible') + + const wing1Reveal = page.assignedRollCountRows().eq(0).find('td').eq(0).find('a') + wing1Reveal.click() + page.assignedRollCountRows().eq(1).find('td').eq(0).should('be.visible') + page.assignedRollCountRows().eq(2).find('td').eq(0).should('be.visible') + + wing1Reveal.click() + page.assignedRollCountRows().eq(1).find('td').eq(0).should('not.be.visible') + page.assignedRollCountRows().eq(2).find('td').eq(0).should('not.be.visible') + + const wing2Reveal = page.assignedRollCountRows().eq(3).find('td').eq(0).find('a') + wing2Reveal.click() + page.assignedRollCountRows().eq(4).find('td').eq(0).should('be.visible') + + wing2Reveal.click() + page.assignedRollCountRows().eq(4).find('td').eq(0).should('not.be.visible') }) }) }) diff --git a/integration_tests/e2e/homepage.cy.ts b/integration_tests/e2e/homepage.cy.ts index 521b44a..3f236bf 100644 --- a/integration_tests/e2e/homepage.cy.ts +++ b/integration_tests/e2e/homepage.cy.ts @@ -154,7 +154,7 @@ context('Homepage - no active caseload', () => { { caseloadFunction: '', caseLoadId: 'MOR', currentlyActive: false, description: 'Moorland', type: '' }, ], }) - cy.task('stubRollCount', 'MOR') + cy.task('stubRollCount', { prisonCode: 'MOR' }) cy.task('stubRollCountUnassigned', 'MOR') cy.task('stubMovements', 'MOR') cy.task('stubWhatsNewPosts') diff --git a/integration_tests/mockApis/prison.ts b/integration_tests/mockApis/prison.ts index 4f2ba8d..c05a333 100644 --- a/integration_tests/mockApis/prison.ts +++ b/integration_tests/mockApis/prison.ts @@ -39,18 +39,18 @@ export default { }) }, - stubRollCount: (prisonCode = 'LEI') => { + stubRollCount: ({ prisonCode = 'LEI', payload = assignedRollCountMock, query = '' } = {}) => { return stubFor({ request: { method: 'GET', - url: `/prison/api/movements/rollcount/${prisonCode}`, + url: `/prison/api/movements/rollcount/${prisonCode}${query}`, }, response: { status: 200, headers: { 'Content-Type': 'application/json;charset=UTF-8', }, - jsonBody: assignedRollCountMock, + jsonBody: payload, }, }) }, diff --git a/integration_tests/pages/EstablishmentRoll.ts b/integration_tests/pages/EstablishmentRoll.ts index b758b93..5b4603a 100644 --- a/integration_tests/pages/EstablishmentRoll.ts +++ b/integration_tests/pages/EstablishmentRoll.ts @@ -17,5 +17,5 @@ export default class EstablishmentRollPage extends Page { noCellAllocated: (): PageElement => cy.get('[data-qa=no-cell-allocated]'), }) - assignedRollCountFirstRow = (): PageElement => cy.get('table.establishment-roll__table tbody tr') + assignedRollCountRows = (): PageElement => cy.get('table.establishment-roll__table tbody tr') } diff --git a/server/data/interfaces/prisonApiClient.ts b/server/data/interfaces/prisonApiClient.ts index 2b3c953..2bc70c3 100644 --- a/server/data/interfaces/prisonApiClient.ts +++ b/server/data/interfaces/prisonApiClient.ts @@ -8,7 +8,7 @@ import { OffenderCell } from './offenderCell' export interface PrisonApiClient { getUserCaseLoads(): Promise getUserLocations(): Promise - getRollCount(options: { prisonId: string; unassigned?: boolean }): Promise + getRollCount(prisonId: string, options?: { unassigned?: boolean; wingOnly?: boolean }): Promise getEnrouteRollCount(prisonId: string): Promise getLocationsForPrison(prisonId: string): Promise getAttributesForLocation(locationId: number): Promise diff --git a/server/data/prisonApiClient.ts b/server/data/prisonApiClient.ts index fc5f26d..268f11f 100644 --- a/server/data/prisonApiClient.ts +++ b/server/data/prisonApiClient.ts @@ -1,3 +1,4 @@ +import * as querystring from 'querystring' import RestClient from './restClient' import { PrisonApiClient } from './interfaces/prisonApiClient' import { CaseLoad } from './interfaces/caseLoad' @@ -26,10 +27,13 @@ export default class PrisonApiRestClient implements PrisonApiClient { return this.get({ path: '/api/users/me/locations' }) } - getRollCount({ prisonId, unassigned }: { prisonId: string; unassigned?: boolean }): Promise { + getRollCount( + prisonId: string, + queryOptions: { unassigned?: boolean; wingOnly?: boolean } = {}, + ): Promise { return this.get({ path: `/api/movements/rollcount/${prisonId}`, - query: unassigned ? 'unassigned=true' : '', + query: querystring.stringify(queryOptions), }) } diff --git a/server/mocks/rollCountMock.ts b/server/mocks/rollCountMock.ts index 1c07d0d..fe8901f 100644 --- a/server/mocks/rollCountMock.ts +++ b/server/mocks/rollCountMock.ts @@ -119,3 +119,82 @@ export const unassignedRollCountMock: BlockRollCount[] = [ outOfOrder: 0, }, ] + +export const assignedRollCountWithSpursMock: BlockRollCount[] = [ + { + currentlyInCell: 900, + outOfLivingUnits: 0, + livingUnitId: 1, + fullLocationPath: 'CKI-A', + locationCode: 'A', + livingUnitDesc: 'A', + bedsInUse: 76, + currentlyOut: 5, + operationalCapacity: 60, + netVacancies: -16, + maximumCapacity: 97, + availablePhysical: 21, + outOfOrder: 0, + }, + { + currentlyInCell: 900, + outOfLivingUnits: 0, + livingUnitId: 2, + parentLocationId: 1, + fullLocationPath: 'CKI-A-1', + locationCode: 'A1', + livingUnitDesc: 'Spur A1', + bedsInUse: 76, + currentlyOut: 5, + operationalCapacity: 60, + netVacancies: -16, + maximumCapacity: 97, + availablePhysical: 21, + outOfOrder: 0, + }, + { + currentlyInCell: 900, + outOfLivingUnits: 0, + livingUnitId: 3, + parentLocationId: 2, + fullLocationPath: 'CKI-A-1-x', + locationCode: 'A1X', + livingUnitDesc: 'Landing A1X', + bedsInUse: 76, + currentlyOut: 5, + operationalCapacity: 60, + netVacancies: -16, + maximumCapacity: 97, + availablePhysical: 21, + outOfOrder: 0, + }, + { + currentlyInCell: 900, + outOfLivingUnits: 0, + livingUnitId: 4, + fullLocationPath: 'CKI-B', + locationCode: 'B', + livingUnitDesc: 'B', + bedsInUse: 76, + currentlyOut: 5, + operationalCapacity: 60, + netVacancies: -16, + maximumCapacity: 97, + availablePhysical: 21, + outOfOrder: 0, + }, + { + currentlyInCell: 900, + outOfLivingUnits: 0, + livingUnitId: 5, + parentLocationId: 4, + fullLocationPath: 'CKI-Y', + locationCode: 'BY', + livingUnitDesc: 'LANDING BY', + bedsInUse: 76, + currentlyOut: 5, + maximumCapacity: 97, + availablePhysical: 21, + outOfOrder: 0, + }, +] diff --git a/server/services/establishmentRollService.test.ts b/server/services/establishmentRollService.test.ts index efba548..5678b3b 100644 --- a/server/services/establishmentRollService.test.ts +++ b/server/services/establishmentRollService.test.ts @@ -1,6 +1,6 @@ import EstablishmentRollService from './establishmentRollService' import prisonApiClientMock from '../test/mocks/prisonApiClientMock' -import { assignedRollCountMock, unassignedRollCountMock } from '../mocks/rollCountMock' +import { assignedRollCountWithSpursMock, unassignedRollCountMock } from '../mocks/rollCountMock' describe('establishmentRollService', () => { let establishmentRollService: EstablishmentRollService @@ -9,7 +9,7 @@ describe('establishmentRollService', () => { establishmentRollService = new EstablishmentRollService(() => prisonApiClientMock) prisonApiClientMock.getRollCount = jest .fn() - .mockResolvedValueOnce(assignedRollCountMock) + .mockResolvedValueOnce(assignedRollCountWithSpursMock) .mockResolvedValueOnce(unassignedRollCountMock) prisonApiClientMock.getMovements = jest.fn().mockResolvedValue({ in: 4, out: 5 }) prisonApiClientMock.getEnrouteRollCount = jest.fn().mockResolvedValue(3) @@ -17,16 +17,22 @@ describe('establishmentRollService', () => { prisonApiClientMock.getAttributesForLocation = jest.fn().mockResolvedValue({ noOfOccupants: 31 }) }) - it('should return establishment roll counts', async () => { + it('should return nested establishment roll counts', async () => { const establishmentRollCounts = await establishmentRollService.getEstablishmentRollCounts('token', 'LEI') - expect(establishmentRollCounts.assignedRollBlocksCounts).toEqual(assignedRollCountMock) + expect(establishmentRollCounts.assignedRollBlocksCounts).toEqual([ + { + ...assignedRollCountWithSpursMock[0], + spurs: [{ ...assignedRollCountWithSpursMock[1], landings: [assignedRollCountWithSpursMock[2]] }], + }, + { ...assignedRollCountWithSpursMock[3], landings: [assignedRollCountWithSpursMock[4]] }, + ]) }) it('should calculate unlock roll', async () => { const establishmentRollCounts = await establishmentRollService.getEstablishmentRollCounts('token', 'LEI') - expect(establishmentRollCounts.todayStats.unlockRoll).toEqual(1024) + expect(establishmentRollCounts.todayStats.unlockRoll).toEqual(1824) }) it('should return inToday', async () => { @@ -50,7 +56,7 @@ describe('establishmentRollService', () => { it('should return currentRoll by summing currentlyInCell, outOfLivingUnits an unassignedIn', async () => { const establishmentRollCounts = await establishmentRollService.getEstablishmentRollCounts('token', 'LEI') - expect(establishmentRollCounts.todayStats.currentRoll).toEqual(1023) + expect(establishmentRollCounts.todayStats.currentRoll).toEqual(1823) }) it('should return enroute count from api', async () => { @@ -75,25 +81,25 @@ describe('establishmentRollService', () => { it('should return total of currentlyOut from assigned counts', async () => { const establishmentRollCounts = await establishmentRollService.getEstablishmentRollCounts('token', 'LEI') - expect(establishmentRollCounts.todayStats.totalCurrentlyOut).toEqual(5) + expect(establishmentRollCounts.todayStats.totalCurrentlyOut).toEqual(10) }) it('should return total of bedsInUse from assigned counts', async () => { const establishmentRollCounts = await establishmentRollService.getEstablishmentRollCounts('token', 'LEI') - expect(establishmentRollCounts.todayStats.bedsInUse).toEqual(332) + expect(establishmentRollCounts.todayStats.bedsInUse).toEqual(152) }) it('should return total of currentlyInCell from assigned counts', async () => { const establishmentRollCounts = await establishmentRollService.getEstablishmentRollCounts('token', 'LEI') - expect(establishmentRollCounts.todayStats.currentlyInCell).toEqual(1000) + expect(establishmentRollCounts.todayStats.currentlyInCell).toEqual(1800) }) it('should return total of netVacancies from assigned counts', async () => { const establishmentRollCounts = await establishmentRollService.getEstablishmentRollCounts('token', 'LEI') - expect(establishmentRollCounts.todayStats.netVacancies).toEqual(-20) + expect(establishmentRollCounts.todayStats.netVacancies).toEqual(-32) }) it('should return total of outOfOrder from assigned counts', async () => { diff --git a/server/services/establishmentRollService.ts b/server/services/establishmentRollService.ts index a9f71fa..5e86d10 100644 --- a/server/services/establishmentRollService.ts +++ b/server/services/establishmentRollService.ts @@ -1,7 +1,8 @@ import { RestClientBuilder } from '../data' import { PrisonApiClient } from '../data/interfaces/prisonApiClient' import { BlockRollCount } from '../data/interfaces/blockRollCount' -import EstablishmentRollCount from './interfaces/EstablishmentRollCount' +import EstablishmentRollCount from './interfaces/establishmentRollService/EstablishmentRollCount' +import nestRollBlocks, { splitRollBlocks } from './utils/nestRollBlocks' const getTotals = (array: BlockRollCount[], figure: keyof BlockRollCount): number => array.reduce((accumulator, block) => accumulator + ((block[figure] as number) || 0), 0) @@ -13,8 +14,8 @@ export default class EstablishmentRollService { const prisonApi = this.prisonApiClientBuilder(clientToken) const [assignedRollBlocksCounts, unassignedRollBlocksCount, movementsCount, enrouteCount, caseLoadLocations] = await Promise.all([ - prisonApi.getRollCount({ prisonId: caseLoadId, unassigned: false }), - prisonApi.getRollCount({ prisonId: caseLoadId, unassigned: true }), + prisonApi.getRollCount(caseLoadId, { wingOnly: false }), + prisonApi.getRollCount(caseLoadId, { unassigned: true }), prisonApi.getMovements(caseLoadId), prisonApi.getEnrouteRollCount(caseLoadId), prisonApi.getLocationsForPrison(caseLoadId), @@ -26,11 +27,14 @@ export default class EstablishmentRollService { ? await prisonApi.getAttributesForLocation(cellSwapLocation.locationId) : { noOfOccupants: 0 } + const wingsSpursLandingsAssigned = splitRollBlocks(assignedRollBlocksCounts) + const assignedWingsRollCount = wingsSpursLandingsAssigned.wings + const unassignedIn = getTotals(unassignedRollBlocksCount, 'currentlyInCell') + getTotals(unassignedRollBlocksCount, 'outOfLivingUnits') const currentRoll = - getTotals(assignedRollBlocksCounts, 'currentlyInCell') + - getTotals(assignedRollBlocksCounts, 'outOfLivingUnits') + + getTotals(assignedWingsRollCount, 'currentlyInCell') + + getTotals(assignedWingsRollCount, 'outOfLivingUnits') + unassignedIn return { @@ -42,14 +46,14 @@ export default class EstablishmentRollService { unassignedIn, enroute: enrouteCount, noCellAllocated: cellSwapDetails?.noOfOccupants ?? 0, - totalCurrentlyOut: getTotals(assignedRollBlocksCounts, 'currentlyOut') ?? 0, - bedsInUse: getTotals(assignedRollBlocksCounts, 'bedsInUse') ?? 0, - currentlyInCell: getTotals(assignedRollBlocksCounts, 'currentlyInCell') ?? 0, - operationalCapacity: getTotals(assignedRollBlocksCounts, 'operationalCapacity') ?? 0, - netVacancies: getTotals(assignedRollBlocksCounts, 'netVacancies') ?? 0, - outOfOrder: getTotals(assignedRollBlocksCounts, 'outOfOrder') ?? 0, + totalCurrentlyOut: getTotals(assignedWingsRollCount, 'currentlyOut') ?? 0, + bedsInUse: getTotals(assignedWingsRollCount, 'bedsInUse') ?? 0, + currentlyInCell: getTotals(assignedWingsRollCount, 'currentlyInCell') ?? 0, + operationalCapacity: getTotals(assignedWingsRollCount, 'operationalCapacity') ?? 0, + netVacancies: getTotals(assignedWingsRollCount, 'netVacancies') ?? 0, + outOfOrder: getTotals(assignedWingsRollCount, 'outOfOrder') ?? 0, }, - assignedRollBlocksCounts, + assignedRollBlocksCounts: nestRollBlocks(wingsSpursLandingsAssigned), } } } diff --git a/server/services/homepageService.test.ts b/server/services/homepageService.test.ts index aad6819..1a38840 100644 --- a/server/services/homepageService.test.ts +++ b/server/services/homepageService.test.ts @@ -23,7 +23,7 @@ describe('Homepage service', () => { beforeEach(() => { prisonApiClient = { ...prisonApiClientMock, - getRollCount: jest.fn(async ({ unassigned }) => { + getRollCount: jest.fn(async (_prisonId: string, { unassigned } = {}) => { if (unassigned) { return unassignedRollCountMock } @@ -41,8 +41,8 @@ describe('Homepage service', () => { it('should return today data', async () => { const todayData = await service.getTodaySection(token, activeCaseLoadId) - expect(prisonApiClient.getRollCount).toHaveBeenCalledWith({ prisonId: activeCaseLoadId }) - expect(prisonApiClient.getRollCount).toHaveBeenCalledWith({ prisonId: activeCaseLoadId, unassigned: true }) + expect(prisonApiClient.getRollCount).toHaveBeenCalledWith(activeCaseLoadId) + expect(prisonApiClient.getRollCount).toHaveBeenCalledWith(activeCaseLoadId, { unassigned: true }) expect(prisonApiClient.getMovements).toHaveBeenCalled() expect(todayData).toEqual({ ...todayDataMock, @@ -53,7 +53,7 @@ describe('Homepage service', () => { it('Should add people outside of the living unit to the totals', async () => { const assignedRollCountWithLivingUnits = assignedRollCountMock.map(i => ({ ...i, outOfLivingUnits: 10 })) const unassignedRollCountWithLivingUnits = unassignedRollCountMock.map(i => ({ ...i, outOfLivingUnits: 10 })) - prisonApiClient.getRollCount = jest.fn(async ({ unassigned }) => { + prisonApiClient.getRollCount = jest.fn(async (_prisonId: string, { unassigned } = {}) => { if (unassigned) { return unassignedRollCountWithLivingUnits } diff --git a/server/services/homepageService.ts b/server/services/homepageService.ts index ca7e76e..38fa679 100644 --- a/server/services/homepageService.ts +++ b/server/services/homepageService.ts @@ -12,8 +12,8 @@ export default class HomepageService { public async getTodaySection(clientToken: string, activeCaseLoadId: string) { const [assignedRollCount, unassignedRollCount, movements] = await Promise.all([ - this.prisonApiClientBuilder(clientToken).getRollCount({ prisonId: activeCaseLoadId }), - this.prisonApiClientBuilder(clientToken).getRollCount({ prisonId: activeCaseLoadId, unassigned: true }), + this.prisonApiClientBuilder(clientToken).getRollCount(activeCaseLoadId), + this.prisonApiClientBuilder(clientToken).getRollCount(activeCaseLoadId, { unassigned: true }), this.prisonApiClientBuilder(clientToken).getMovements(activeCaseLoadId), ]) diff --git a/server/services/interfaces/EstablishmentRollCount.ts b/server/services/interfaces/establishmentRollService/EstablishmentRollCount.ts similarity index 53% rename from server/services/interfaces/EstablishmentRollCount.ts rename to server/services/interfaces/establishmentRollService/EstablishmentRollCount.ts index d3419ce..1836c9d 100644 --- a/server/services/interfaces/EstablishmentRollCount.ts +++ b/server/services/interfaces/establishmentRollService/EstablishmentRollCount.ts @@ -1,4 +1,4 @@ -import { BlockRollCount } from '../../data/interfaces/blockRollCount' +import { BlockRollCount } from '../../../data/interfaces/blockRollCount' export default interface EstablishmentRollCount { todayStats: { @@ -16,5 +16,16 @@ export default interface EstablishmentRollCount { netVacancies: number outOfOrder: number } - assignedRollBlocksCounts: BlockRollCount[] + assignedRollBlocksCounts: Wing[] +} + +export interface Landing extends BlockRollCount {} + +export interface Spur extends BlockRollCount { + landings?: BlockRollCount[] +} + +export interface Wing extends BlockRollCount { + spurs?: BlockRollCount[] + landings?: BlockRollCount[] } diff --git a/server/services/utils/nestRollBlocks.test.ts b/server/services/utils/nestRollBlocks.test.ts new file mode 100644 index 0000000..3aa1a96 --- /dev/null +++ b/server/services/utils/nestRollBlocks.test.ts @@ -0,0 +1,129 @@ +import { BlockRollCount } from '../../data/interfaces/blockRollCount' +import nestRollBlocks, { splitRollBlocks } from './nestRollBlocks' + +describe('nestRollBlocks', () => { + describe('when there are wings and landings', () => { + const blocks: Partial[] = [ + { livingUnitId: 1 }, + { livingUnitId: 2 }, + { livingUnitId: 3, parentLocationId: 1 }, + { livingUnitId: 4, parentLocationId: 1 }, + { livingUnitId: 5, parentLocationId: 2 }, + { livingUnitId: 6, parentLocationId: 2 }, + ] + + it('should nest the landings withing the parent wing', () => { + const splitBlocks = splitRollBlocks(blocks as BlockRollCount[]) + const response = nestRollBlocks(splitBlocks) + expect(response).toEqual([ + { + livingUnitId: 1, + landings: [ + { livingUnitId: 3, parentLocationId: 1 }, + { livingUnitId: 4, parentLocationId: 1 }, + ], + }, + { + livingUnitId: 2, + landings: [ + { livingUnitId: 5, parentLocationId: 2 }, + { livingUnitId: 6, parentLocationId: 2 }, + ], + }, + ]) + }) + }) + + describe('when there are wings, spurs and landings', () => { + const blocks: Partial[] = [ + { livingUnitId: 1 }, + { livingUnitId: 2 }, + { livingUnitId: 3, parentLocationId: 1 }, + { livingUnitId: 4, parentLocationId: 1 }, + { livingUnitId: 5, parentLocationId: 2 }, + { livingUnitId: 6, parentLocationId: 2 }, + { livingUnitId: 7, parentLocationId: 3 }, + { livingUnitId: 8, parentLocationId: 4 }, + { livingUnitId: 9, parentLocationId: 5 }, + { livingUnitId: 10, parentLocationId: 6 }, + ] + + it('should nest the wings, spurs and landings', () => { + const splitBlocks = splitRollBlocks(blocks as BlockRollCount[]) + const response = nestRollBlocks(splitBlocks) + expect(response).toEqual([ + { + livingUnitId: 1, + spurs: [ + { + livingUnitId: 3, + parentLocationId: 1, + landings: [{ livingUnitId: 7, parentLocationId: 3 }], + }, + { + livingUnitId: 4, + parentLocationId: 1, + landings: [{ livingUnitId: 8, parentLocationId: 4 }], + }, + ], + }, + { + livingUnitId: 2, + spurs: [ + { + livingUnitId: 5, + parentLocationId: 2, + landings: [{ livingUnitId: 9, parentLocationId: 5 }], + }, + { + livingUnitId: 6, + parentLocationId: 2, + landings: [{ livingUnitId: 10, parentLocationId: 6 }], + }, + ], + }, + ]) + }) + }) + + describe('when there is a mixture of wings, spurs and landings', () => { + const blocks: Partial[] = [ + { livingUnitId: 1 }, + { livingUnitId: 2 }, + { livingUnitId: 3, parentLocationId: 1 }, + { livingUnitId: 4, parentLocationId: 1 }, + { livingUnitId: 5, parentLocationId: 2 }, + { livingUnitId: 6, parentLocationId: 2 }, + { livingUnitId: 7, parentLocationId: 5 }, + { livingUnitId: 8, parentLocationId: 5 }, + ] + + it('should nest the wings, spurs and landings', () => { + const splitBlocks = splitRollBlocks(blocks as BlockRollCount[]) + const response = nestRollBlocks(splitBlocks) + expect(response).toEqual([ + { + livingUnitId: 1, + landings: [ + { livingUnitId: 3, parentLocationId: 1 }, + { livingUnitId: 4, parentLocationId: 1 }, + ], + }, + { + livingUnitId: 2, + spurs: [ + { + livingUnitId: 5, + parentLocationId: 2, + landings: [ + { livingUnitId: 7, parentLocationId: 5 }, + { livingUnitId: 8, parentLocationId: 5 }, + ], + }, + ], + landings: [{ livingUnitId: 6, parentLocationId: 2 }], + }, + ]) + }) + }) +}) diff --git a/server/services/utils/nestRollBlocks.ts b/server/services/utils/nestRollBlocks.ts new file mode 100644 index 0000000..37ba948 --- /dev/null +++ b/server/services/utils/nestRollBlocks.ts @@ -0,0 +1,38 @@ +import { BlockRollCount } from '../../data/interfaces/blockRollCount' +import { Landing, Spur, Wing } from '../interfaces/establishmentRollService/EstablishmentRollCount' + +const blockHasParent = (block: BlockRollCount) => !!block.parentLocationId +const blockHasChildren = (block: BlockRollCount, allBlocks: BlockRollCount[]) => + !!allBlocks.find(b => b.parentLocationId === block.livingUnitId) + +interface WingsSpursLandings { + wings: Wing[] + spurs: Spur[] + landings: Landing[] +} + +export const splitRollBlocks = (rollBlocks: BlockRollCount[]): WingsSpursLandings => { + return rollBlocks.reduce( + (acc, block) => { + if (!blockHasParent(block)) return { ...acc, wings: [...acc.wings, { ...block }] } + if (blockHasChildren(block, rollBlocks)) return { ...acc, spurs: [...acc.spurs, { ...block }] } + return { ...acc, landings: [...acc.landings, { ...block }] } + }, + { wings: [], spurs: [], landings: [] }, + ) +} + +export default ({ wings, spurs, landings }: WingsSpursLandings): Wing[] => { + const spursWithLandings = spurs.map(spur => { + const spurLandings = landings.filter(landing => landing.parentLocationId === spur.livingUnitId) + return { ...spur, landings: spurLandings } + }) + + return wings.map(wing => { + const wingSpurs = spursWithLandings.filter(spur => spur.parentLocationId === wing.livingUnitId) + const wingWithSpurs = wingSpurs.length ? { ...wing, spurs: wingSpurs } : wing + + const wingLandings = landings.filter(landing => landing.parentLocationId === wing.livingUnitId) + return wingLandings.length ? { ...wingWithSpurs, landings: wingLandings } : wingWithSpurs + }) +} diff --git a/server/views/macros/printLink.njk b/server/views/macros/printLink.njk index c6b291b..68b8184 100644 --- a/server/views/macros/printLink.njk +++ b/server/views/macros/printLink.njk @@ -1,6 +1,6 @@ {% macro printLink(linkText = 'Print this page', align = "left") %} diff --git a/server/views/pages/establishmentRoll.njk b/server/views/pages/establishmentRoll.njk index ccb1332..89b68fa 100644 --- a/server/views/pages/establishmentRoll.njk +++ b/server/views/pages/establishmentRoll.njk @@ -13,6 +13,25 @@ } ] %} +{% macro blockRow(block, type, wing, lastInGroup) %} + + {{ block.livingUnitDesc }} + {{ block.bedsInUse }} + {{ block.currentlyInCell }} + + {% if block.currentlyOut > 0 %} + {{block.currentlyOut}} + {% else %} 0 {% endif %} + + {{ block.operationalCapacity }} + {{ block.netVacancies }} + {{ block.outOfOrder }} + +{% endmacro %} + {% block content %}
@@ -111,22 +130,22 @@ - {% for block in establishmentRollCounts.assignedRollBlocksCounts %} - - {{ block.livingUnitDesc }} - {{ block.bedsInUse }} - {{ block.currentlyInCell }} - - {% if block.currentlyOut > 0 %} - {{block.currentlyOut}} - {% else %} 0 {% endif %} - - {{ block.operationalCapacity }} - {{ block.netVacancies }} - {{ block.outOfOrder }} - + {% for wing in establishmentRollCounts.assignedRollBlocksCounts %} + {{ blockRow(block=wing) }} + + {% for spur in wing.spurs %} + {{ blockRow(block=spur, type='SPUR', wing=wing.livingUnitId) }} + {% for landing in spur.landings %} + {{ blockRow(block=landing, type='LANDING', wing=wing.livingUnitId, lastInGroup=loop.index === spur.landings.length) }} + {% endfor %} + {% endfor %} + + + {% for landing in wing.landings %} + {{ blockRow(block=landing, type='LANDING', wing=wing.livingUnitId) }} + {% endfor %} {% endfor %} - + Totals {{ todayStats.bedsInUse }} {{ todayStats.currentlyInCell }} @@ -143,3 +162,7 @@
{% endblock %} + +{% block pageScripts %} + +{% endblock %} \ No newline at end of file diff --git a/server/views/partials/homepage/today.njk b/server/views/partials/homepage/today.njk index e3325b6..846edca 100644 --- a/server/views/partials/homepage/today.njk +++ b/server/views/partials/homepage/today.njk @@ -10,7 +10,7 @@

Current population

{{ currentPopulationCount }}
diff --git a/server/views/partials/layout.njk b/server/views/partials/layout.njk index 1f9468b..5a6dde8 100644 --- a/server/views/partials/layout.njk +++ b/server/views/partials/layout.njk @@ -81,6 +81,7 @@ + {% endblock %} {% block footer %}