From a3283e3598a514577689c276d68ea7c4b5455ac4 Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sat, 8 Mar 2025 15:08:30 +0200 Subject: [PATCH 1/7] another level of tabs --- frontend/src/employee-mobile-frontend/App.tsx | 37 ++++- .../common/BottomNavbar.tsx | 4 +- .../staff-attendance/StaffAttendancesPage.tsx | 129 +++++++++++++----- .../StaffMarkDepartedPage.tsx | 2 +- .../staff-attendance/StaffMemberPage.tsx | 2 +- .../StaffPreviousAttendancesPage.tsx | 2 +- 6 files changed, 128 insertions(+), 48 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/App.tsx b/frontend/src/employee-mobile-frontend/App.tsx index 9677c51d685..dd037d71a42 100755 --- a/frontend/src/employee-mobile-frontend/App.tsx +++ b/frontend/src/employee-mobile-frontend/App.tsx @@ -304,15 +304,32 @@ function StaffAttendanceRouter({ unitOrGroup }: { unitOrGroup: UnitOrGroup }) { return ( + } /> + + } + /> + } /> } /> - } /> + } /> ) } @@ -439,8 +456,14 @@ export const routes = { staffAttendanceRoot(unitOrGroup: UnitOrGroup): Uri { return uri`${this.unitOrGroup(unitOrGroup)}/staff-attendance` }, - staffAttendances(unitOrGroup: UnitOrGroup, tab: 'absent' | 'present'): Uri { - return uri`${this.staffAttendanceRoot(unitOrGroup)}/${tab}` + staffAttendancesToday( + unitOrGroup: UnitOrGroup, + tab: 'absent' | 'present' + ): Uri { + return uri`${this.staffAttendanceRoot(unitOrGroup)}/today/${tab}` + }, + staffAttendancesPlanned(unitOrGroup: UnitOrGroup): Uri { + return uri`${this.staffAttendanceRoot(unitOrGroup)}/planned` }, externalStaffAttendances(unitOrGroup: UnitOrGroup): Uri { return uri`${this.staffAttendanceRoot(unitOrGroup)}/external` diff --git a/frontend/src/employee-mobile-frontend/common/BottomNavbar.tsx b/frontend/src/employee-mobile-frontend/common/BottomNavbar.tsx index 508e4894689..61348463a11 100644 --- a/frontend/src/employee-mobile-frontend/common/BottomNavbar.tsx +++ b/frontend/src/employee-mobile-frontend/common/BottomNavbar.tsx @@ -147,7 +147,9 @@ export default function BottomNavbar({ selected={selected === 'staff'} onClick={() => selected !== 'staff' && - navigate(routes.staffAttendances(unitOrGroup, 'absent').value) + navigate( + routes.staffAttendancesToday(unitOrGroup, 'absent').value + ) } > { void navigate( - routes.staffAttendances(toUnitOrGroup(unitId, group?.id), tab).value + props.primaryTab === 'today' + ? routes.staffAttendancesToday( + toUnitOrGroup(unitId, group?.id), + props.statusTab + ).value + : routes.staffAttendancesPlanned(toUnitOrGroup(unitId, group?.id)) + .value ) }, - [navigate, tab, unitId] + [navigate, props, unitId] + ) + + const selectedGroup = useMemo( + () => + unitInfoResponse + .map(({ groups }) => + unitOrGroup.type === 'unit' + ? undefined + : groups.find((g) => g.id === unitOrGroup.id) + ) + .getOrElse(undefined), + [unitOrGroup, unitInfoResponse] + ) + + const tabs = useMemo( + () => [ + { + id: 'today', + link: routes.staffAttendancesToday(unitOrGroup, 'absent'), + label: i18n.attendances.views.TODAY + }, + { + id: 'planned', + link: routes.staffAttendancesPlanned(unitOrGroup), + label: i18n.attendances.views.NEXT_DAYS + } + ], + [unitOrGroup, i18n] + ) + + return ( + + + {props.primaryTab === 'today' ? ( + + ) : ( +
todo
+ )} +
+ ) +}) + +const StaffAttendancesToday = React.memo(function StaffAttendancesToday({ + unitOrGroup, + tab +}: { + unitOrGroup: UnitOrGroup + tab: StatusTab +}) { + const { i18n } = useTranslation() + const navigate = useNavigate() + const unitId = unitOrGroup.unitId + + const staffAttendanceResponse = useQueryResult( + staffAttendanceQuery({ unitId }) ) const navigateToExternalMemberArrival = useCallback( @@ -85,12 +157,12 @@ export default React.memo(function StaffAttendancesPage({ () => [ { id: 'absent', - link: routes.staffAttendances(unitOrGroup, 'absent'), + link: routes.staffAttendancesToday(unitOrGroup, 'absent'), label: i18n.attendances.types.ABSENT }, { id: 'present', - link: routes.staffAttendances(unitOrGroup, 'present'), + link: routes.staffAttendancesToday(unitOrGroup, 'present'), label: ( <> {i18n.attendances.types.PRESENT} @@ -127,25 +199,8 @@ export default React.memo(function StaffAttendancesPage({ [unitOrGroup, tab, staffAttendanceResponse] ) - const selectedGroup = useMemo( - () => - unitInfoResponse - .map(({ groups }) => - unitOrGroup.type === 'unit' - ? undefined - : groups.find((g) => g.id === unitOrGroup.id) - ) - .getOrElse(undefined), - [unitOrGroup, unitInfoResponse] - ) - return ( - + <> {renderResult(filteredStaff, (staff) => ( @@ -172,6 +227,6 @@ export default React.memo(function StaffAttendancesPage({ {i18n.attendances.staff.externalPerson} - + ) }) diff --git a/frontend/src/employee-mobile-frontend/staff-attendance/StaffMarkDepartedPage.tsx b/frontend/src/employee-mobile-frontend/staff-attendance/StaffMarkDepartedPage.tsx index 984fe57c8c9..cbb22775ecb 100644 --- a/frontend/src/employee-mobile-frontend/staff-attendance/StaffMarkDepartedPage.tsx +++ b/frontend/src/employee-mobile-frontend/staff-attendance/StaffMarkDepartedPage.tsx @@ -181,7 +181,7 @@ export default React.memo(function StaffMarkDepartedPage({ return ( ) } diff --git a/frontend/src/employee-mobile-frontend/staff-attendance/StaffMemberPage.tsx b/frontend/src/employee-mobile-frontend/staff-attendance/StaffMemberPage.tsx index 7a22182b003..133a5b28709 100644 --- a/frontend/src/employee-mobile-frontend/staff-attendance/StaffMemberPage.tsx +++ b/frontend/src/employee-mobile-frontend/staff-attendance/StaffMemberPage.tsx @@ -79,7 +79,7 @@ export default React.memo(function StaffMemberPage({ employeeResponse, ({ isOperationalDate, staffMember }) => ( {staffMember === undefined ? ( { return ( From 87cf9e166d376246118b60d69c38b845c2ea5acf Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sat, 8 Mar 2025 15:26:56 +0200 Subject: [PATCH 2/7] include a list of all units where staff member is in the response --- .../src/lib-common/generated/api-types/attendance.ts | 1 + .../kotlin/fi/espoo/evaka/attendance/StaffAttendance.kt | 9 +++++++++ .../fi/espoo/evaka/attendance/StaffAttendanceQueries.kt | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/frontend/src/lib-common/generated/api-types/attendance.ts b/frontend/src/lib-common/generated/api-types/attendance.ts index 3d959513398..ce8003800ef 100644 --- a/frontend/src/lib-common/generated/api-types/attendance.ts +++ b/frontend/src/lib-common/generated/api-types/attendance.ts @@ -410,6 +410,7 @@ export interface StaffMember { plannedAttendances: PlannedStaffAttendance[] present: GroupId | null spanningPlans: HelsinkiDateTimeRange[] + unitIds: DaycareId[] } /** diff --git a/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendance.kt b/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendance.kt index 5e9e9cb2929..b0acfa0bf37 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendance.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendance.kt @@ -5,6 +5,7 @@ package fi.espoo.evaka.attendance import fi.espoo.evaka.ConstList +import fi.espoo.evaka.shared.DaycareId import fi.espoo.evaka.shared.EmployeeId import fi.espoo.evaka.shared.GroupId import fi.espoo.evaka.shared.StaffAttendanceExternalId @@ -42,6 +43,13 @@ data class CurrentDayStaffAttendanceResponse( val extraAttendances: List, ) +data class PlannedStaffAttendanceResponse( + val staff: List, + val extraAttendances: List, +) + +data class PlannedEmployee(val employeeId: EmployeeId) + data class ExternalStaffMember( val id: StaffAttendanceExternalId, val name: String, @@ -54,6 +62,7 @@ data class StaffMember( val employeeId: EmployeeId, val firstName: String, val lastName: String, + val unitIds: List, val groupIds: List, val occupancyEffect: Boolean, @Nested("attendance") val latestCurrentDayAttendance: StaffMemberAttendance?, diff --git a/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendanceQueries.kt b/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendanceQueries.kt index 3b98f0cbd66..35a0bf9ebb5 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendanceQueries.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/attendance/StaffAttendanceQueries.kt @@ -44,6 +44,10 @@ SELECT DISTINCT att.type AS attendance_type, att.departed_automatically AS attendance_departed_automatically, att.occupancy_coefficient AS attendance_occupancy_coefficient, + coalesce( + (SELECT array_agg(acl.daycare_id) FROM daycare_acl acl WHERE acl.employee_id = e.id), + '{}'::uuid[] + ) AS unit_ids, coalesce(dgacl.group_ids, '{}'::uuid[]) AS group_ids, EXISTS ( SELECT 1 From 9f8a0aed37b675987dc4ecd1a6a51992e94d20cd Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sun, 9 Mar 2025 16:07:11 +0200 Subject: [PATCH 3/7] add date range param --- .../generated/api-clients/attendance.ts | 6 ++++-- ...ileRealtimeStaffAttendanceControllerIntegrationTest.kt | 3 --- .../attendance/MobileRealtimeStaffAttendanceController.kt | 8 +++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/generated/api-clients/attendance.ts b/frontend/src/employee-mobile-frontend/generated/api-clients/attendance.ts index 52b4d90051a..0b886470eff 100644 --- a/frontend/src/employee-mobile-frontend/generated/api-clients/attendance.ts +++ b/frontend/src/employee-mobile-frontend/generated/api-clients/attendance.ts @@ -247,12 +247,14 @@ export async function returnToPresent( export async function getAttendancesByUnit( request: { unitId: DaycareId, - date?: LocalDate | null + startDate?: LocalDate | null, + endDate?: LocalDate | null } ): Promise { const params = createUrlSearchParams( ['unitId', request.unitId], - ['date', request.date?.formatIso()] + ['startDate', request.startDate?.formatIso()], + ['endDate', request.endDate?.formatIso()] ) const { data: json } = await client.request>({ url: uri`/employee-mobile/realtime-staff-attendances`.toString(), diff --git a/service/src/integrationTest/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceControllerIntegrationTest.kt b/service/src/integrationTest/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceControllerIntegrationTest.kt index d3bb63d6e80..cf80015c9e6 100644 --- a/service/src/integrationTest/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceControllerIntegrationTest.kt +++ b/service/src/integrationTest/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceControllerIntegrationTest.kt @@ -1068,7 +1068,6 @@ class MobileRealtimeStaffAttendanceControllerIntegrationTest : mobileUser, MockEvakaClock(now), testDaycare.id, - date = null, ) assertThat(attendances.staff).hasSize(1) assertThat(attendances.staff.first().attendances).hasSize(1) @@ -1116,7 +1115,6 @@ class MobileRealtimeStaffAttendanceControllerIntegrationTest : mobileUser, MockEvakaClock(now), testDaycare.id, - date = null, ) assertThat(attendances2.staff).hasSize(1) assertThat(attendances2.staff.first().attendances).hasSize(1) @@ -1481,7 +1479,6 @@ class MobileRealtimeStaffAttendanceControllerIntegrationTest : user, MockEvakaClock(now), unitId, - date = null, ) } diff --git a/service/src/main/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceController.kt b/service/src/main/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceController.kt index 5def2f0d26b..d913cb12e4c 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceController.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/attendance/MobileRealtimeStaffAttendanceController.kt @@ -22,7 +22,6 @@ import fi.espoo.evaka.shared.domain.EvakaClock import fi.espoo.evaka.shared.domain.FiniteDateRange import fi.espoo.evaka.shared.domain.HelsinkiDateTime import fi.espoo.evaka.shared.domain.NotFound -import fi.espoo.evaka.shared.domain.toFiniteDateRange import fi.espoo.evaka.shared.security.AccessControl import fi.espoo.evaka.shared.security.Action import java.math.BigDecimal @@ -46,8 +45,11 @@ class MobileRealtimeStaffAttendanceController(private val ac: AccessControl) { user: AuthenticatedUser.MobileDevice, clock: EvakaClock, @RequestParam unitId: DaycareId, - @RequestParam date: LocalDate?, + @RequestParam startDate: LocalDate? = null, + @RequestParam endDate: LocalDate? = null, ): CurrentDayStaffAttendanceResponse { + val today = clock.today() + val dateRange = FiniteDateRange(startDate ?: today, endDate ?: today) return db.connect { dbc -> dbc.read { tx -> ac.requirePermissionFor( @@ -61,7 +63,7 @@ class MobileRealtimeStaffAttendanceController(private val ac: AccessControl) { staff = tx.getStaffAttendances( unitId = unitId, - dateRange = (date ?: clock.today()).toFiniteDateRange(), + dateRange = dateRange, now = clock.now(), ), extraAttendances = tx.getExternalStaffAttendances(unitId), From fde198d101aa30013084d91e56458ed2a2f2abfb Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sun, 9 Mar 2025 16:10:08 +0200 Subject: [PATCH 4/7] planned attendances table --- .../StaffAttendanceEditPage.tsx | 2 +- .../staff-attendance/StaffAttendancesPage.tsx | 161 +++++++++++++++++- .../employee-mobile-frontend/i18n/fi.ts | 3 +- 3 files changed, 158 insertions(+), 8 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendanceEditPage.tsx b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendanceEditPage.tsx index 649ca15f8e4..6ac775608e4 100644 --- a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendanceEditPage.tsx +++ b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendanceEditPage.tsx @@ -237,7 +237,7 @@ export default React.memo(function StaffAttendanceEditPage({ const unitId = unitOrGroup.unitId const unitInfoResponse = useQueryResult(unitInfoQuery({ unitId })) const staffAttendanceResponse = useQueryResult( - staffAttendanceQuery({ unitId, date }) + staffAttendanceQuery({ unitId, startDate: date, endDate: date }) ) const combinedResult = useMemo( () => diff --git a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx index 7864c233dea..275bdbd3e32 100644 --- a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx +++ b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx @@ -3,15 +3,24 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { useCallback, useMemo } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router' -import styled from 'styled-components' +import styled, { useTheme } from 'styled-components' +import { Result } from 'lib-common/api' import { GroupInfo } from 'lib-common/generated/api-types/attendance' +import { EmployeeId } from 'lib-common/generated/api-types/shared' +import LocalDate from 'lib-common/local-date' +import LocalTime from 'lib-common/local-time' import { useQueryResult } from 'lib-common/query' import { LegacyButton } from 'lib-components/atoms/buttons/LegacyButton' -import { FixedSpaceColumn } from 'lib-components/layout/flex-helpers' +import { + FixedSpaceColumn, + FixedSpaceRow +} from 'lib-components/layout/flex-helpers' import { TabLinks } from 'lib-components/molecules/Tabs' +import { fontWeights } from 'lib-components/typography' +import { faChevronDown, faChevronUp } from 'lib-icons' import { faPlus } from 'lib-icons' import { routes } from '../App' @@ -111,7 +120,7 @@ export default React.memo(function StaffAttendancesPage(props: Props) { tab={props.statusTab} /> ) : ( -
todo
+ )} ) @@ -126,10 +135,9 @@ const StaffAttendancesToday = React.memo(function StaffAttendancesToday({ }) { const { i18n } = useTranslation() const navigate = useNavigate() - const unitId = unitOrGroup.unitId const staffAttendanceResponse = useQueryResult( - staffAttendanceQuery({ unitId }) + staffAttendanceQuery({ unitId: unitOrGroup.unitId }) ) const navigateToExternalMemberArrival = useCallback( @@ -230,3 +238,144 @@ const StaffAttendancesToday = React.memo(function StaffAttendancesToday({ ) }) + +interface StaffMemberDay { + employeeId: EmployeeId + firstName: string + lastName: string + occupancyEffect: boolean + plans: { + start: LocalTime | null // null if started on previous day + end: LocalTime | null // null if ends on the next day + }[] + confidence: 'full' | 'maybeInOtherGroup' | 'maybeInOtherUnit' +} + +interface StaffMembersByDate { + date: LocalDate + staff: StaffMemberDay[] +} + +const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ + unitOrGroup +}: { + unitOrGroup: UnitOrGroup +}) { + const { i18n, lang } = useTranslation() + const theme = useTheme() + const today = LocalDate.todayInHelsinkiTz() + + const [expandedDate, setExpandedDate] = useState(null) + + const staffAttendanceResponse = useQueryResult( + staffAttendanceQuery({ + unitId: unitOrGroup.unitId, + startDate: today, + endDate: today.addDays(5) + }) + ) + + const staffMemberDays: Result = useMemo( + () => + staffAttendanceResponse.map((res) => + [1, 2, 3, 4, 5].map((i) => { + const date = today.addDays(i) + return { + date, + staff: res.staff + .filter( + (s) => + unitOrGroup.type !== 'group' || + s.groupIds.includes(unitOrGroup.id) + ) + .map((s) => ({ + employeeId: s.employeeId, + firstName: s.firstName, + lastName: s.lastName, + occupancyEffect: s.occupancyEffect, + plans: s.plannedAttendances + .filter( + (p) => + p.start.toLocalDate().isEqual(date) || + p.end.toLocalDate().isEqual(date) + ) + .map((p) => ({ + start: p.start.toLocalDate().isEqual(date) + ? p.start.toLocalTime() + : null, + end: p.end.toLocalDate().isEqual(date) + ? p.end.toLocalTime() + : null + })), + confidence: + s.unitIds.length > 1 + ? 'maybeInOtherUnit' + : s.groupIds.length > 1 + ? 'maybeInOtherGroup' + : 'full' + })) + } + }) + ), + [unitOrGroup, staffAttendanceResponse, today] + ) + + return renderResult(staffMemberDays, (days) => ( + + + + {i18n.attendances.staff.plannedCount} +
+ + {days.map(({ date, staff }) => ( + <> + + setExpandedDate(expandedDate?.isEqual(date) ? null : date) + } + $open={expandedDate?.isEqual(date) ?? false} + > + {date.formatExotic('EEEEEE d.M.', lang)} + + {staff.filter(({ plans }) => plans.length > 0).length} + +
+ +
+
+ + + ))} + + )) +}) + +const HeaderRow = styled(FixedSpaceRow)` + padding: 16px 8px 8px; + font-size: 14px; + color: ${(p) => p.theme.colors.grayscale.g70}; + font-weight: ${fontWeights.bold}; + line-height: 1.3em; + text-transform: uppercase; + vertical-align: middle; +` +const DayRow = styled(FixedSpaceRow)<{ $open: boolean }>` + border-left: 4px solid + ${(p) => (p.$open ? p.theme.colors.main.m2 : 'transparent')}; + cursor: pointer; + padding: 8px; + background-color: ${(p) => p.theme.colors.grayscale.g0}; +` + +const DayRowCol1 = styled.div` + width: 30%; +` + +const DayRowCol2 = styled.div` + width: 50%; +` diff --git a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts index 6062efe8ba7..496e126e011 100644 --- a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts +++ b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts @@ -330,7 +330,8 @@ export const fi = { add: '+ Lisää uusi kirjaus', openAttendanceInAnotherUnitWarning: 'Avoin kirjaus ', openAttendanceInAnotherUnitWarningCont: - '. Kirjaus on päätettävä ennen uuden lisäystä.' + '. Kirjaus on päätettävä ennen uuden lisäystä.', + plannedCount: 'Suunniteltuja työvuoroja' }, timeDiffTooBigNotification: 'Voit tehdä sisäänkirjauksen +/- 30 min päähän nykyhetkestä. Kirjauksia voi tarvittaessa muokata työpöytäselaimen kautta.', From b7f2036252f1d508c99fee4ead510df788d51ac1 Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sun, 9 Mar 2025 19:45:56 +0200 Subject: [PATCH 5/7] expanded staff list --- .../staff-attendance/StaffAttendancesPage.tsx | 119 +++++++++++++++++- .../employee-mobile-frontend/i18n/fi.ts | 3 +- 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx index 275bdbd3e32..5f59f332d77 100644 --- a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx +++ b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx @@ -3,9 +3,10 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import React, { useCallback, useMemo, useState } from 'react' +import sortBy from 'lodash/sortBy' +import React, { Fragment, useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router' -import styled, { useTheme } from 'styled-components' +import styled, { css, useTheme } from 'styled-components' import { Result } from 'lib-common/api' import { GroupInfo } from 'lib-common/generated/api-types/attendance' @@ -13,6 +14,8 @@ import { EmployeeId } from 'lib-common/generated/api-types/shared' import LocalDate from 'lib-common/local-date' import LocalTime from 'lib-common/local-time' import { useQueryResult } from 'lib-common/query' +import HorizontalLine from 'lib-components/atoms/HorizontalLine' +import RoundIcon from 'lib-components/atoms/RoundIcon' import { LegacyButton } from 'lib-components/atoms/buttons/LegacyButton' import { FixedSpaceColumn, @@ -348,7 +351,77 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ />
+ {expandedDate?.isEqual(date) && ( + + {sortBy( + staff.filter((s) => s.plans.length > 0), + (s) => s.firstName, + (s) => s.lastName + ).map((s) => ( + + + + + + {s.occupancyEffect && ( + + )} + + {`${s.firstName} ${s.lastName}`} + + + + + {s.plans.map((p, i) => ( +
+ {`${p.start?.format() ?? '→'} - ${p.end?.format() ?? '→'}`} + {i < s.plans.length - 1 && ', '} +
+ ))} +
+
+
+
+ ))} + {staff.some((s) => s.plans.length > 0) && + staff.some((s) => s.plans.length === 0) && } + + {sortBy( + staff.filter((s) => s.plans.length === 0), + (s) => s.firstName, + (s) => s.lastName + ).map((s) => ( + + + + + + {s.occupancyEffect && ( + + )} + + {`${s.firstName} ${s.lastName}`} + + + + {i18n.attendances.staff.noPlan} + + + + ))} +
+ )} ))}
@@ -379,3 +452,45 @@ const DayRowCol1 = styled.div` const DayRowCol2 = styled.div` width: 50%; ` + +const ExpandedStaff = styled(FixedSpaceColumn)` + padding: 8px; + background-color: ${(p) => p.theme.colors.grayscale.g0}; +` + +const StaffCol = styled.div<{ $absent?: boolean }>` + ${(p) => + p.$absent + ? css` + color: ${p.theme.colors.grayscale.g70}; + ` + : ''} +` + +const StaffCol1 = styled(StaffCol)` + width: 60%; + min-width: 60%; + max-width: 60%; +` + +const StaffCol2 = styled(StaffCol)` + width: 35%; + min-width: 35%; + max-width: 35%; +` + +const StaffName = styled.div` + font-weight: ${fontWeights.semibold}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +const OccupancyIconWrapper = styled.div` + min-width: 30px; +` + +const NoPlansSeparator = styled(HorizontalLine)` + margin-block-start: 8px; + margin-block-end: 16px; +` diff --git a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts index 496e126e011..956b3ffe077 100644 --- a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts +++ b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts @@ -331,7 +331,8 @@ export const fi = { openAttendanceInAnotherUnitWarning: 'Avoin kirjaus ', openAttendanceInAnotherUnitWarningCont: '. Kirjaus on päätettävä ennen uuden lisäystä.', - plannedCount: 'Suunniteltuja työvuoroja' + plannedCount: 'Suunniteltuja työvuoroja', + noPlan: 'Ei suunnitteltua työvuoroa' }, timeDiffTooBigNotification: 'Voit tehdä sisäänkirjauksen +/- 30 min päähän nykyhetkestä. Kirjauksia voi tarvittaessa muokata työpöytäselaimen kautta.', From 936793b7555438aa05f6e5f84213083862dbdb7d Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sun, 9 Mar 2025 20:15:44 +0200 Subject: [PATCH 6/7] confidence warnings --- .../staff-attendance/StaffAttendancesPage.tsx | 35 ++++++++++++++----- .../employee-mobile-frontend/i18n/fi.ts | 6 +++- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx index 5f59f332d77..c9542410762 100644 --- a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx +++ b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx @@ -23,6 +23,7 @@ import { } from 'lib-components/layout/flex-helpers' import { TabLinks } from 'lib-components/molecules/Tabs' import { fontWeights } from 'lib-components/typography' +import { fasExclamationTriangle } from 'lib-icons' import { faChevronDown, faChevronUp } from 'lib-icons' import { faPlus } from 'lib-icons' @@ -352,17 +353,17 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ {expandedDate?.isEqual(date) && ( - + {sortBy( staff.filter((s) => s.plans.length > 0), (s) => s.firstName, (s) => s.lastName ).map((s) => ( - + - + {s.occupancyEffect && ( )} - + {`${s.firstName} ${s.lastName}`} @@ -386,7 +387,20 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ - + {s.confidence !== 'full' && ( + + + + + + {i18n.attendances.staff.planWarnings[s.confidence]} + + + )} + ))} {staff.some((s) => s.plans.length > 0) && @@ -401,7 +415,7 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ - + {s.occupancyEffect && ( )} - + {`${s.firstName} ${s.lastName}`} @@ -486,8 +500,11 @@ const StaffName = styled.div` white-space: nowrap; ` -const OccupancyIconWrapper = styled.div` - min-width: 30px; +const IconWrapper = styled.div` + min-width: 36px; + display: flex; + justify-content: center; + align-items: center; ` const NoPlansSeparator = styled(HorizontalLine)` diff --git a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts index 956b3ffe077..5375c4a99a7 100644 --- a/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts +++ b/frontend/src/lib-customizations/defaults/employee-mobile-frontend/i18n/fi.ts @@ -332,7 +332,11 @@ export const fi = { openAttendanceInAnotherUnitWarningCont: '. Kirjaus on päätettävä ennen uuden lisäystä.', plannedCount: 'Suunniteltuja työvuoroja', - noPlan: 'Ei suunnitteltua työvuoroa' + noPlan: 'Ei suunnitteltua työvuoroa', + planWarnings: { + maybeInOtherUnit: 'Työvuoro voi olla toisessa yksikössä', + maybeInOtherGroup: 'Työvuoro voi olla toisessa ryhmässä' + } }, timeDiffTooBigNotification: 'Voit tehdä sisäänkirjauksen +/- 30 min päähän nykyhetkestä. Kirjauksia voi tarvittaessa muokata työpöytäselaimen kautta.', From 40bc875c67f664160de1e4a60a31871dc6558871 Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Mon, 10 Mar 2025 17:04:40 +0200 Subject: [PATCH 7/7] add e2e test --- .../src/e2e-test/pages/mobile/staff-page.ts | 54 +++++- .../planned-staff-attendances.spec.ts | 178 ++++++++++++++++++ .../realtime-staff-attendances.spec.ts | 14 +- .../staff-attendance/StaffAttendancesPage.tsx | 62 +++--- 4 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 frontend/src/e2e-test/specs/6_mobile/planned-staff-attendances.spec.ts diff --git a/frontend/src/e2e-test/pages/mobile/staff-page.ts b/frontend/src/e2e-test/pages/mobile/staff-page.ts index 27c8ae050f7..b3b46794641 100644 --- a/frontend/src/e2e-test/pages/mobile/staff-page.ts +++ b/frontend/src/e2e-test/pages/mobile/staff-page.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import { StaffAttendanceType } from 'lib-common/generated/api-types/attendance' +import { EmployeeId } from 'lib-common/generated/api-types/shared' import LocalDate from 'lib-common/local-date' import { UUID } from 'lib-common/types' @@ -27,7 +28,8 @@ export class StaffAttendancePage { departureTime: Element #addNewExternalMemberButton: Element - #tabs: { present: Element; absent: Element } + #primaryTabs: { today: Element; planned: Element } + #todayTabs: { present: Element; absent: Element } pinInput: Element previousAttendancesPage: { @@ -90,7 +92,11 @@ export class StaffAttendancePage { this.#addNewExternalMemberButton = page.findByDataQa( 'add-external-member-btn' ) - this.#tabs = { + this.#primaryTabs = { + today: page.findByDataQa('today-tab'), + planned: page.findByDataQa('planned-tab') + } + this.#todayTabs = { present: page.findByDataQa('present-tab'), absent: page.findByDataQa('absent-tab') } @@ -211,7 +217,7 @@ export class StaffAttendancePage { } async assertPresentStaffCount(expected: number) { - await this.#tabs.present.assertTextEquals(`LÄSNÄ\n(${expected})`) + await this.#todayTabs.present.assertTextEquals(`LÄSNÄ\n(${expected})`) } async openStaffPage(name: string) { @@ -222,8 +228,12 @@ export class StaffAttendancePage { await this.anyMemberPage.status.assertTextEquals(expected) } + async selectPrimaryTab(tab: 'today' | 'planned') { + await this.#primaryTabs[tab].click() + } + async selectTab(tab: 'present' | 'absent') { - await this.#tabs[tab].click() + await this.#todayTabs[tab].click() } async goBackFromMemberPage() { @@ -348,7 +358,7 @@ export class StaffAttendancePage { ) } - async selectGroup(groupId: string) { + async selectArrivalGroup(groupId: string) { await this.staffArrivalPage.groupSelect.selectOption(groupId) } @@ -418,3 +428,37 @@ export class StaffAttendanceEditPage { await new AsyncButton(this.page.findByDataQa('confirm')).click() } } + +export class PlannedAttendancesPage { + private page: Page + + constructor(page: Page) { + this.page = page + } + + getDateRow(date: LocalDate) { + return this.page.findByDataQa(`date-row-${date.formatIso()}`) + } + + getExpandedDate(date: LocalDate) { + return this.page.findByDataQa(`expanded-date-${date.formatIso()}`) + } + + getPresentEmployee(date: LocalDate, id: EmployeeId) { + return this.getExpandedDate(date).findByDataQa(`present-employee-${id}`) + } + + getAbsentEmployee(date: LocalDate, id: EmployeeId) { + return this.getExpandedDate(date).findByDataQa(`absent-employee-${id}`) + } + + getConfidenceWarning(date: LocalDate, id: EmployeeId) { + return this.getPresentEmployee(date, id).findByDataQa('confidence-warning') + } + + async assertPresentCount(date: LocalDate, count: number) { + return this.getDateRow(date) + .findByDataQa('present-count') + .assertTextEquals(count.toString()) + } +} diff --git a/frontend/src/e2e-test/specs/6_mobile/planned-staff-attendances.spec.ts b/frontend/src/e2e-test/specs/6_mobile/planned-staff-attendances.spec.ts new file mode 100644 index 00000000000..d7be02e70a2 --- /dev/null +++ b/frontend/src/e2e-test/specs/6_mobile/planned-staff-attendances.spec.ts @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2017-2025 City of Espoo +// +// SPDX-License-Identifier: LGPL-2.1-or-later + +import HelsinkiDateTime from 'lib-common/helsinki-date-time' +import { randomId } from 'lib-common/id-type' +import LocalDate from 'lib-common/local-date' +import LocalTime from 'lib-common/local-time' + +import { mobileViewport } from '../../browser' +import { testDaycare2, testDaycareGroup, Fixture } from '../../dev-api/fixtures' +import { resetServiceState } from '../../generated/api-clients' +import { + DevCareArea, + DevDaycareGroup, + DevEmployee +} from '../../generated/api-types' +import MobileNav from '../../pages/mobile/mobile-nav' +import { + PlannedAttendancesPage, + StaffAttendancePage +} from '../../pages/mobile/staff-page' +import { pairMobileDevice } from '../../utils/mobile' +import { Page } from '../../utils/page' + +let page: Page +let nav: MobileNav +let staffPage: StaffAttendancePage +let plannedAttendancesPage: PlannedAttendancesPage + +let careArea: DevCareArea +let aku: DevEmployee +let mikki: DevEmployee + +const pin = '4242' + +const today = LocalDate.of(2025, 3, 3) // Monday + +const daycareGroup2Fixture: DevDaycareGroup = { + ...testDaycareGroup, + id: randomId(), + name: 'Ryhmä 2' +} + +beforeEach(async () => { + await resetServiceState() + + careArea = await Fixture.careArea().save() + await Fixture.daycare({ + ...testDaycare2, + areaId: careArea.id, + enabledPilotFeatures: ['REALTIME_STAFF_ATTENDANCE'] + }).save() + + await Fixture.daycareGroup({ + ...testDaycareGroup, + daycareId: testDaycare2.id + }).save() + await Fixture.daycareGroup({ + ...daycareGroup2Fixture, + daycareId: testDaycare2.id + }).save() + + aku = await Fixture.employee({ + preferredFirstName: 'Aku', + firstName: 'Antero', + lastName: 'Ankka' + }) + .staff(testDaycare2.id) + .withGroupAcl(testDaycareGroup.id) + .save() + mikki = await Fixture.employee({ + firstName: 'Mikki', + lastName: 'Hiiri' + }) + .staff(testDaycare2.id) + .withGroupAcl(testDaycareGroup.id) + .withGroupAcl(daycareGroup2Fixture.id) + .save() + await Fixture.employeePin({ userId: aku.id, pin }).save() + await Fixture.employeePin({ userId: mikki.id, pin }).save() +}) + +const initPages = async (mockedTime: HelsinkiDateTime) => { + page = await Page.open({ + viewport: mobileViewport, + mockedTime + }) + nav = new MobileNav(page) + staffPage = new StaffAttendancePage(page) + plannedAttendancesPage = new PlannedAttendancesPage(page) + + const mobileSignupUrl = await pairMobileDevice(testDaycare2.id) + await page.goto(mobileSignupUrl) + await nav.staff.click() +} + +describe('Planned staff attendances', () => { + test('shows who has planned attendances during next days', async () => { + const tuesday = today.addDays(1) + const wednesday = today.addDays(2) + const thursday = today.addDays(3) + const friday = today.addDays(4) + + await Fixture.staffAttendancePlan({ + employeeId: aku.id, + startTime: HelsinkiDateTime.fromLocal(tuesday, LocalTime.of(9, 0)), + endTime: HelsinkiDateTime.fromLocal(tuesday, LocalTime.of(17, 0)) + }).save() + await Fixture.staffAttendancePlan({ + employeeId: mikki.id, + startTime: HelsinkiDateTime.fromLocal(wednesday, LocalTime.of(9, 0)), + endTime: HelsinkiDateTime.fromLocal(wednesday, LocalTime.of(12, 0)) + }).save() + await Fixture.staffAttendancePlan({ + employeeId: mikki.id, + startTime: HelsinkiDateTime.fromLocal(wednesday, LocalTime.of(22, 0)), + endTime: HelsinkiDateTime.fromLocal(thursday, LocalTime.of(7, 0)) + }).save() + + await initPages(HelsinkiDateTime.fromLocal(today, LocalTime.of(6, 0))) + await staffPage.selectPrimaryTab('planned') + await plannedAttendancesPage.assertPresentCount(tuesday, 1) + await plannedAttendancesPage.assertPresentCount(wednesday, 1) + await plannedAttendancesPage.assertPresentCount(thursday, 1) + await plannedAttendancesPage.assertPresentCount(friday, 0) + + await plannedAttendancesPage.getExpandedDate(tuesday).waitUntilHidden() + await plannedAttendancesPage.getDateRow(tuesday).click() + await plannedAttendancesPage.getExpandedDate(tuesday).waitUntilVisible() + await plannedAttendancesPage + .getPresentEmployee(tuesday, aku.id) + .assertText((s) => s.includes('Aku Ankka') && s.includes('09:00 - 17:00')) + await plannedAttendancesPage + .getConfidenceWarning(tuesday, aku.id) + .waitUntilHidden() + await plannedAttendancesPage + .getAbsentEmployee(tuesday, mikki.id) + .assertText((s) => s.includes('Mikki Hiiri')) + + await plannedAttendancesPage.getDateRow(wednesday).click() + await plannedAttendancesPage.getExpandedDate(tuesday).waitUntilHidden() + await plannedAttendancesPage.getExpandedDate(wednesday).waitUntilVisible() + await plannedAttendancesPage + .getAbsentEmployee(wednesday, aku.id) + .waitUntilVisible() + await plannedAttendancesPage + .getPresentEmployee(wednesday, mikki.id) + .assertText((s) => s.includes('09:00 - 12:00,\n22:00 - →')) + await plannedAttendancesPage + .getConfidenceWarning(wednesday, mikki.id) + .assertText((s) => s.includes('Työvuoro voi olla toisessa ryhmässä')) + + await plannedAttendancesPage.getDateRow(thursday).click() + await plannedAttendancesPage + .getPresentEmployee(thursday, mikki.id) + .assertText((s) => s.includes('→ - 07:00')) + + // select group where only Mikki has been authorized + await nav.selectGroup(daycareGroup2Fixture.id) + await plannedAttendancesPage.assertPresentCount(tuesday, 0) + await plannedAttendancesPage.assertPresentCount(wednesday, 1) + await plannedAttendancesPage.assertPresentCount(thursday, 1) + await plannedAttendancesPage.assertPresentCount(friday, 0) + + // On Tuesday Mikki is absent while Aku is neither present nor absent + await plannedAttendancesPage.getDateRow(tuesday).click() + await plannedAttendancesPage + .getAbsentEmployee(tuesday, mikki.id) + .waitUntilVisible() + await plannedAttendancesPage + .getPresentEmployee(tuesday, aku.id) + .waitUntilHidden() + await plannedAttendancesPage + .getAbsentEmployee(tuesday, aku.id) + .waitUntilHidden() + }) +}) diff --git a/frontend/src/e2e-test/specs/6_mobile/realtime-staff-attendances.spec.ts b/frontend/src/e2e-test/specs/6_mobile/realtime-staff-attendances.spec.ts index 562b310c227..92645248441 100644 --- a/frontend/src/e2e-test/specs/6_mobile/realtime-staff-attendances.spec.ts +++ b/frontend/src/e2e-test/specs/6_mobile/realtime-staff-attendances.spec.ts @@ -386,7 +386,7 @@ describe('Realtime staff attendance page', () => { // Within 30min from now so ok await staffAttendancePage.setArrivalTime('12:30') - await staffAttendancePage.selectGroup(testDaycareGroup.id) + await staffAttendancePage.selectArrivalGroup(testDaycareGroup.id) await staffAttendancePage.assertDoneButtonEnabled(true) // 1min too far in the future @@ -421,14 +421,14 @@ describe('Realtime staff attendance page', () => { // Within 30min from planned start so ok, type required await staffAttendancePage.setArrivalTime('07:30') - await staffAttendancePage.selectGroup(testDaycareGroup.id) + await staffAttendancePage.selectArrivalGroup(testDaycareGroup.id) await staffAttendancePage.assertDoneButtonEnabled(false) await staffAttendancePage.selectAttendanceType('JUSTIFIED_CHANGE') await staffAttendancePage.assertDoneButtonEnabled(true) // Within 5min from planned start so ok, type not required await staffAttendancePage.setArrivalTime('07:55') - await staffAttendancePage.selectGroup(testDaycareGroup.id) + await staffAttendancePage.selectArrivalGroup(testDaycareGroup.id) await staffAttendancePage.assertDoneButtonEnabled(true) // Not ok because >+-30min from current time @@ -466,12 +466,12 @@ describe('Realtime staff attendance page', () => { // Within 5min from planned start so ok, type not required await staffAttendancePage.setArrivalTime('08:00') - await staffAttendancePage.selectGroup(testDaycareGroup.id) + await staffAttendancePage.selectArrivalGroup(testDaycareGroup.id) await staffAttendancePage.assertDoneButtonEnabled(true) // More than 5min from planned start, type is not required but can be selected await staffAttendancePage.setArrivalTime('08:15') - await staffAttendancePage.selectGroup(testDaycareGroup.id) + await staffAttendancePage.selectArrivalGroup(testDaycareGroup.id) await staffAttendancePage.assertDoneButtonEnabled(true) await staffAttendancePage.selectAttendanceType('JUSTIFIED_CHANGE') await staffAttendancePage.assertDoneButtonEnabled(true) @@ -665,7 +665,7 @@ describe('Realtime staff attendance page', () => { await staffAttendancePage.clickStaffArrivedAndSetPin(pin) await staffAttendancePage.setArrivalTime('15:00') - await staffAttendancePage.selectGroup(testDaycareGroup.id) + await staffAttendancePage.selectArrivalGroup(testDaycareGroup.id) await staffAttendancePage.assertDoneButtonEnabled(true) await staffAttendancePage.clickDoneButton() await staffAttendancePage.assertAttendanceTimeTextShown( @@ -693,7 +693,7 @@ describe('Realtime staff attendance page', () => { await staffAttendancePage.clickStaffArrivedAndSetPin(pin) await staffAttendancePage.setArrivalTime('12:04') - await staffAttendancePage.selectGroup(testDaycareGroup.id) + await staffAttendancePage.selectArrivalGroup(testDaycareGroup.id) await staffAttendancePage.assertDoneButtonEnabled(true) await staffAttendancePage.selectAttendanceType('TRAINING') await staffAttendancePage.clickDoneButton() diff --git a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx index c9542410762..235d0995383 100644 --- a/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx +++ b/frontend/src/employee-mobile-frontend/staff-attendance/StaffAttendancesPage.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import sortBy from 'lodash/sortBy' -import React, { Fragment, useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router' import styled, { css, useTheme } from 'styled-components' @@ -334,6 +334,7 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ {days.map(({ date, staff }) => ( <> setExpandedDate(expandedDate?.isEqual(date) ? null : date) @@ -341,7 +342,7 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ $open={expandedDate?.isEqual(date) ?? false} > {date.formatExotic('EEEEEE d.M.', lang)} - + {staff.filter(({ plans }) => plans.length > 0).length}
@@ -353,13 +354,20 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({
{expandedDate?.isEqual(date) && ( - + {sortBy( staff.filter((s) => s.plans.length > 0), (s) => s.firstName, (s) => s.lastName ).map((s) => ( - + @@ -395,7 +403,7 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ color={theme.colors.status.warning} /> - + {i18n.attendances.staff.planWarnings[s.confidence]} @@ -411,28 +419,28 @@ const StaffAttendancesPlanned = React.memo(function StaffAttendancesPlanned({ (s) => s.firstName, (s) => s.lastName ).map((s) => ( - - - - - - {s.occupancyEffect && ( - - )} - - {`${s.firstName} ${s.lastName}`} - - - - {i18n.attendances.staff.noPlan} - - - + + + + + {s.occupancyEffect && ( + + )} + + {`${s.firstName} ${s.lastName}`} + + + {i18n.attendances.staff.noPlan} + ))} )}