From 7369a4e5fdc685080bbbef0f755f28770bd3c340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarkko=20P=C3=A4t=C3=A4ri?= Date: Wed, 16 Oct 2024 13:31:15 +0300 Subject: [PATCH] Add preschool application report --- .../src/e2e-test/pages/employee/reports.ts | 19 ++ .../preschool-application-report.spec.ts | 113 +++++++++++ frontend/src/employee-frontend/App.tsx | 9 + .../employee-frontend/components/Reports.tsx | 14 ++ .../reports/PreschoolApplicationReport.tsx | 181 ++++++++++++++++++ .../components/reports/queries.ts | 7 + .../generated/api-clients/reports.ts | 14 ++ frontend/src/lib-common/generated/action.d.ts | 1 + .../lib-common/generated/api-types/reports.ts | 27 +++ frontend/src/lib-components/layout/Table.tsx | 4 +- .../defaults/employee/i18n/fi.tsx | 14 ++ .../reports/PreschoolApplicationReportTest.kt | 164 ++++++++++++++++ .../config/SharedIntegrationTestConfig.kt | 28 ++- .../reports/PreschoolApplicationReport.kt | 93 +++++++++ .../espoo/evaka/reports/ReportPermissions.kt | 6 + .../fi/espoo/evaka/shared/security/Action.kt | 1 + 16 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 frontend/src/e2e-test/specs/5_employee/preschool-application-report.spec.ts create mode 100644 frontend/src/employee-frontend/components/reports/PreschoolApplicationReport.tsx create mode 100644 service/src/integrationTest/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReportTest.kt create mode 100644 service/src/main/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReport.kt diff --git a/frontend/src/e2e-test/pages/employee/reports.ts b/frontend/src/e2e-test/pages/employee/reports.ts index db4956bebf8..ee947686307 100644 --- a/frontend/src/e2e-test/pages/employee/reports.ts +++ b/frontend/src/e2e-test/pages/employee/reports.ts @@ -66,6 +66,11 @@ export default class ReportsPage { return new PreschoolAbsenceReport(this.page) } + async openPreschoolApplicationReport() { + await this.page.findByDataQa('report-preschool-application').click() + return new PreschoolApplicationReport(this.page) + } + async openHolidayPeriodAttendanceReport() { await this.page.findByDataQa('report-holiday-period-attendance').click() return new HolidayPeriodAttendanceReport(this.page) @@ -594,6 +599,20 @@ export class PreschoolAbsenceReport { } } +export class PreschoolApplicationReport { + constructor(private page: Page) {} + + async assertNoResults() { + const noResults = this.page.findByDataQa('no-results') + await noResults.waitUntilVisible() + } + + async assertRows(expected: string[]) { + const rows = this.page.findAllByDataQa('row') + await rows.assertTextsEqual(expected) + } +} + export class HolidayPeriodAttendanceReport { constructor(private page: Page) {} diff --git a/frontend/src/e2e-test/specs/5_employee/preschool-application-report.spec.ts b/frontend/src/e2e-test/specs/5_employee/preschool-application-report.spec.ts new file mode 100644 index 00000000000..59ecc136f59 --- /dev/null +++ b/frontend/src/e2e-test/specs/5_employee/preschool-application-report.spec.ts @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +import LocalDate from 'lib-common/local-date' +import LocalTime from 'lib-common/local-time' + +import config from '../../config' +import { applicationFixture, Fixture, uuidv4 } from '../../dev-api/fixtures' +import { + createApplications, + resetServiceState +} from '../../generated/api-clients' +import { DevEmployee } from '../../generated/api-types' +import EmployeeNav from '../../pages/employee/employee-nav' +import ReportsPage from '../../pages/employee/reports' +import { Page } from '../../utils/page' +import { employeeLogin } from '../../utils/user' + +const mockedToday = LocalDate.of(2024, 10, 16) + +beforeEach(resetServiceState) + +xdescribe('Preschool application report', () => { + test('no results is shown', async () => { + const admin = await Fixture.employee().admin().save() + const page = await Page.open({ + mockedTime: mockedToday.toHelsinkiDateTime(LocalTime.of(8, 0)) + }) + const report = await navigateToReport(page, admin) + await report.assertNoResults() + }) + + test('rows are shown', async () => { + const area = await Fixture.careArea().save() + const unit1 = await Fixture.daycare({ + areaId: area.id, + name: 'Koulu A' + }).save() + const unit2 = await Fixture.daycare({ + areaId: area.id, + name: 'Koulu B' + }).save() + + const guardian = await Fixture.person({ + lastName: 'Testiläinen', + firstName: 'Matti', + dateOfBirth: LocalDate.of(2000, 1, 1), + ssn: null + }).saveAdult() + + const child1 = await Fixture.person({ + lastName: 'Testiläinen', + firstName: 'Teppo', + dateOfBirth: LocalDate.of(2019, 1, 1), + ssn: null + }).saveChild() + await Fixture.guardian(child1, guardian).save() + const application1 = { + ...applicationFixture( + child1, + guardian, + undefined, + 'PRESCHOOL', + null, + [unit1.id], + false, + 'WAITING_UNIT_CONFIRMATION' + ), + id: uuidv4() + } + + const child2 = await Fixture.person({ + lastName: 'Testiläinen', + firstName: 'Seppo', + dateOfBirth: LocalDate.of(2019, 1, 2), + ssn: null + }).saveChild() + await Fixture.guardian(child2, guardian).save() + const application2 = { + ...applicationFixture( + child2, + guardian, + undefined, + 'PRESCHOOL', + null, + [unit2.id], + false, + 'WAITING_UNIT_CONFIRMATION' + ), + id: uuidv4() + } + + await createApplications({ body: [application1, application2] }) + + const admin = await Fixture.employee().admin().save() + const page = await Page.open({ + mockedTime: mockedToday.toHelsinkiDateTime(LocalTime.of(8, 0)) + }) + const report = await navigateToReport(page, admin) + await report.assertRows([ + `${unit1.name}\t${child1.lastName}\t${child1.firstName}\t01.01.2019\t${child1.streetAddress}\t${child1.postalCode}\t\tEi`, + `${unit2.name}\t${child2.lastName}\t${child2.firstName}\t02.01.2019\t${child2.streetAddress}\t${child2.postalCode}\t\tEi` + ]) + }) +}) + +const navigateToReport = async (page: Page, user: DevEmployee) => { + await employeeLogin(page, user) + await page.goto(config.employeeUrl) + await new EmployeeNav(page).openTab('reports') + return await new ReportsPage(page).openPreschoolApplicationReport() +} diff --git a/frontend/src/employee-frontend/App.tsx b/frontend/src/employee-frontend/App.tsx index cc0f3e981d8..4de6b6d38ba 100755 --- a/frontend/src/employee-frontend/App.tsx +++ b/frontend/src/employee-frontend/App.tsx @@ -98,6 +98,7 @@ import PlacementCount from './components/reports/PlacementCount' import PlacementGuarantee from './components/reports/PlacementGuarantee' import PlacementSketching from './components/reports/PlacementSketching' import PreschoolAbsenceReport from './components/reports/PreschoolAbsenceReport' +import PreschoolApplicationReport from './components/reports/PreschoolApplicationReport' import ReportPresences from './components/reports/PresenceReport' import ReportRaw from './components/reports/Raw' import ReportServiceNeeds from './components/reports/ServiceNeeds' @@ -790,6 +791,14 @@ export default createBrowserRouter( ) }, + { + path: '/reports/preschool-application', + element: ( + + + + ) + }, { path: '/reports/future-preschoolers', element: ( diff --git a/frontend/src/employee-frontend/components/Reports.tsx b/frontend/src/employee-frontend/components/Reports.tsx index 71eb16bbca6..127c4e99f26 100755 --- a/frontend/src/employee-frontend/components/Reports.tsx +++ b/frontend/src/employee-frontend/components/Reports.tsx @@ -580,6 +580,20 @@ export default React.memo(function Reports() { ) } : null, + reports.has('PRESCHOOL_APPLICATIONS') + ? { + name: i18n.reports.preschoolApplications.title, + item: ( + + ) + } + : null, reports.has('FAMILY_DAYCARE_MEAL_REPORT') ? { name: i18n.reports.familyDaycareMealCount.title, diff --git a/frontend/src/employee-frontend/components/reports/PreschoolApplicationReport.tsx b/frontend/src/employee-frontend/components/reports/PreschoolApplicationReport.tsx new file mode 100644 index 00000000000..1f84deff5bb --- /dev/null +++ b/frontend/src/employee-frontend/components/reports/PreschoolApplicationReport.tsx @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +import orderBy from 'lodash/orderBy' +import React, { useCallback, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' + +import { PreschoolApplicationReportRow } from 'lib-common/generated/api-types/reports' +import { useQueryResult } from 'lib-common/query' +import Title from 'lib-components/atoms/Title' +import ReturnButton from 'lib-components/atoms/buttons/ReturnButton' +import Container, { ContentArea } from 'lib-components/layout/Container' +import { + SortableTh, + SortDirection, + Tbody, + Td, + Thead, + Tr +} from 'lib-components/layout/Table' + +import { useTranslation } from '../../state/i18n' +import { renderResult } from '../async-rendering' + +import { TableScrollable } from './common' +import { preschoolApplicationReportQuery } from './queries' + +export default React.memo(function PreschoolApplicationReport() { + const { i18n } = useTranslation() + const result = useQueryResult(preschoolApplicationReportQuery()) + + return ( + + + + {i18n.reports.preschoolApplications.title} + {renderResult(result, (rows) => ( + + ))} + + + ) +}) + +type SortColumn = keyof PreschoolApplicationReportRow + +const PreschoolApplicationReportTable = ({ + rows +}: { + rows: PreschoolApplicationReportRow[] +}) => { + const { i18n } = useTranslation() + const [sort, setSort] = useState<{ + column: SortColumn + direction: SortDirection + }>({ column: 'applicationUnitName', direction: 'ASC' }) + const sortedRows = useMemo( + () => + orderBy( + rows, + [sort.column, 'childLastName', 'childFirstName', 'childDateOfBirth'], + [sort.direction === 'ASC' ? 'asc' : 'desc'] + ), + [rows, sort.column, sort.direction] + ) + const sortBy = useCallback( + (column: SortColumn) => () => + setSort({ + column, + direction: + sort.column === column && sort.direction === 'ASC' ? 'DESC' : 'ASC' + }), + [sort.column, sort.direction] + ) + const sorted = useCallback( + (column: SortColumn) => + sort.column === column ? sort.direction : undefined, + [sort.column, sort.direction] + ) + + return ( + + + + + {i18n.reports.preschoolApplications.columns.applicationUnitName} + + + {i18n.reports.preschoolApplications.columns.childLastName} + + + {i18n.reports.preschoolApplications.columns.childFirstName} + + + {i18n.reports.preschoolApplications.columns.childDateOfBirth} + + + {i18n.reports.preschoolApplications.columns.childStreetAddress} + + + {i18n.reports.preschoolApplications.columns.childPostalCode} + + + {i18n.reports.preschoolApplications.columns.currentUnitName} + + + {i18n.reports.preschoolApplications.columns.isDaycareAssistanceNeed} + + + + + {sortedRows.length === 0 ? ( + + + {i18n.common.noResults} + + + ) : ( + sortedRows.map((row) => { + return ( + + + + {row.applicationUnitName} + + + {row.childLastName} + + + {row.childFirstName} + + + {row.childDateOfBirth.format()} + {row.childStreetAddress} + {row.childPostalCode} + + {row.currentUnitId !== null && + row.currentUnitName !== null && ( + + {row.currentUnitName} + + )} + + + {row.isDaycareAssistanceNeed + ? i18n.common.yes + : i18n.common.no} + + + ) + }) + )} + + + ) +} diff --git a/frontend/src/employee-frontend/components/reports/queries.ts b/frontend/src/employee-frontend/components/reports/queries.ts index 3b2c4389f15..8d83d930070 100644 --- a/frontend/src/employee-frontend/components/reports/queries.ts +++ b/frontend/src/employee-frontend/components/reports/queries.ts @@ -24,6 +24,7 @@ import { getOccupancyUnitReport, getPlacementGuaranteeReport, getPreschoolAbsenceReport, + getPreschoolApplicationReport, getServiceVoucherReportForAllUnits, getUnitsReport, getVardaChildErrorsReport, @@ -85,6 +86,7 @@ const queryKeys = createQueryKeys('reports', { 'preschoolAbsenceReport', filters ], + preschoolApplicationReport: () => ['preschoolApplicationReport'], holidayPeriodAttendanceReport: ( filters: Arg0 ) => ['holidayPeriodPresenceReport', filters] @@ -205,6 +207,11 @@ export const preschoolAbsenceReportQuery = query({ queryKey: queryKeys.preschoolAbsenceReport }) +export const preschoolApplicationReportQuery = query({ + api: getPreschoolApplicationReport, + queryKey: queryKeys.preschoolApplicationReport +}) + export const holidayPeriodAttendanceReportQuery = query({ api: getHolidayPeriodAttendanceReport, queryKey: queryKeys.holidayPeriodAttendanceReport diff --git a/frontend/src/employee-frontend/generated/api-clients/reports.ts b/frontend/src/employee-frontend/generated/api-clients/reports.ts index a04d7cd4261..e703d6867b6 100644 --- a/frontend/src/employee-frontend/generated/api-clients/reports.ts +++ b/frontend/src/employee-frontend/generated/api-clients/reports.ts @@ -47,6 +47,7 @@ import { PlacementCountReportResult } from 'lib-common/generated/api-types/repor import { PlacementGuaranteeReportRow } from 'lib-common/generated/api-types/reports' import { PlacementSketchingReportRow } from 'lib-common/generated/api-types/reports' import { PlacementType } from 'lib-common/generated/api-types/placement' +import { PreschoolApplicationReportRow } from 'lib-common/generated/api-types/reports' import { PreschoolUnitsReportRow } from 'lib-common/generated/api-types/reports' import { PresenceReportRow } from 'lib-common/generated/api-types/reports' import { ProviderType } from 'lib-common/generated/api-types/daycare' @@ -78,6 +79,7 @@ import { deserializeJsonMissingHeadOfFamilyReportRow } from 'lib-common/generate import { deserializeJsonNonSsnChildrenReportRow } from 'lib-common/generated/api-types/reports' import { deserializeJsonPlacementGuaranteeReportRow } from 'lib-common/generated/api-types/reports' import { deserializeJsonPlacementSketchingReportRow } from 'lib-common/generated/api-types/reports' +import { deserializeJsonPreschoolApplicationReportRow } from 'lib-common/generated/api-types/reports' import { deserializeJsonPresenceReportRow } from 'lib-common/generated/api-types/reports' import { deserializeJsonRawReportRow } from 'lib-common/generated/api-types/reports' import { deserializeJsonServiceVoucherReport } from 'lib-common/generated/api-types/reports' @@ -802,6 +804,18 @@ export async function getPreschoolAbsenceReport( } +/** +* Generated from fi.espoo.evaka.reports.PreschoolApplicationReport.getPreschoolApplicationReport +*/ +export async function getPreschoolApplicationReport(): Promise { + const { data: json } = await client.request>({ + url: uri`/employee/reports/preschool-application`.toString(), + method: 'GET' + }) + return json.map(e => deserializeJsonPreschoolApplicationReportRow(e)) +} + + /** * Generated from fi.espoo.evaka.reports.PresenceReportController.getPresenceReport */ diff --git a/frontend/src/lib-common/generated/action.d.ts b/frontend/src/lib-common/generated/action.d.ts index f797a04169d..fa493de177f 100644 --- a/frontend/src/lib-common/generated/action.d.ts +++ b/frontend/src/lib-common/generated/action.d.ts @@ -461,6 +461,7 @@ export type Unit = | 'READ_PLACEMENT_GUARANTEE_REPORT' | 'READ_PLACEMENT_PLAN' | 'READ_PRESCHOOL_ABSENCE_REPORT' + | 'READ_PRESCHOOL_APPLICATION_REPORT' | 'READ_REALTIME_STAFF_ATTENDANCES' | 'READ_SERVICE_APPLICATIONS' | 'READ_SERVICE_NEED_REPORT' diff --git a/frontend/src/lib-common/generated/api-types/reports.ts b/frontend/src/lib-common/generated/api-types/reports.ts index ffcc9290bda..7f7aed74e49 100644 --- a/frontend/src/lib-common/generated/api-types/reports.ts +++ b/frontend/src/lib-common/generated/api-types/reports.ts @@ -679,6 +679,24 @@ export interface PlacementSketchingReportRow { siblingBasis: boolean | null } +/** +* Generated from fi.espoo.evaka.reports.PreschoolApplicationReportRow +*/ +export interface PreschoolApplicationReportRow { + applicationId: UUID + applicationUnitId: UUID + applicationUnitName: string + childDateOfBirth: LocalDate + childFirstName: string + childId: UUID + childLastName: string + childPostalCode: string + childStreetAddress: string + currentUnitId: UUID | null + currentUnitName: string | null + isDaycareAssistanceNeed: boolean +} + /** * Generated from fi.espoo.evaka.reports.PreschoolUnitsReportRow */ @@ -786,6 +804,7 @@ export type Report = | 'PLACEMENT_GUARANTEE' | 'PLACEMENT_SKETCHING' | 'PRESCHOOL_ABSENCES' + | 'PRESCHOOL_APPLICATIONS' | 'PRESENCE' | 'RAW' | 'SERVICE_NEED' @@ -1125,6 +1144,14 @@ export function deserializeJsonPlacementSketchingReportRow(json: JsonOf): PreschoolApplicationReportRow { + return { + ...json, + childDateOfBirth: LocalDate.parseIso(json.childDateOfBirth) + } +} + + export function deserializeJsonPresenceReportRow(json: JsonOf): PresenceReportRow { return { ...json, diff --git a/frontend/src/lib-components/layout/Table.tsx b/frontend/src/lib-components/layout/Table.tsx index a3cf2cfabb7..8a4f42abe6b 100644 --- a/frontend/src/lib-components/layout/Table.tsx +++ b/frontend/src/lib-components/layout/Table.tsx @@ -154,10 +154,12 @@ const SortableIconContainer = styled.div` flex-direction: column; ` +export type SortDirection = 'ASC' | 'DESC' + interface SortableProps { children?: React.ReactNode onClick: () => void - sorted?: 'ASC' | 'DESC' + sorted?: SortDirection sticky?: boolean top?: string 'data-qa'?: string diff --git a/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx b/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx index 736476b327f..5b6ee35f188 100755 --- a/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx +++ b/frontend/src/lib-customizations/defaults/employee/i18n/fi.tsx @@ -4134,6 +4134,20 @@ export const fi = { } } }, + preschoolApplications: { + title: 'Ehdottava EO-raportti', + description: 'EO-raportti, josta johtaja voi tarkistaa oppilaan koulun', + columns: { + applicationUnitName: 'Yksikkö', + childLastName: 'Sukunimi', + childFirstName: 'Etunimi', + childDateOfBirth: 'Syntymäaika', + childStreetAddress: 'Postiosoite', + childPostalCode: 'Postinumero', + currentUnitName: 'Nykyinen yksikkö', + isDaycareAssistanceNeed: 'Tuen tarve' + } + }, holidayPeriodAttendance: { title: 'Lomakyselyraportti', description: 'Yksikön läsnäolojen päivätason seuranta lomakyselyn aikana', diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReportTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReportTest.kt new file mode 100644 index 00000000000..b5be1dad6b4 --- /dev/null +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReportTest.kt @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.reports + +import fi.espoo.evaka.FullApplicationTest +import fi.espoo.evaka.application.ApplicationStatus +import fi.espoo.evaka.application.ApplicationType +import fi.espoo.evaka.application.persistence.daycare.Adult +import fi.espoo.evaka.application.persistence.daycare.Apply +import fi.espoo.evaka.application.persistence.daycare.Child +import fi.espoo.evaka.application.persistence.daycare.DaycareFormV0 +import fi.espoo.evaka.shared.DaycareId +import fi.espoo.evaka.shared.auth.UserRole +import fi.espoo.evaka.shared.dev.DevCareArea +import fi.espoo.evaka.shared.dev.DevDaycare +import fi.espoo.evaka.shared.dev.DevEmployee +import fi.espoo.evaka.shared.dev.DevGuardian +import fi.espoo.evaka.shared.dev.DevPerson +import fi.espoo.evaka.shared.dev.DevPersonType +import fi.espoo.evaka.shared.dev.insert +import fi.espoo.evaka.shared.dev.insertTestApplication +import fi.espoo.evaka.shared.domain.HelsinkiDateTime +import fi.espoo.evaka.shared.domain.MockEvakaClock +import java.time.LocalDate +import java.time.LocalTime +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.groups.Tuple +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +class PreschoolApplicationReportTest : FullApplicationTest(resetDbBeforeEach = true) { + + @Autowired private lateinit var preschoolApplicationReport: PreschoolApplicationReport + + private val clock = + MockEvakaClock(HelsinkiDateTime.of(LocalDate.of(2024, 10, 16), LocalTime.of(8, 0))) + private lateinit var unitId1: DaycareId + private lateinit var unitId2: DaycareId + + @BeforeEach + fun setup() { + db.transaction { tx -> + val areaId = tx.insert(DevCareArea()) + unitId1 = tx.insert(DevDaycare(areaId = areaId, name = "Koulu A")) + unitId2 = tx.insert(DevDaycare(areaId = areaId, name = "Koulu B")) + + val guardian = DevPerson(lastName = "Testiläinen", firstName = "Matti") + val guardianId = tx.insert(guardian, DevPersonType.ADULT) + + val child1 = + DevPerson( + lastName = "Testiläinen", + firstName = "Teppo", + dateOfBirth = LocalDate.of(2019, 1, 1), + ssn = null, + ) + val childId1 = tx.insert(child1, DevPersonType.CHILD) + tx.insert(DevGuardian(guardianId = guardianId, childId = childId1)) + tx.insertTestApplication( + type = ApplicationType.PRESCHOOL, + status = ApplicationStatus.WAITING_UNIT_CONFIRMATION, + guardianId = guardianId, + childId = childId1, + document = + DaycareFormV0( + type = ApplicationType.PRESCHOOL, + child = Child(dateOfBirth = child1.dateOfBirth), + guardian = Adult(), + apply = Apply(preferredUnits = listOf(unitId1)), + ), + ) + + val child2 = + DevPerson( + lastName = "Testiläinen", + firstName = "Seppo", + dateOfBirth = LocalDate.of(2019, 1, 2), + ssn = null, + ) + val childId2 = tx.insert(child2, DevPersonType.CHILD) + tx.insert(DevGuardian(guardianId = guardianId, childId = childId2)) + tx.insertTestApplication( + type = ApplicationType.PRESCHOOL, + status = ApplicationStatus.WAITING_UNIT_CONFIRMATION, + guardianId = guardianId, + childId = childId2, + document = + DaycareFormV0( + type = ApplicationType.PRESCHOOL, + child = Child(dateOfBirth = child2.dateOfBirth), + guardian = Adult(), + apply = Apply(preferredUnits = listOf(unitId2)), + ), + ) + } + } + + @Test + fun `admin can see the report`() { + val user = + db.transaction { tx -> + val employee = DevEmployee().copy(roles = setOf(UserRole.ADMIN)) + tx.insert(employee) + employee.user + } + + val rows = + preschoolApplicationReport.getPreschoolApplicationReport(dbInstance(), user, clock) + + assertThat(rows) + .extracting( + { it.applicationUnitName }, + { it.childLastName }, + { it.childFirstName }, + { it.currentUnitName }, + { it.isDaycareAssistanceNeed }, + ) + .containsExactlyInAnyOrder( + Tuple("Koulu A", "Testiläinen", "Teppo", null, false), + Tuple("Koulu B", "Testiläinen", "Seppo", null, false), + ) + } + + @Test + fun `unit supervisor can see the report`() { + val user = + db.transaction { tx -> + val employee = DevEmployee() + tx.insert(employee, unitRoles = mapOf(unitId1 to UserRole.UNIT_SUPERVISOR)) + employee.user + } + + val rows = + preschoolApplicationReport.getPreschoolApplicationReport(dbInstance(), user, clock) + + assertThat(rows) + .extracting( + { it.applicationUnitName }, + { it.childLastName }, + { it.childFirstName }, + { it.currentUnitName }, + { it.isDaycareAssistanceNeed }, + ) + .containsExactly(Tuple("Koulu A", "Testiläinen", "Teppo", null, false)) + } + + @Test + fun `staff cannot see the report`() { + val user = + db.transaction { tx -> + val employee = DevEmployee() + tx.insert(employee, unitRoles = mapOf(unitId1 to UserRole.STAFF)) + employee.user + } + + val rows = + preschoolApplicationReport.getPreschoolApplicationReport(dbInstance(), user, clock) + + assertThat(rows).isEmpty() + } +} diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt index b7cdeec7911..95743c36ce5 100755 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/shared/config/SharedIntegrationTestConfig.kt @@ -27,6 +27,7 @@ import fi.espoo.evaka.reports.patu.PatuIntegrationClient import fi.espoo.evaka.shared.ArchiveProcessConfig import fi.espoo.evaka.shared.ArchiveProcessType import fi.espoo.evaka.shared.FeatureConfig +import fi.espoo.evaka.shared.auth.UserRole import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.db.configureJdbi import fi.espoo.evaka.shared.dev.resetDatabase @@ -34,8 +35,12 @@ import fi.espoo.evaka.shared.dev.runDevScript import fi.espoo.evaka.shared.message.EvakaMessageProvider import fi.espoo.evaka.shared.message.IMessageProvider import fi.espoo.evaka.shared.noopTracer +import fi.espoo.evaka.shared.security.Action import fi.espoo.evaka.shared.security.actionrule.ActionRuleMapping -import fi.espoo.evaka.shared.security.actionrule.DefaultActionRuleMapping +import fi.espoo.evaka.shared.security.actionrule.HasGlobalRole +import fi.espoo.evaka.shared.security.actionrule.HasUnitRole +import fi.espoo.evaka.shared.security.actionrule.ScopedActionRule +import fi.espoo.evaka.shared.security.actionrule.UnscopedActionRule import fi.espoo.evaka.shared.template.EvakaTemplateProvider import fi.espoo.evaka.shared.template.ITemplateProvider import fi.espoo.evaka.titania.TitaniaEmployeeIdConverter @@ -195,7 +200,7 @@ class SharedIntegrationTestConfig { fun coefficientMultiplierProvider(): IncomeCoefficientMultiplierProvider = EspooIncomeCoefficientMultiplierProvider() - @Bean fun actionRuleMapping(): ActionRuleMapping = DefaultActionRuleMapping() + @Bean fun actionRuleMapping(): ActionRuleMapping = TestActionRuleMapping() @Bean fun titaniaEmployeeIdConverter(): TitaniaEmployeeIdConverter = @@ -259,3 +264,22 @@ val testFeatureConfig = ), ), ) + +private class TestActionRuleMapping : ActionRuleMapping { + override fun rulesOf(action: Action.UnscopedAction): Sequence = + action.defaultRules.asSequence() + + override fun rulesOf(action: Action.ScopedAction): Sequence> = + when (action) { + Action.Unit.READ_PRESCHOOL_APPLICATION_REPORT -> { + @Suppress("UNCHECKED_CAST") + sequenceOf( + HasGlobalRole(UserRole.ADMIN, UserRole.SERVICE_WORKER) as ScopedActionRule + ) + + sequenceOf( + HasUnitRole(UserRole.UNIT_SUPERVISOR).inUnit() as ScopedActionRule + ) + } + else -> action.defaultRules.asSequence() + } +} diff --git a/service/src/main/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReport.kt b/service/src/main/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReport.kt new file mode 100644 index 00000000000..c82be01770e --- /dev/null +++ b/service/src/main/kotlin/fi/espoo/evaka/reports/PreschoolApplicationReport.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2017-2024 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +package fi.espoo.evaka.reports + +import fi.espoo.evaka.shared.ApplicationId +import fi.espoo.evaka.shared.ChildId +import fi.espoo.evaka.shared.DaycareId +import fi.espoo.evaka.shared.auth.AuthenticatedUser +import fi.espoo.evaka.shared.db.Database +import fi.espoo.evaka.shared.domain.EvakaClock +import fi.espoo.evaka.shared.security.AccessControl +import fi.espoo.evaka.shared.security.Action +import fi.espoo.evaka.shared.security.actionrule.AccessControlFilter +import fi.espoo.evaka.shared.security.actionrule.forTable +import java.time.LocalDate +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class PreschoolApplicationReport(private val accessControl: AccessControl) { + + @GetMapping("/employee/reports/preschool-application") + fun getPreschoolApplicationReport( + db: Database, + user: AuthenticatedUser.Employee, + clock: EvakaClock, + ): List { + return db.connect { dbc -> + dbc.read { tx -> + tx.setStatementTimeout(REPORT_STATEMENT_TIMEOUT) + val filter = + accessControl.requireAuthorizationFilter( + tx, + user, + clock, + Action.Unit.READ_PRESCHOOL_APPLICATION_REPORT, + ) + tx.getPreschoolApplicationReportRows(clock.today(), filter) + } + } + } +} + +private fun Database.Read.getPreschoolApplicationReportRows( + today: LocalDate, + filter: AccessControlFilter, +): List = + createQuery { + sql( + """ +SELECT + application.id AS application_id, + application_unit.id AS application_unit_id, + application_unit.name AS application_unit_name, + person.id AS child_id, + person.last_name AS child_last_name, + person.first_name AS child_first_name, + person.street_address AS child_street_address, + person.postal_code AS child_postal_code, + current_unit.id AS current_unit_id, + current_unit.name AS current_unit_name, + person.date_of_birth AS child_date_of_birth, + EXISTS (SELECT FROM daycare_assistance WHERE child_id = person.id AND valid_during @> ${bind(today)}) AS is_daycare_assistance_need +FROM application +JOIN daycare application_unit ON (application.document -> 'apply' -> 'preferredUnits' ->> 0)::uuid = application_unit.id +JOIN person on application.child_id = person.id +LEFT JOIN placement ON person.id = placement.child_id AND ${bind(today)} BETWEEN placement.start_date AND placement.end_date +LEFT JOIN daycare current_unit ON placement.unit_id = current_unit.id +WHERE application.type = 'PRESCHOOL' + AND application.status = 'WAITING_UNIT_CONFIRMATION' + AND ${predicate(filter.forTable("application_unit"))} + """ + .trimIndent() + ) + } + .toList() + +data class PreschoolApplicationReportRow( + val applicationId: ApplicationId, + val applicationUnitId: DaycareId, + val applicationUnitName: String, + val childId: ChildId, + val childLastName: String, + val childFirstName: String, + val childStreetAddress: String, + val childPostalCode: String, + val currentUnitId: DaycareId?, + val currentUnitName: String?, + val childDateOfBirth: LocalDate, + val isDaycareAssistanceNeed: Boolean, +) diff --git a/service/src/main/kotlin/fi/espoo/evaka/reports/ReportPermissions.kt b/service/src/main/kotlin/fi/espoo/evaka/reports/ReportPermissions.kt index 37e88a20346..9597f295fb0 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/reports/ReportPermissions.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/reports/ReportPermissions.kt @@ -39,6 +39,7 @@ enum class Report { PLACEMENT_GUARANTEE, PLACEMENT_SKETCHING, PRESCHOOL_ABSENCES, + PRESCHOOL_APPLICATIONS, PRESENCE, RAW, SERVICE_NEED, @@ -216,6 +217,11 @@ class ReportPermissions(private val accessControl: AccessControl) { Action.Unit.READ_PRESCHOOL_ABSENCE_REPORT ) }, + Report.PRESCHOOL_APPLICATIONS.takeIf { + permittedActionsForSomeUnit.contains( + Action.Unit.READ_PRESCHOOL_APPLICATION_REPORT + ) + }, Report.HOLIDAY_PERIOD_ATTENDANCE.takeIf { permittedActionsForSomeUnit.contains( Action.Unit.READ_HOLIDAY_PERIOD_ATTENDANCE_REPORT diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt index 398115d6eca..acf87e3ebf4 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/security/Action.kt @@ -2252,6 +2252,7 @@ sealed interface Action { HasGlobalRole(ADMIN), HasUnitRole(UNIT_SUPERVISOR, STAFF).inUnit(), ), + READ_PRESCHOOL_APPLICATION_REPORT, READ_HOLIDAY_PERIOD_ATTENDANCE_REPORT( HasGlobalRole(ADMIN), HasUnitRole(UNIT_SUPERVISOR).withUnitFeatures(PilotFeature.RESERVATIONS).inUnit(),