diff --git a/package-lock.json b/package-lock.json index 669a18e..20763f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cpp.ui.core", - "version": "19.0.5", + "version": "19.0.5-CCSPH2.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cpp.ui.core", - "version": "19.0.5", + "version": "19.0.5-CCSPH2.9", "license": "UNLICENSED", "dependencies": { "@ngrx/operators": "^19.2.1", diff --git a/package.json b/package.json index fcbbabb..971f4f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cpp.ui.core", - "version": "19.0.5", + "version": "19.0.5-CCSPH2.9", "repository": { "type": "git", "url": "https://github.com/hmcts/cpp-ui-core" diff --git a/projects/reference-data/src/components/rota-business-types.select.ts b/projects/reference-data/src/components/rota-business-types.select.ts index 62f351c..df906cd 100644 --- a/projects/reference-data/src/components/rota-business-types.select.ts +++ b/projects/reference-data/src/components/rota-business-types.select.ts @@ -25,7 +25,7 @@ import { ReferenceDataState } from '../reducers/index'; import { AsyncPipe, CommonModule } from '@angular/common'; -import { RotaBusinessType, RotaBusinessTypeJurisdiction } from '../reference-data.interfaces'; +import { Jurisdiction, RotaBusinessType } from '../reference-data.interfaces'; import { sortSelectOptionAlphabetical } from '../utils/sort-select-options-Alphabetical'; @Component({ @@ -74,7 +74,7 @@ export class RotaBusinessTypeSelectComponent this._filterBy = fn; this._buildOptions$.next(); } - @Input() jurisdiction: RotaBusinessTypeJurisdiction | 'ALL' = 'MAGISTRATES'; + @Input() jurisdiction: Jurisdiction | 'ALL' = 'MAGISTRATES'; @Output() change = new EventEmitter(); @ViewChild(PdkSelectComponent, { static: true }) selectRef!: PdkSelectComponent; diff --git a/projects/reference-data/src/mocks/organisation-unit.mocks.ts b/projects/reference-data/src/mocks/organisation-unit.mocks.ts index 5aa6f16..d5dfa50 100644 --- a/projects/reference-data/src/mocks/organisation-unit.mocks.ts +++ b/projects/reference-data/src/mocks/organisation-unit.mocks.ts @@ -3,11 +3,13 @@ import { OrganisationUnit } from '../reference-data.interfaces'; export const organisationUnitMockOne: OrganisationUnit = { id: '4cc41e45-b594-5ba6-906e-1a4626b08fed', oucodeL3Code: 'LH', - oucodeL3Name: 'Lark Hill' + oucodeL3Name: 'Lark Hill', + oucodeL1Code: 'B' }; export const organisationUnitMockTwo: OrganisationUnit = { id: '3cc41e45-b594-4ba6-906e-1a4626b08fed', oucodeL3Code: 'LCC', - oucodeL3Name: 'Liverpool Crown Court' + oucodeL3Name: 'Liverpool Crown Court', + oucodeL1Code: 'C' }; diff --git a/projects/reference-data/src/reducers/index.ts b/projects/reference-data/src/reducers/index.ts index 9dd0169..de61680 100644 --- a/projects/reference-data/src/reducers/index.ts +++ b/projects/reference-data/src/reducers/index.ts @@ -3,7 +3,7 @@ import { referenceData, ReferenceDataState as ReferenceDataFeatureState } from './reference-data.reducer'; -import { RotaBusinessTypeJurisdiction, RotaBusinessType } from '../reference-data.interfaces'; +import { Jurisdiction, RotaBusinessType } from '../reference-data.interfaces'; export interface ReferenceDataState { referenceData: ReferenceDataFeatureState; @@ -83,9 +83,9 @@ export const getRotaBusinessTypes = createSelector(getRotaBusinesTypesState, (ro ); export const getRotaBusinessTypesByJurisdiction = ( - jurisdiction: RotaBusinessTypeJurisdiction | 'ALL' + jurisdiction: Jurisdiction | 'ALL' ): MemoizedSelector => - defaultMemoize((jurisdiction: RotaBusinessTypeJurisdiction | 'ALL') => + defaultMemoize((jurisdiction: Jurisdiction | 'ALL') => createSelector( getRotaBusinesTypesState, (rotaBusinessTypes: RotaBusinessType[] | null | undefined) => { diff --git a/projects/reference-data/src/reference-data.interfaces.ts b/projects/reference-data/src/reference-data.interfaces.ts index 283ce12..31c4ed7 100644 --- a/projects/reference-data/src/reference-data.interfaces.ts +++ b/projects/reference-data/src/reference-data.interfaces.ts @@ -1,6 +1,8 @@ import { UserGroupType } from '@cpp/users-groups'; import { JudiciaryTypePayload, RefDataJudiciaryType } from './helpers/judiciary-type.helper'; +export type Jurisdiction = 'MAGISTRATES' | 'CROWN'; + export enum BreachType { GENERIC_BREACH = 'GENERIC_BREACH', COMMISSION_OF_NEW_OFFENCE_BREACH = 'COMMISSION_OF_NEW_OFFENCE_BREACH', @@ -197,7 +199,7 @@ export interface PleaType { pleaStatusCode: string; pleaTypeUIFlag: boolean; pleaValue: string; - jurisdiction: 'MAGISTRATES' | 'CROWN' | 'EITHER'; + jurisdiction: Jurisdiction | 'EITHER'; } export interface PoliceForce { @@ -371,7 +373,7 @@ export interface RotaBusinessType { typeDescription: string; slot: boolean; duration: boolean; - jurisdiction?: RotaBusinessTypeJurisdiction; + jurisdiction?: Jurisdiction; } export interface WitnessCareUnit { diff --git a/projects/reference-data/src/services/reference-data.service.ts b/projects/reference-data/src/services/reference-data.service.ts index 0ea7071..0d82c64 100644 --- a/projects/reference-data/src/services/reference-data.service.ts +++ b/projects/reference-data/src/services/reference-data.service.ts @@ -21,7 +21,7 @@ import { ResultDefinition, ReusableInfoDefinitions, RotaBusinessType, - RotaBusinessTypeJurisdiction, + Jurisdiction, TrialType, SelectedJudiciaryOptions, WitnessCareUnit, @@ -224,7 +224,7 @@ export class ReferenceDataService { } fetchRotaBusinessTypes( - params: { jurisdiction: RotaBusinessTypeJurisdiction | 'ALL' } = { jurisdiction: 'ALL' } + params: { jurisdiction: Jurisdiction | 'ALL' } = { jurisdiction: 'ALL' } ): Observable { return this.cppHttp .query<{ rotaBusinessTypes: RotaBusinessType[] }>({ diff --git a/projects/scheduling/src/actions/scheduling.actions.spec.ts b/projects/scheduling/src/actions/scheduling.actions.spec.ts index ca05e6a..ba861b6 100644 --- a/projects/scheduling/src/actions/scheduling.actions.spec.ts +++ b/projects/scheduling/src/actions/scheduling.actions.spec.ts @@ -1,11 +1,11 @@ -import { HearingSlot, SearchHearingSlotsParams } from '../types'; // adjust import if enums are elsewhere +import { HearingSlot, SearchHearingSlotsParams } from '../types'; import { loadHearingSlotsSuccess, resetHearingSlots } from './scheduling.actions'; describe('Scheduling slots actions', () => { it('Should create a loadHearingSlotsSuccess action', () => { - const params: SearchHearingSlotsParams = { + const params = { sessionStartDate: '2025-04-10' - }; + } as SearchHearingSlotsParams; const hearingSlots: HearingSlot[] = [ { diff --git a/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.html b/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.html new file mode 100644 index 0000000..59bfb82 --- /dev/null +++ b/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.html @@ -0,0 +1,135 @@ +
+

Search for a hearing slot

+
+ +
+ + + + +
+
+ + + + + + + @if (formModel.isMultiday) { + All day + } + + + + + + + +
+ + +
to
+
+ @if (sessionStartDate.valid && sessionStartDate.value) { + + Selected day:  + {{ sessionStartDate.value | date : 'EEEE' }} + + } +
+
+ + + + + + @if ( sessionEndDate.value && !sessionEndDate.errors?.dateExists && + !sessionEndDate.errors?.dateFormat ) { + + Selected day:  + {{ sessionEndDate.value | date : 'EEEE' }} + + } + + +
+ +
+ + +
+
+
diff --git a/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.spec.ts b/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.spec.ts new file mode 100644 index 0000000..806507f --- /dev/null +++ b/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.spec.ts @@ -0,0 +1,165 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CrownSchedulingFiltersComponent } from './crown-scheduling-filters.component'; +import { CppHttp } from '@cpp/core'; +import { organisationUnitMockOne } from '@cpp/reference-data'; +import { CrownSessionStatusFilterOption, CrownSchedulingFilters } from '../../types/filters'; +import { CrownSessionStatus } from '../../types/hearingSlot'; +import { of } from 'rxjs'; +import { provideMockStore } from '@ngrx/store/testing'; + +describe('CrownSchedulingFiltersComponent', () => { + let component: CrownSchedulingFiltersComponent; + let fixture: ComponentFixture; + + const defaultFilters: CrownSchedulingFilters = { + organisationUnit: undefined, + sessionStartDate: '2025-03-21', + sessionEndDate: '2025-05-01' + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CrownSchedulingFiltersComponent], + providers: [ + provideMockStore({ initialState: {} }), + { + provide: CppHttp, + useValue: { + query: jest.fn(() => of([])) + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CrownSchedulingFiltersComponent); + component = fixture.componentInstance; + component.defaultValues = defaultFilters; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should sync operational unit when defaultValues include oucodeL2Code', () => { + const spy = jest.spyOn(component, 'handleOperationalUnitChanged'); + component.defaultValues = { ...defaultFilters, oucodeL2Code: 'XYZ' }; + expect(spy).toHaveBeenCalledWith('XYZ'); + }); + + it('should default sessionStatusFilter to NONE when defaultSessionStatus is omitted or DRAFT', () => { + component.defaultValues = defaultFilters; + expect(component.formModel.sessionStatusFilter).toBe(CrownSessionStatusFilterOption.NONE); + + component.defaultSessionStatus = CrownSessionStatus.DRAFT; + component.defaultValues = defaultFilters; + expect(component.formModel.sessionStatusFilter).toBe(CrownSessionStatusFilterOption.NONE); + }); + + it('should default sessionStatusFilter to ALL when defaultSessionStatus is ALL', () => { + component.defaultSessionStatus = CrownSessionStatus.ALL; + component.defaultValues = defaultFilters; + expect(component.formModel.sessionStatusFilter).toBe(CrownSessionStatusFilterOption.ALL); + }); + + it('should default sessionStatusFilter to ALL when defaultSessionStatus is FINAL', () => { + component.defaultSessionStatus = CrownSessionStatus.FINAL; + component.defaultValues = defaultFilters; + expect(component.formModel.sessionStatusFilter).toBe(CrownSessionStatusFilterOption.ALL); + }); + + it('should default isSlotBased to false', () => { + component.defaultValues = defaultFilters; + expect(component.formModel.isSlotBased).toBe(false); + }); + + describe('effectiveMinDate', () => { + const today = '2026-04-10'; + const tomorrow = '2026-04-11'; + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date(today)); + }); + + it('should resolve minDate to today by default', () => { + expect(component.effectiveMinDate()).toBe(today); + }); + + it('should resolve minDate to custom value when provided', () => { + fixture.componentRef.setInput('minDate', tomorrow); + fixture.detectChanges(); + expect(component.effectiveMinDate()).toBe(tomorrow); + }); + + it('should not restrict minDate when allowPastDates is true and no minDate provided', () => { + fixture.componentRef.setInput('allowPastDates', true); + fixture.componentRef.setInput('minDate', undefined); + fixture.detectChanges(); + expect(component.effectiveMinDate()).toBeUndefined(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + }); + + it('should omit courtRoomId and set status FINAL in filterFormModel when All is selected', () => { + component.defaultSessionStatus = CrownSessionStatus.FINAL; + component.defaultValues = defaultFilters; + component.formModel.sessionStatusFilter = CrownSessionStatusFilterOption.ALL; + const result = component.filterFormModel(component.formModel); + expect(result.sessionStatusFilter).toBe(CrownSessionStatusFilterOption.ALL); + expect(result.courtRoomId).toBeUndefined(); + expect(result.status).toBe(CrownSessionStatus.FINAL); + }); + + it('should clear formModel.courtRoomId when session status filter is ALL or NONE', () => { + component.formModel.courtRoomId = organisationUnitMockOne.id; + component.handleSessionStatusFilterChange(CrownSessionStatusFilterOption.ALL); + expect(component.formModel.courtRoomId).toBeUndefined(); + component.formModel.courtRoomId = organisationUnitMockOne.id; + component.handleSessionStatusFilterChange(CrownSessionStatusFilterOption.NONE); + expect(component.formModel.courtRoomId).toBeUndefined(); + }); + + it('should set formModel.courtRoomId when a specific courtroom is selected', () => { + component.handleSessionStatusFilterChange(organisationUnitMockOne.id); + expect(component.formModel.courtRoomId).toBe(organisationUnitMockOne.id); + }); + + it('should include availableDurationMins in filterFormModel for duration-based multi-day search', () => { + component.defaultSessionStatus = CrownSessionStatus.FINAL; + component.defaultValues = defaultFilters; + component.formModel.isSlotBased = false; + component.formModel.isMultiday = true; + component.updateCourtSession(true); + component.formModel.availableDurationMins = 2880; + component.formModel.sessionStatusFilter = CrownSessionStatusFilterOption.ALL; + const result = component.filterFormModel(component.formModel); + expect(result.availableDurationMins).toBe(2880); + }); + + it('should clear availableDurationMins when toggling multi-day so duration matches the active journey', () => { + component.defaultValues = defaultFilters; + component.formModel.isSlotBased = false; + component.formModel.availableDurationMins = 180; + component.updateCourtSession(true); + expect(component.formModel.availableDurationMins).toBeUndefined(); + expect(component.formModel.courtSession).toBe('AD'); + + component.formModel.availableDurationMins = 2880; + component.updateCourtSession(false); + expect(component.formModel.availableDurationMins).toBeUndefined(); + expect(component.formModel.courtSession).toBeUndefined(); + }); + + it('should include availableDurationMins in filterFormModel for duration-based non-multi-day search', () => { + component.defaultSessionStatus = CrownSessionStatus.FINAL; + component.defaultValues = defaultFilters; + component.formModel.isSlotBased = false; + component.formModel.isMultiday = false; + component.formModel.availableDurationMins = 180; + component.formModel.sessionStatusFilter = CrownSessionStatusFilterOption.ALL; + const result = component.filterFormModel(component.formModel); + expect(result.availableDurationMins).toBe(180); + }); +}); diff --git a/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.ts b/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.ts new file mode 100644 index 0000000..8e65448 --- /dev/null +++ b/projects/scheduling/src/components/crown-scheduling-filters/crown-scheduling-filters.component.ts @@ -0,0 +1,244 @@ +import { + ChangeDetectionStrategy, + computed, + Component, + EventEmitter, + Input, + input, + Output, + ViewEncapsulation +} from '@angular/core'; +import { + PdkButton, + PdkCore, + PdkDateInput, + PdkForm, + PdkGrid, + PdkInsetTextComponent, + PdkRadio, + PdkTextInput, + PdkSelectComponent, + SelectOption, + ValidationError, + PdkInput +} from '@cpp/pdk'; +import { + getCrownCourtroomOptions, + getOperationalUnitOptions, + isCrownCourt, + operationalUnitAllCourtsPlaceholder +} from '../../utils'; +import { + defaultSessionStatusFilterOption, + searchFieldsForSessionFilter +} from '../../utils/sessionStatusFilter'; +import { + CrownSessionStatusFilter, + CrownSessionStatusFilterOption, + CrownSchedulingFilters +} from '../../types/filters'; +import { COURT_SESSION_SELECT_OPTIONS } from '../../types/schedulingFilterOptions'; +import { CrownSessionStatus } from '../../types/hearingSlot'; +import { + CppReferenceDataComponents, + OrganisationUnit, + RotaBusinessType +} from '@cpp/reference-data'; +import { DatePipe, formatDate } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { SchedulingFiltersCourtAndBookingComponent } from '../scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component'; + +@Component({ + selector: 'crown-scheduling-filters', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './crown-scheduling-filters.component.html', + styleUrls: ['../scheduling-filters.layout.scss'], + encapsulation: ViewEncapsulation.None, + imports: [ + FormsModule, + DatePipe, + PdkSelectComponent, + PdkGrid, + PdkRadio, + PdkButton, + PdkCore, + PdkDateInput, + PdkInsetTextComponent, + CppReferenceDataComponents, + PdkForm, + PdkInput, + PdkTextInput, + SchedulingFiltersCourtAndBookingComponent + ] +}) +export class CrownSchedulingFiltersComponent { + @Input() set defaultValues(defaultValues: CrownSchedulingFilters) { + const defaultFilter = this.getDefaultSessionStatusFilter(); + const isSlotBased = defaultValues?.isSlotBased ?? false; + this.formModel = { + ...defaultValues, + isSlotBased, + sessionStatusFilter: defaultValues?.sessionStatusFilter ?? defaultFilter, + ...(this.enableMultiDay && + !isSlotBased && { + isMultiday: defaultValues?.isMultiday ?? false + }) + }; + if (!this.initialValues) { + this.initialValues = { + sessionStartDate: defaultValues?.sessionStartDate, + sessionEndDate: defaultValues?.sessionEndDate, + organisationUnit: defaultValues?.organisationUnit, + oucodeL2Code: defaultValues?.oucodeL2Code, + isSlotBased: false, + isMultiday: false, + sessionStatusFilter: defaultFilter + }; + } + this.applyOperationalUnitByDefault(); + } + @Input() set organisationUnits(organisationUnits: OrganisationUnit[]) { + this.operationalUnitOptions = getOperationalUnitOptions(organisationUnits); + } + @Input() rotaBusinessTypes?: RotaBusinessType[]; + @Input() enableMultiDay = true; + @Input() defaultSessionStatus?: CrownSessionStatus; + @Output() errors = new EventEmitter(); + @Output() filtersSubmit = new EventEmitter(); + + formModel!: CrownSchedulingFilters; + initialValues!: CrownSchedulingFilters; + operationalUnitOptions: SelectOption[] = []; + organisationUnitPlaceholder?: OrganisationUnit; + courtSessionOptions = COURT_SESSION_SELECT_OPTIONS; + slotFilterFn = (rotaBusinessType: RotaBusinessType) => !rotaBusinessType.duration; + durationFilterFn = (rotaBusinessType: RotaBusinessType) => rotaBusinessType.duration; + readonly minDate = input(undefined); + readonly allowPastDates = input(false); + readonly effectiveMinDate = computed( + () => + this.minDate() ?? + (this.allowPastDates() ? undefined : formatDate(new Date(), 'yyyy-MM-dd', 'en-GB')) + ); + + private applyOperationalUnitByDefault(): void { + const code = this.formModel?.oucodeL2Code; + if (!code) return; + this.handleOperationalUnitChanged(code); + } + + getCourtroomOptions(): SelectOption[] { + return getCrownCourtroomOptions(this.formModel.organisationUnit, this.defaultSessionStatus); + } + + getDefaultSessionStatusFilter(): CrownSessionStatusFilter { + return defaultSessionStatusFilterOption(this.defaultSessionStatus ?? CrownSessionStatus.DRAFT); + } + + handleFiltersSubmit() { + const filteredFormModel = this.filterFormModel(this.formModel); + this.filtersSubmit.emit(filteredFormModel); + } + + filterFormModel(formModel: CrownSchedulingFilters): CrownSchedulingFilters { + const reduced = (Object.keys(formModel) as (keyof CrownSchedulingFilters)[]).reduce( + (reducedParams, key) => { + if (key === 'isMultiday' || key === 'isSlotBased' || key === 'sessionStatusFilter') { + return { ...reducedParams, [key]: formModel[key] }; + } + if ( + !formModel[key] || + (key === 'organisationUnit' && formModel[key] === this.organisationUnitPlaceholder) || + key === 'hearingType' || + (key === 'availableDurationMins' && formModel.isSlotBased) + ) { + return reducedParams; + } + return { + ...reducedParams, + [key]: formModel[key] + }; + }, + {} as CrownSchedulingFilters + ); + const searchFields = searchFieldsForSessionFilter(reduced.sessionStatusFilter); + return { ...reduced, ...searchFields }; + } + + handleSessionStatusFilterChange(filter: CrownSessionStatusFilter | undefined): void { + if ( + filter === CrownSessionStatusFilterOption.NONE || + filter === CrownSessionStatusFilterOption.ALL + ) { + this.formModel.courtRoomId = undefined; + } else if (filter != null) { + this.formModel.courtRoomId = filter; + } + } + + filterByOperationalUnit = (organisationUnit: OrganisationUnit): boolean => + isCrownCourt(organisationUnit) && + (!this.formModel?.oucodeL2Code || + this.formModel.oucodeL2Code === organisationUnit.oucodeL2Code); + + handleOperationalUnitChanged(oucodeL2Code?: string): void { + const orgUnitPlaceholder = operationalUnitAllCourtsPlaceholder('C', oucodeL2Code); + + this.organisationUnitPlaceholder = orgUnitPlaceholder; + + if ( + !this.formModel.organisationUnit || + this.formModel.organisationUnit.oucodeL2Code !== oucodeL2Code + ) { + this.formModel.organisationUnit = orgUnitPlaceholder; + } + + if (!oucodeL2Code) { + if (this.formModel.organisationUnit === this.organisationUnitPlaceholder) { + this.formModel.organisationUnit = undefined; + } + this.organisationUnitPlaceholder = undefined; + } + } + + handleCourtChanged(): void { + this.formModel.sessionStatusFilter = this.getDefaultSessionStatusFilter(); + this.formModel.courtRoomId = undefined; + } + + bookingTypeChange(isSlotBased: boolean): void { + if (isSlotBased) { + if (this.enableMultiDay) { + this.formModel.availableDurationMins = undefined; + } + this.formModel.isMultiday = undefined; + this.formModel.courtSession = undefined; + } else { + this.formModel.isMultiday = false; + + if (this.enableMultiDay) { + this.updateCourtSession(false); + } + } + this.formModel.businessType = undefined; + } + + updateCourtSession(isMultiday: boolean): void { + if (isMultiday) { + this.formModel.availableDurationMins = undefined; + this.formModel.courtSession = 'AD'; + } else { + this.formModel.availableDurationMins = undefined; + this.formModel.courtSession = undefined; + } + } + + handleResetForm() { + this.formModel = { + ...this.initialValues, + organisationUnit: undefined, + sessionStatusFilter: this.getDefaultSessionStatusFilter() + }; + this.organisationUnitPlaceholder = undefined; + } +} diff --git a/projects/scheduling/src/components/crown-scheduling-slots/__snapshots__/crown-scheduling-slots.component.spec.ts.snap b/projects/scheduling/src/components/crown-scheduling-slots/__snapshots__/crown-scheduling-slots.component.spec.ts.snap new file mode 100644 index 0000000..3454b7f --- /dev/null +++ b/projects/scheduling/src/components/crown-scheduling-slots/__snapshots__/crown-scheduling-slots.component.spec.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CrownSchedulingSlotsComponent should render 1`] = ` + + +`; diff --git a/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.html b/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.html new file mode 100644 index 0000000..4ba4bff --- /dev/null +++ b/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.html @@ -0,0 +1,84 @@ +@if (totalResults > 0) { +
+
+ @if (selectionMode === 'single') { + + + + } @else { + + } @if (totalResults > pageSize) { + + + } @if (formConfig) { + + @if (isFieldEnabled('hearingType')) { + + + + + + + + + } @if (isFieldEnabled('sendNotificationToParties')) { + + + + Yes + No + + + + } + + } +
+ @if (selectionMode !== 'readonly') { + + } +
+ +
+} diff --git a/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.spec.ts b/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.spec.ts new file mode 100644 index 0000000..a589b8e --- /dev/null +++ b/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CrownSchedulingSlotsComponent } from './crown-scheduling-slots.component'; +import { DatePipe } from '@angular/common'; +import { RotaBusinessType } from '@cpp/reference-data'; + +describe('CrownSchedulingSlotsComponent', () => { + let component: CrownSchedulingSlotsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CrownSchedulingSlotsComponent, DatePipe] + }).compileComponents(); + + fixture = TestBed.createComponent(CrownSchedulingSlotsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render', () => { + expect(fixture).toMatchSnapshot(); + }); + + it('should set rotaBusinessTypes correctly and generate a mapping', () => { + const businessTypes: RotaBusinessType[] = [ + { typeCode: 'PTPH', typeDescription: 'PTPH' }, + { typeCode: 'SEN', typeDescription: 'Sentence' } + ] as unknown as RotaBusinessType[]; + + component.rotaBusinessTypes = businessTypes; + expect(component.rotaBusinessTypesByCode).toEqual({ + PTPH: businessTypes[0], + SEN: businessTypes[1] + }); + }); +}); diff --git a/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.ts b/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.ts new file mode 100644 index 0000000..d981c3e --- /dev/null +++ b/projects/scheduling/src/components/crown-scheduling-slots/crown-scheduling-slots.component.ts @@ -0,0 +1,182 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { + PdkPaginationComponent, + PdkButton, + PdkCore, + PdkForm, + PdkGrid, + PdkRadio, + PdkSelectComponent, + SelectOption, + ValidationError +} from '@cpp/pdk'; +import { + AllocationsFormConfig, + AllocationsFormConfigField, + HearingSlot, + HearingSlotAllocation, + SchedulingSlotAllocationSubmit +} from '../../types'; +import * as utils from '../../utils'; +import { HearingType, RotaBusinessType } from '@cpp/reference-data'; +import { HearingSlotsTableComponent } from '../hearing-slots-table/hearing-slots-table.component'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'crown-scheduling-slots', + templateUrl: './crown-scheduling-slots.component.html', + styles: [''], + imports: [ + HearingSlotsTableComponent, + PdkPaginationComponent, + PdkSelectComponent, + PdkGrid, + PdkButton, + PdkRadio, + PdkCore, + PdkForm, + FormsModule + ] +}) +export class CrownSchedulingSlotsComponent { + @Input() selectionMode: 'readonly' | 'single' = 'single'; + @Input() currentPage = 1; + @Input() hearingSlots: HearingSlot[] = []; + @Input() hearingSlotMinutes?: number = 0; + @Input() pageSize = 10; + @Input() totalResults = 0; + @Input() formConfig: AllocationsFormConfig | undefined; + + @Input() + set rotaBusinessTypes(rotaBusinessTypes: RotaBusinessType[]) { + this.rotaBusinessTypesByCode = rotaBusinessTypes.reduce((map, rotaBusinessType) => { + map[rotaBusinessType.typeCode] = rotaBusinessType; + return map; + }, {} as Record); + } + + private _hearingTypes: HearingType[] = []; + + @Input() + set hearingTypes(hearingTypes: HearingType[]) { + this._hearingTypes = hearingTypes; + this.hearingTypesOptions = hearingTypes.map((hearingType) => ({ + value: hearingType.id, + label: hearingType.hearingDescription + })); + } + + get hearingTypes(): HearingType[] { + return this._hearingTypes; + } + + @Input() + set hearingType(hearingType: HearingType | undefined) { + this.selectedHearingTypeId = hearingType?.id ?? null; + } + + @Output() errors = new EventEmitter(); + @Output() hearingSlotAllocations = new EventEmitter(); + @Output() pageChange = new EventEmitter(); + + selectedHearingTypeId: string | null = null; + selectedHearingSlotTimestamps: { [courtScheduleId: string]: string } = {}; + allocations: HearingSlotAllocation[] = []; + selectedSlotsModel: string[] = []; + rotaBusinessTypesByCode: Record = {}; + hearingTypesOptions: SelectOption[] = []; + + private _sendNotificationToParties = false; + + get sendNotificationToParties(): boolean | undefined { + return this.isFieldEnabled('sendNotificationToParties') + ? this._sendNotificationToParties + : undefined; + } + + set sendNotificationToParties(value: boolean | undefined) { + this._sendNotificationToParties = value ?? false; + } + + handleSubmitAllocations() { + this.errors.emit(null); + this.allocations.sort((a, b) => +new Date(a.hearingSlotTime) - +new Date(b.hearingSlotTime)); + + this.hearingSlotAllocations.emit({ + hearingSlotAllocations: this.allocations, + sendNotificationToParties: this.sendNotificationToParties, + hearingType: this._hearingTypes.find( + (hearingType) => hearingType.id === this.selectedHearingTypeId + ) + }); + } + + reset() { + this.selectedSlotsModel = []; + this.allocations = []; + } + + handleAllocationChanged(newSelectedSlotsModel: string[]) { + const existingAllocations = this.allocations.filter(({ hearingSlot }) => + newSelectedSlotsModel.includes(hearingSlot.courtScheduleId) + ); + + const existingIds = existingAllocations.map(({ hearingSlot }) => hearingSlot.courtScheduleId); + + const newAllocations = newSelectedSlotsModel + .filter((id) => !existingIds.includes(id)) + .map((id) => { + const hearingSlot = this.getHearingSlot(id); + if (!hearingSlot) return null; + + const startTime = this.selectedHearingSlotTimestamps[id]; + const hearingSlotTime = startTime || utils.getHearingSlotTimestamp(hearingSlot); + + return { + hearingSlot, + hearingSlotTime, + ...(!hearingSlot.slotBased ? { duration: this.hearingSlotMinutes } : {}) + } as HearingSlotAllocation; + }) + .filter((allocation): allocation is HearingSlotAllocation => allocation !== null); + + this.allocations = [...existingAllocations, ...newAllocations]; + } + + handleHearingSlotTimeChanged(selectedHearingSlotTimestamp: Record) { + const courtScheduleId = Object.keys(selectedHearingSlotTimestamp)[0]; + + this.selectedHearingSlotTimestamps = { + ...this.selectedHearingSlotTimestamps, + ...selectedHearingSlotTimestamp + }; + + const allocation = this.getAllocation(courtScheduleId); + + if (allocation) { + allocation.hearingSlotTime = this.selectedHearingSlotTimestamps[courtScheduleId]; + } + } + + isFieldEnabled(field: AllocationsFormConfigField): boolean { + return this.formConfig?.formFields?.includes(field) ?? false; + } + + handleDisabled(): boolean { + const hasAllocations = this.allocations.length > 0; + const hearingTypeValid = !this.isFieldEnabled('hearingType') || !!this.selectedHearingTypeId; + + return !(hasAllocations && hearingTypeValid); + } + + private getAllocation(courtScheduleId: string): HearingSlotAllocation | undefined { + return this.allocations.find( + (allocation) => allocation.hearingSlot.courtScheduleId === courtScheduleId + ); + } + + private getHearingSlot(courtScheduleId: string): HearingSlot | undefined { + return this.hearingSlots.find((hearingSlot) => hearingSlot.courtScheduleId === courtScheduleId); + } +} diff --git a/projects/scheduling/src/components/estimate-input/estimate-input.html b/projects/scheduling/src/components/estimate-input/estimate-input.html index 9d9aeca..98d1b25 100644 --- a/projects/scheduling/src/components/estimate-input/estimate-input.html +++ b/projects/scheduling/src/components/estimate-input/estimate-input.html @@ -58,7 +58,7 @@ } -
+
{ hoursInput.nativeElement.value = hours || ''; hoursInput.nativeElement.dispatchEvent(new Event('input')); } - minutesInput.nativeElement.value = minutes || ''; - minutesInput.nativeElement.dispatchEvent(new Event('input')); + if (minutesInput) { + minutesInput.nativeElement.value = minutes || ''; + minutesInput.nativeElement.dispatchEvent(new Event('input')); + } }; describe('when only minutes are used', () => { @@ -290,6 +292,52 @@ describe('estimate-input', () => { })); }); + describe('when days are enabled and minutes-disabled is set', () => { + @Component({ + selector: 'estimate-days-only-input', + template: ` +
+ + +
+ `, + standalone: false + }) + class EstimateDaysOnly { + model!: number; + } + + beforeEach(initTestWith(EstimateDaysOnly)); + + it('hides the minutes input', () => { + const minutesEl = fixture.debugElement.query(By.css('[name=estimateMinutes]')); + expect(minutesEl).toBeTruthy(); + expect(minutesEl.nativeElement.closest('.form-group')?.hidden).toBe(true); + }); + + it('sets the model from whole days only', () => { + setTimeValues({ days: '2' }); + expect(estimate.value).toEqual(720); + }); + + it('keeps total minutes accurate when the model has a remainder after whole days', fakeAsync(() => { + fixture.componentInstance.model = 2 * 360 + 45; + fixture.detectChanges(); + tick(); + expect(daysInput.nativeElement.value).toEqual('2'); + const minutesEl = fixture.debugElement.query(By.css('[name=estimateMinutes]'))!; + expect(minutesEl.nativeElement.value).toEqual('45'); + expect(minutesEl.nativeElement.closest('.form-group')?.hidden).toBe(true); + expect(estimate.value).toEqual(765); + })); + }); + describe('when weeks are enabled', () => { @Component({ selector: 'estimate-weeks-minutes-input', diff --git a/projects/scheduling/src/components/estimate-input/estimate-input.ts b/projects/scheduling/src/components/estimate-input/estimate-input.ts index 929062e..4198965 100644 --- a/projects/scheduling/src/components/estimate-input/estimate-input.ts +++ b/projects/scheduling/src/components/estimate-input/estimate-input.ts @@ -71,6 +71,7 @@ export class EstimateInput @Input() hoursPerDay!: number; @Input() daysPerWeek!: number; @Input() minMinutesValue!: number; + @Input() maxMinutesValue?: number; weeksEnabled = input(null, { transform: (value) => coerceBooleanProperty(value), @@ -84,6 +85,10 @@ export class EstimateInput transform: (value) => coerceBooleanProperty(value), alias: 'hours-enabled' }); + minutesDisabled = input(null, { + transform: (value) => coerceBooleanProperty(value), + alias: 'minutes-disabled' + }); readonly weeksRef = viewChild>('weeksInput', { read: ElementRef @@ -113,8 +118,7 @@ export class EstimateInput get errorMessages() { const expected = this.minMinutesValue; const suffix = expected === 1 ? 'minute' : 'minutes'; - - return [ + const messages: { rule: string; message: string }[] = [ { rule: 'estimateFormat', message: `Time not recognised, use this format, for example 1 5 15` @@ -124,12 +128,19 @@ export class EstimateInput message: `Estimate is too low - you must enter at least ${expected} ${suffix}` } ]; + if (this.maxMinutesValue != null) { + messages.push({ + rule: 'maxMinutesEstimate', + message: `Estimate must not exceed ${this.maxMinutesValue} minutes` + }); + } + return messages; } @Output() blur = new EventEmitter(); @Output() focus = new EventEmitter(); - constructor(private injector: Injector, elementRef: ElementRef) { + constructor(private injector: Injector) { i += 1; this.id = `estimate-input-${i}`; @@ -273,6 +284,10 @@ export class EstimateInput if (minMinutesEstimate) { return minMinutesEstimate; } + const maxMinutesEstimate = this.validateMaximumMinutes(c); + if (maxMinutesEstimate) { + return maxMinutesEstimate; + } return null; } @@ -288,6 +303,18 @@ export class EstimateInput return null; } + validateMaximumMinutes(c: FormControl): { [k: string]: any } | null { + if (this.maxMinutesValue != null && c.value > this.maxMinutesValue) { + return { + maxMinutesEstimate: { + expected: this.maxMinutesValue, + actual: c.value + } + }; + } + return null; + } + validateFormat(): { [k: string]: any } | null { return ['weeks', 'days', 'hours', 'minutes'].reduce( (errors: { [k: string]: any } = {}, controlName) => { diff --git a/projects/scheduling/src/components/hearing-slots-row/__snapshots__/hearing-slots-row.component.spec.ts.snap b/projects/scheduling/src/components/hearing-slots-row/__snapshots__/hearing-slots-row.component.spec.ts.snap index d6cd4aa..e31f9f0 100644 --- a/projects/scheduling/src/components/hearing-slots-row/__snapshots__/hearing-slots-row.component.spec.ts.snap +++ b/projects/scheduling/src/components/hearing-slots-row/__snapshots__/hearing-slots-row.component.spec.ts.snap @@ -15,7 +15,13 @@ exports[`HearingSlotsRowComponent should render 1`] = ` data-test-id="sessionDate" pdk-table-cell="" > - 25-03-2025 +
+ + 25-03-2025 + +
-
+ + + + + + + + AM + + + +
    +
  1. + 0 +
  2. +
+ + + + +
    +
  1. +
  2. +
+ + +`; + +exports[`HearingSlotsRowComponent should render when hearing slot is draft 1`] = ` + + +
+ + 25-03-2025 + + +
+ Draft +
+
+
+ + + + + + + +
+ Luton Crown Court - {{ hearingSlotData.sessionDate | date : 'dd-MM-yyyy' }} +
+ {{ hearingSlotData.sessionDate | date : 'dd-MM-yyyy' }} + @if (hearingSlotData.draft) { + Draft + } +
@@ -16,10 +28,12 @@ - @if (selectionMode !== 'readonly') { + @if (selectionMode !== 'readonly' && !hearingSlotData.draft) { + + + + +
{{ hearingSlotData.courtHouseName }} + @if (!!hearingSlotData.courtRoomId) {
{{ hearingSlotData.courtRoomName }} + }
@@ -36,7 +50,7 @@
table td { width: 210px; } +.session-date-cell { + display: flex; + flex-direction: column; + align-items: flex-start; +} + :host ::ng-deep label.govuk-label.govuk-checkboxes__label, :host ::ng-deep label.govuk-radios__label.govuk-label { display: contents; diff --git a/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.spec.ts b/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.spec.ts index ac242a7..320d6e6 100644 --- a/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.spec.ts +++ b/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.spec.ts @@ -2,6 +2,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HearingSlotsRowComponent } from './hearing-slots-row.component'; import { DurationPipe } from '../../pipes/duration.pipe'; import { HearingSlot } from '../../types'; +import { + mockHearingSlotAmSession, + mockHearingSlotDraftSession +} from '../../mocks/hearing-slot.mocks'; import * as utils from '../../utils'; import { BusinessTypeDescriptionPipe } from '../../pipes/businessTypeDescription.pipe'; import { @@ -10,7 +14,8 @@ import { PdkCheckboxComponent, PdkTable, PdkForm, - PdkCore + PdkCore, + PdkTagComponent } from '@cpp/pdk'; jest.mock('../../utils', () => ({ @@ -23,19 +28,6 @@ describe('HearingSlotsRowComponent', () => { let component: HearingSlotsRowComponent; let fixture: ComponentFixture; - const hearingSlot = { - courtSession: 'AM', - sessionDate: '2025-03-25', - slotStartTimes: [ - { - sessionStartTime: '2025-03-25T10:00:00.000Z', - sessionEndTime: '2025-03-25T11:00:00.000Z', - count: 1 - } - ], - allDaySplit: false - } as HearingSlot; - beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ @@ -47,13 +39,15 @@ describe('HearingSlotsRowComponent', () => { PdkCheckboxComponent, PdkTable, PdkForm, - PdkCore + PdkCore, + PdkTagComponent ] }).compileComponents(); fixture = TestBed.createComponent(HearingSlotsRowComponent); component = fixture.componentInstance; - fixture.componentRef.setInput('hearingSlot', hearingSlot); + fixture.componentRef.setInput('hearingSlot', mockHearingSlotAmSession); + fixture.componentRef.setInput('selectedHearingSlotTimestamps', {}); fixture.detectChanges(); }); @@ -65,9 +59,15 @@ describe('HearingSlotsRowComponent', () => { expect(fixture).toMatchSnapshot(); }); + it('should render when hearing slot is draft', () => { + fixture.componentRef.setInput('hearingSlot', mockHearingSlotDraftSession); + fixture.detectChanges(); + expect(fixture).toMatchSnapshot(); + }); + it('should call getHearingSlotTimeOptions from utils', () => { - const result = component.getHearingSlotTimeOptions(hearingSlot); - expect(utils.getHearingSlotTimeOptions).toHaveBeenCalledWith(hearingSlot); + const result = component.getHearingSlotTimeOptions(mockHearingSlotAmSession); + expect(utils.getHearingSlotTimeOptions).toHaveBeenCalledWith(mockHearingSlotAmSession); expect(result).toEqual([{ value: '10:00', label: '10:00am to 11:00am', count: 1 }]); }); diff --git a/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.ts b/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.ts index 8f27585..847d481 100644 --- a/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.ts +++ b/projects/scheduling/src/components/hearing-slots-row/hearing-slots-row.component.ts @@ -6,6 +6,7 @@ import { PdkTable, PdkRadioButtonComponent, PdkSelectComponent, + PdkTagComponent, ValidationError } from '@cpp/pdk'; import { HearingSlot } from '../../types'; @@ -26,6 +27,7 @@ import { FormsModule } from '@angular/forms'; DatePipe, DurationPipe, PdkSelectComponent, + PdkTagComponent, PdkRadioButtonComponent, PdkCheckboxComponent, PdkTable, diff --git a/projects/scheduling/src/components/hearing-slots-table/__snapshots__/hearing-slots-table.component.spec.ts.snap b/projects/scheduling/src/components/hearing-slots-table/__snapshots__/hearing-slots-table.component.spec.ts.snap index bf7420a..ea0a67a 100644 --- a/projects/scheduling/src/components/hearing-slots-table/__snapshots__/hearing-slots-table.component.spec.ts.snap +++ b/projects/scheduling/src/components/hearing-slots-table/__snapshots__/hearing-slots-table.component.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`HearingSlotsTableComponent should display hearing slots correctly 1`] = ` +exports[`HearingSlotsTableComponent should display draft hearing slots correctly 1`] = ` - 25-03-2025 +
+ + 25-03-2025 + + +
+ Draft +
+
+
+ Luton Crown Court -
+
+ + + + + AM + + +
    +
  1. + 0 +
  2. +
+
+
+
    +
  1. +
  2. +
+
+ +`; + +exports[`HearingSlotsTableComponent should display hearing slots correctly 1`] = ` + + + + + + + + + + + + + + + +
+ Date + + Court Details + + Business + + Session + + Time/slots remaining + + Booked +
+
+ + 25-03-2025 + +
+
+ + + diff --git a/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.html b/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.html index 6c53409..daf8e0d 100644 --- a/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.html +++ b/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.html @@ -15,7 +15,7 @@ - @for (hearingSlot of hearingSlots; track hearingSlot.courtScheduleId) { + @for (hearingSlot of hearingSlots; track trackByCourtScheduleId(hearingSlot)) { { let component: HearingSlotsTableComponent; @@ -34,20 +37,14 @@ describe('HearingSlotsTableComponent', () => { }); it('should display hearing slots correctly', () => { - component.hearingSlots = [ - { - courtSession: 'AM', - sessionDate: '2025-03-25', - slotStartTimes: [ - { - sessionStartTime: '2025-03-25T10:00:00.000Z', - sessionEndTime: '2025-03-25T11:00:00.000Z', - count: 1 - } - ], - allDaySplit: false - } as HearingSlot - ]; + component.hearingSlots = [mockHearingSlotAmSession]; + fixture.detectChanges(); + + expect(fixture).toMatchSnapshot(); + }); + + it('should display draft hearing slots correctly', () => { + component.hearingSlots = [mockHearingSlotDraftSession]; fixture.detectChanges(); expect(fixture).toMatchSnapshot(); diff --git a/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.ts b/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.ts index c29dafd..2c39bc9 100644 --- a/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.ts +++ b/projects/scheduling/src/components/hearing-slots-table/hearing-slots-table.component.ts @@ -26,4 +26,16 @@ export class HearingSlotsTableComponent { handleHearingSlotTimeChanged(selectedHearingSlotTimestamp: Record) { this.selectedHearingSlotTimestamp.emit(selectedHearingSlotTimestamp); } + + /** + * The radio group wraps the table from the parent, while `pdk-radio-button` instances live in + * nested row templates. Selecting a radio still updates the group value, but if the parent sets the + * group value from outside, the nested radios are not being updated because the + * group’s `ContentChildren` lookup cannot see into another component’s template. + * As a workaround, this track returns a new unique key each + * time so `@for` remounts rows and the visible selection can match the model. + */ + trackByCourtScheduleId(hearingSlot: HearingSlot): string { + return `${hearingSlot.courtScheduleId}-${(Date.now() * Math.random()).toString()}`; + } } diff --git a/projects/scheduling/src/components/scheduling-filters/__snapshots__/scheduling-filters.component.spec.ts.snap b/projects/scheduling/src/components/magistrates-scheduling-filters/__snapshots__/magistrates-scheduling-filters.component.spec.ts.snap similarity index 69% rename from projects/scheduling/src/components/scheduling-filters/__snapshots__/scheduling-filters.component.spec.ts.snap rename to projects/scheduling/src/components/magistrates-scheduling-filters/__snapshots__/magistrates-scheduling-filters.component.spec.ts.snap index d3151c8..97aac17 100644 --- a/projects/scheduling/src/components/scheduling-filters/__snapshots__/scheduling-filters.component.spec.ts.snap +++ b/projects/scheduling/src/components/magistrates-scheduling-filters/__snapshots__/magistrates-scheduling-filters.component.spec.ts.snap @@ -1,15 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SchedulingFiltersComponent should render 1`] = ` - - - - - - + + - - - - - - - + Choose operational unit + + + + + + + - - - - - - -
-
- - - When autocomplete results are available, use the up and down arrows to review and Enter to select. Touch device users, explore options with swipe gestures. - - - + + + + + +
+ + + When autocomplete results are available, use the up and down arrows to review and Enter to select. Touch device users, explore options with swipe gestures. + + + +
+
-
- - - - - - - - - + + + + + + + - - - - - - - + Courtroom + + + + + + +
+
- - - - -
- - Booking type - - -
- -
- - -
-
- + +
-
- For example, PSR -
- - -
+
+ + +
+ + - -
+ + +
- Duration based - -
-
- -
- For example, Trial -
-
-
-
-
-
-
-
- - + + + + +
+ For example, Trial +
+
+ + + + +
+
+ - - - - - - All slot based business types - - - - - - + + + + + + +
- + @@ -367,7 +378,7 @@ exports[`SchedulingFiltersComponent should render 1`] = ` @@ -387,7 +398,7 @@ exports[`SchedulingFiltersComponent should render 1`] = ` @@ -407,7 +418,7 @@ exports[`SchedulingFiltersComponent should render 1`] = ` @@ -462,7 +473,7 @@ exports[`SchedulingFiltersComponent should render 1`] = ` @@ -482,7 +493,7 @@ exports[`SchedulingFiltersComponent should render 1`] = ` @@ -548,7 +559,7 @@ exports[`SchedulingFiltersComponent should render 1`] = ` >
+ `; diff --git a/projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.html b/projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.html new file mode 100644 index 0000000..6fa3faf --- /dev/null +++ b/projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.html @@ -0,0 +1,150 @@ +
+

Search for a hearing slot

+
+ +
+ + + + +
+
+ + + + + + + + @if (formModel.isMultiday) { + All day + } + + + + + + + + + + + + + + +
+ + +
to
+
+ @if (sessionStartDate.valid && sessionStartDate.value) { + + Selected day:  + {{ sessionStartDate.value | date : 'EEEE' }} + + } +
+
+ + + + + + @if ( sessionEndDate.value && !sessionEndDate.errors?.dateExists && + !sessionEndDate.errors?.dateFormat ) { + + Selected day:  + {{ sessionEndDate.value | date : 'EEEE' }} + + } + + +
+ +
+ + +
+ +
diff --git a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.spec.ts b/projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.spec.ts similarity index 74% rename from projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.spec.ts rename to projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.spec.ts index 3063d68..0b0aa46 100644 --- a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.spec.ts +++ b/projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.spec.ts @@ -1,16 +1,16 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SchedulingFiltersComponent } from './scheduling-filters.component'; +import { MagistratesSchedulingFiltersComponent } from './magistrates-scheduling-filters.component'; import { CppHttp } from '@cpp/core'; import { OrganisationUnit } from '@cpp/reference-data'; -import { SchedulingFilters } from '../../types'; +import { MagistratesSchedulingFilters } from '../../types'; import { of } from 'rxjs'; import { provideMockStore } from '@ngrx/store/testing'; -describe('SchedulingFiltersComponent', () => { - let component: SchedulingFiltersComponent; - let fixture: ComponentFixture; +describe('MagistratesSchedulingFiltersComponent', () => { + let component: MagistratesSchedulingFiltersComponent; + let fixture: ComponentFixture; - const defaultFilters: SchedulingFilters = { + const defaultFilters: MagistratesSchedulingFilters = { organisationUnit: { id: 'c133d0de-c989-48b9-bd20-0431943e347e', oucode: 'B01DU00', @@ -36,7 +36,7 @@ describe('SchedulingFiltersComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SchedulingFiltersComponent], + imports: [MagistratesSchedulingFiltersComponent], providers: [ provideMockStore({ initialState: {} }), { @@ -48,7 +48,7 @@ describe('SchedulingFiltersComponent', () => { ] }).compileComponents(); - fixture = TestBed.createComponent(SchedulingFiltersComponent); + fixture = TestBed.createComponent(MagistratesSchedulingFiltersComponent); component = fixture.componentInstance; component.defaultValues = defaultFilters; fixture.detectChanges(); @@ -62,6 +62,35 @@ describe('SchedulingFiltersComponent', () => { expect(fixture).toMatchSnapshot(); }); + describe('effectiveMinDate', () => { + const today = '2026-04-10'; + const tomorrow = '2026-04-11'; + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date(today)); + }); + + it('should resolve minDate to today by default', () => { + expect(component.effectiveMinDate()).toBe(today); + }); + + it('should resolve minDate to custom value when provided', () => { + fixture.componentRef.setInput('minDate', tomorrow); + fixture.detectChanges(); + expect(component.effectiveMinDate()).toBe(tomorrow); + }); + + it('should not restrict minDate when allowPastDates is true and no minDate provided', () => { + fixture.componentRef.setInput('allowPastDates', true); + fixture.componentRef.setInput('minDate', undefined); + fixture.detectChanges(); + expect(component.effectiveMinDate()).toBeUndefined(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + }); + it('should emit filtersSubmit with filtered form model on submit', () => { jest.spyOn(component.filtersSubmit, 'emit'); component.formModel = { ...defaultFilters, courtSession: 'AM' }; @@ -103,13 +132,15 @@ describe('SchedulingFiltersComponent', () => { expect(component.formModel.organisationUnit!.oucodeL2Code).toBe('LONDON'); }); - it('should reset form model to initial values', () => { + it('should reset form model to initial values with court cleared', () => { component.formModel.courtSession = 'PM'; component.handleResetForm(); const expected = { - ...component.initialValues + ...component.initialValues, + organisationUnit: undefined }; expect(component.formModel).toEqual(expected); + expect(component.organisationUnitPlaceholder).toBeUndefined(); }); it('should generate operational unit options from input', () => { @@ -118,11 +149,13 @@ describe('SchedulingFiltersComponent', () => { expect(component.operationalUnitOptions.length).toBeGreaterThan(0); }); - it('should call handleOperationalUnitChanged in ngOnChanges when formModel.oucodeL2Code is set', () => { + it('should sync operational unit when defaultValues include oucodeL2Code', () => { const spy = jest.spyOn(component, 'handleOperationalUnitChanged'); - component.formModel = { oucodeL2Code: 'ABC' } as SchedulingFilters; - component.ngOnChanges(); + component.defaultValues = { + ...defaultFilters, + oucodeL2Code: 'ABC' + }; expect(spy).toHaveBeenCalledWith('ABC'); }); diff --git a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.ts b/projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.ts similarity index 56% rename from projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.ts rename to projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.ts index f8d3fb5..f2cb30c 100644 --- a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.ts +++ b/projects/scheduling/src/components/magistrates-scheduling-filters/magistrates-scheduling-filters.component.ts @@ -1,74 +1,68 @@ import { ChangeDetectionStrategy, + computed, Component, EventEmitter, Input, - OnChanges, + input, Output, ViewEncapsulation } from '@angular/core'; import { - PdkHintComponent, - PdkInsetTextComponent, PdkButton, PdkCore, PdkDateInput, PdkForm, PdkGrid, + PdkInsetTextComponent, PdkRadio, PdkTextInput, PdkSelectComponent, SelectOption, ValidationError } from '@cpp/pdk'; -import { SchedulingFilters } from '../../types/filters'; +import { MagistratesSchedulingFilters } from '../../types/filters'; +import { COURT_SESSION_SELECT_OPTIONS } from '../../types/schedulingFilterOptions'; import { CppReferenceDataComponents, OrganisationUnit, RotaBusinessType } from '@cpp/reference-data'; import * as utils from '../../utils'; -import { DatePipe } from '@angular/common'; -import { EstimateInput } from '../estimate-input/estimate-input'; +import { operationalUnitAllCourtsPlaceholder } from '../../utils/operationalUnit'; +import { DatePipe, formatDate } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { SchedulingFiltersCourtAndBookingComponent } from '../scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component'; -const courtSessionOptions: SelectOption[] = [ - { value: undefined, label: 'Any' }, - { value: 'AM', label: 'AM' }, - { value: 'PM', label: 'PM' }, - { value: 'AD', label: 'All day' } -]; - -const panelOptions: SelectOption[] = [ +const panelOptions: SelectOption[] = [ { value: 'ADULT', label: 'Adult' }, { value: 'YOUTH', label: 'Youth' } ]; @Component({ - selector: 'scheduling-filters', + selector: 'magistrates-scheduling-filters', changeDetection: ChangeDetectionStrategy.OnPush, - templateUrl: `./scheduling-filters.component.html`, - styleUrls: ['./scheduling-filters.component.scss'], + templateUrl: './magistrates-scheduling-filters.component.html', + styleUrls: ['../scheduling-filters.layout.scss'], encapsulation: ViewEncapsulation.None, imports: [ FormsModule, + DatePipe, PdkSelectComponent, - PdkInsetTextComponent, PdkGrid, PdkRadio, PdkButton, PdkCore, + PdkDateInput, + PdkInsetTextComponent, CppReferenceDataComponents, - DatePipe, - EstimateInput, PdkForm, - PdkHintComponent, PdkTextInput, - PdkDateInput + SchedulingFiltersCourtAndBookingComponent ] }) -export class SchedulingFiltersComponent implements OnChanges { - @Input() set defaultValues(defaultValues: SchedulingFilters) { +export class MagistratesSchedulingFiltersComponent { + @Input() set defaultValues(defaultValues: MagistratesSchedulingFilters) { this.formModel = { ...defaultValues, isSlotBased: defaultValues?.isSlotBased ?? true }; if (!this.initialValues) { this.initialValues = { @@ -79,32 +73,37 @@ export class SchedulingFiltersComponent implements OnChanges { isSlotBased: true }; } + this.applyOperationalUnitByDefault(); } @Input() set organisationUnits(organisationUnits: OrganisationUnit[]) { this.operationalUnitOptions = utils.getOperationalUnitOptions(organisationUnits); } @Input() rotaBusinessTypes?: RotaBusinessType[]; - @Input() minimumDate?: string; @Input() enableMultiDay = true; @Output() errors = new EventEmitter(); - @Output() filtersSubmit = new EventEmitter(); + @Output() filtersSubmit = new EventEmitter(); - formModel!: SchedulingFilters; - initialValues!: SchedulingFilters; + formModel!: MagistratesSchedulingFilters; + initialValues!: MagistratesSchedulingFilters; operationalUnitOptions: SelectOption[] = []; organisationUnitPlaceholder?: OrganisationUnit; - courtSessionOptions: SelectOption[] = - courtSessionOptions; - panelOptions: SelectOption[] = panelOptions; + courtSessionOptions = COURT_SESSION_SELECT_OPTIONS; + panelOptions: SelectOption[] = panelOptions; slotFilterFn = (rotaBusinessType: RotaBusinessType) => !rotaBusinessType.duration; durationFilterFn = (rotaBusinessType: RotaBusinessType) => rotaBusinessType.duration; protected readonly utils = utils; - constructor() {} - - ngOnChanges(): void { - if (this.formModel?.oucodeL2Code) { - this.handleOperationalUnitChanged(this.formModel.oucodeL2Code!); - } + readonly minDate = input(undefined); + readonly allowPastDates = input(false); + readonly effectiveMinDate = computed( + () => + this.minDate() ?? + (this.allowPastDates() ? undefined : formatDate(new Date(), 'yyyy-MM-dd', 'en-GB')) + ); + + private applyOperationalUnitByDefault(): void { + const ouCode = this.formModel?.oucodeL2Code; + if (!ouCode) return; + this.handleOperationalUnitChanged(ouCode); } handleFiltersSubmit() { @@ -112,24 +111,27 @@ export class SchedulingFiltersComponent implements OnChanges { this.filtersSubmit.emit(filteredFormModel); } - filterFormModel(formModel: SchedulingFilters): SchedulingFilters { - return (Object.keys(formModel) as (keyof SchedulingFilters)[]).reduce((reducedParams, key) => { - if (key === 'isMultiday' || key === 'isSlotBased') { - return { ...reducedParams, [key]: formModel[key] }; - } - if ( - !formModel[key] || - (key === 'organisationUnit' && formModel[key] === this.organisationUnitPlaceholder) || - key === 'hearingType' || // Exclude hearingType as it's not part of the scheduling filters form - (key === 'availableDurationMins' && formModel.isSlotBased) // SJP will populate duration on a previous step instead of using duration input, as this will be disabled by enableMultiDay = false, so multi day is not allowed. - ) { - return reducedParams; - } - return { - ...reducedParams, - [key]: formModel[key] - }; - }, {}) as SchedulingFilters; + filterFormModel(formModel: MagistratesSchedulingFilters): MagistratesSchedulingFilters { + return (Object.keys(formModel) as (keyof MagistratesSchedulingFilters)[]).reduce( + (reducedParams, key) => { + if (key === 'isMultiday' || key === 'isSlotBased') { + return { ...reducedParams, [key]: formModel[key] }; + } + if ( + !formModel[key] || + (key === 'organisationUnit' && formModel[key] === this.organisationUnitPlaceholder) || + key === 'hearingType' || + (key === 'availableDurationMins' && formModel.isSlotBased) + ) { + return reducedParams; + } + return { + ...reducedParams, + [key]: formModel[key] + }; + }, + {} + ) as MagistratesSchedulingFilters; } filterByOperationalUnit = (organisationUnit: OrganisationUnit): boolean => @@ -138,21 +140,15 @@ export class SchedulingFiltersComponent implements OnChanges { this.formModel.oucodeL2Code === organisationUnit.oucodeL2Code); handleOperationalUnitChanged(oucodeL2Code?: string): void { - const orgUnitplaceholder = { - id: '', - oucodeL1Code: 'B', - oucodeL2Code, - oucodeL3Name: 'All courts', - oucodeL3Code: 'All courts' - } as OrganisationUnit; + const orgUnitPlaceholder = operationalUnitAllCourtsPlaceholder('B', oucodeL2Code); - this.organisationUnitPlaceholder = orgUnitplaceholder; + this.organisationUnitPlaceholder = orgUnitPlaceholder; if ( !this.formModel.organisationUnit || this.formModel.organisationUnit.oucodeL2Code !== oucodeL2Code ) { - this.formModel.organisationUnit = orgUnitplaceholder; + this.formModel.organisationUnit = orgUnitPlaceholder; } if (!oucodeL2Code) { @@ -196,8 +192,9 @@ export class SchedulingFiltersComponent implements OnChanges { handleResetForm() { this.formModel = { - ...this.initialValues + ...this.initialValues, + organisationUnit: undefined }; - this.handleOperationalUnitChanged(this.initialValues.organisationUnit?.oucodeL2Code); + this.organisationUnitPlaceholder = undefined; } } diff --git a/projects/scheduling/src/components/scheduling-slots/__snapshots__/scheduling-slots.component.spec.ts.snap b/projects/scheduling/src/components/magistrates-scheduling-slots/__snapshots__/magistrates-scheduling-slots.component.spec.ts.snap similarity index 84% rename from projects/scheduling/src/components/scheduling-slots/__snapshots__/scheduling-slots.component.spec.ts.snap rename to projects/scheduling/src/components/magistrates-scheduling-slots/__snapshots__/magistrates-scheduling-slots.component.spec.ts.snap index a16b578..e88d5b0 100644 --- a/projects/scheduling/src/components/scheduling-slots/__snapshots__/scheduling-slots.component.spec.ts.snap +++ b/projects/scheduling/src/components/magistrates-scheduling-slots/__snapshots__/magistrates-scheduling-slots.component.spec.ts.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SchedulingSlotsComponent should render 1`] = ` - - + `; diff --git a/projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.html b/projects/scheduling/src/components/magistrates-scheduling-slots/magistrates-scheduling-slots.component.html similarity index 96% rename from projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.html rename to projects/scheduling/src/components/magistrates-scheduling-slots/magistrates-scheduling-slots.component.html index 6c3f28c..3250a9c 100644 --- a/projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.html +++ b/projects/scheduling/src/components/magistrates-scheduling-slots/magistrates-scheduling-slots.component.html @@ -13,6 +13,7 @@ [rotaBusinessTypesByCode]="rotaBusinessTypesByCode" [selectedHearingSlotTimestamps]="selectedHearingSlotTimestamps" (selectedHearingSlotTimestamp)="handleHearingSlotTimeChanged($event)" + (errors)="errors.emit($event)" /> } @case ('multi') { @@ -27,12 +28,14 @@ [rotaBusinessTypesByCode]="rotaBusinessTypesByCode" [selectedHearingSlotTimestamps]="selectedHearingSlotTimestamps" (selectedHearingSlotTimestamp)="handleHearingSlotTimeChanged($event)" + (errors)="errors.emit($event)" /> } @default { } } @if (totalResults > pageSize) { { + let component: MagistratesSchedulingSlotsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MagistratesSchedulingSlotsComponent, DatePipe] + }).compileComponents(); + + fixture = TestBed.createComponent(MagistratesSchedulingSlotsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render', () => { + expect(fixture).toMatchSnapshot(); + }); + + it('should set rotaBusinessTypes correctly and generate a mapping', () => { + const businessTypes: RotaBusinessType[] = [ + { typeCode: 'TRL', typeDescription: 'Trial' }, + { typeCode: 'TFL', typeDescription: 'Tfl' } + ] as unknown as RotaBusinessType[]; + + component.rotaBusinessTypes = businessTypes; + expect(component.rotaBusinessTypesByCode).toEqual({ + TRL: businessTypes[0], + TFL: businessTypes[1] + }); + }); +}); diff --git a/projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.ts b/projects/scheduling/src/components/magistrates-scheduling-slots/magistrates-scheduling-slots.component.ts similarity index 93% rename from projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.ts rename to projects/scheduling/src/components/magistrates-scheduling-slots/magistrates-scheduling-slots.component.ts index f84cc31..b3bb40d 100644 --- a/projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.ts +++ b/projects/scheduling/src/components/magistrates-scheduling-slots/magistrates-scheduling-slots.component.ts @@ -17,7 +17,8 @@ import { AllocationsFormConfig, AllocationsFormConfigField, HearingSlot, - HearingSlotAllocation + HearingSlotAllocation, + SchedulingSlotAllocationSubmit } from '../../types'; import * as utils from '../../utils'; import { HearingType, RotaBusinessType } from '@cpp/reference-data'; @@ -25,8 +26,8 @@ import { HearingSlotsTableComponent } from '../hearing-slots-table/hearing-slots import { FormsModule } from '@angular/forms'; @Component({ - selector: 'scheduling-slots', - templateUrl: './scheduling-slots.component.html', + selector: 'magistrates-scheduling-slots', + templateUrl: './magistrates-scheduling-slots.component.html', styles: [''], imports: [ HearingSlotsTableComponent, @@ -41,7 +42,7 @@ import { FormsModule } from '@angular/forms'; FormsModule ] }) -export class SchedulingSlotsComponent { +export class MagistratesSchedulingSlotsComponent { @Input() selectionMode: 'readonly' | 'single' | 'multi' = 'readonly'; @Input() currentPage = 1; @Input() hearingSlots: HearingSlot[] = []; @@ -80,11 +81,7 @@ export class SchedulingSlotsComponent { } @Output() errors = new EventEmitter(); - @Output() hearingSlotAllocations = new EventEmitter<{ - hearingSlotAllocations: HearingSlotAllocation[]; - sendNotificationToParties?: boolean | undefined; - hearingType?: HearingType | undefined; - }>(); + @Output() hearingSlotAllocations = new EventEmitter(); @Output() pageChange = new EventEmitter(); selectedHearingTypeId: string | null = null; @@ -197,7 +194,6 @@ export class SchedulingSlotsComponent { } private validateAllocations(allocations: HearingSlotAllocation[]): boolean { - // each slot must have a unique session date return allocations.length === new Set(allocations.map((a) => a.hearingSlot.sessionDate)).size; } } diff --git a/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.html b/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.html new file mode 100644 index 0000000..6a4ce5b --- /dev/null +++ b/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.html @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + Slot based + For example, PSR + Duration based + For example, Trial + @if (enableMultiDay && !formModel.isSlotBased) { + + + + @switch (jurisdiction) { @case ('MAGISTRATES') { + Yes + No + } @case ('CROWN') { + Yes + @if (formModel.isMultiday === true) { + + + + + + + } + No + } } @if (formModel.isMultiday === false) { + + + + + + + } + + + + } + + + + + + + + + diff --git a/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.scss b/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.scss new file mode 100644 index 0000000..51f8fcc --- /dev/null +++ b/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.scss @@ -0,0 +1,8 @@ +:host { + pdk-radio-group[name='bookingType'] pdk-radio-button { + margin-bottom: 0; + & .govuk-radios--small .govuk-radios__item .govuk-radios__label { + white-space: nowrap; + } + } +} diff --git a/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.ts b/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.ts new file mode 100644 index 0000000..09279a7 --- /dev/null +++ b/projects/scheduling/src/components/scheduling-filters-court-and-booking/scheduling-filters-court-and-booking.component.ts @@ -0,0 +1,55 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + PdkGrid, + PdkRadio, + PdkHintComponent, + PdkForm, + PdkCore, + PdkTextInput, + PdkSelectComponent, + SelectOption +} from '@cpp/pdk'; +import { + CppReferenceDataComponents, + Jurisdiction, + OrganisationUnit, + RotaBusinessType +} from '@cpp/reference-data'; +import { SchedulingFilters } from '../../types/filters'; +import { EstimateInput } from '../estimate-input/estimate-input'; + +@Component({ + selector: 'scheduling-filters-court-and-booking', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './scheduling-filters-court-and-booking.component.html', + styleUrls: ['./scheduling-filters-court-and-booking.component.scss'], + imports: [ + FormsModule, + PdkGrid, + PdkRadio, + PdkHintComponent, + PdkForm, + PdkCore, + PdkTextInput, + PdkSelectComponent, + CppReferenceDataComponents, + EstimateInput + ] +}) +export class SchedulingFiltersCourtAndBookingComponent { + @Input({ required: true }) formModel!: SchedulingFilters; + @Input() operationalUnitOptions: SelectOption[] = []; + @Input() organisationUnitPlaceholder?: OrganisationUnit; + @Input({ required: true }) filterByOperationalUnit!: (unit: OrganisationUnit) => boolean; + + @Input({ required: true }) jurisdiction!: Jurisdiction; + @Input() enableMultiDay = true; + @Input({ required: true }) slotFilterFn!: (rotaBusinessType: RotaBusinessType) => boolean; + @Input({ required: true }) durationFilterFn!: (rotaBusinessType: RotaBusinessType) => boolean; + + @Output() operationalUnitChanged = new EventEmitter(); + @Output() courtChanged = new EventEmitter(); + @Output() bookingTypeChange = new EventEmitter(); + @Output() multidayChange = new EventEmitter(); +} diff --git a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.scss b/projects/scheduling/src/components/scheduling-filters.layout.scss similarity index 64% rename from projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.scss rename to projects/scheduling/src/components/scheduling-filters.layout.scss index b7c4669..13554f6 100644 --- a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.scss +++ b/projects/scheduling/src/components/scheduling-filters.layout.scss @@ -2,20 +2,18 @@ display: flex; align-items: center; } + .scheduling-filters__start-date { display: flex; align-items: flex-end; } + .scheduling-filters__start-date-conjunction { padding-bottom: 7px; } -scheduling-filters { - pdk-radio-group[name='bookingType'] pdk-radio-button { - margin-bottom: 0; - & .govuk-radios--small .govuk-radios__item .govuk-radios__label { - white-space: nowrap; - } - } + +magistrates-scheduling-filters, +crown-scheduling-filters { pdk-radio-group[name='courtSession'] { & .govuk-radios--small .govuk-radios__item .govuk-radios__label { white-space: nowrap; diff --git a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.html b/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.html deleted file mode 100644 index 9f0d230..0000000 --- a/projects/scheduling/src/components/scheduling-filters/scheduling-filters.component.html +++ /dev/null @@ -1,230 +0,0 @@ -
-

Search for a hearing slot

-
- - - - - - - - - - - - - - - - - - - - - - - - - Slot based - For example, PSR - Duration based - For example, Trial - @if (enableMultiDay && !formModel.isSlotBased) { - - - - Yes - No - @if (formModel.isMultiday === false) { - - - - - - - } - - - - } - - - - - - - - - - - - - - - - @if (formModel.isMultiday) { - All day - } - - - - - - - - - - - - - -
- - -
to
-
- @if (sessionStartDate.valid && sessionStartDate.value) { - - Selected day:  - {{ sessionStartDate.value | date : 'EEEE' }} - - } -
-
- - - - - - @if ( sessionEndDate.value && !sessionEndDate.errors?.dateExists && - !sessionEndDate.errors?.dateFormat ) { - - Selected day:  - {{ sessionEndDate.value | date : 'EEEE' }} - - } - - -
-
- - -
- -
diff --git a/projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.spec.ts b/projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.spec.ts deleted file mode 100644 index bb54161..0000000 --- a/projects/scheduling/src/components/scheduling-slots/scheduling-slots.component.spec.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SchedulingSlotsComponent } from './scheduling-slots.component'; -import { DatePipe } from '@angular/common'; -import { ValidationError } from '@cpp/pdk'; -import { HearingType, RotaBusinessType } from '@cpp/reference-data'; -import { HearingSlot, HearingSlotAllocation } from '../../types'; - -describe('SchedulingSlotsComponent', () => { - let component: SchedulingSlotsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [SchedulingSlotsComponent, DatePipe] - }).compileComponents(); - - fixture = TestBed.createComponent(SchedulingSlotsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create the component', () => { - expect(component).toBeTruthy(); - }); - - it('should render', () => { - expect(fixture).toMatchSnapshot(); - }); - - it('should set rotaBusinessTypes correctly and generate a mapping', () => { - const businessTypes: RotaBusinessType[] = [ - { typeCode: 'TRL', typeDescription: 'Trial' }, - { typeCode: 'TFL', typeDescription: 'Tfl' } - ] as unknown as RotaBusinessType[]; - - component.rotaBusinessTypes = businessTypes; - expect(component.rotaBusinessTypesByCode).toEqual({ - TRL: businessTypes[0], - TFL: businessTypes[1] - }); - }); - - it('should set hearingTypes correctly and generate hearingTypesOptions', () => { - component.hearingTypes = [{ id: '1', hearingDescription: 'Trial' }] as unknown as HearingType[]; - expect(component.hearingTypesOptions).toEqual([ - { - value: '1', - label: 'Trial' - } - ]); - }); - - it('should emit pageChange event when triggered', () => { - jest.spyOn(component.pageChange, 'emit'); - component.pageChange.emit(2); - expect(component.pageChange.emit).toHaveBeenCalledWith(2); - }); - - it('should emit errors event when triggered', () => { - jest.spyOn(component.errors, 'emit'); - const errors: ValidationError[] = [{ message: 'Error message', id: 'id' }]; - component.errors.emit(errors); - expect(component.errors.emit).toHaveBeenCalledWith(errors); - }); - - describe('handleSubmitAllocations', () => { - const hearingType = { id: '1', hearingDescription: 'Trial' } as HearingType; - - beforeEach(() => { - jest.spyOn(component.hearingSlotAllocations, 'emit'); - component.allocations = [ - { - hearingSlot: { courtScheduleId: '1', slotBased: true } as HearingSlot, - hearingSlotTime: '2025-04-09T09:00:00.000Z', - duration: 99 - } - ]; - component.selectedSlotsModel = ['1']; - }); - - it('should reset allocation and selectedSlotsModel', () => { - component.reset(); - - expect(component.selectedSlotsModel).toEqual([]); - expect(component.allocations).toEqual([]); - }); - - describe('slot based ', () => { - it('should emit allocation when sendNotificationToParties is defined', () => { - component.sendNotificationToParties = true; - component.formConfig = { - formFields: ['sendNotificationToParties'] - }; - - component.handleSubmitAllocations(); - - expect(component.hearingSlotAllocations.emit).toHaveBeenCalledWith({ - hearingSlotAllocations: component.allocations, - sendNotificationToParties: true - }); - }); - }); - - it('should emit allocation and sendNotificationToParties when defined but hearingType is undefined', () => { - component.sendNotificationToParties = true; - component.hearingType = undefined; - component.formConfig = { - formFields: ['sendNotificationToParties'] - }; - - jest.spyOn(component.hearingSlotAllocations, 'emit'); - - component.handleSubmitAllocations(); - - expect(component.hearingSlotAllocations.emit).toHaveBeenCalledWith({ - hearingSlotAllocations: component.allocations, - sendNotificationToParties: true - }); - }); - it('should emit allocation and hearingType when sendNotificationToParties is undefined', () => { - component.sendNotificationToParties = undefined; - component.hearingType = hearingType; - - component.handleSubmitAllocations(); - - expect(component.hearingSlotAllocations.emit).toHaveBeenCalledWith({ - hearingSlotAllocations: component.allocations, - hearingType: component.hearingType - }); - }); - - it('should emit allocation, sendNotificationToParties, and hearingType when both are defined', () => { - component.sendNotificationToParties = true; - component.hearingType = hearingType; - component.formConfig = { - formFields: ['sendNotificationToParties'] - }; - - component.handleSubmitAllocations(); - - expect(component.hearingSlotAllocations.emit).toHaveBeenCalledWith({ - hearingSlotAllocations: component.allocations, - sendNotificationToParties: true, - hearingType: component.hearingType - }); - }); - }); - - describe('duration based', () => { - it('should handle hearing slot time changes and update allocation', () => { - const courtScheduleId = '1'; - const defaultHearingSlotTime = '2025-04-09T09:00:00.000Z'; - const updatedHearingSlotTime = '2025-04-09T10:00:00.000Z'; - - const hearingSlot = { - courtScheduleId, - slotBased: false - } as HearingSlot; - - component.hearingSlots = [hearingSlot]; - component.allocations = [ - { - hearingSlot, - hearingSlotTime: defaultHearingSlotTime - } as HearingSlotAllocation - ]; - - component.handleHearingSlotTimeChanged({ [courtScheduleId]: updatedHearingSlotTime }); - - expect(component.selectedHearingSlotTimestamps[courtScheduleId]).toEqual( - updatedHearingSlotTime - ); - expect(component.allocations[0].hearingSlotTime).toEqual(updatedHearingSlotTime); - }); - - it('should update allocation with selected hearingStartTime', () => { - const courtScheduleId = '1'; - const hearingSlotTime = '2025-04-09T09:00:00.000Z'; - - const hearingSlot = { - courtScheduleId, - slotBased: false - } as HearingSlot; - - component.hearingSlots = [hearingSlot]; - component.selectedHearingSlotTimestamps = { - [courtScheduleId]: hearingSlotTime - }; - - component.handleAllocationChanged([courtScheduleId]); - - expect(component.allocations.length).toBe(1); - expect(component.allocations[0].hearingSlot.courtScheduleId).toBe(courtScheduleId); - expect(component.allocations[0].hearingSlotTime).toBe(hearingSlotTime); - }); - - it('should handle submitting duration-based all day split slots', () => { - const courtScheduleId = '1'; - const hearingSlotTime = '2025-04-09T09:00:00.000Z'; - - const hearingSlot = { - courtScheduleId, - slotBased: false, - sessionDate: '2025-01-24', - courtHouseName: `Lavender Hill Magistrates' Court`, - courtRoomName: 'Courtroom 1', - courtSession: 'AD', - businessType: 'TRL', - panel: 'ADULT', - allDaySplit: true, - availableDuration: 0, - availableDurationForMorning: 90, - availableDurationForAfternoon: 120, - availableSlots: 0, - maxDuration: 0, - maxDurationForMorning: 90, - maxDurationForAfternoon: 120, - maxSlots: 0, - slotStartTimes: [ - { - sessionStartTime: '2025-01-24T10:00:00.000Z', - sessionEndTime: '2025-01-24T14:00:00.000Z', - count: 1 - } - ] - } as HearingSlot; - - component.hearingSlots = [hearingSlot]; - component.selectedHearingSlotTimestamps = { - [courtScheduleId]: hearingSlotTime - }; - - component.handleAllocationChanged([courtScheduleId]); - - expect(component.allocations.length).toBe(1); - expect(component.allocations[0].hearingSlot.courtScheduleId).toBe(courtScheduleId); - expect(component.allocations[0].hearingSlotTime).toBe(hearingSlotTime); - }); - - it('should update allocation with default hearingStartTime if none selected', () => { - const courtScheduleId = '1'; - const hearingSlotTime = '2025-04-09T10:00:00.000Z'; - - const hearingSlot = { - courtScheduleId, - sessionDate: '2025-04-09', - courtSession: 'AM', - slotBased: false, - slotStartTimes: [ - { - sessionStartTime: '2025-04-09T10:00:00.000Z', - sessionEndTime: '2025-04-09T11:00:00.000Z', - count: 1 - } - ] - } as HearingSlot; - - component.hearingSlots = [hearingSlot]; - component.handleAllocationChanged([courtScheduleId]); - - expect(component.allocations.length).toBe(1); - expect(component.allocations[0].hearingSlotTime).toBe(hearingSlotTime); - }); - }); - - it('should prevent submission of slots that share the same date', () => { - jest.spyOn(component.errors, 'emit'); - - const courtScheduleId = '1'; - - const hearingSlot = { - courtScheduleId, - sessionDate: '2025-04-09', - courtSession: 'AM', - slotBased: true - } as HearingSlot; - - component.hearingSlots = [ - hearingSlot, - { - ...hearingSlot, - courtScheduleId: '2' - } - ]; - - const selectedSlots = [courtScheduleId, '2']; - component.handleAllocationChanged(selectedSlots); - component.handleSubmitAllocations(); - - expect(component.errors.emit).toHaveBeenCalled(); - }); -}); diff --git a/projects/scheduling/src/mocks/hearing-slot.mocks.ts b/projects/scheduling/src/mocks/hearing-slot.mocks.ts new file mode 100644 index 0000000..8cb6c2f --- /dev/null +++ b/projects/scheduling/src/mocks/hearing-slot.mocks.ts @@ -0,0 +1,25 @@ +import { HearingSlot } from '../types'; + +const mockHearingSlotAmSessionFields = { + courtSession: 'AM', + sessionDate: '2025-03-25', + slotStartTimes: [ + { + sessionStartTime: '2025-03-25T10:00:00.000Z', + sessionEndTime: '2025-03-25T11:00:00.000Z', + count: 1 + } + ], + allDaySplit: false +}; + +export const mockHearingSlotAmSession = { + courtScheduleId: 'slot-1', + ...mockHearingSlotAmSessionFields +} as HearingSlot; + +export const mockHearingSlotDraftSession = { + ...mockHearingSlotAmSession, + draft: true, + courtHouseName: 'Luton Crown Court' +} as HearingSlot; diff --git a/projects/scheduling/src/public-api.ts b/projects/scheduling/src/public-api.ts index 2d9089e..b05ec7e 100644 --- a/projects/scheduling/src/public-api.ts +++ b/projects/scheduling/src/public-api.ts @@ -2,25 +2,31 @@ * Public API Surface of scheduling */ -import { SchedulingSlotsComponent } from './components/scheduling-slots/scheduling-slots.component'; -import { SchedulingFiltersComponent } from './components/scheduling-filters/scheduling-filters.component'; +import { MagistratesSchedulingFiltersComponent } from './components/magistrates-scheduling-filters/magistrates-scheduling-filters.component'; +import { MagistratesSchedulingSlotsComponent } from './components/magistrates-scheduling-slots/magistrates-scheduling-slots.component'; +import { CrownSchedulingFiltersComponent } from './components/crown-scheduling-filters/crown-scheduling-filters.component'; +import { CrownSchedulingSlotsComponent } from './components/crown-scheduling-slots/crown-scheduling-slots.component'; import { EstimateInput } from './components/estimate-input/estimate-input'; import { ListingNoteContainerComponent } from './components/listing-notes/listing-note.container'; + export const cppSchedulingComponents = [ - SchedulingSlotsComponent, - SchedulingFiltersComponent, + MagistratesSchedulingFiltersComponent, + MagistratesSchedulingSlotsComponent, + CrownSchedulingFiltersComponent, + CrownSchedulingSlotsComponent, EstimateInput, ListingNoteContainerComponent ] as const; export { - SchedulingSlotsComponent, - SchedulingFiltersComponent, + MagistratesSchedulingFiltersComponent, + MagistratesSchedulingSlotsComponent, + CrownSchedulingFiltersComponent, + CrownSchedulingSlotsComponent, EstimateInput, ListingNoteContainerComponent }; -export * from './scheduling.module'; export * from './types/'; export * from './reducers/index'; export * from './actions/scheduling.actions'; diff --git a/projects/scheduling/src/reducers/scheduling.spec.ts b/projects/scheduling/src/reducers/scheduling.spec.ts index caa501a..5a043c4 100644 --- a/projects/scheduling/src/reducers/scheduling.spec.ts +++ b/projects/scheduling/src/reducers/scheduling.spec.ts @@ -1,5 +1,5 @@ import { SchedulingActions } from '../actions'; -import { HearingSlot, SearchHearingSlotsParams, ListingNote } from '../types'; +import { HearingSlot, ListingNote, SearchHearingSlotsParams } from '../types'; import { scheduling } from './scheduling'; import { createListingNoteSuccess, diff --git a/projects/scheduling/src/scheduling.module.ts b/projects/scheduling/src/scheduling.module.ts deleted file mode 100644 index 4b80f3f..0000000 --- a/projects/scheduling/src/scheduling.module.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NgModule } from '@angular/core'; -import { SchedulingSlotsComponent } from './components/scheduling-slots/scheduling-slots.component'; -import { SchedulingFiltersComponent } from './components/scheduling-filters/scheduling-filters.component'; -import { EstimateInput } from './components/estimate-input/estimate-input'; -import { StoreModule } from '@ngrx/store'; -import { schedulingReducer } from './reducers'; -import { ALLOCATION_FORM_CONFIGS, allocationFormConfigs } from './utils'; -import { ListingNoteContainerComponent } from './components/listing-notes/listing-note.container'; - -// Reference: https://v16.angular.io/cli/generate#library-command -// TODO: After upgrading from Angular 15, update the library to use the standalone API. - -const SHARED = [ - SchedulingSlotsComponent, - SchedulingFiltersComponent, - EstimateInput, - ListingNoteContainerComponent -]; - -/** - * @deprecated - * This will be removed in some release moving forward but is - * left here for Backward compatilibity. - * - * To use scheduling, remove the module where used. - * In the app module or Bootstrap function (Standalone) or Route , - * provide Scheduling context using the following as per preference - * @method provideSchedulingEnvironmentContext - * - This will provide the configs inclusive of the feature store. - * @method provideSchedulingstore - * - You can provide just the feature store using this method in your module or route providers and import the config you need on demand. - * - * - * PLEASE ENSURE THAT YOUR BOOTSTRAP OR APPMODULE USES THE PROVIDESTORE as the root prior to using any of the methods above mentioned. You can - * mix StoreModule.forRoot and provideStore if the application is still modular - Please refer to Ngrx docs for details - * - * Finally all scheduling components are standalone and can be imported on demand in the modules or standalone components - * when needed. All exposed shared components have been exported as @constant cppSchedulingComponents - */ -@NgModule({ - declarations: [], - imports: [...SHARED, StoreModule.forFeature('scheduling', schedulingReducer)], - exports: SHARED, - providers: [ - { - provide: ALLOCATION_FORM_CONFIGS, - useValue: allocationFormConfigs - } - ] -}) -export class SchedulingModule {} diff --git a/projects/scheduling/src/services/scheduling.service.spec.ts b/projects/scheduling/src/services/scheduling.service.spec.ts index f597d60..6bd4519 100644 --- a/projects/scheduling/src/services/scheduling.service.spec.ts +++ b/projects/scheduling/src/services/scheduling.service.spec.ts @@ -37,9 +37,9 @@ describe('SchedulingService', () => { oucodeL2Code: undefined, sessionStartDate: '2020-09-16', sessionEndDate: '2020-09-23', - panel: 'YOUTH', courtSession: 'AM', - businessType: '*' + businessType: '*', + jurisdiction: 'MAGISTRATES' }; (http.query as jest.Mock).mockReturnValue( diff --git a/projects/scheduling/src/services/scheduling.service.ts b/projects/scheduling/src/services/scheduling.service.ts index 553e083..203c1f1 100644 --- a/projects/scheduling/src/services/scheduling.service.ts +++ b/projects/scheduling/src/services/scheduling.service.ts @@ -4,7 +4,6 @@ import { CppHttp, HttpQueryOptions, mapObjectToHttpParams } from '@cpp/core'; import { Observable, throwError, timer } from 'rxjs'; import { map, retryWhen, switchMap, take, timeout } from 'rxjs/operators'; import { HearingSlot, ListingNote, SearchHearingSlotsParams } from '../types'; -import { omit } from 'lodash-es'; @Injectable({ providedIn: 'root' @@ -15,11 +14,7 @@ export class SchedulingService { searchHearingSlots( paramsValues: SearchHearingSlotsParams, inBackground = false - ): Observable<{ - hearingSlots: HearingSlot[]; - totalResults: number; - notes: ListingNote[]; - }> { + ): Observable<{ hearingSlots: HearingSlot[]; totalResults: number; notes: ListingNote[] }> { return this.cppHttp .query<{ hearingSlots: HearingSlot[]; results: number; notes: ListingNote[] }>({ url: '/listing-query-api/query/api/rest/listing/hearingSlots', @@ -44,7 +39,7 @@ export class SchedulingService { ); } - async hasHearingSlotsFor(paramsValues: SearchHearingSlotsParams) { + async hasHearingSlotsFor(paramsValues: SearchHearingSlotsParams): Promise { const extendedParams: SearchHearingSlotsParams = { ...paramsValues, showOverbookedSlots: true diff --git a/projects/scheduling/src/types/allocation.ts b/projects/scheduling/src/types/allocation.ts index 197e3f5..167e32a 100644 --- a/projects/scheduling/src/types/allocation.ts +++ b/projects/scheduling/src/types/allocation.ts @@ -1,7 +1,14 @@ -import { HearingSlot, SearchHearingSlotsParams } from './hearingSlot'; +import { HearingType } from '@cpp/reference-data'; +import { HearingSlot, HearingSlotAllocation, SearchHearingSlotsParams } from './hearingSlot'; export type AllocationsFormConfigField = 'hearingType' | 'sendNotificationToParties'; +export interface SchedulingSlotAllocationSubmit { + hearingSlotAllocations: HearingSlotAllocation[]; + sendNotificationToParties?: boolean; + hearingType?: HearingType; +} + export interface AllocationsFormConfig { formFields: AllocationsFormConfigField[]; } diff --git a/projects/scheduling/src/types/filters.ts b/projects/scheduling/src/types/filters.ts index 5a42ac8..4cae094 100644 --- a/projects/scheduling/src/types/filters.ts +++ b/projects/scheduling/src/types/filters.ts @@ -1,5 +1,5 @@ import { HearingType, OrganisationUnit } from '@cpp/reference-data'; -import { CourtSession, Panel } from './hearingSlot'; +import { CourtSession, CrownSessionStatus, Panel } from './hearingSlot'; export interface SchedulingFilters { oucodeL2Code?: string; @@ -8,10 +8,24 @@ export interface SchedulingFilters { sessionStartDate: string; sessionEndDate?: string; courtSession?: CourtSession; - panel?: Panel; businessType?: string; availableDurationMins?: number; hearingType?: HearingType; isMultiday?: boolean; isSlotBased?: boolean; + panel?: Panel; +} + +export type MagistratesSchedulingFilters = SchedulingFilters; + +export enum CrownSessionStatusFilterOption { + NONE = 'NONE', + ALL = 'ALL' +} + +export type CrownSessionStatusFilter = CrownSessionStatusFilterOption | string; + +export interface CrownSchedulingFilters extends SchedulingFilters { + sessionStatusFilter?: CrownSessionStatusFilter; + status?: CrownSessionStatus; } diff --git a/projects/scheduling/src/types/hearingSlot.ts b/projects/scheduling/src/types/hearingSlot.ts index d308f3c..757fec2 100644 --- a/projects/scheduling/src/types/hearingSlot.ts +++ b/projects/scheduling/src/types/hearingSlot.ts @@ -1,3 +1,5 @@ +import { Jurisdiction } from '@cpp/reference-data'; + enum PanelType { ADULT = 'ADULT', YOUTH = 'YOUTH' @@ -45,6 +47,7 @@ export interface HearingSlot { maxDurationForAfternoon?: number; createdOn: string; updatedOn: string; + draft?: boolean; } export interface HearingSlotJudiciary { @@ -62,6 +65,12 @@ export interface HearingSlotAllocation { duration?: number; } +export enum CrownSessionStatus { + DRAFT = 'DRAFT', + FINAL = 'FINAL', + ALL = 'ALL' +} + export interface SearchHearingSlotsParams { oucodeL2Code?: string; oucodeL3Code?: string; @@ -81,4 +90,6 @@ export interface SearchHearingSlotsParams { hearingTypeId?: string; showOverbookedSlots?: boolean; hearingStartTime?: string; + status?: CrownSessionStatus; + jurisdiction: Jurisdiction; } diff --git a/projects/scheduling/src/types/index.ts b/projects/scheduling/src/types/index.ts index ce85d86..94f70f2 100644 --- a/projects/scheduling/src/types/index.ts +++ b/projects/scheduling/src/types/index.ts @@ -1,4 +1,5 @@ export * from './hearingSlot'; +export * from './schedulingFilterOptions'; export * from './filters'; export * from './allocation'; export * from './listingNotes'; diff --git a/projects/scheduling/src/types/schedulingFilterOptions.ts b/projects/scheduling/src/types/schedulingFilterOptions.ts new file mode 100644 index 0000000..b7eb047 --- /dev/null +++ b/projects/scheduling/src/types/schedulingFilterOptions.ts @@ -0,0 +1,9 @@ +import { SelectOption } from '@cpp/pdk'; +import type { CourtSession } from './hearingSlot'; + +export const COURT_SESSION_SELECT_OPTIONS: SelectOption[] = [ + { value: undefined, label: 'Any' }, + { value: 'AM', label: 'AM' }, + { value: 'PM', label: 'PM' }, + { value: 'AD', label: 'All day' } +]; diff --git a/projects/scheduling/src/utils/__tests__/crownCourtroom.spec.ts b/projects/scheduling/src/utils/__tests__/crownCourtroom.spec.ts new file mode 100644 index 0000000..ecc85f4 --- /dev/null +++ b/projects/scheduling/src/utils/__tests__/crownCourtroom.spec.ts @@ -0,0 +1,45 @@ +import { organisationUnitMockTwo, CourtRoom, OrganisationUnit } from '@cpp/reference-data'; +import { getCrownCourtroomOptions } from '../crownCourtroom'; +import { CrownSessionStatusFilterOption } from '../../types/filters'; +import { CrownSessionStatus } from '../../types/hearingSlot'; + +describe('getCrownCourtroomOptions', () => { + const organisationUnit: OrganisationUnit = { + ...organisationUnitMockTwo, + courtrooms: [ + { id: '1', courtroomId: 1, courtroomName: 'Court 1' }, + { id: '2', courtroomId: 2, courtroomName: 'Court 2' } + ] as CourtRoom[] + }; + + const allNoneAndCourtrooms = [ + { value: CrownSessionStatusFilterOption.ALL, label: 'All' }, + { value: CrownSessionStatusFilterOption.NONE, label: 'No courtroom selected' }, + { value: '1', label: 'Court 1' }, + { value: '2', label: 'Court 2' } + ]; + + it.each([CrownSessionStatus.DRAFT, CrownSessionStatus.ALL] as const)( + 'should include All, No courtroom selected then courtrooms for %s', + (sessionStatus) => { + expect(getCrownCourtroomOptions(organisationUnit, sessionStatus)).toEqual( + allNoneAndCourtrooms + ); + } + ); + + it('should exclude NONE and use courtroomName for courtrooms for FINAL', () => { + expect(getCrownCourtroomOptions(organisationUnit, CrownSessionStatus.FINAL)).toEqual([ + { value: CrownSessionStatusFilterOption.ALL, label: 'All' }, + { value: '1', label: 'Court 1' }, + { value: '2', label: 'Court 2' } + ]); + }); + + it('should behave like DRAFT when defaultSessionStatus is omitted', () => { + expect(getCrownCourtroomOptions(undefined)).toEqual([ + { value: CrownSessionStatusFilterOption.ALL, label: 'All' }, + { value: CrownSessionStatusFilterOption.NONE, label: 'No courtroom selected' } + ]); + }); +}); diff --git a/projects/scheduling/src/utils/__tests__/operationalUnit.spec.ts b/projects/scheduling/src/utils/__tests__/operationalUnit.spec.ts index 66c8fee..f551e20 100644 --- a/projects/scheduling/src/utils/__tests__/operationalUnit.spec.ts +++ b/projects/scheduling/src/utils/__tests__/operationalUnit.spec.ts @@ -1,4 +1,9 @@ -import { getOperationalUnitOptions, isMagistratesCourt } from '../operationalUnit'; +import { + getOperationalUnitOptions, + isMagistratesCourt, + isCrownCourt, + operationalUnitAllCourtsPlaceholder +} from '../operationalUnit'; describe('getOperationalUnitOptions', () => { it('should generate unique sorted operational unit options', () => { @@ -25,3 +30,30 @@ describe('isMagistratesCourt', () => { expect(isMagistratesCourt({ oucodeL1Code: 'X' } as any)).toBe(false); }); }); + +describe('isCrownCourt', () => { + it('should return true if oucodeL1Code is C', () => { + expect(isCrownCourt({ oucodeL1Code: 'C' } as any)).toBe(true); + }); + + it('should return false if oucodeL1Code is not C', () => { + expect(isCrownCourt({ oucodeL1Code: 'B' } as any)).toBe(false); + }); +}); + +describe('operationalUnitAllCourtsPlaceholder', () => { + it('should set L1 and L2 for magistrates', () => { + expect(operationalUnitAllCourtsPlaceholder('B', '1')).toMatchObject({ + oucodeL1Code: 'B', + oucodeL2Code: '1', + oucodeL3Name: 'All courts' + }); + }); + + it('should set L1 for crown', () => { + expect(operationalUnitAllCourtsPlaceholder('C')).toMatchObject({ + oucodeL1Code: 'C', + oucodeL3Code: 'All courts' + }); + }); +}); diff --git a/projects/scheduling/src/utils/__tests__/sessionStatusFilter.spec.ts b/projects/scheduling/src/utils/__tests__/sessionStatusFilter.spec.ts new file mode 100644 index 0000000..b08b7dc --- /dev/null +++ b/projects/scheduling/src/utils/__tests__/sessionStatusFilter.spec.ts @@ -0,0 +1,74 @@ +import { + defaultSessionStatusFilterOption, + searchFieldsForSessionFilter, + sessionFilterFromParams +} from '../sessionStatusFilter'; +import { CrownSessionStatusFilterOption } from '../../types/filters'; +import { CrownSessionStatus } from '../../types/hearingSlot'; + +describe('searchFieldsForSessionFilter', () => { + it('should return DRAFT status and omit courtRoomId for NONE', () => { + expect(searchFieldsForSessionFilter(CrownSessionStatusFilterOption.NONE)).toEqual({ + status: CrownSessionStatus.DRAFT, + courtRoomId: undefined + }); + }); + + it('should return FINAL status and omit courtRoomId for ALL', () => { + expect(searchFieldsForSessionFilter(CrownSessionStatusFilterOption.ALL)).toEqual({ + status: CrownSessionStatus.FINAL, + courtRoomId: undefined + }); + }); + + it('should return FINAL status and courtRoomId for courtroom id', () => { + expect(searchFieldsForSessionFilter('1')).toEqual({ + status: CrownSessionStatus.FINAL, + courtRoomId: '1' + }); + }); + + it('should return empty object for undefined', () => { + expect(searchFieldsForSessionFilter(undefined)).toEqual({}); + }); +}); + +describe('sessionFilterFromParams', () => { + it('should return courtroom id when courtRoomId is set', () => { + expect( + sessionFilterFromParams({ + courtRoomId: '1', + status: CrownSessionStatus.FINAL + }) + ).toBe('1'); + }); + + it('should return ALL when status is FINAL and courtRoomId is absent', () => { + expect(sessionFilterFromParams({ status: CrownSessionStatus.FINAL })).toBe( + CrownSessionStatusFilterOption.ALL + ); + }); + + it('should return NONE when status is DRAFT and courtRoomId is absent', () => { + expect(sessionFilterFromParams({ status: CrownSessionStatus.DRAFT })).toBe( + CrownSessionStatusFilterOption.NONE + ); + }); +}); + +describe('defaultSessionStatusFilterOption', () => { + it('should return NONE for DRAFT status', () => { + expect(defaultSessionStatusFilterOption(CrownSessionStatus.DRAFT)).toBe( + CrownSessionStatusFilterOption.NONE + ); + }); + + it.each([CrownSessionStatus.ALL, CrownSessionStatus.FINAL] as const)( + 'should return ALL for %s status', + (sessionStatus) => { + expect(defaultSessionStatusFilterOption(sessionStatus)).toBe( + CrownSessionStatusFilterOption.ALL + ); + } + ); +}); diff --git a/projects/scheduling/src/utils/crownCourtroom.ts b/projects/scheduling/src/utils/crownCourtroom.ts new file mode 100644 index 0000000..62ba8e2 --- /dev/null +++ b/projects/scheduling/src/utils/crownCourtroom.ts @@ -0,0 +1,30 @@ +import { SelectOption } from '@cpp/pdk'; +import { OrganisationUnit } from '@cpp/reference-data'; +import { CrownSessionStatusFilter, CrownSessionStatusFilterOption } from '../types/filters'; +import { CrownSessionStatus } from '../types/hearingSlot'; + +const NONE_OPTION: SelectOption = { + value: CrownSessionStatusFilterOption.NONE, + label: 'No courtroom selected' +}; + +export const getCrownCourtroomOptions = ( + organisationUnit?: OrganisationUnit, + defaultSessionStatus: CrownSessionStatus = CrownSessionStatus.DRAFT +): SelectOption[] => { + const includeDraftSessionsOption = + defaultSessionStatus === CrownSessionStatus.DRAFT || + defaultSessionStatus === CrownSessionStatus.ALL; + + const options: SelectOption[] = []; + options.push({ value: CrownSessionStatusFilterOption.ALL, label: 'All' }); + if (includeDraftSessionsOption) { + options.push(NONE_OPTION); + } + if (organisationUnit?.courtrooms) { + for (const courtroom of organisationUnit.courtrooms) { + options.push({ value: courtroom.id, label: courtroom.courtroomName }); + } + } + return options; +}; diff --git a/projects/scheduling/src/utils/index.ts b/projects/scheduling/src/utils/index.ts index 799210e..a7c3aff 100644 --- a/projects/scheduling/src/utils/index.ts +++ b/projects/scheduling/src/utils/index.ts @@ -1,6 +1,8 @@ export * from './rotaBusinessType'; export * from './operationalUnit'; export * from './courtrooms'; +export * from './crownCourtroom'; +export * from './sessionStatusFilter'; export * from './hearingSlotTime'; export * from './allocations'; export * from './sessionTimings'; diff --git a/projects/scheduling/src/utils/operationalUnit.ts b/projects/scheduling/src/utils/operationalUnit.ts index 0e401bf..32560f1 100644 --- a/projects/scheduling/src/utils/operationalUnit.ts +++ b/projects/scheduling/src/utils/operationalUnit.ts @@ -19,3 +19,18 @@ export const getOperationalUnitOptions = ( export const isMagistratesCourt = (organisationUnit: OrganisationUnit): boolean => organisationUnit?.oucodeL1Code === 'B'; + +export const isCrownCourt = (organisationUnit: OrganisationUnit): boolean => + organisationUnit?.oucodeL1Code === 'C'; + +export const operationalUnitAllCourtsPlaceholder = ( + oucodeL1Code: 'B' | 'C', + oucodeL2Code?: string +): OrganisationUnit => + ({ + id: '', + oucodeL1Code, + oucodeL2Code, + oucodeL3Name: 'All courts', + oucodeL3Code: 'All courts' + } as OrganisationUnit); diff --git a/projects/scheduling/src/utils/sessionStatusFilter.ts b/projects/scheduling/src/utils/sessionStatusFilter.ts new file mode 100644 index 0000000..4995df5 --- /dev/null +++ b/projects/scheduling/src/utils/sessionStatusFilter.ts @@ -0,0 +1,39 @@ +import { CrownSessionStatusFilter, CrownSessionStatusFilterOption } from '../types/filters'; +import { CrownSessionStatus } from '../types/hearingSlot'; + +export const searchFieldsForSessionFilter = ( + filter: CrownSessionStatusFilter | undefined +): { status?: CrownSessionStatus; courtRoomId?: string } => { + if (filter == null) return {}; + if (filter === CrownSessionStatusFilterOption.NONE) { + return { status: CrownSessionStatus.DRAFT, courtRoomId: undefined }; + } + return { + status: CrownSessionStatus.FINAL, + courtRoomId: filter === CrownSessionStatusFilterOption.ALL ? undefined : filter + }; +}; + +export const sessionFilterFromParams = (params: { + courtRoomId?: string; + status?: CrownSessionStatus; +}): CrownSessionStatusFilter | undefined => { + const { courtRoomId, status } = params; + if (courtRoomId) { + return courtRoomId; + } + if (status === CrownSessionStatus.DRAFT) { + return CrownSessionStatusFilterOption.NONE; + } + if (status === CrownSessionStatus.FINAL) { + return CrownSessionStatusFilterOption.ALL; + } + return undefined; +}; + +export const defaultSessionStatusFilterOption = ( + defaultSessionStatus: CrownSessionStatus = CrownSessionStatus.DRAFT +): CrownSessionStatusFilterOption => + defaultSessionStatus === CrownSessionStatus.DRAFT + ? CrownSessionStatusFilterOption.NONE + : CrownSessionStatusFilterOption.ALL;