diff --git a/integration_tests/pages/bookings/cancelledVisitsPage.ts b/integration_tests/pages/bookings/cancelledVisitsPage.ts index d8126ece..41a7a1a9 100644 --- a/integration_tests/pages/bookings/cancelledVisitsPage.ts +++ b/integration_tests/pages/bookings/cancelledVisitsPage.ts @@ -11,5 +11,5 @@ export default class CancelledVisitsPage extends Page { visitEndTime = (index: number): PageElement => cy.get(`[data-test=visit-end-time-${index}]`) - visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-${index}]`) + visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-booking-${index}]`) } diff --git a/integration_tests/pages/bookings/index.ts b/integration_tests/pages/bookings/index.ts index 5b419830..93570f11 100644 --- a/integration_tests/pages/bookings/index.ts +++ b/integration_tests/pages/bookings/index.ts @@ -13,7 +13,7 @@ export default class BookingsPage extends Page { visitReference = (index: number): PageElement => cy.get(`[data-test=visit-reference-${index}]`) - visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-${index}]`) + visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-booking-${index}]`) pastVisitsLink = (): PageElement => cy.get('[data-test="past-visits-link"]') diff --git a/integration_tests/pages/bookings/pastVisitsPage.ts b/integration_tests/pages/bookings/pastVisitsPage.ts index 7fd1e3ac..58df70b4 100644 --- a/integration_tests/pages/bookings/pastVisitsPage.ts +++ b/integration_tests/pages/bookings/pastVisitsPage.ts @@ -11,5 +11,5 @@ export default class PastVisitsPage extends Page { visitEndTime = (index: number): PageElement => cy.get(`[data-test=visit-end-time-${index}]`) - visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-${index}]`) + visitLink = (index: number): PageElement => cy.get(`[data-test=visit-link-booking-${index}]`) } diff --git a/server/@types/orchestration-api.d.ts b/server/@types/orchestration-api.d.ts index 5249d792..7121f7d8 100644 --- a/server/@types/orchestration-api.d.ts +++ b/server/@types/orchestration-api.d.ts @@ -768,6 +768,8 @@ export interface components { | 'NOT_KNOWN' | 'NOT_APPLICABLE' | 'BY_PRISONER' + /** @description Username for user who actioned this request */ + actionedBy: string } /** * @description Contact Phone Number @@ -800,6 +802,7 @@ export interface components { | 'VISIT_ORDER_CANCELLED' | 'SUPERSEDED_CANCELLATION' | 'DETAILS_CHANGED_AFTER_BOOKING' + | 'BOOKER_CANCELLED' /** * @description Outcome text * @example Because he got covid @@ -902,6 +905,7 @@ export interface components { | 'VISIT_ORDER_CANCELLED' | 'SUPERSEDED_CANCELLATION' | 'DETAILS_CHANGED_AFTER_BOOKING' + | 'BOOKER_CANCELLED' /** * @description Visit Restriction * @example OPEN @@ -1325,9 +1329,9 @@ export interface components { sort?: components['schemas']['SortObject'][] /** Format: int32 */ pageSize?: number - paged?: boolean /** Format: int32 */ pageNumber?: number + paged?: boolean unpaged?: boolean } SortObject: { @@ -1616,6 +1620,7 @@ export interface components { | 'VISIT_ORDER_CANCELLED' | 'SUPERSEDED_CANCELLATION' | 'DETAILS_CHANGED_AFTER_BOOKING' + | 'BOOKER_CANCELLED' /** * Format: date-time * @description The date and time of the visit diff --git a/server/constants/paths.ts b/server/constants/paths.ts index 9e4d0d57..ce894135 100644 --- a/server/constants/paths.ts +++ b/server/constants/paths.ts @@ -27,6 +27,10 @@ const paths = { VISIT: '/bookings/details', VISIT_PAST: '/bookings/past/details', VISIT_CANCELLED: '/bookings/cancelled/details', + + // Cancel journey + CANCEL_VISIT: '/bookings/cancel-booking', + CANCEL_CONFIRMATION: '/bookings/booking-cancelled', }, VISITORS: '/visitors', diff --git a/server/data/orchestrationApiClient.test.ts b/server/data/orchestrationApiClient.test.ts index ec720f9f..e1d6373a 100644 --- a/server/data/orchestrationApiClient.test.ts +++ b/server/data/orchestrationApiClient.test.ts @@ -7,6 +7,7 @@ import { AvailableVisitSessionDto, AvailableVisitSessionRestrictionDto, BookingOrchestrationRequestDto, + CancelVisitOrchestrationDto, ChangeApplicationDto, CreateApplicationDto, VisitDto, @@ -53,6 +54,30 @@ describe('orchestrationApiClient', () => { }) }) + describe('cancelVisit', () => { + it('should cancel a visit for the booker', async () => { + const applicationReference = 'aaa-bbb-ccc' + + fakeOrchestrationApi + .put(`/visits/${applicationReference}/cancel`, { + cancelOutcome: { + outcomeStatus: 'BOOKER_CANCELLED', + }, + applicationMethodType: 'WEBSITE', + actionedBy: bookerReference.value, + }) + .matchHeader('authorization', `Bearer ${token}`) + .reply(200) + + await orchestrationApiClient.cancelVisit({ + applicationReference, + actionedBy: bookerReference.value, + }) + + expect(fakeOrchestrationApi.isDone()).toBe(true) + }) + }) + describe('getFuturePublicVisits', () => { it('should retrieve all future visits associated with a booker', async () => { const visits = [TestData.orchestrationVisitDto({ outcomeStatus: null })] diff --git a/server/data/orchestrationApiClient.ts b/server/data/orchestrationApiClient.ts index 333c44c3..f99fb54d 100644 --- a/server/data/orchestrationApiClient.ts +++ b/server/data/orchestrationApiClient.ts @@ -14,6 +14,7 @@ import { VisitDto, VisitorInfoDto, AvailableVisitSessionRestrictionDto, + CancelVisitOrchestrationDto, } from './orchestrationApiTypes' export type SessionRestriction = AvailableVisitSessionDto['sessionRestriction'] @@ -44,6 +45,25 @@ export default class OrchestrationApiClient { }) } + async cancelVisit({ + applicationReference, + actionedBy, + }: { + applicationReference: string + actionedBy: string + }): Promise { + await this.restClient.put({ + path: `/visits/${applicationReference}/cancel`, + data: { + cancelOutcome: { + outcomeStatus: 'BOOKER_CANCELLED', + }, + applicationMethodType: 'WEBSITE', + actionedBy, + }, + }) + } + async getFuturePublicVisits(bookerReference: string): Promise { return this.restClient.get({ path: `/public/booker/${bookerReference}/visits/booked/future` }) } diff --git a/server/data/orchestrationApiTypes.ts b/server/data/orchestrationApiTypes.ts index 0ca2b92b..7f506acc 100644 --- a/server/data/orchestrationApiTypes.ts +++ b/server/data/orchestrationApiTypes.ts @@ -16,6 +16,8 @@ export type BookerPrisonerInfoDto = components['schemas']['BookerPrisonerInfoDto export type BookerReference = components['schemas']['BookerReference'] +export type CancelVisitOrchestrationDto = components['schemas']['CancelVisitOrchestrationDto'] + export type ChangeApplicationDto = components['schemas']['ChangeApplicationDto'] export type CreateApplicationDto = components['schemas']['CreateApplicationDto'] diff --git a/server/routes/bookings/bookingDetailsController.ts b/server/routes/bookings/bookingDetailsController.ts index c9579eb1..14040468 100644 --- a/server/routes/bookings/bookingDetailsController.ts +++ b/server/routes/bookings/bookingDetailsController.ts @@ -30,12 +30,17 @@ export default class BookingDetailsController { const prison = await this.prisonService.getPrison(visit.prisonId) + const nowTimestamp = new Date() + const visitStartTimestamp = new Date(visit.startTimestamp) + const showCancel = nowTimestamp < visitStartTimestamp + return res.render('pages/bookings/visit', { booker, prison, prisoner, type, visit, + showCancel, showServiceNav: true, }) } diff --git a/server/routes/bookings/bookingsController.test.ts b/server/routes/bookings/bookingsController.test.ts index 06473eea..f49fd0c5 100644 --- a/server/routes/bookings/bookingsController.test.ts +++ b/server/routes/bookings/bookingsController.test.ts @@ -51,9 +51,12 @@ describe('Bookings homepage (future visits)', () => { expect($('[data-test="visit-start-time-1"]').text()).toBe('10am') expect($('[data-test="visit-end-time-1"]').text()).toBe('11:30am') expect($('[data-test="visit-reference-1"]').text()).toBe('ab-cd-ef-gh') - expect($('[data-test="visit-link-1"]').attr('href')).toBe( + expect($('[data-test="visit-link-booking-1"]').attr('href')).toBe( `${paths.BOOKINGS.VISIT}/${futureVisitDetails.visitDisplayId}`, ) + expect($('[data-test="visit-link-cancel-1"]').attr('href')).toBe( + `${paths.BOOKINGS.CANCEL_VISIT}/${futureVisitDetails.visitDisplayId}`, + ) expect($('[data-test=change-booking-heading]').length).toBeTruthy() expect($('[data-test="prison-name"]').text()).toBe(prison.prisonName) @@ -122,9 +125,10 @@ describe('Past visits page', () => { expect($('[data-test="visit-date-1"]').text()).toBe('Thursday 30 May 2024') expect($('[data-test="visit-start-time-1"]').text()).toBe('10am') expect($('[data-test="visit-end-time-1"]').text()).toBe('11:30am') - expect($('[data-test="visit-link-1"]').attr('href')).toBe( + expect($('[data-test="visit-link-booking-1"]').attr('href')).toBe( `${paths.BOOKINGS.VISIT_PAST}/${pastVisitDetails.visitDisplayId}`, ) + expect($('[data-test="visit-link-cancel-1"]').attr('href')).toBe(undefined) expect($('[data-test="no-visits"]').length).toBeFalsy() @@ -189,9 +193,10 @@ describe('Cancelled visits page', () => { expect($('[data-test="visit-date-1"]').text()).toBe('Thursday 30 May 2024') expect($('[data-test="visit-start-time-1"]').text()).toBe('10am') expect($('[data-test="visit-end-time-1"]').text()).toBe('11:30am') - expect($('[data-test="visit-link-1"]').attr('href')).toBe( + expect($('[data-test="visit-link-booking-1"]').attr('href')).toBe( `${paths.BOOKINGS.VISIT_CANCELLED}/${cancelledVisitDetails.visitDisplayId}`, ) + expect($('[data-test="visit-link-cancel-1"]').attr('href')).toBe(undefined) expect($('[data-test="no-visits"]').length).toBeFalsy() diff --git a/server/routes/bookings/cancelVisitController.test.ts b/server/routes/bookings/cancelVisitController.test.ts new file mode 100644 index 00000000..52d36f6b --- /dev/null +++ b/server/routes/bookings/cancelVisitController.test.ts @@ -0,0 +1,164 @@ +import type { Express } from 'express' +import request from 'supertest' +import * as cheerio from 'cheerio' +import { SessionData } from 'express-session' +import { randomUUID } from 'crypto' +import { FieldValidationError } from 'express-validator' +import { appWithAllRoutes, flashProvider } from '../testutils/appSetup' +import { createMockBookerService, createMockVisitService } from '../../services/testutils/mocks' +import TestData from '../testutils/testData' +import paths from '../../constants/paths' +import { VisitDetails } from '../../services/visitService' + +let app: Express + +const bookerService = createMockBookerService() +const visitService = createMockVisitService() + +const bookerReference = TestData.bookerReference().value +const prisoner = TestData.prisoner() +const visitDisplayId = randomUUID() + +let sessionData: SessionData +let bookings: SessionData['bookings'] +let visitDetails: VisitDetails + +beforeEach(() => { + visitDetails = TestData.visitDetails({ visitDisplayId }) + bookings = { type: 'future', visits: [visitDetails] } + + sessionData = { + booker: { + reference: bookerReference, + prisoners: [prisoner], + }, + bookings, + } as SessionData + + app = appWithAllRoutes({ services: { bookerService, visitService }, sessionData }) +}) + +afterEach(() => { + jest.resetAllMocks() +}) + +describe('Cancel a booking', () => { + describe('GET - Display visit information on cancellation page', () => { + it('should render the cancel confirmation page', () => { + return request(app) + .get(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`) + .expect('Content-Type', /html/) + .expect(res => { + const $ = cheerio.load(res.text) + expect($('title').text()).toMatch(/^Cancel your booking -/) + expect($('[data-test="back-link"]').attr('href')).toBe( + `${paths.BOOKINGS.VISIT}/${visitDetails.visitDisplayId}`, + ) + expect($('h1').text()).toBe('Are you sure you want to cancel your booking?') + + expect($('[data-test="visit-date"]').text()).toBe('Thursday 30 May 2024') + expect($('[data-test="visit-start-time"]').text()).toBe('10am') + expect($('[data-test="visit-end-time"]').text()).toBe('11:30am') + expect($('[data-test="prisoner-name"]').text()).toBe('John Smith') + expect($('[data-test="visitor-name-1"]').text()).toContain('Keith Phillips') + expect($('form[method=POST]').attr('action')).toBe( + `${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`, + ) + }) + }) + + describe('GET - Display cancellation confirmed page', () => { + it('should render the page confirming the visit has been cancelled', () => { + return request(app) + .get(`${paths.BOOKINGS.CANCEL_CONFIRMATION}`) + .expect('Content-Type', /html/) + .expect(res => { + const $ = cheerio.load(res.text) + expect($('title').text()).toMatch(/^Booking cancelled -/) + expect($('[data-test="back-link"]').attr('href')).toBe(undefined) + expect($('h1').text()).toContain('Booking cancelled') + expect($('h2').text()).toContain('What happens next') + expect($('p').text()).toContain( + 'The main contact for this booking will get a text message to confirm it has been cancelled.', + ) + }) + }) + }) + }) + + describe('POST - cancel booking', () => { + it('should have cancelled the visit and redirect to confirmation page', () => { + return request(app) + .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`) + .send('cancelBooking=yes') + .expect(302) + .expect('location', paths.BOOKINGS.CANCEL_CONFIRMATION) + .expect(() => { + expect(visitService.cancelVisit).toHaveBeenCalledTimes(1) + expect(visitService.cancelVisit).toHaveBeenCalledWith({ + actionedBy: 'aaaa-bbbb-cccc', + applicationReference: 'ab-cd-ef-gh', + }) + }) + }) + + it('should redirect to visit details page if "no" is selected', () => { + return request(app) + .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`) + .send('cancelBooking=no') + .expect(302) + .expect('location', `${paths.BOOKINGS.VISIT}/${visitDetails.visitDisplayId}`) + .expect(() => { + expect(visitService.cancelVisit).toHaveBeenCalledTimes(0) + }) + }) + + it('should NOT redirect when incorrect value posted', () => { + const expectedValidationError: FieldValidationError = { + location: 'body', + msg: 'No answer selected', + path: 'cancelBooking', + type: 'field', + value: 'test', + } + return request(app) + .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`) + .send('cancelBooking=test') + .expect(302) + .expect('location', `${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`) + .expect(() => { + expect(visitService.cancelVisit).toHaveBeenCalledTimes(0) + expect(flashProvider).toHaveBeenCalledWith('errors', [expectedValidationError]) + }) + }) + + it('should NOT redirect when no value posted', () => { + const expectedValidationError: FieldValidationError = { + location: 'body', + msg: 'No answer selected', + path: 'cancelBooking', + type: 'field', + value: undefined, + } + return request(app) + .post(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`) + .expect(302) + .expect('location', `${paths.BOOKINGS.CANCEL_VISIT}/${visitDetails.visitDisplayId}`) + .expect(() => { + expect(visitService.cancelVisit).toHaveBeenCalledTimes(0) + expect(flashProvider).toHaveBeenCalledWith('errors', [expectedValidationError]) + }) + }) + + it('should NOT cancel the visit if invalid visit ID is posted', () => { + return request(app) + .post(`${paths.BOOKINGS.CANCEL_VISIT}/test`) + .send('cancelBooking=yes') + .expect(302) + .expect('location', paths.BOOKINGS.HOME) + .expect(() => { + expect(visitService.cancelVisit).toHaveBeenCalledTimes(0) + }) + }) + }) +}) diff --git a/server/routes/bookings/cancelVisitController.ts b/server/routes/bookings/cancelVisitController.ts new file mode 100644 index 00000000..a7a516f4 --- /dev/null +++ b/server/routes/bookings/cancelVisitController.ts @@ -0,0 +1,112 @@ +import type { RequestHandler } from 'express' +import { Meta, ValidationChain, matchedData, param, body, validationResult } from 'express-validator' +import { BookerService, VisitService } from '../../services' +import paths from '../../constants/paths' + +export default class CancelVisitController { + public constructor( + private readonly bookerService: BookerService, + private readonly visitService: VisitService, + ) {} + + public confirmCancelView(): RequestHandler { + return async (req, res) => { + const { booker, bookings } = req.session + const { visits } = bookings + + const errors = validationResult(req) + if (!errors.isEmpty() || bookings.type !== 'future') { + return res.redirect(paths.BOOKINGS.HOME) + } + + const prisoner = booker.prisoners?.[0] + ? booker.prisoners?.[0] + : (await this.bookerService.getPrisoners(booker.reference))?.[0] + + const { visitDisplayId } = matchedData<{ visitDisplayId: string }>(req) + + const visit = visits.find(v => v.visitDisplayId === visitDisplayId) + + return res.render('pages/bookings/cancel/cancel', { + errors: req.flash('errors'), + booker, + prisoner, + visit, + visitDisplayId, + showServiceNav: true, + }) + } + } + + public submit(): RequestHandler { + return async (req, res, next) => { + const { booker, bookings } = req.session + const { visits } = bookings + const { cancelBooking, visitDisplayId } = matchedData<{ + cancelBooking: 'yes' | 'no' + visitDisplayId: string + }>(req) + + const errors = validationResult(req) + if (!errors.isEmpty()) { + req.flash('errors', errors.array()) + + if (!visitDisplayId) { + return res.redirect(paths.BOOKINGS.HOME) + } + + return res.redirect(`${paths.BOOKINGS.CANCEL_VISIT}/${visitDisplayId}`) + } + + if (cancelBooking === 'no') { + return res.redirect(`${paths.BOOKINGS.VISIT}/${visitDisplayId}`) + } + + const visit = visits.find(v => v.visitDisplayId === visitDisplayId) + + await this.visitService.cancelVisit({ + applicationReference: visit.reference, + actionedBy: booker.reference, + }) + + return res.redirect(paths.BOOKINGS.CANCEL_CONFIRMATION) + } + } + + public visitCancelled(): RequestHandler { + return async (req, res) => { + return res.render('pages/bookings/cancel/cancelConfirmation', { + showServiceNav: true, + }) + } + } + + public validateDisplayId(): ValidationChain[] { + return [ + param('visitDisplayId') + .isUUID() + .bail() + .custom((visitDisplayId: string, { req }: Meta & { req: Express.Request }) => { + const { bookings } = req.session + const visits = bookings?.visits ?? [] + + return visits.some(visit => visit.visitDisplayId === visitDisplayId) + }), + ] + } + + public validateCancelChoice(): ValidationChain[] { + return [ + body('cancelBooking').isIn(['yes', 'no']).withMessage('No answer selected'), + param('visitDisplayId') + .isUUID() + .bail() + .custom((visitDisplayId: string, { req }: Meta & { req: Express.Request }) => { + const { bookings } = req.session + const visits = bookings?.visits ?? [] + + return visits.some(visit => visit.visitDisplayId === visitDisplayId) + }), + ] + } +} diff --git a/server/routes/bookings/index.ts b/server/routes/bookings/index.ts index 545ae692..5c1b5a82 100644 --- a/server/routes/bookings/index.ts +++ b/server/routes/bookings/index.ts @@ -5,6 +5,7 @@ import asyncMiddleware from '../../middleware/asyncMiddleware' import paths from '../../constants/paths' import BookingsController from './bookingsController' import BookingDetailsController from './bookingDetailsController' +import CancelVisitController from './cancelVisitController' export default function routes(services: Services): Router { const router = Router() @@ -12,9 +13,12 @@ export default function routes(services: Services): Router { const get = (path: string | string[], handler: RequestHandler) => router.get(path, asyncMiddleware(handler)) const getWithValidation = (path: string | string[], validationChain: ValidationChain[], handler: RequestHandler) => router.get(path, ...validationChain, asyncMiddleware(handler)) + const postWithValidation = (path: string | string[], validationChain: ValidationChain[], handler: RequestHandler) => + router.post(path, ...validationChain, asyncMiddleware(handler)) const bookingsController = new BookingsController(services.prisonService, services.visitService) const bookingDetailsController = new BookingDetailsController(services.bookerService, services.prisonService) + const cancelVisitController = new CancelVisitController(services.bookerService, services.visitService) get(paths.BOOKINGS.HOME, bookingsController.view('future')) get(paths.BOOKINGS.PAST, bookingsController.view('past')) @@ -38,5 +42,19 @@ export default function routes(services: Services): Router { bookingDetailsController.view('cancelled'), ) + getWithValidation( + `${paths.BOOKINGS.CANCEL_VISIT}/:visitDisplayId`, + cancelVisitController.validateDisplayId(), + cancelVisitController.confirmCancelView(), + ) + + postWithValidation( + `${paths.BOOKINGS.CANCEL_VISIT}/:visitDisplayId`, + cancelVisitController.validateCancelChoice(), + cancelVisitController.submit(), + ) + + get(`${paths.BOOKINGS.CANCEL_CONFIRMATION}`, cancelVisitController.visitCancelled()) + return router } diff --git a/server/services/visitService.test.ts b/server/services/visitService.test.ts index 68549ab3..5832e47d 100644 --- a/server/services/visitService.test.ts +++ b/server/services/visitService.test.ts @@ -168,6 +168,22 @@ describe('Visit service', () => { expect(results).toStrictEqual(visit) }) }) + + describe('cancelVisit', () => { + it('should cancel a visit for the booker', async () => { + orchestrationApiClient.cancelVisit.mockResolvedValue() + + await visitService.cancelVisit({ + applicationReference: bookingJourney.applicationReference, + actionedBy: 'aaaa-bbbb-cccc', + }) + + expect(orchestrationApiClient.cancelVisit).toHaveBeenCalledWith({ + applicationReference: bookingJourney.applicationReference, + actionedBy: 'aaaa-bbbb-cccc', + }) + }) + }) }) describe('Booking listings', () => { diff --git a/server/services/visitService.ts b/server/services/visitService.ts index 67e0db1f..43ecac28 100644 --- a/server/services/visitService.ts +++ b/server/services/visitService.ts @@ -90,6 +90,21 @@ export default class VisitService { return visit } + async cancelVisit({ + applicationReference, + actionedBy, + }: { + applicationReference: string + actionedBy: string + }): Promise { + const token = await this.hmppsAuthClient.getSystemClientToken() + const orchestrationApiClient = this.orchestrationApiClientFactory(token) + + await orchestrationApiClient.cancelVisit({ applicationReference, actionedBy }) + + logger.info(`Visit '${applicationReference}' has been cancelled by booker '${actionedBy}`) + } + async getFuturePublicVisits(bookerReference: string): Promise { const token = await this.hmppsAuthClient.getSystemClientToken() const orchestrationApiClient = this.orchestrationApiClientFactory(token) diff --git a/server/views/pages/bookings/cancel/cancel.njk b/server/views/pages/bookings/cancel/cancel.njk new file mode 100644 index 00000000..0af92ada --- /dev/null +++ b/server/views/pages/bookings/cancel/cancel.njk @@ -0,0 +1,70 @@ +{% extends "../../../partials/layout.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/radios/macro.njk" import govukRadios %} + +{% set pageTitle = "Cancel your booking" %} + +{% set backLinkHref = paths.BOOKINGS.VISIT + '/' + visit.visitDisplayId %} + +{% block content %} +
+
+ + {% include "partials/errorSummary.njk" %} + +

Are you sure you want to cancel your booking?

+ +

Date and time

+ +

{{ visit.startTimestamp | formatDate(dateFormats.PRETTY_DATE) }}

+

+ {{ visit.startTimestamp | formatTimeFromDateTime }} + to + {{ visit.endTimestamp | formatTimeFromDateTime }} +

+ +

Prisoner

+ +

{{ prisoner.firstName | capitalize }} {{ prisoner.lastName | capitalize }}

+ +

Visitors

+ + {% for visitor in visit.visitors %} +

{{ visitor.firstName }} {{ visitor.lastName }}

+ {% endfor %} + +
+ + + {{ govukRadios({ + name: "cancelBooking", + items: [ + { + value: "yes", + text: "Yes, cancel this booking", + attributes: { + 'data-test': 'cancel-booking-yes' + } + }, + { + value: "no", + text: "No, keep this booking", + attributes: { + 'data-test': 'cancel-booking-no' + } + } + ], + errorMessage: errors | findError('cancelBooking') + }) }} + + {{ govukButton({ + text: "Confirm", + attributes: { + "data-test": "confirm-button" + }, + preventDoubleClick: true + }) }} +
+
+
+{% endblock %} diff --git a/server/views/pages/bookings/cancel/cancelConfirmation.njk b/server/views/pages/bookings/cancel/cancelConfirmation.njk new file mode 100644 index 00000000..3c7c6d12 --- /dev/null +++ b/server/views/pages/bookings/cancel/cancelConfirmation.njk @@ -0,0 +1,20 @@ +{% extends "../../../partials/layout.njk" %} +{% from "govuk/components/panel/macro.njk" import govukPanel %} + +{% set pageTitle = "Booking cancelled" %} + +{% block content %} +
+
+ + {{ govukPanel({ + titleText: pageTitle + }) }} + +

What happens next

+ +

The main contact for this booking will get a text message to confirm it has been cancelled.

+ +
+
+{% endblock %} diff --git a/server/views/pages/bookings/cancelled.njk b/server/views/pages/bookings/cancelled.njk index d57c1d94..c91a1312 100644 --- a/server/views/pages/bookings/cancelled.njk +++ b/server/views/pages/bookings/cancelled.njk @@ -22,7 +22,12 @@ {{ visit.endTimestamp | formatTimeFromDateTime }}

- View booking details + + View details + + of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }} + +

{% endfor %} diff --git a/server/views/pages/bookings/future.njk b/server/views/pages/bookings/future.njk index faeb400b..111a89f2 100644 --- a/server/views/pages/bookings/future.njk +++ b/server/views/pages/bookings/future.njk @@ -25,7 +25,16 @@ Booking reference: {{ visit.reference }}

- View booking details + + View details + + of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }} + + + + Cancel booking + of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }} +

