From a45516b94c9bdb265e81a8e8322185fb2d93220a Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Wed, 30 Oct 2024 11:45:36 +0200 Subject: [PATCH 01/16] add multiselect mode toggle --- .../child-attendance/ChildList.tsx | 60 +++++++++++++++---- .../child-attendance/ChildListItem.tsx | 2 + 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx b/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx index 6ed808a2fd..badd65b2f8 100644 --- a/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx +++ b/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx @@ -2,13 +2,15 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import React from 'react' +import React, { useState } from 'react' import styled from 'styled-components' import { AttendanceChild, AttendanceStatus } from 'lib-common/generated/api-types/attendance' +import { UUID } from 'lib-common/types' +import Checkbox from 'lib-components/atoms/form/Checkbox' import { FixedSpaceColumn } from 'lib-components/layout/flex-helpers' import { defaultMargins, @@ -46,21 +48,47 @@ export default React.memo(function ChildList({ const { i18n } = useTranslation() const unitId = unitOrGroup.unitId + const [multiSelectMode, setMultiSelectMode] = useState(false) + const [selectedChildren, setSelectedChildren] = useState([]) + return ( {items.length > 0 ? ( - items.map((ac) => ( -
  • - -
  • - )) + <> + {type === 'COMING' && ( +
  • + + + +
  • + )} + {items.map((ac) => ( +
  • + { + setSelectedChildren((prev) => + selected + ? [...prev, ac.id] + : prev.filter((id) => id !== ac.id) + ) + }} + /> +
  • + ))} + ) : ( {i18n.mobile.emptyList(type || 'ABSENT')} @@ -102,3 +130,11 @@ const Li = styled.li` left: ${defaultMargins.s}; } ` + +const MultiselectToggleBox = styled.div` + align-items: center; + display: flex; + padding: ${defaultMargins.s} ${defaultMargins.m}; + border-radius: 2px; + background-color: ${colors.grayscale.g0}; +` diff --git a/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx b/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx index 85942fca19..80624b04f6 100644 --- a/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx +++ b/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx @@ -101,6 +101,8 @@ interface ChildListItemProps { onClick?: () => void type?: AttendanceStatus childAttendanceUrl: string + selected: boolean | null // null = not in multiselect mode + onChangeSelected: (selected: boolean) => void } export default React.memo(function ChildListItem({ From caef4b118b946cbe02ea98ea6eba67d605912db4 Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Wed, 30 Oct 2024 11:55:11 +0200 Subject: [PATCH 02/16] refactor layout --- .../child-attendance/ChildListItem.tsx | 236 +++++++++--------- 1 file changed, 118 insertions(+), 118 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx b/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx index 80624b04f6..63274a1bfc 100644 --- a/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx +++ b/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx @@ -30,6 +30,8 @@ import { unitInfoQuery } from '../units/queries' import { ListItem } from './ChildList' import { Reservations } from './Reservations' +const imageHeight = '56px' + const ChildBox = styled.div` align-items: center; display: flex; @@ -40,37 +42,47 @@ const ChildBox = styled.div` const AttendanceLinkBox = styled(Link)` display: flex; + flex-direction: row; align-items: center; + justify-content: space-between; width: 100%; ` -const imageHeight = '56px' +export const IconBox = styled.div<{ type: AttendanceStatus }>` + background-color: ${(props) => attendanceColors[props.type]}; + border-radius: 50%; + box-shadow: 0 0 0 2px ${(props) => attendanceColors[props.type]}; + border: 2px solid ${colors.grayscale.g0}; +` -const ChildBoxInfo = styled.div` +const MainInfoColumn = styled.div` margin-left: 24px; flex-grow: 1; display: flex; flex-direction: column; - align-items: flex-start; justify-content: space-between; min-height: ${imageHeight}; ` -export const IconBox = styled.div<{ type: AttendanceStatus }>` - background-color: ${(props) => attendanceColors[props.type]}; - border-radius: 50%; - box-shadow: 0 0 0 2px ${(props) => attendanceColors[props.type]}; - border: 2px solid ${colors.grayscale.g0}; +const RightColumn = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + min-height: ${imageHeight}; + margin-left: ${defaultMargins.xs}; ` -const DetailsRow = styled.div` +const NameRow = styled.div` display: flex; - flex-direction: row; justify-content: space-between; - align-items: center; + width: 100%; + word-break: break-word; +` + +const DetailsText = styled.div` color: ${colors.grayscale.g70}; font-size: 0.875em; - width: 100%; ` const RoundImage = styled.img` @@ -80,25 +92,29 @@ const RoundImage = styled.img` display: block; ` -const FixedSpaceRowWithLeftMargin = styled(FixedSpaceRow)` - margin-left: ${defaultMargins.m}; +const GroupName = styled(InformationText)` + text-align: right; ` -const NameRow = styled.div` - display: flex; - justify-content: space-between; - width: 100%; - word-break: break-word; +const RoundIconOnTop = styled(RoundIcon)` + position: absolute; + left: 40px; + top: -20px; + z-index: 2; ` -const GroupName = styled(InformationText)` - text-align: right; +const IconPlacementBox = styled.div` + position: relative; + width: 0; + height: 0; + &.m { + font-size: 14px; + } ` interface ChildListItemProps { unitOrGroup: UnitOrGroup child: ListItem - onClick?: () => void type?: AttendanceStatus childAttendanceUrl: string selected: boolean | null // null = not in multiselect mode @@ -108,7 +124,6 @@ interface ChildListItemProps { export default React.memo(function ChildListItem({ unitOrGroup, child, - onClick, type, childAttendanceUrl }: ChildListItemProps) { @@ -137,7 +152,6 @@ export default React.memo(function ChildListItem({ const maybeGroupName = type && unitOrGroup.type === 'unit' ? groupName : undefined const today = LocalDate.todayInSystemTz() - const childAge = today.differenceInYears(child.dateOfBirth) const hasActiveStickyNote = useMemo( () => child.stickyNotes.some((n) => n.expires.isEqualOrAfter(today)), @@ -147,86 +161,97 @@ export default React.memo(function ChildListItem({ return ( - - {child.imageUrl ? ( - - ) : ( - - )} - - - - - + + {child.firstName} {child.lastName} {child.preferredName ? ` (${child.preferredName})` : null} - - {maybeGroupName} - - - - {infoText} - {child.backup && ( - - )} - - - {hasActiveStickyNote && ( - - - - )} - {child.dailyNote && ( - - - - )} - {child.groupId && groupNotes.length > 0 ? ( - - - - ) : null} - - - + + {infoText} + {child.backup && ( + + )} + + + + + {maybeGroupName} + + + {hasActiveStickyNote && ( + + + + )} + {child.dailyNote && ( + + + + )} + {child.groupId && groupNotes.length > 0 ? ( + + + + ) : null} + + ) }) +const ChildImage = React.memo(function ChildImage({ + child, + type +}: { + child: ListItem + type?: AttendanceStatus +}) { + const childAge = LocalDate.todayInHelsinkiTz().differenceInYears( + child.dateOfBirth + ) + return ( + + {child.imageUrl ? ( + + ) : ( + + )} + + + + + ) +}) + function ChildReservationInfo(props: { child: AttendanceChild }) { const { reservations, dailyServiceTimes, scheduleType } = props.child const { i18n } = useTranslation() @@ -262,28 +287,3 @@ function ChildReservationInfo(props: { child: AttendanceChild }) { ) } - -const LeftDetailsDiv = styled.div` - > * { - margin-left: ${defaultMargins.xs}; - - &:first-child { - margin-left: 0; - } - } -` - -const RoundIconOnTop = styled(RoundIcon)` - position: absolute; - left: 40px; - top: -20px; - z-index: 2; -` -const IconPlacementBox = styled.div` - position: relative; - width: 0; - height: 0; - &.m { - font-size: 14px; - } -` From d68f40e5e75d0d07315a36b8a7c0ca024770067a Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Wed, 30 Oct 2024 12:34:24 +0200 Subject: [PATCH 03/16] handle multiselect --- .../child-attendance/ChildListItem.tsx | 142 +++++++++++------- 1 file changed, 86 insertions(+), 56 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx b/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx index 63274a1bfc..79b314656b 100644 --- a/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx +++ b/frontend/src/employee-mobile-frontend/child-attendance/ChildListItem.tsx @@ -48,6 +48,14 @@ const AttendanceLinkBox = styled(Link)` width: 100%; ` +const MultiselectBox = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; +` + export const IconBox = styled.div<{ type: AttendanceStatus }>` background-color: ${(props) => attendanceColors[props.type]}; border-radius: 50%; @@ -125,6 +133,8 @@ export default React.memo(function ChildListItem({ unitOrGroup, child, type, + selected, + onChangeSelected, childAttendanceUrl }: ChildListItemProps) { const unitId = unitOrGroup.unitId @@ -160,62 +170,82 @@ export default React.memo(function ChildListItem({ return ( - - - - - - {child.firstName} {child.lastName} - {child.preferredName ? ` (${child.preferredName})` : null} - - - - {infoText} - {child.backup && ( - - )} - - - - - {maybeGroupName} - - - {hasActiveStickyNote && ( - - - - )} - {child.dailyNote && ( - - - - )} - {child.groupId && groupNotes.length > 0 ? ( - - - - ) : null} - - - + {selected === null ? ( + + + + + + {child.firstName} {child.lastName} + {child.preferredName ? ` (${child.preferredName})` : null} + + + + {infoText} + {child.backup && ( + + )} + + + + + {maybeGroupName} + + + {hasActiveStickyNote && ( + + + + )} + {child.dailyNote && ( + + + + )} + {child.groupId && groupNotes.length > 0 ? ( + + + + ) : null} + + + + ) : ( + onChangeSelected(!selected)}> + + + + + {child.firstName} {child.lastName} + {child.preferredName ? ` (${child.preferredName})` : null} + + + {selected ? 'Valittu' : 'Valitse'} + + {selected ? '[x]' : '[ ]'} + + )} ) }) From acf2a9f27c34e72611f3ead7fc87806fe9c9e548 Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Wed, 30 Oct 2024 12:47:23 +0200 Subject: [PATCH 04/16] reseting state --- .../child-attendance/ChildList.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx b/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx index badd65b2f8..2fb3ff7bc3 100644 --- a/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx +++ b/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import styled from 'styled-components' import { @@ -51,6 +51,20 @@ export default React.memo(function ChildList({ const [multiSelectMode, setMultiSelectMode] = useState(false) const [selectedChildren, setSelectedChildren] = useState([]) + const enterMultiSelectMode = () => { + setMultiSelectMode(true) + setSelectedChildren([]) + } + + const exitMultiSelectMode = () => { + setMultiSelectMode(false) + setSelectedChildren([]) + } + + useEffect(() => { + if (type !== 'COMING') exitMultiSelectMode() + }, [type]) + return ( @@ -61,7 +75,9 @@ export default React.memo(function ChildList({ + checked ? enterMultiSelectMode() : exitMultiSelectMode() + } label="Kirjaa useampi lapsi" /> From b80280f83c070719eb0f228b433deebe97a5cb4c Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sat, 2 Nov 2024 12:48:18 +0200 Subject: [PATCH 05/16] preliminary support for passing multiple children to MarkPresent --- frontend/src/employee-mobile-frontend/App.tsx | 11 ++++----- .../child-attendance/actions/MarkPresent.tsx | 24 +++++++++++++++---- .../AttendanceChildComing.tsx | 2 +- .../AttendanceChildDeparted.tsx | 2 +- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/App.tsx b/frontend/src/employee-mobile-frontend/App.tsx index 720045b3a7..65dd386740 100755 --- a/frontend/src/employee-mobile-frontend/App.tsx +++ b/frontend/src/employee-mobile-frontend/App.tsx @@ -163,6 +163,7 @@ function UnitRouter() { path="/children/:childId/*" element={} /> + } /> } /> ) @@ -266,10 +267,6 @@ function ChildRouter({ unitId }: { unitId: UUID }) { index element={} /> - } - /> } @@ -402,6 +399,9 @@ export const routes = { settings(unitId: UUID): Uri { return uri`${this.unit(unitId)}/settings` }, + markPresent(unitId: UUID, childIds: UUID[]): Uri { + return uri`${this.unit(unitId)}/mark-present?children=${childIds.join(',')}` + }, unitOrGroup(unitOrGroup: UnitOrGroup): Uri { const id = unitOrGroup.type === 'unit' ? 'all' : unitOrGroup.id return uri`${this.unit(unitOrGroup.unitId)}/groups/${id}` @@ -427,9 +427,6 @@ export const routes = { childMarkAbsentBeforehand(unitId: UUID, child: UUID): Uri { return uri`${this.child(unitId, child)}/mark-absent-beforehand` }, - markPresent(unitId: UUID, child: UUID): Uri { - return uri`${this.child(unitId, child)}/mark-present` - }, markAbsent(unitId: UUID, child: UUID): Uri { return uri`${this.child(unitId, child)}/mark-absent` }, diff --git a/frontend/src/employee-mobile-frontend/child-attendance/actions/MarkPresent.tsx b/frontend/src/employee-mobile-frontend/child-attendance/actions/MarkPresent.tsx index 35ff5a8c6a..f83521606a 100644 --- a/frontend/src/employee-mobile-frontend/child-attendance/actions/MarkPresent.tsx +++ b/frontend/src/employee-mobile-frontend/child-attendance/actions/MarkPresent.tsx @@ -4,7 +4,7 @@ import { isAfter } from 'date-fns' import React, { useCallback, useMemo, useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { Navigate, useNavigate, useSearchParams } from 'react-router-dom' import styled from 'styled-components' import { combine } from 'lib-common/api' @@ -28,6 +28,7 @@ import { ContentArea } from 'lib-components/layout/Container' import { FixedSpaceRow } from 'lib-components/layout/flex-helpers' import { Gap } from 'lib-components/white-space' +import { routes } from '../../App' import { renderResult } from '../../async-rendering' import { groupNotesQuery } from '../../child-notes/queries' import ChildNameBackButton from '../../common/ChildNameBackButton' @@ -171,13 +172,16 @@ const JustifyContainer = styled.div` justify-content: center; ` -export default React.memo(function MarkPresent({ +const MarkPresentWithChildIds = React.memo(function MarkPresentWithChildIds({ unitId, - childId + childIds }: { unitId: UUID - childId: UUID + childIds: UUID[] }) { + // TODO: using first child for now, need to implement multi-child support + const childId = childIds[0] + const child = useChild(useQueryResult(childrenQuery(unitId)), childId) const attendanceStatuses = useQueryResult(attendanceStatusesQuery({ unitId })) @@ -192,3 +196,15 @@ export default React.memo(function MarkPresent({ ) ) }) + +export default React.memo(function MarkPresent({ unitId }: { unitId: UUID }) { + const [searchParams] = useSearchParams() + const children = searchParams.get('children') + if (children === null) + return + const childIds = children.split(',').filter((id) => id.length > 0) + if (childIds.length === 0) + return + + return +}) diff --git a/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildComing.tsx b/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildComing.tsx index 1bc37a9632..2506af16f5 100644 --- a/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildComing.tsx +++ b/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildComing.tsx @@ -42,7 +42,7 @@ export default React.memo(function AttendanceChildComing({ {i18n.attendances.actions.markPresent} diff --git a/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildDeparted.tsx b/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildDeparted.tsx index b4dd05fdcb..2e2260036f 100644 --- a/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildDeparted.tsx +++ b/frontend/src/employee-mobile-frontend/child-info/child-state-pages/AttendanceChildDeparted.tsx @@ -32,7 +32,7 @@ export default React.memo(function AttendanceChildDeparted({ navigate(routes.markPresent(unitId, child.id).value)} + onClick={() => navigate(routes.markPresent(unitId, [child.id]).value)} data-qa="return-to-present-btn" /> ) From af90690b02661f502d29298b299f656612205f20 Mon Sep 17 00:00:00 2001 From: Joosa Kurvinen Date: Sat, 2 Nov 2024 13:44:02 +0200 Subject: [PATCH 06/16] multiselect action buttons --- .../child-attendance/ChildList.tsx | 145 ++++++++++++------ 1 file changed, 100 insertions(+), 45 deletions(-) diff --git a/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx b/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx index 2fb3ff7bc3..bb62084579 100644 --- a/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx +++ b/frontend/src/employee-mobile-frontend/child-attendance/ChildList.tsx @@ -3,6 +3,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later import React, { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' import styled from 'styled-components' import { @@ -10,8 +11,12 @@ import { AttendanceStatus } from 'lib-common/generated/api-types/attendance' import { UUID } from 'lib-common/types' +import { Button } from 'lib-components/atoms/buttons/Button' import Checkbox from 'lib-components/atoms/form/Checkbox' -import { FixedSpaceColumn } from 'lib-components/layout/flex-helpers' +import { + FixedSpaceColumn, + FixedSpaceRow +} from 'lib-components/layout/flex-helpers' import { defaultMargins, isSpacingSize, @@ -46,6 +51,7 @@ export default React.memo(function ChildList({ type }: Props) { const { i18n } = useTranslation() + const navigate = useNavigate() const unitId = unitOrGroup.unitId const [multiSelectMode, setMultiSelectMode] = useState(false) @@ -66,52 +72,84 @@ export default React.memo(function ChildList({ }, [type]) return ( - - - {items.length > 0 ? ( - <> - {type === 'COMING' && ( -
  • - - - checked ? enterMultiSelectMode() : exitMultiSelectMode() + <> + + + {items.length > 0 ? ( + <> + {type === 'COMING' && ( +
  • + + + checked ? enterMultiSelectMode() : exitMultiSelectMode() + } + label="Kirjaa useampi lapsi" + /> + +
  • + )} + {items.map((ac) => ( +
  • + { + setSelectedChildren((prev) => + selected + ? [...prev, ac.id] + : prev.filter((id) => id !== ac.id) + ) + }} /> - -
  • - )} - {items.map((ac) => ( -
  • - { - setSelectedChildren((prev) => - selected - ? [...prev, ac.id] - : prev.filter((id) => id !== ac.id) - ) - }} - /> -
  • - ))} - - ) : ( - - {i18n.mobile.emptyList(type || 'ABSENT')} - - )} -
    -
    + + ))} + + ) : ( + + {i18n.mobile.emptyList(type || 'ABSENT')} + + )} +
    +
    + {multiSelectMode && ( + + +