diff --git a/frontend/src/employee-frontend/components/ApplicationPage.tsx b/frontend/src/employee-frontend/components/ApplicationPage.tsx index 1fc6501a146..3a6607d438e 100755 --- a/frontend/src/employee-frontend/components/ApplicationPage.tsx +++ b/frontend/src/employee-frontend/components/ApplicationPage.tsx @@ -48,7 +48,7 @@ import { TitleContext, TitleState } from '../state/title' import { asUnitType } from '../types/daycare' import { isSsnValid, isTimeValid } from '../utils/validation/validations' -import { applicationMetadataQuery } from './application-page/applications-queries' +import { applicationMetadataQuery } from './application-page/queries' import MetadataSection from './archive-metadata/MetadataSection' import { renderResult, UnwrapResult } from './async-rendering' diff --git a/frontend/src/employee-frontend/components/application-page/ApplicationNoteBox.tsx b/frontend/src/employee-frontend/components/application-page/ApplicationNoteBox.tsx index e1c2d6a397b..364c32f11cd 100755 --- a/frontend/src/employee-frontend/components/application-page/ApplicationNoteBox.tsx +++ b/frontend/src/employee-frontend/components/application-page/ApplicationNoteBox.tsx @@ -8,10 +8,10 @@ import { Link } from 'react-router' import styled from 'styled-components' import { - createApplicationNote, - deleteApplicationNote, - updateApplicationNote -} from 'employee-frontend/components/application-page/applications-queries' + createApplicationNoteMutation, + deleteApplicationNoteMutation, + updateApplicationNoteMutation +} from 'employee-frontend/components/application-page/queries' import { ApplicationNote } from 'lib-common/generated/api-types/application' import { ApplicationId } from 'lib-common/generated/api-types/shared' import { useMutation } from 'lib-common/query' @@ -116,9 +116,9 @@ export default React.memo(function ApplicationNoteBox(props: Props) { setText(isEdit(props) ? props.note.content : '') }, [props]) - const { mutateAsync: updateNote } = useMutation(updateApplicationNote) - const { mutateAsync: createNote } = useMutation(createApplicationNote) - const { mutateAsync: deleteNote } = useMutation(deleteApplicationNote) + const { mutateAsync: updateNote } = useMutation(updateApplicationNoteMutation) + const { mutateAsync: createNote } = useMutation(createApplicationNoteMutation) + const { mutateAsync: deleteNote } = useMutation(deleteApplicationNoteMutation) const save = () => { if (!isInput(props)) return diff --git a/frontend/src/employee-frontend/components/application-page/ApplicationNotes.tsx b/frontend/src/employee-frontend/components/application-page/ApplicationNotes.tsx index fb34897eae8..284f0b5c410 100755 --- a/frontend/src/employee-frontend/components/application-page/ApplicationNotes.tsx +++ b/frontend/src/employee-frontend/components/application-page/ApplicationNotes.tsx @@ -14,10 +14,9 @@ import { defaultMargins, Gap } from 'lib-components/white-space' import ApplicationNoteBox from '../../components/application-page/ApplicationNoteBox' import { useTranslation } from '../../state/i18n' +import { applicationNotesQuery } from '../application-page/queries' import { renderResult } from '../async-rendering' -import { applicationNotesQuery } from './applications-queries' - const Sticky = styled.div` position: sticky; top: ${defaultMargins.s}; diff --git a/frontend/src/employee-frontend/components/application-page/applications-queries.ts b/frontend/src/employee-frontend/components/application-page/queries.ts similarity index 76% rename from frontend/src/employee-frontend/components/application-page/applications-queries.ts rename to frontend/src/employee-frontend/components/application-page/queries.ts index 12a7082c44a..241b59416cc 100644 --- a/frontend/src/employee-frontend/components/application-page/applications-queries.ts +++ b/frontend/src/employee-frontend/components/application-page/queries.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2017-2023 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later @@ -15,21 +15,21 @@ import { getApplicationMetadata } from '../../generated/api-clients/process' const q = new Queries() -export const applicationNotesQuery = q.query(getNotes) - export const applicationMetadataQuery = q.query(getApplicationMetadata) -export const createApplicationNote = q.mutation(createNote, [ +export const applicationNotesQuery = q.query(getNotes) + +export const createApplicationNoteMutation = q.mutation(createNote, [ ({ applicationId }) => applicationNotesQuery({ applicationId }) ]) -export const updateApplicationNote = q.parametricMutation<{ +export const updateApplicationNoteMutation = q.parametricMutation<{ applicationId: ApplicationId }>()(updateNote, [ ({ applicationId }) => applicationNotesQuery({ applicationId }) ]) -export const deleteApplicationNote = q.parametricMutation<{ +export const deleteApplicationNoteMutation = q.parametricMutation<{ applicationId: ApplicationId }>()(deleteNote, [ ({ applicationId }) => applicationNotesQuery({ applicationId }) diff --git a/frontend/src/employee-frontend/components/applications/ActionBar.tsx b/frontend/src/employee-frontend/components/applications/ActionBar.tsx index 411dd1e64c8..8f9636b88f8 100755 --- a/frontend/src/employee-frontend/components/applications/ActionBar.tsx +++ b/frontend/src/employee-frontend/components/applications/ActionBar.tsx @@ -1,175 +1,116 @@ -// SPDX-FileCopyrightText: 2017-2022 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later -import React, { useContext, useEffect, useRef, useState } from 'react' +import React, { useContext, useMemo } from 'react' -import { LegacyButton } from 'lib-components/atoms/buttons/LegacyButton' +import { MutateButton } from 'lib-components/atoms/buttons/MutateButton' import { FixedSpaceRow } from 'lib-components/layout/flex-helpers' import StickyActionBar from '../../components/common/StickyActionBar' -import { simpleBatchAction } from '../../generated/api-clients/application' import { ApplicationUIContext } from '../../state/application-ui' import { useTranslation } from '../../state/i18n' -import { UIContext } from '../../state/ui' import { CheckedRowsInfo } from '../common/CheckedRowsInfo' -type Action = { - id: string - label: string - primary: boolean - enabled: boolean - disabled: boolean - onClick: () => void -} +import { SimpleApplicationMutationAction } from './ApplicationActions' +import { simpleBatchActionMutation } from './queries' -type Props = { - reloadApplications: () => void - fullWidth?: boolean -} - -export default React.memo(function ActionBar({ reloadApplications }: Props) { +export default React.memo(function ActionBar({ + actionInProgress, + onActionStarted, + onActionEnded +}: { + actionInProgress: boolean + onActionStarted: () => void + onActionEnded: () => void +}) { const { i18n } = useTranslation() - const isMounted = useRef(true) - useEffect( - () => () => { - isMounted.current = false - }, - [] - ) - - const { checkedIds, setCheckedIds, applicationSearchFilters } = - useContext(ApplicationUIContext) - const [actionInFlight, setActionInFlight] = useState(false) - const { setErrorMessage } = useContext(UIContext) - const clearApplicationList = () => { - setCheckedIds([]) - reloadApplications() - } + const { + checkedIds, + setCheckedIds, + confirmedSearchFilters: searchFilters + } = useContext(ApplicationUIContext) - const disabled = actionInFlight || checkedIds.length === 0 - const handlePromise = (promise: Promise) => { - void promise - .then(() => { - if (!isMounted.current) return - clearApplicationList() - }) - .catch(() => { - if (!isMounted.current) return - setErrorMessage({ - type: 'error', - title: i18n.common.error.unknown, - resolveLabel: i18n.common.ok - }) - }) - .finally(() => { - if (!isMounted.current) return - setActionInFlight(false) - }) - } + const actions: SimpleApplicationMutationAction[] = useMemo(() => { + if (!searchFilters) return [] - const actions: Action[] = [ - { - id: 'moveToWaitingPlacement', - label: i18n.applications.actions.moveToWaitingPlacement, - primary: true, - enabled: applicationSearchFilters.status === 'SENT', - disabled, - onClick: () => - handlePromise( - simpleBatchAction({ - action: 'MOVE_TO_WAITING_PLACEMENT', - body: { applicationIds: checkedIds } - }) - ) - }, - { - id: 'returnToSent', - label: i18n.applications.actions.returnToSent, - primary: false, - enabled: applicationSearchFilters.status === 'WAITING_PLACEMENT', - disabled, - onClick: () => - handlePromise( - simpleBatchAction({ - action: 'RETURN_TO_SENT', - body: { applicationIds: checkedIds } - }) - ) - }, - { - id: 'cancelPlacementPlan', - label: i18n.applications.actions.cancelPlacementPlan, - primary: false, - enabled: applicationSearchFilters.status === 'WAITING_DECISION', - disabled, - onClick: () => - handlePromise( - simpleBatchAction({ - action: 'CANCEL_PLACEMENT_PLAN', - body: { applicationIds: checkedIds } - }) - ) - }, - { - id: 'sendDecisionsWithoutProposal', - label: i18n.applications.actions.sendDecisionsWithoutProposal, - primary: false, - enabled: applicationSearchFilters.status === 'WAITING_DECISION', - disabled, - onClick: () => - handlePromise( - simpleBatchAction({ - action: 'SEND_DECISIONS_WITHOUT_PROPOSAL', - body: { applicationIds: checkedIds } - }) - ) - }, - { - id: 'sendPlacementProposal', - label: i18n.applications.actions.sendPlacementProposal, - primary: true, - enabled: applicationSearchFilters.status === 'WAITING_DECISION', - disabled, - onClick: () => - handlePromise( - simpleBatchAction({ - action: 'SEND_PLACEMENT_PROPOSAL', - body: { applicationIds: checkedIds } - }) - ) - }, - { - id: 'withdrawPlacementProposal', - label: i18n.applications.actions.withdrawPlacementProposal, - primary: false, - enabled: applicationSearchFilters.status === 'WAITING_UNIT_CONFIRMATION', - disabled, - onClick: () => - handlePromise( - simpleBatchAction({ - action: 'WITHDRAW_PLACEMENT_PROPOSAL', - body: { applicationIds: checkedIds } - }) - ) + switch (searchFilters.status) { + case 'ALL': + return [] + case 'SENT': + return [ + { + id: 'moveToWaitingPlacement', + label: i18n.applications.actions.moveToWaitingPlacement, + actionType: 'MOVE_TO_WAITING_PLACEMENT', + primary: true + } + ] + case 'WAITING_PLACEMENT': + return [ + { + id: 'returnToSent', + label: i18n.applications.actions.returnToSent, + actionType: 'RETURN_TO_SENT' + } + ] + case 'WAITING_DECISION': + return [ + { + id: 'cancelPlacementPlan', + label: i18n.applications.actions.cancelPlacementPlan, + actionType: 'CANCEL_PLACEMENT_PLAN' + }, + { + id: 'sendDecisionsWithoutProposal', + label: i18n.applications.actions.sendDecisionsWithoutProposal, + actionType: 'SEND_DECISIONS_WITHOUT_PROPOSAL' + }, + { + id: 'sendPlacementProposal', + label: i18n.applications.actions.sendPlacementProposal, + primary: true, + actionType: 'SEND_PLACEMENT_PROPOSAL' + } + ] + case 'WAITING_UNIT_CONFIRMATION': + return [ + { + id: 'withdrawPlacementProposal', + label: i18n.applications.actions.withdrawPlacementProposal, + actionType: 'WITHDRAW_PLACEMENT_PROPOSAL' + } + ] } - ].filter(({ enabled }) => enabled) + }, [searchFilters, i18n.applications.actions]) return actions.length > 0 ? ( - + {checkedIds.length > 0 ? ( {i18n.applications.actions.checked(checkedIds.length)} ) : null} - {actions.map(({ id, label, disabled, onClick, primary }) => ( - ( + { + onActionStarted() + return { + action: actionType, + body: { applicationIds: checkedIds } + } + }} + disabled={actionInProgress || checkedIds.length === 0} + onSuccess={() => { + setCheckedIds([]) + onActionEnded() + }} + onFailure={onActionEnded} text={label} - disabled={disabled} primary={primary} data-qa={`action-bar-${id}`} /> diff --git a/frontend/src/employee-frontend/components/applications/ActionCheckbox.tsx b/frontend/src/employee-frontend/components/applications/ActionCheckbox.tsx index 5acaf0cd249..b59f3781afb 100644 --- a/frontend/src/employee-frontend/components/applications/ActionCheckbox.tsx +++ b/frontend/src/employee-frontend/components/applications/ActionCheckbox.tsx @@ -33,7 +33,7 @@ export default React.memo(function ActionCheckbox({ applicationId }: Props) { checked={checkedIds.includes(applicationId)} label="hidden" hiddenLabel={true} - data-qa={'application-row-checkbox-' + applicationId} + data-qa="application-row-checkbox" onChange={(checked) => updateCheckedIds(applicationId, checked)} /> diff --git a/frontend/src/employee-frontend/components/applications/ApplicationActions.tsx b/frontend/src/employee-frontend/components/applications/ApplicationActions.tsx index 98656574402..ebd1c9290d3 100755 --- a/frontend/src/employee-frontend/components/applications/ApplicationActions.tsx +++ b/frontend/src/employee-frontend/components/applications/ApplicationActions.tsx @@ -1,245 +1,203 @@ -// SPDX-FileCopyrightText: 2017-2022 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later -import React, { useContext, useMemo, useState } from 'react' +import React, { useMemo, useState } from 'react' import { useNavigate } from 'react-router' import styled from 'styled-components' -import { ApplicationSummary } from 'lib-common/generated/api-types/application' +import { + ApplicationSummary, + SimpleApplicationAction as SimpleApplicationActionType +} from 'lib-common/generated/api-types/application' +import { ApplicationId } from 'lib-common/generated/api-types/shared' +import { useMutation } from 'lib-common/query' import Radio from 'lib-components/atoms/form/Radio' import { FixedSpaceColumn, FixedSpaceRow } from 'lib-components/layout/flex-helpers' -import InfoModal from 'lib-components/molecules/modals/InfoModal' +import { MutateFormModal } from 'lib-components/molecules/modals/FormModal' import { Label } from 'lib-components/typography' import ActionCheckbox from '../../components/applications/ActionCheckbox' import PrimaryAction from '../../components/applications/PrimaryAction' -import { - cancelApplication, - simpleApplicationAction -} from '../../generated/api-clients/application' import { useTranslation } from '../../state/i18n' -import { UIContext } from '../../state/ui' import EllipsisMenu, { MenuItem } from '../common/EllipsisMenu' -import { ApplicationSummaryStatusOptions } from '../common/Filters' -export type Action = { +import { + cancelApplicationMutation, + simpleApplicationActionMutation +} from './queries' + +export type BaseAction = { id: string label: string - enabled: boolean - disabled?: boolean - onClick: () => undefined | void - primaryStatus?: ApplicationSummaryStatusOptions + primary?: boolean +} + +export type SimpleApplicationMutationAction = BaseAction & { + actionType: SimpleApplicationActionType } +export type OnClickAction = BaseAction & { + onClick: () => void +} + +export function isSimpleApplicationMutationAction( + action: ApplicationAction +): action is SimpleApplicationMutationAction { + return 'actionType' in action +} + +export type ApplicationAction = SimpleApplicationMutationAction | OnClickAction + type Props = { application: ApplicationSummary - reloadApplications: () => void + actionInProgress: boolean + onActionStarted: () => void + onActionEnded: () => void } export default React.memo(function ApplicationActions({ application, - reloadApplications + actionInProgress, + onActionStarted, + onActionEnded }: Props) { const navigate = useNavigate() const { i18n } = useTranslation() - const { setErrorMessage } = useContext(UIContext) - const [actionInFlight, setActionInFlight] = useState(false) const [confirmingApplicationCancel, setConfirmingApplicationCancel] = useState(false) - const handlePromise = (promise: Promise) => { - void promise - .then(() => reloadApplications()) - .catch(() => - setErrorMessage({ - type: 'error', - title: i18n.common.error.unknown, - resolveLabel: i18n.common.ok - }) - ) - .finally(() => { - setActionInFlight(false) - setConfirmingApplicationCancel(false) - }) - } - - const actions: Action[] = useMemo( - () => [ - { - id: 'move-to-waiting-placement', - label: i18n.applications.actions.moveToWaitingPlacement, - enabled: application.status === 'SENT', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - handlePromise( - simpleApplicationAction({ - applicationId: application.id, - action: 'MOVE_TO_WAITING_PLACEMENT' - }) - ) - } - }, - { - id: 'return-to-sent', - label: i18n.applications.actions.returnToSent, - enabled: - application.status === 'WAITING_PLACEMENT' || - application.status === 'CANCELLED', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - handlePromise( - simpleApplicationAction({ - applicationId: application.id, - action: 'RETURN_TO_SENT' - }) - ) - } - }, - { - id: 'cancel-application', - label: i18n.applications.actions.cancelApplication, - enabled: - application.status === 'SENT' || - application.status === 'WAITING_PLACEMENT', - disabled: actionInFlight, - onClick: () => { - setConfirmingApplicationCancel(true) - } - }, - { - id: application.checkedByAdmin ? 'create-placement-plan' : 'check', - label: application.checkedByAdmin - ? i18n.applications.actions.createPlacementPlan - : i18n.applications.actions.check, - enabled: application.status === 'WAITING_PLACEMENT', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - if (application.checkedByAdmin) { - void navigate(`/applications/${application.id}/placement`) - } else { - void navigate(`/applications/${application.id}`) + const actions: ApplicationAction[] = useMemo(() => { + switch (application.status) { + case 'CREATED': + return [] + case 'SENT': + return [ + { + id: 'move-to-waiting-placement', + label: i18n.applications.actions.moveToWaitingPlacement, + actionType: 'MOVE_TO_WAITING_PLACEMENT' + }, + { + id: 'cancel-application', + label: i18n.applications.actions.cancelApplication, + onClick: () => setConfirmingApplicationCancel(true) } - }, - primaryStatus: 'WAITING_PLACEMENT' - }, - { - id: 'cancel-placement-plan', - label: i18n.applications.actions.cancelPlacementPlan, - enabled: application.status === 'WAITING_DECISION', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - handlePromise( - simpleApplicationAction({ - applicationId: application.id, - action: 'CANCEL_PLACEMENT_PLAN' - }) - ) - } - }, - { - id: 'edit-decisions', - label: i18n.applications.actions.editDecisions, - enabled: application.status === 'WAITING_DECISION', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - void navigate(`/applications/${application.id}/decisions`) - }, - primaryStatus: 'WAITING_DECISION' - }, - { - id: 'send-decisions-without-proposal', - label: i18n.applications.actions.sendDecisionsWithoutProposal, - enabled: application.status === 'WAITING_DECISION', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - handlePromise( - simpleApplicationAction({ - applicationId: application.id, - action: 'SEND_DECISIONS_WITHOUT_PROPOSAL' - }) - ) - } - }, - { - id: 'send-placement-proposal', - label: i18n.applications.actions.sendPlacementProposal, - enabled: application.status === 'WAITING_DECISION', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - handlePromise( - simpleApplicationAction({ - applicationId: application.id, - action: 'SEND_PLACEMENT_PROPOSAL' - }) - ) - } - }, - { - id: 'withdraw-placement-proposal', - label: i18n.applications.actions.withdrawPlacementProposal, - enabled: application.status === 'WAITING_UNIT_CONFIRMATION', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - handlePromise( - simpleApplicationAction({ - applicationId: application.id, - action: 'WITHDRAW_PLACEMENT_PROPOSAL' - }) - ) - } - }, - { - id: 'confirm-decision-mailed', - label: i18n.applications.actions.confirmDecisionMailed, - enabled: application.status === 'WAITING_MAILING', - disabled: actionInFlight, - onClick: () => { - setActionInFlight(true) - handlePromise( - simpleApplicationAction({ - applicationId: application.id, - action: 'CONFIRM_DECISION_MAILED' - }) - ) - } - } - ], - [i18n, application] // eslint-disable-line react-hooks/exhaustive-deps - ) + ] + case 'WAITING_PLACEMENT': + return [ + { + id: 'return-to-sent', + label: i18n.applications.actions.returnToSent, + actionType: 'RETURN_TO_SENT' + }, + { + id: 'cancel-application', + label: i18n.applications.actions.cancelApplication, + onClick: () => setConfirmingApplicationCancel(true) + }, + { + id: application.checkedByAdmin ? 'create-placement-plan' : 'check', + label: application.checkedByAdmin + ? i18n.applications.actions.createPlacementPlan + : i18n.applications.actions.check, + onClick: () => { + if (application.checkedByAdmin) { + void navigate(`/applications/${application.id}/placement`) + } else { + void navigate(`/applications/${application.id}`) + } + }, + primary: true + } + ] + case 'WAITING_DECISION': + return [ + { + id: 'cancel-placement-plan', + label: i18n.applications.actions.cancelPlacementPlan, + actionType: 'CANCEL_PLACEMENT_PLAN' + }, + { + id: 'edit-decisions', + label: i18n.applications.actions.editDecisions, + onClick: () => { + void navigate(`/applications/${application.id}/decisions`) + }, + primary: true + }, + { + id: 'send-decisions-without-proposal', + label: i18n.applications.actions.sendDecisionsWithoutProposal, + actionType: 'SEND_DECISIONS_WITHOUT_PROPOSAL' + }, + { + id: 'send-placement-proposal', + label: i18n.applications.actions.sendPlacementProposal, + actionType: 'SEND_PLACEMENT_PROPOSAL' + } + ] + case 'WAITING_UNIT_CONFIRMATION': + return [ + { + id: 'withdraw-placement-proposal', + label: i18n.applications.actions.withdrawPlacementProposal, + actionType: 'WITHDRAW_PLACEMENT_PROPOSAL' + } + ] + case 'WAITING_MAILING': + return [ + { + id: 'confirm-decision-mailed', + label: i18n.applications.actions.confirmDecisionMailed, + actionType: 'CONFIRM_DECISION_MAILED' + } + ] + case 'WAITING_CONFIRMATION': + return [] + case 'ACTIVE': + return [] + case 'REJECTED': + return [] + case 'CANCELLED': + return [ + { + id: 'return-to-sent', + label: i18n.applications.actions.returnToSent, + actionType: 'RETURN_TO_SENT' + } + ] + } + }, [application, navigate, i18n.applications.actions]) - const applicableActions = useMemo( - () => actions.filter(({ enabled }) => enabled), - [actions] - ) const primaryAction = useMemo( - () => actions.find((action) => action.primaryStatus === application.status), - [application.status, actions] + () => actions.find((action) => action.primary), + [actions] ) return ( <> - - + + {confirmingApplicationCancel && ( setActionInFlight(true)} - handlePromise={handlePromise} onClose={() => setConfirmingApplicationCancel(false)} /> )} @@ -250,38 +208,29 @@ export default React.memo(function ApplicationActions({ const ConfirmCancelApplicationModal = React.memo( function ConfirmCancelApplicationModal({ application, - onSubmit, - handlePromise, onClose }: { application: ApplicationSummary - onSubmit: () => void - handlePromise: (promise: Promise) => void onClose: () => void }) { const { i18n } = useTranslation() const [confidential, setConfidential] = useState(null) return ( - { - onSubmit() - handlePromise( - cancelApplication({ - applicationId: application.id, - confidential - }) - ) - }, - label: i18n.common.confirm, - disabled: application.confidential === null && confidential === null - }} - reject={{ - action: onClose, - label: i18n.common.cancel - }} + resolveMutation={cancelApplicationMutation} + resolveAction={() => ({ + applicationId: application.id, + confidential + })} + resolveLabel={i18n.common.confirm} + resolveDisabled={ + application.confidential === null && confidential === null + } + rejectAction={onClose} + rejectLabel={i18n.common.cancel} + onSuccess={onClose} > {application.confidential === null && ( @@ -304,7 +253,7 @@ const ConfirmCancelApplicationModal = React.memo( )} - + ) } ) @@ -316,9 +265,26 @@ const ActionsContainer = styled.div` ` const ActionMenu = React.memo(function ActionMenu({ - actions + applicationId, + actions, + actionInProgress }: { - actions: MenuItem[] + applicationId: ApplicationId + actions: ApplicationAction[] + actionInProgress: boolean }) { - return + const { mutateAsync } = useMutation(simpleApplicationActionMutation) + const menuItems: MenuItem[] = useMemo( + () => + actions.map((action) => ({ + id: action.id, + label: action.label, + onClick: isSimpleApplicationMutationAction(action) + ? () => mutateAsync({ applicationId, action: action.actionType }) + : action.onClick, + disabled: actionInProgress + })), + [applicationId, actions, actionInProgress, mutateAsync] + ) + return }) diff --git a/frontend/src/employee-frontend/components/applications/ApplicationsFilters.tsx b/frontend/src/employee-frontend/components/applications/ApplicationsFilters.tsx index 4a654c0ede3..1bdb82a8f46 100755 --- a/frontend/src/employee-frontend/components/applications/ApplicationsFilters.tsx +++ b/frontend/src/employee-frontend/components/applications/ApplicationsFilters.tsx @@ -1,10 +1,10 @@ -// SPDX-FileCopyrightText: 2017-2022 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later import React, { Fragment, useContext, useEffect } from 'react' -import { Loading, wrapResult } from 'lib-common/api' +import { wrapResult } from 'lib-common/api' import { ApplicationBasis, ApplicationStatusOption, @@ -47,171 +47,147 @@ export default React.memo(function ApplicationFilters() { allUnits, setAllUnits, availableAreas, - clearSearchFilters, - setApplicationsResult, - applicationSearchFilters, - setApplicationSearchFilters + searchFilters, + setSearchFilters, + confirmSearchFilters, + clearSearchFilters } = useContext(ApplicationUIContext) const { i18n } = useTranslation() useEffect( () => { - const areas = - applicationSearchFilters.area.length > 0 - ? applicationSearchFilters.area - : null + const areas = searchFilters.area.length > 0 ? searchFilters.area : null void getUnitsResult({ areaIds: areas, - type: applicationSearchFilters.type, + type: searchFilters.type, from: null }).then(setAllUnits) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [ - applicationSearchFilters.type, - availableAreas, - applicationSearchFilters.area - ] + [searchFilters.type, availableAreas, searchFilters.area] ) useEffect(() => { if ( - applicationSearchFilters.units.length === 0 && - applicationSearchFilters.distinctions.includes('SECONDARY') + searchFilters.units.length === 0 && + searchFilters.distinctions.includes('SECONDARY') ) { - setApplicationSearchFilters({ - ...applicationSearchFilters, - distinctions: applicationSearchFilters.distinctions.filter( + setSearchFilters({ + ...searchFilters, + distinctions: searchFilters.distinctions.filter( (v) => v !== 'SECONDARY' ) }) } - }, [applicationSearchFilters.units]) // eslint-disable-line react-hooks/exhaustive-deps + }, [searchFilters.units]) // eslint-disable-line react-hooks/exhaustive-deps const toggleBasis = (toggledBasis: ApplicationBasis) => () => { - setApplicationsResult(Loading.of()) - setApplicationSearchFilters({ - ...applicationSearchFilters, - basis: applicationSearchFilters.basis.includes(toggledBasis) - ? applicationSearchFilters.basis.filter((v) => v != toggledBasis) - : [...applicationSearchFilters.basis, toggledBasis] + setSearchFilters({ + ...searchFilters, + basis: searchFilters.basis.includes(toggledBasis) + ? searchFilters.basis.filter((v) => v != toggledBasis) + : [...searchFilters.basis, toggledBasis] }) } const toggleStatus = (newStatus: ApplicationSummaryStatusOptions) => () => { - setApplicationsResult(Loading.of()) if ( - (newStatus === 'ALL' && applicationSearchFilters.status !== 'ALL') || - applicationSearchFilters.allStatuses.length === 0 + (newStatus === 'ALL' && searchFilters.status !== 'ALL') || + searchFilters.allStatuses.length === 0 ) { - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, status: newStatus, allStatuses: [...applicationStatusOptions] }) - } else if ( - newStatus === 'ALL' && - applicationSearchFilters.status === 'ALL' - ) { - setApplicationSearchFilters({ - ...applicationSearchFilters, + } else if (newStatus === 'ALL' && searchFilters.status === 'ALL') { + setSearchFilters({ + ...searchFilters, status: newStatus, allStatuses: [] }) } else { - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, status: newStatus }) } } const toggleApplicationType = (type: ApplicationTypeToggle) => () => { - setApplicationsResult(Loading.of()) - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, type, preschoolType: - type === 'PRESCHOOL' - ? [...preschoolTypes] - : applicationSearchFilters.preschoolType + type === 'PRESCHOOL' ? [...preschoolTypes] : searchFilters.preschoolType }) } const toggleDate = (toggledDateType: ApplicationDateType) => () => { - setApplicationsResult(Loading.of()) - setApplicationSearchFilters({ - ...applicationSearchFilters, - dateType: applicationSearchFilters.dateType.includes(toggledDateType) - ? applicationSearchFilters.dateType.filter((v) => v !== toggledDateType) - : [...applicationSearchFilters.dateType, toggledDateType] + setSearchFilters({ + ...searchFilters, + dateType: searchFilters.dateType.includes(toggledDateType) + ? searchFilters.dateType.filter((v) => v !== toggledDateType) + : [...searchFilters.dateType, toggledDateType] }) } const toggleApplicationPreschoolType = (type: PreschoolType) => () => { - setApplicationsResult(Loading.of()) - setApplicationSearchFilters({ - ...applicationSearchFilters, - preschoolType: applicationSearchFilters.preschoolType.includes(type) - ? applicationSearchFilters.preschoolType.filter((v) => v !== type) - : [...applicationSearchFilters.preschoolType, type] + setSearchFilters({ + ...searchFilters, + preschoolType: searchFilters.preschoolType.includes(type) + ? searchFilters.preschoolType.filter((v) => v !== type) + : [...searchFilters.preschoolType, type] }) } const toggleAllStatuses = (status: ApplicationStatusOption) => () => { - setApplicationsResult(Loading.of()) - setApplicationSearchFilters({ - ...applicationSearchFilters, - allStatuses: applicationSearchFilters.allStatuses.includes(status) - ? applicationSearchFilters.allStatuses.filter((v) => v !== status) - : [...applicationSearchFilters.allStatuses, status] + setSearchFilters({ + ...searchFilters, + allStatuses: searchFilters.allStatuses.includes(status) + ? searchFilters.allStatuses.filter((v) => v !== status) + : [...searchFilters.allStatuses, status] }) } const changeUnits = (selectedUnits: DaycareId[]) => { - setApplicationsResult(Loading.of()) - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, units: selectedUnits.map((selectedUnit) => selectedUnit) }) } const toggleApplicationDistinctions = (distinction: ApplicationDistinctions) => () => { - setApplicationsResult(Loading.of()) - setApplicationSearchFilters({ - ...applicationSearchFilters, - distinctions: applicationSearchFilters.distinctions.includes( - distinction - ) - ? applicationSearchFilters.distinctions.filter( - (v) => v !== distinction - ) - : [...applicationSearchFilters.distinctions, distinction] + setSearchFilters({ + ...searchFilters, + distinctions: searchFilters.distinctions.includes(distinction) + ? searchFilters.distinctions.filter((v) => v !== distinction) + : [...searchFilters.distinctions, distinction] }) } return ( - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, searchTerms }) } + onSearch={confirmSearchFilters} clearFilters={clearSearchFilters} - clearMargin={applicationSearchFilters.status === 'ALL' ? 0 : -40} column1={ <> - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, area: area }) } @@ -219,29 +195,29 @@ export default React.memo(function ApplicationFilters() { @@ -249,20 +225,20 @@ export default React.memo(function ApplicationFilters() { column2={ - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, transferApplications }) } /> - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, voucherApplications }) } @@ -272,28 +248,28 @@ export default React.memo(function ApplicationFilters() { column3={ - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, startDate }) } - endDate={applicationSearchFilters.endDate} + endDate={searchFilters.endDate} setEndDate={(endDate) => - setApplicationSearchFilters({ - ...applicationSearchFilters, + setSearchFilters({ + ...searchFilters, endDate }) } - toggled={applicationSearchFilters.dateType} + toggled={searchFilters.dateType} toggle={toggleDate} /> diff --git a/frontend/src/employee-frontend/components/applications/ApplicationsList.tsx b/frontend/src/employee-frontend/components/applications/ApplicationsList.tsx index 09a66d5c00b..96e4fc77376 100755 --- a/frontend/src/employee-frontend/components/applications/ApplicationsList.tsx +++ b/frontend/src/employee-frontend/components/applications/ApplicationsList.tsx @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2017-2022 City of Espoo +// SPDX-FileCopyrightText: 2017-2024 City of Espoo // // SPDX-License-Identifier: LGPL-2.1-or-later @@ -6,7 +6,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import React, { useContext, useState } from 'react' import styled from 'styled-components' -import { wrapResult } from 'lib-common/api' +import { useBoolean } from 'lib-common/form/hooks' import { ApplicationSortColumn, ApplicationSummary, @@ -34,7 +34,7 @@ import { FixedSpaceColumn, FixedSpaceRow } from 'lib-components/layout/flex-helpers' -import { AsyncFormModal } from 'lib-components/molecules/modals/FormModal' +import { MutateFormModal } from 'lib-components/molecules/modals/FormModal' import { Bold, H1, Italic, Light } from 'lib-components/typography' import { defaultMargins, Gap } from 'lib-components/white-space' import colors, { applicationBasisColors } from 'lib-customizations/common' @@ -50,7 +50,6 @@ import { import ActionBar from '../../components/applications/ActionBar' import ApplicationActions from '../../components/applications/ApplicationActions' import { getEmployeeUrlPrefix } from '../../constants' -import { updateServiceWorkerNote } from '../../generated/api-clients/application' import { ApplicationUIContext } from '../../state/application-ui' import { useTranslation } from '../../state/i18n' import { UserContext } from '../../state/user' @@ -62,8 +61,7 @@ import { AgeIndicatorChip } from '../common/AgeIndicatorChip' import { CareTypeChip } from '../common/CareTypeLabel' import { CircleIconGreen, CircleIconRed } from './CircleIcon' - -const updateServiceWorkerNoteResult = wrapResult(updateServiceWorkerNote) +import { updateServiceWorkerNoteMutation } from './queries' const TitleRowContainer = styled.div` display: flex; @@ -100,10 +98,6 @@ const SortableThWithBorder = styled(SortableTh)` ` const ApplicationsTableContainer = styled.div` - table { - width: auto; - } - @media screen and (min-width: 1024px) { table { max-width: 960px; @@ -160,33 +154,29 @@ const ApplicationsTableContainer = styled.div` interface Props { applicationsResult: PagedApplicationSummaries - currentPage: number - setPage: (page: number) => void sortBy: ApplicationSortColumn setSortBy: (v: ApplicationSortColumn) => void sortDirection: SearchOrder setSortDirection: (v: SearchOrder) => void - reloadApplications: () => void } const ApplicationsList = React.memo(function Applications({ applicationsResult, - currentPage, - setPage, sortBy, setSortBy, sortDirection, - setSortDirection, - reloadApplications + setSortDirection }: Props) { const { data: applications, pages, total } = applicationsResult const { i18n } = useTranslation() const { + page, + setPage, showCheckboxes, checkedIds, setCheckedIds, - applicationSearchFilters + searchFilters } = useContext(ApplicationUIContext) const { roles } = useContext(UserContext) @@ -195,6 +185,10 @@ const ApplicationsList = React.memo(function Applications({ hasRole(roles, 'FINANCE_ADMIN') || hasRole(roles, 'ADMIN') + // used to disable all actions when one is in progress + const [actionInProgress, { on: actionStarted, off: actionEnded }] = + useBoolean(false) + const [editedNote, setEditedNote] = useState(null) const [editedNoteText, setEditedNoteText] = useState('') @@ -535,7 +529,9 @@ const ApplicationsList = React.memo(function Applications({ {enableApplicationActions && ( )} @@ -547,9 +543,9 @@ const ApplicationsList = React.memo(function Applications({

- {applicationSearchFilters.status === 'ALL' + {searchFilters.status === 'ALL' ? i18n.applications.list.title - : i18n.application.statuses[applicationSearchFilters.status]} + : i18n.application.statuses[searchFilters.status]}

@@ -559,7 +555,7 @@ const ApplicationsList = React.memo(function Applications({
@@ -628,23 +624,23 @@ const ApplicationsList = React.memo(function Applications({ {rows} - + {!!editedNote && ( - - updateServiceWorkerNoteResult({ - applicationId: editedNote, - body: { text: editedNoteText } - }) - } + resolveMutation={updateServiceWorkerNoteMutation} + resolveAction={() => ({ + applicationId: editedNote, + body: { text: editedNoteText } + })} resolveLabel={i18n.common.save} - onSuccess={() => { - setEditedNote(null) - reloadApplications() - }} + onSuccess={() => setEditedNote(null)} rejectAction={() => setEditedNote(null)} rejectLabel={i18n.common.cancel} > @@ -659,7 +655,7 @@ const ApplicationsList = React.memo(function Applications({