{% endfor %} diff --git a/server/views/pages/bookings/past.njk b/server/views/pages/bookings/past.njk index 781dac80..ca75caac 100644 --- a/server/views/pages/bookings/past.njk +++ b/server/views/pages/bookings/past.njk @@ -22,7 +22,12 @@ {{ visit.endTimestamp | formatTimeFromDateTime }}

- View booking details + + View details + + of your booking for {{ visit.startTimestamp | formatTimeFromDateTime }} on {{ visit.startTimestamp | formatDate('d MMMM') }} + +

{% endfor %} diff --git a/server/views/pages/bookings/visit.njk b/server/views/pages/bookings/visit.njk index fe3eae65..3e16c1f1 100644 --- a/server/views/pages/bookings/visit.njk +++ b/server/views/pages/bookings/visit.njk @@ -2,6 +2,7 @@ {% extends "../../partials/layout.njk" %} {% from "govuk/components/warning-text/macro.njk" import govukWarningText %} {%-from "moj/components/banner/macro.njk" import mojBanner %} +{% from "govuk/components/button/macro.njk" import govukButton %} {% set pageTitle = "Visit booking details" %} @@ -71,6 +72,16 @@

{{ visit.visitContact.name }}

{{ visit.visitContact.telephone | d("No phone number provided", true) }}

+ {% if showCancel %} + {{ govukButton({ + text: "Cancel booking", + classes: "govuk-!-margin-top-5 govuk-button--secondary", + href: paths.BOOKINGS.CANCEL_VISIT + '/' + visit.visitDisplayId, + attributes: { "data-test": "cancel-visit" }, + preventDoubleClick: true + }) }} + {% endif %} + {% if type === 'future' %} {% include "partials/howToChangeBooking.njk" %}