From 1c23af41ef12225648adb6a96fc0eeede07df700 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:33:04 +0200 Subject: [PATCH 01/37] feat: add uncomplete event functionality to view event --- i18n/en.pot | 7 +- .../EventDetailsSection.component.tsx | 43 ++++++++++--- .../UncompleteEventMenuItem.component.tsx | 64 +++++++++++++++++++ .../UncompleteEventMenuItem.types.ts | 5 ++ .../UncompleteEventMenuItem/index.ts | 1 + .../WidgetEventEdit.container.tsx | 4 +- .../WidgetHeader/WidgetHeader.container.tsx | 54 ++++++++++++---- .../WidgetHeader/WidgetHeader.types.ts | 2 + 8 files changed, 156 insertions(+), 24 deletions(-) create mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx create mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.types.ts create mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index 3d463f16ca..00ee68b48e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-08T07:19:14.135Z\n" -"PO-Revision-Date: 2026-07-08T07:19:14.135Z\n" +"POT-Creation-Date: 2026-07-20T11:33:06.165Z\n" +"PO-Revision-Date: 2026-07-20T11:33:06.165Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1518,6 +1518,9 @@ msgstr "Geometry (Area)" msgid "Geometry (Coordinate)" msgstr "Geometry (Coordinate)" +msgid "An error occurred when uncompleting the event" +msgstr "An error occurred when uncompleting the event" + msgid "Polygon captured" msgstr "Polygon captured" diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 31ff7d7479..ffe02500b1 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { dataEntryIds, dataEntryKeys } from 'capture-core/constants'; import { withStyles } from 'capture-core-utils/styles'; import { FEATURES, useFeature } from 'capture-core-utils'; @@ -27,6 +27,10 @@ import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useC import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { useProgramExpiryForUser } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; +import { UncompleteEventMenuItem } from '../../../WidgetEventEdit/UncompleteEventMenuItem'; +import { eventStatuses } from '../../../WidgetEventEdit/constants/status.const'; +import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; +import { pageKeys } from '../../../App/withAppUrlSync'; import type { PlainProps } from './EventDetailsSection.types'; const getStyles: any = () => ({ @@ -74,7 +78,9 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { showEditButton, ...passOnProps } = props; + const dispatch = useDispatch(); const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); + const eventStatus = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.status); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); @@ -84,6 +90,14 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const showUncompleteAction = !isEditEventPage + && eventStatus === eventStatuses.COMPLETED + && canUncompleteEvent + && Boolean(eventAccess?.write); + + const onUncompleted = useCallback(() => { + dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); + }, [dispatch, eventId]); const onSaveExternal = useCallback(() => { const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; @@ -131,7 +145,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { {i18n.t('Edit event')} } - {supportsChangelog && ( + {(supportsChangelog || showUncompleteAction) && ( setActionsIsOpen(prev => !prev)} @@ -145,14 +159,23 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { maxWidth="250px" dataTest="event-program-event-overflow-menu" > - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> + {showUncompleteAction && ( + setActionsIsOpen(false)} + /> + )} + {supportsChangelog && ( + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + )} )} /> diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx new file mode 100644 index 0000000000..ca1780ae67 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; +import { MenuItem, IconCheckmark16 } from '@dhis2/ui'; +import { useMutation } from '@tanstack/react-query'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { errorCreator } from 'capture-core-utils'; +import { eventStatuses } from '../constants/status.const'; +import type { Props } from './UncompleteEventMenuItem.types'; + +export const UncompleteEventMenuItem = ({ eventId, onUncompleted, onClose }: Props) => { + const dataEngine = useDataEngine(); + const { show: showError } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const { mutate: uncompleteEvent, isLoading } = useMutation( + async () => { + const { event: apiEvent } = await dataEngine.query({ + event: { + resource: 'tracker/events', + id: eventId, + params: { + fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + }, + }, + }) as any; + return dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE&atomicMode=OBJECT', + type: 'create', + data: { + events: [{ + ...apiEvent, + status: eventStatuses.ACTIVE, + }], + }, + }); + }, + { + onError: (error: unknown) => { + showError({ message: i18n.t('An error occurred when uncompleting the event') }); + log.error(errorCreator('An error occurred when uncompleting the event')({ error, eventId })); + }, + onSuccess: () => { + onUncompleted(); + }, + }, + ); + + return ( + } + label={i18n.t('Mark incomplete')} + suffix="" + onClick={() => { + onClose(); + !isLoading && uncompleteEvent(); + }} + /> + ); +}; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.types.ts new file mode 100644 index 0000000000..63fa9f2a77 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.types.ts @@ -0,0 +1,5 @@ +export type Props = { + eventId: string; + onUncompleted: () => void; + onClose: () => void; +}; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts b/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts new file mode 100644 index 0000000000..a913bd803d --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts @@ -0,0 +1 @@ +export { UncompleteEventMenuItem } from './UncompleteEventMenuItem.component'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 01c9f28d52..12ddd298c7 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,7 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { readOnly, expiryPeriod, canUncompleteEvent } = useEventEditPermissions({ + const { readOnly, expiryPeriod, canUncompleteEvent, eventAccess } = useEventEditPermissions({ programId, stage, eventStatus, @@ -135,12 +135,14 @@ const WidgetEventEditPlain = ({ } noncollapsible diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 63a28ab9b2..86ba5869f3 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; @@ -11,6 +11,14 @@ import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { OverflowButton } from '../../Buttons'; import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, +} from '../../Pages/common/EnrollmentOverviewDomain'; +import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; +import { pageKeys } from '../../App/withAppUrlSync'; +import { eventStatuses } from '../constants/status.const'; +import { UncompleteEventMenuItem } from '../UncompleteEventMenuItem'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -30,6 +38,7 @@ const styles: Readonly = { type Props = PlainProps & WithStyles; const WidgetHeaderPlain = ({ + eventId, eventStatus, stage, programId, @@ -37,6 +46,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, + canUncompleteEvent, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -46,8 +56,21 @@ const WidgetHeaderPlain = ({ const [actionsIsOpen, setActionsIsOpen] = useState(false); const showEditButton = !readOnly; + const showUncompleteAction = Boolean(canUncompleteEvent) && eventStatus === eventStatuses.COMPLETED; const { programCategory } = useCategoryCombinations(programId); + const storedEvent = useSelector((state: any) => + state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); + + const onUncompleted = useCallback(() => { + if (storedEvent) { + const { completedAt, completedBy, ...uncompletedEvent } = storedEvent; + dispatch(updateEnrollmentEvent(eventId, { ...uncompletedEvent, status: eventStatuses.ACTIVE })); + dispatch(commitEnrollmentEvent(eventId)); + } + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); + }, [dispatch, storedEvent, eventId]); + const { icon, name } = stage; return ( @@ -79,7 +102,7 @@ const WidgetHeaderPlain = ({ )} - {supportsChangelog && ( + {(supportsChangelog || showUncompleteAction) && ( setActionsIsOpen(prev => !prev)} @@ -93,14 +116,23 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> + {showUncompleteAction && ( + setActionsIsOpen(false)} + /> + )} + {supportsChangelog && ( + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + )} } /> diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index c33bee7127..6a4b469f40 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -2,10 +2,12 @@ import type { OrgUnit } from '@dhis2/rules-engine-javascript'; import { ProgramStage } from '../../../metaData'; export type PlainProps = { + eventId: string, eventStatus?: string, stage: ProgramStage, programId: string, orgUnit: OrgUnit, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, + canUncompleteEvent: boolean, }; From ee06506b07dfe1500f4f4520fc46d4fdfdea1960 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:37:30 +0200 Subject: [PATCH 02/37] feat: add icon to changelog meny item --- i18n/en.pot | 4 ++-- .../EventDetailsSection/EventDetailsSection.component.tsx | 2 ++ .../WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx | 3 ++- .../WidgetProfile/OverflowMenu/OverflowMenu.component.tsx | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 00ee68b48e..c43e9f84c8 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-20T11:33:06.165Z\n" -"PO-Revision-Date: 2026-07-20T11:33:06.165Z\n" +"POT-Creation-Date: 2026-07-20T11:37:31.262Z\n" +"PO-Revision-Date: 2026-07-20T11:37:31.262Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index ffe02500b1..1c60b9c1c6 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -8,6 +8,7 @@ import { IconFileDocument24, Button, IconMore16, + IconClockHistory16, FlyoutMenu, MenuItem, } from '@dhis2/ui'; @@ -168,6 +169,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { )} {supportsChangelog && ( } label={i18n.t('View changelog')} suffix={null} onClick={() => { diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 86ba5869f3..50d41966ce 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; +import { spacersNum, Button, IconEdit24, IconMore16, IconClockHistory16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; import { FEATURES, useFeature } from 'capture-core-utils'; @@ -125,6 +125,7 @@ const WidgetHeaderPlain = ({ )} {supportsChangelog && ( } label={i18n.t('View changelog')} suffix="" onClick={() => { diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx index e6c443a798..c6c71ce792 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { FlyoutMenu, IconMore16, MenuItem } from '@dhis2/ui'; +import { FlyoutMenu, IconMore16, IconClockHistory16, MenuItem } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import type { PlainProps, MenuFlyoutProps, ModalsProps } from './OverflowMenu.types'; import { DeleteMenuItem, DeleteModal } from './Delete'; @@ -23,6 +23,7 @@ const MenuFlyout = ({ {displayChangelog && ( } label={i18n.t('View changelog')} onClick={() => { setChangelogIsOpen(true); From 82231d51cfdd39557df21ac8bcad916e9cc67e33 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:38:02 +0200 Subject: [PATCH 03/37] fix: complexity error --- i18n/en.pot | 4 +-- .../EventDetailsSection.component.tsx | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c43e9f84c8..c4dc066a28 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-20T11:37:31.262Z\n" -"PO-Revision-Date: 2026-07-20T11:37:31.262Z\n" +"POT-Creation-Date: 2026-07-20T11:38:03.713Z\n" +"PO-Revision-Date: 2026-07-20T11:38:03.714Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 1c60b9c1c6..16fa731d7f 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -65,6 +65,21 @@ const getStyles: any = () => ({ editButtonContainer: {}, }); +const getShowUncompleteAction = ({ + isEditEventPage, + eventStatus, + canUncompleteEvent, + eventAccess, +}: { + isEditEventPage?: boolean; + eventStatus?: string; + canUncompleteEvent: boolean; + eventAccess: { read: boolean, write: boolean }; +}) => !isEditEventPage + && eventStatus === eventStatuses.COMPLETED + && canUncompleteEvent + && Boolean(eventAccess?.write); + const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { classes, @@ -91,10 +106,12 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const showUncompleteAction = !isEditEventPage - && eventStatus === eventStatuses.COMPLETED - && canUncompleteEvent - && Boolean(eventAccess?.write); + const showUncompleteAction = getShowUncompleteAction({ + isEditEventPage, + eventStatus, + canUncompleteEvent, + eventAccess, + }); const onUncompleted = useCallback(() => { dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); From d43d0d73e05662bd19e2579e971699d2b6bf0c0c Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:41:01 +0200 Subject: [PATCH 04/37] feat: implement event completion functionality --- i18n/en.pot | 22 +++++------ .../EventCompletionMenuItem.component.tsx} | 35 +++++++++++------- .../EventCompletionMenuItem/index.ts | 1 + .../EventDetailsSection.component.tsx | 37 +++++-------------- .../EventDetailsSection.types.ts | 1 + .../ViewEvent.component.tsx | 2 + .../UncompleteEventMenuItem.types.ts | 5 --- .../UncompleteEventMenuItem/index.ts | 1 - .../WidgetEventEdit.container.tsx | 4 +- .../WidgetHeader/WidgetHeader.container.tsx | 21 +++++------ .../WidgetHeader/WidgetHeader.types.ts | 2 +- .../hooks/useEventEditPermissions.ts | 26 +++++++++++++ 12 files changed, 85 insertions(+), 72 deletions(-) rename src/core_modules/capture-core/components/{WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx => EventCompletionMenuItem/EventCompletionMenuItem.component.tsx} (58%) create mode 100644 src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts delete mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.types.ts delete mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index c4dc066a28..e1b99233ee 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-20T11:38:03.713Z\n" -"PO-Revision-Date: 2026-07-20T11:38:03.714Z\n" +"POT-Creation-Date: 2026-07-20T13:41:03.116Z\n" +"PO-Revision-Date: 2026-07-20T13:41:03.117Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -437,6 +437,15 @@ msgstr "" "This event has unsaved changes. Leaving this page without saving will lose " "these changes. Are you sure you want to discard unsaved changes?" +msgid "An error occurred when updating event status" +msgstr "An error occurred when updating event status" + +msgid "Mark incomplete" +msgstr "Mark incomplete" + +msgid "Mark complete" +msgstr "Mark complete" + msgid "No events to display" msgstr "No events to display" @@ -1347,9 +1356,6 @@ msgstr "Reactivate" msgid "Mark as cancelled" msgstr "Mark as cancelled" -msgid "Mark incomplete" -msgstr "Mark incomplete" - msgid "You do not have access to delete this enrollment" msgstr "You do not have access to delete this enrollment" @@ -1518,9 +1524,6 @@ msgstr "Geometry (Area)" msgid "Geometry (Coordinate)" msgstr "Geometry (Coordinate)" -msgid "An error occurred when uncompleting the event" -msgstr "An error occurred when uncompleting the event" - msgid "Polygon captured" msgstr "Polygon captured" @@ -1766,9 +1769,6 @@ msgstr "An error occurred while deleting the event" msgid "Are you sure you want to delete this event?" msgstr "Are you sure you want to delete this event?" -msgid "An error occurred when updating event status" -msgstr "An error occurred when updating event status" - msgid "Unskip" msgstr "Unskip" diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx b/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx similarity index 58% rename from src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx rename to src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx index ca1780ae67..97c8e70cc4 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/UncompleteEventMenuItem.component.tsx +++ b/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx @@ -1,21 +1,30 @@ import React from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; -import { MenuItem, IconCheckmark16 } from '@dhis2/ui'; +import { MenuItem, IconCheckmark16, IconUndo16 } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; -import { eventStatuses } from '../constants/status.const'; -import type { Props } from './UncompleteEventMenuItem.types'; +import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; -export const UncompleteEventMenuItem = ({ eventId, onUncompleted, onClose }: Props) => { +type Props = { + eventId: string; + eventStatus?: string; + onUpdated: (newStatus: string) => void; + onClose: () => void; +}; + +export const EventCompletionMenuItem = ({ eventId, eventStatus, onUpdated, onClose }: Props) => { const dataEngine = useDataEngine(); const { show: showError } = useAlert( ({ message }) => message, { critical: true }, ); - const { mutate: uncompleteEvent, isLoading } = useMutation( + const isCompleted = eventStatus === eventStatuses.COMPLETED; + const newStatus = isCompleted ? eventStatuses.ACTIVE : eventStatuses.COMPLETED; + + const { mutate: updateCompletionStatus, isLoading } = useMutation( async () => { const { event: apiEvent } = await dataEngine.query({ event: { @@ -32,18 +41,18 @@ export const UncompleteEventMenuItem = ({ eventId, onUncompleted, onClose }: Pro data: { events: [{ ...apiEvent, - status: eventStatuses.ACTIVE, + status: newStatus, }], }, }); }, { onError: (error: unknown) => { - showError({ message: i18n.t('An error occurred when uncompleting the event') }); - log.error(errorCreator('An error occurred when uncompleting the event')({ error, eventId })); + showError({ message: i18n.t('An error occurred when updating event status') }); + log.error(errorCreator('An error occurred when updating event status')({ error, eventId, newStatus })); }, onSuccess: () => { - onUncompleted(); + onUpdated(newStatus); }, }, ); @@ -51,13 +60,13 @@ export const UncompleteEventMenuItem = ({ eventId, onUncompleted, onClose }: Pro return ( } - label={i18n.t('Mark incomplete')} + dataTest={isCompleted ? 'uncomplete-event-menu-item' : 'complete-event-menu-item'} + icon={isCompleted ? : } + label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} suffix="" onClick={() => { onClose(); - !isLoading && uncompleteEvent(); + !isLoading && updateCompletionStatus(); }} /> ); diff --git a/src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts b/src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts new file mode 100644 index 0000000000..af2879a761 --- /dev/null +++ b/src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts @@ -0,0 +1 @@ +export { EventCompletionMenuItem } from './EventCompletionMenuItem.component'; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 16fa731d7f..d68bfadc7e 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -28,8 +28,7 @@ import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useC import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; import { useProgramExpiryForUser } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; -import { UncompleteEventMenuItem } from '../../../WidgetEventEdit/UncompleteEventMenuItem'; -import { eventStatuses } from '../../../WidgetEventEdit/constants/status.const'; +import { EventCompletionMenuItem } from '../../../EventCompletionMenuItem'; import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../../App/withAppUrlSync'; import type { PlainProps } from './EventDetailsSection.types'; @@ -65,21 +64,6 @@ const getStyles: any = () => ({ editButtonContainer: {}, }); -const getShowUncompleteAction = ({ - isEditEventPage, - eventStatus, - canUncompleteEvent, - eventAccess, -}: { - isEditEventPage?: boolean; - eventStatus?: string; - canUncompleteEvent: boolean; - eventAccess: { read: boolean, write: boolean }; -}) => !isEditEventPage - && eventStatus === eventStatuses.COMPLETED - && canUncompleteEvent - && Boolean(eventAccess?.write); - const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { classes, @@ -92,6 +76,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onBackToAllEvents, programId, showEditButton, + canChangeCompletionStatus, ...passOnProps } = props; const dispatch = useDispatch(); @@ -106,14 +91,9 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const showUncompleteAction = getShowUncompleteAction({ - isEditEventPage, - eventStatus, - canUncompleteEvent, - eventAccess, - }); + const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; - const onUncompleted = useCallback(() => { + const onCompletionStatusUpdated = useCallback(() => { dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); }, [dispatch, eventId]); @@ -163,7 +143,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { {i18n.t('Edit event')} } - {(supportsChangelog || showUncompleteAction) && ( + {(supportsChangelog || showCompletionAction) && ( setActionsIsOpen(prev => !prev)} @@ -177,10 +157,11 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { maxWidth="250px" dataTest="event-program-event-overflow-menu" > - {showUncompleteAction && ( - setActionsIsOpen(false)} /> )} diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts index 337cb058f4..65a66586ef 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts @@ -10,4 +10,5 @@ export type PlainProps = { programId: string; onBackToAllEvents: () => void; showEditButton: boolean; + canChangeCompletionStatus: boolean; }; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index bbee70a42a..a87b271e4f 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -101,6 +101,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { isEventWithinValidPeriod, isWithinCompleteExpiry, canEditCompletedEvent, + canChangeCompletionStatus, readOnly, } = useEventEditPermissions({ programId, @@ -135,6 +136,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { onBackToViewEvent={onBackToViewEvent} onBackToAllEvents={onBackToAllEvents} showEditButton={showEditButton} + canChangeCompletionStatus={canChangeCompletionStatus} /> void; - onClose: () => void; -}; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts b/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts deleted file mode 100644 index a913bd803d..0000000000 --- a/src/core_modules/capture-core/components/WidgetEventEdit/UncompleteEventMenuItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UncompleteEventMenuItem } from './UncompleteEventMenuItem.component'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 12ddd298c7..026c8bb607 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,7 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { readOnly, expiryPeriod, canUncompleteEvent, eventAccess } = useEventEditPermissions({ + const { readOnly, expiryPeriod, canUncompleteEvent, canChangeCompletionStatus } = useEventEditPermissions({ programId, stage, eventStatus, @@ -142,7 +142,7 @@ const WidgetEventEditPlain = ({ orgUnit={orgUnit} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={readOnly} - canUncompleteEvent={canUncompleteEvent && Boolean(eventAccess?.write)} + canChangeCompletionStatus={canChangeCompletionStatus} /> } noncollapsible diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 50d41966ce..abbf5f5ef6 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -17,8 +17,7 @@ import { } from '../../Pages/common/EnrollmentOverviewDomain'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; -import { eventStatuses } from '../constants/status.const'; -import { UncompleteEventMenuItem } from '../UncompleteEventMenuItem'; +import { EventCompletionMenuItem } from '../../EventCompletionMenuItem'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -46,7 +45,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - canUncompleteEvent, + canChangeCompletionStatus, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -56,16 +55,15 @@ const WidgetHeaderPlain = ({ const [actionsIsOpen, setActionsIsOpen] = useState(false); const showEditButton = !readOnly; - const showUncompleteAction = Boolean(canUncompleteEvent) && eventStatus === eventStatuses.COMPLETED; const { programCategory } = useCategoryCombinations(programId); const storedEvent = useSelector((state: any) => state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); - const onUncompleted = useCallback(() => { + const onCompletionStatusUpdated = useCallback((newStatus: string) => { if (storedEvent) { - const { completedAt, completedBy, ...uncompletedEvent } = storedEvent; - dispatch(updateEnrollmentEvent(eventId, { ...uncompletedEvent, status: eventStatuses.ACTIVE })); + const { completedAt, completedBy, ...eventWithoutCompletion } = storedEvent; + dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); dispatch(commitEnrollmentEvent(eventId)); } dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); @@ -102,7 +100,7 @@ const WidgetHeaderPlain = ({ )} - {(supportsChangelog || showUncompleteAction) && ( + {(supportsChangelog || canChangeCompletionStatus) && ( setActionsIsOpen(prev => !prev)} @@ -116,10 +114,11 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > - {showUncompleteAction && ( - setActionsIsOpen(false)} /> )} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index 6a4b469f40..d72277e368 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -9,5 +9,5 @@ export type PlainProps = { orgUnit: OrgUnit, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, - canUncompleteEvent: boolean, + canChangeCompletionStatus: boolean, }; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 4eb20023de..d845dd1fff 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -19,10 +19,29 @@ type Output = { isWithinCompleteExpiry: boolean, canEditCompletedEvent: boolean, canUncompleteEvent: boolean, + canChangeCompletionStatus: boolean, expiryPeriod: ReturnType, readOnly: boolean, }; +const getCanChangeCompletionStatus = ({ + eventStatus, + canUncompleteEvent, + eventAccess, +}: { + eventStatus?: string, + canUncompleteEvent: boolean, + eventAccess: { read: boolean, write: boolean } | null, +}): boolean => { + if (!eventAccess?.write) { + return false; + } + if (eventStatus === eventStatuses.COMPLETED) { + return canUncompleteEvent; + } + return eventStatus === eventStatuses.ACTIVE; +}; + // An event is read-only when ANY of the following is true: // - No write access to the program stage (eventAccess.write is false). // - occurredAt is outside the program's expiry period (overridden by F_EDIT_EXPIRED). @@ -56,12 +75,19 @@ export const useEventEditPermissions = ({ || !isWithinCompleteExpiry || !canEditCompletedEvent; + const canChangeCompletionStatus = getCanChangeCompletionStatus({ + eventStatus, + canUncompleteEvent, + eventAccess, + }); + return { eventAccess, isEventWithinValidPeriod, isWithinCompleteExpiry, canEditCompletedEvent, canUncompleteEvent, + canChangeCompletionStatus, expiryPeriod, readOnly, }; From 937443830abff34f95647f2ce3de16cc1b3345c3 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:37:38 +0000 Subject: [PATCH 05/37] fix: remove added changelog icon --- i18n/en.pot | 4 ++-- .../EventDetailsSection/EventDetailsSection.component.tsx | 2 -- .../WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx | 3 +-- .../WidgetProfile/OverflowMenu/OverflowMenu.component.tsx | 3 +-- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e1b99233ee..5923270b41 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-20T13:41:03.116Z\n" -"PO-Revision-Date: 2026-07-20T13:41:03.117Z\n" +"POT-Creation-Date: 2026-07-22T09:37:40.160Z\n" +"PO-Revision-Date: 2026-07-22T09:37:40.160Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index d68bfadc7e..4c57f1a93a 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -8,7 +8,6 @@ import { IconFileDocument24, Button, IconMore16, - IconClockHistory16, FlyoutMenu, MenuItem, } from '@dhis2/ui'; @@ -167,7 +166,6 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { )} {supportsChangelog && ( } label={i18n.t('View changelog')} suffix={null} onClick={() => { diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index abbf5f5ef6..75c4726f9d 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { spacersNum, Button, IconEdit24, IconMore16, IconClockHistory16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; +import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; import { FEATURES, useFeature } from 'capture-core-utils'; @@ -124,7 +124,6 @@ const WidgetHeaderPlain = ({ )} {supportsChangelog && ( } label={i18n.t('View changelog')} suffix="" onClick={() => { diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx index c6c71ce792..e6c443a798 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.component.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { FlyoutMenu, IconMore16, IconClockHistory16, MenuItem } from '@dhis2/ui'; +import { FlyoutMenu, IconMore16, MenuItem } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import type { PlainProps, MenuFlyoutProps, ModalsProps } from './OverflowMenu.types'; import { DeleteMenuItem, DeleteModal } from './Delete'; @@ -23,7 +23,6 @@ const MenuFlyout = ({ {displayChangelog && ( } label={i18n.t('View changelog')} onClick={() => { setChangelogIsOpen(true); From 6a87302d13e8f217aa81329a2da56283c0015f8e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:11:24 +0000 Subject: [PATCH 06/37] feat: add hook to determine event completion status permissions --- i18n/en.pot | 4 +-- .../hooks/useCanChangeCompletionStatus.ts | 19 ++++++++++++++ .../hooks/useEventEditPermissions.ts | 26 ++----------------- 3 files changed, 23 insertions(+), 26 deletions(-) create mode 100644 src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts diff --git a/i18n/en.pot b/i18n/en.pot index 5923270b41..0b0fa33225 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-22T09:37:40.160Z\n" -"PO-Revision-Date: 2026-07-22T09:37:40.160Z\n" +"POT-Creation-Date: 2026-07-22T10:11:25.239Z\n" +"PO-Revision-Date: 2026-07-22T10:11:25.239Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts new file mode 100644 index 0000000000..472b3e671f --- /dev/null +++ b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts @@ -0,0 +1,19 @@ +import { useAuthorities } from '../utils/authority/useAuthorities'; +import { eventStatuses } from '../components/WidgetEventEdit/constants/status.const'; + +type Input = { + eventStatus?: string, + eventAccess: { read: boolean, write: boolean } | null, +}; + +export const useCanChangeCompletionStatus = ({ eventStatus, eventAccess }: Input): boolean => { + const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + + if (!eventAccess?.write) { + return false; + } + if (eventStatus === eventStatuses.COMPLETED) { + return canUncompleteEvent; + } + return eventStatus === eventStatuses.ACTIVE; +}; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index d845dd1fff..f662b86428 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -1,5 +1,6 @@ import { useProgramExpiryForUser } from './useProgramExpiryForUser'; import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; +import { useCanChangeCompletionStatus } from './useCanChangeCompletionStatus'; import { getProgramEventAccess, ProgramStage } from '../metaData'; import { isValidPeriod, isWithinCompleteEventsExpiry } from '../utils/validation/validators/form'; import { eventStatuses } from '../components/WidgetEventEdit/constants/status.const'; @@ -24,24 +25,6 @@ type Output = { readOnly: boolean, }; -const getCanChangeCompletionStatus = ({ - eventStatus, - canUncompleteEvent, - eventAccess, -}: { - eventStatus?: string, - canUncompleteEvent: boolean, - eventAccess: { read: boolean, write: boolean } | null, -}): boolean => { - if (!eventAccess?.write) { - return false; - } - if (eventStatus === eventStatuses.COMPLETED) { - return canUncompleteEvent; - } - return eventStatus === eventStatuses.ACTIVE; -}; - // An event is read-only when ANY of the following is true: // - No write access to the program stage (eventAccess.write is false). // - occurredAt is outside the program's expiry period (overridden by F_EDIT_EXPIRED). @@ -61,6 +44,7 @@ export const useEventEditPermissions = ({ const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); const { hasAuthority: canEditExpired } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const canChangeCompletionStatus = useCanChangeCompletionStatus({ eventStatus, eventAccess }); const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); @@ -75,12 +59,6 @@ export const useEventEditPermissions = ({ || !isWithinCompleteExpiry || !canEditCompletedEvent; - const canChangeCompletionStatus = getCanChangeCompletionStatus({ - eventStatus, - canUncompleteEvent, - eventAccess, - }); - return { eventAccess, isEventWithinValidPeriod, From 54e718095d3a77ccc2c5a9c8b0a29d73dcfa8e2e Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:22:34 +0000 Subject: [PATCH 07/37] feat: optimize query client usage for event completion updates --- i18n/en.pot | 4 ++-- .../EventDetailsSection.component.tsx | 10 +++++++--- .../WidgetHeader/WidgetHeader.container.tsx | 9 ++++++++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 0b0fa33225..67a392ff9c 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-22T10:11:25.239Z\n" -"PO-Revision-Date: 2026-07-22T10:11:25.239Z\n" +"POT-Creation-Date: 2026-07-22T11:22:35.621Z\n" +"PO-Revision-Date: 2026-07-22T11:22:35.621Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 4c57f1a93a..04b8ab32aa 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -93,12 +93,16 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; const onCompletionStatusUpdated = useCallback(() => { + queryClient.removeQueries({ + queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], + }); dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); - }, [dispatch, eventId]); + }, [dispatch, eventId, queryClient]); const onSaveExternal = useCallback(() => { - const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; - queryClient.removeQueries(queryKey); + queryClient.removeQueries({ + queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], + }); onBackToAllEvents(); }, [eventId, queryClient, onBackToAllEvents]); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 75c4726f9d..8af3b4ba86 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; +import { useQueryClient } from '@tanstack/react-query'; import { FEATURES, useFeature } from 'capture-core-utils'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; @@ -18,6 +19,8 @@ import { import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import { EventCompletionMenuItem } from '../../EventCompletionMenuItem'; +import { CHANGELOG_ENTITY_TYPES } from '../../WidgetsChangelog'; +import { ReactQueryAppNamespace } from '../../../utils/reactQueryHelpers'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -49,6 +52,7 @@ const WidgetHeaderPlain = ({ }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); + const queryClient = useQueryClient(); const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); @@ -66,8 +70,11 @@ const WidgetHeaderPlain = ({ dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); dispatch(commitEnrollmentEvent(eventId)); } + queryClient.removeQueries({ + queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], + }); dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); - }, [dispatch, storedEvent, eventId]); + }, [dispatch, storedEvent, eventId, queryClient]); const { icon, name } = stage; From dc68891e8db690589df7ec4cd443a36c856f4957 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:36:15 +0000 Subject: [PATCH 08/37] fix: remove unused query client --- i18n/en.pot | 4 ++-- .../EventDetailsSection.component.tsx | 5 +---- .../WidgetHeader/WidgetHeader.container.tsx | 9 +-------- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 67a392ff9c..9faf189ce1 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-22T11:22:35.621Z\n" -"PO-Revision-Date: 2026-07-22T11:22:35.621Z\n" +"POT-Creation-Date: 2026-07-22T11:36:16.279Z\n" +"PO-Revision-Date: 2026-07-22T11:36:16.279Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 04b8ab32aa..37c076b9d2 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -93,11 +93,8 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; const onCompletionStatusUpdated = useCallback(() => { - queryClient.removeQueries({ - queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], - }); dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); - }, [dispatch, eventId, queryClient]); + }, [dispatch, eventId]); const onSaveExternal = useCallback(() => { queryClient.removeQueries({ diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 8af3b4ba86..75c4726f9d 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -4,7 +4,6 @@ import { useDispatch, useSelector } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import { useQueryClient } from '@tanstack/react-query'; import { FEATURES, useFeature } from 'capture-core-utils'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; @@ -19,8 +18,6 @@ import { import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import { EventCompletionMenuItem } from '../../EventCompletionMenuItem'; -import { CHANGELOG_ENTITY_TYPES } from '../../WidgetsChangelog'; -import { ReactQueryAppNamespace } from '../../../utils/reactQueryHelpers'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -52,7 +49,6 @@ const WidgetHeaderPlain = ({ }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); - const queryClient = useQueryClient(); const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); @@ -70,11 +66,8 @@ const WidgetHeaderPlain = ({ dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); dispatch(commitEnrollmentEvent(eventId)); } - queryClient.removeQueries({ - queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], - }); dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); - }, [dispatch, storedEvent, eventId, queryClient]); + }, [dispatch, storedEvent, eventId]); const { icon, name } = stage; From f0f539fd115eab641614c8f373ea55733209a327 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:18:42 +0000 Subject: [PATCH 09/37] feat: add onMutate and onError callbacks to EventCompletionMenuItem --- i18n/en.pot | 4 ++-- .../EventCompletionMenuItem.component.tsx | 17 +++++++++++++++-- .../WidgetHeader/WidgetHeader.container.tsx | 19 +++++++++++++------ 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index bb649a6f04..88a8d2e518 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-23T14:21:07.161Z\n" -"PO-Revision-Date: 2026-07-23T14:21:07.161Z\n" +"POT-Creation-Date: 2026-07-24T08:18:43.711Z\n" +"PO-Revision-Date: 2026-07-24T08:18:43.711Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx b/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx index 97c8e70cc4..c2d9ad6530 100644 --- a/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx +++ b/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx @@ -10,11 +10,20 @@ import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; type Props = { eventId: string; eventStatus?: string; + onMutate?: (newStatus: string) => void; onUpdated: (newStatus: string) => void; + onError?: () => void; onClose: () => void; }; -export const EventCompletionMenuItem = ({ eventId, eventStatus, onUpdated, onClose }: Props) => { +export const EventCompletionMenuItem = ({ + eventId, + eventStatus, + onMutate, + onUpdated, + onError, + onClose, +}: Props) => { const dataEngine = useDataEngine(); const { show: showError } = useAlert( ({ message }) => message, @@ -47,9 +56,13 @@ export const EventCompletionMenuItem = ({ eventId, eventStatus, onUpdated, onClo }); }, { + onMutate: () => { + onMutate?.(newStatus); + }, onError: (error: unknown) => { showError({ message: i18n.t('An error occurred when updating event status') }); log.error(errorCreator('An error occurred when updating event status')({ error, eventId, newStatus })); + onError?.(); }, onSuccess: () => { onUpdated(newStatus); @@ -63,7 +76,7 @@ export const EventCompletionMenuItem = ({ eventId, eventStatus, onUpdated, onClo dataTest={isCompleted ? 'uncomplete-event-menu-item' : 'complete-event-menu-item'} icon={isCompleted ? : } label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} - suffix="" + suffix={null} onClick={() => { onClose(); !isLoading && updateCompletionStatus(); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 75c4726f9d..f35e35dcbb 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -14,9 +14,8 @@ import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; import { updateEnrollmentEvent, commitEnrollmentEvent, + rollbackEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; -import { pageKeys } from '../../App/withAppUrlSync'; import { EventCompletionMenuItem } from '../../EventCompletionMenuItem'; import type { PlainProps } from './WidgetHeader.types'; @@ -60,15 +59,21 @@ const WidgetHeaderPlain = ({ const storedEvent = useSelector((state: any) => state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); - const onCompletionStatusUpdated = useCallback((newStatus: string) => { + const onCompletionStatusMutate = useCallback((newStatus: string) => { if (storedEvent) { const { completedAt, completedBy, ...eventWithoutCompletion } = storedEvent; dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); - dispatch(commitEnrollmentEvent(eventId)); } - dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, storedEvent, eventId]); + const onCompletionStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + const { icon, name } = stage; return ( @@ -118,14 +123,16 @@ const WidgetHeaderPlain = ({ setActionsIsOpen(false)} /> )} {supportsChangelog && ( { setChangeLogIsOpen(true); setActionsIsOpen(false); From 0e9aa8e344852243fb4d527fd6cb4c96a5d4f43a Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:06:03 +0000 Subject: [PATCH 10/37] feat: implement event status management in EventCompletionMenuItem --- i18n/en.pot | 4 ++-- .../EventCompletionMenuItem.component.tsx | 17 ++------------ .../EventDetailsSection.component.tsx | 7 +++--- .../ViewEventComponent/viewEvent.actions.ts | 4 ++++ .../WidgetHeader/WidgetHeader.container.tsx | 19 +++++----------- .../viewEvent.reducerDescription.ts | 22 +++++++++++++++++++ 6 files changed, 39 insertions(+), 34 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 88a8d2e518..9f4e82135d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-24T08:18:43.711Z\n" -"PO-Revision-Date: 2026-07-24T08:18:43.711Z\n" +"POT-Creation-Date: 2026-07-27T13:06:05.301Z\n" +"PO-Revision-Date: 2026-07-27T13:06:05.301Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx b/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx index c2d9ad6530..97c8e70cc4 100644 --- a/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx +++ b/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx @@ -10,20 +10,11 @@ import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; type Props = { eventId: string; eventStatus?: string; - onMutate?: (newStatus: string) => void; onUpdated: (newStatus: string) => void; - onError?: () => void; onClose: () => void; }; -export const EventCompletionMenuItem = ({ - eventId, - eventStatus, - onMutate, - onUpdated, - onError, - onClose, -}: Props) => { +export const EventCompletionMenuItem = ({ eventId, eventStatus, onUpdated, onClose }: Props) => { const dataEngine = useDataEngine(); const { show: showError } = useAlert( ({ message }) => message, @@ -56,13 +47,9 @@ export const EventCompletionMenuItem = ({ }); }, { - onMutate: () => { - onMutate?.(newStatus); - }, onError: (error: unknown) => { showError({ message: i18n.t('An error occurred when updating event status') }); log.error(errorCreator('An error occurred when updating event status')({ error, eventId, newStatus })); - onError?.(); }, onSuccess: () => { onUpdated(newStatus); @@ -76,7 +63,7 @@ export const EventCompletionMenuItem = ({ dataTest={isCompleted ? 'uncomplete-event-menu-item' : 'complete-event-menu-item'} icon={isCompleted ? : } label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} - suffix={null} + suffix="" onClick={() => { onClose(); !isLoading && updateCompletionStatus(); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 37c076b9d2..11a88e7745 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -28,8 +28,7 @@ import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramS import { useProgramExpiryForUser } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; import { EventCompletionMenuItem } from '../../../EventCompletionMenuItem'; -import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; -import { pageKeys } from '../../../App/withAppUrlSync'; +import { setEventStatus } from '../ViewEventComponent/viewEvent.actions'; import type { PlainProps } from './EventDetailsSection.types'; const getStyles: any = () => ({ @@ -92,8 +91,8 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; - const onCompletionStatusUpdated = useCallback(() => { - dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); + const onCompletionStatusUpdated = useCallback((newStatus: string) => { + dispatch(setEventStatus(newStatus, eventId)); }, [dispatch, eventId]); const onSaveExternal = useCallback(() => { diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts index 88af3a7479..92218c02c0 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts @@ -17,6 +17,7 @@ export const actionTypes = { INITIALIZE_WORKING_LISTS_ON_BACK_TO_MAIN_PAGE: 'InitializeWorkingListsOnBackToMainPage', ASSIGNEE_SET: 'SingleEvent.AssigneeSet', ASSIGNEE_SAVE_FAILED: 'SingleEvent.AssigneeSaveFailed', + EVENT_STATUS_SET: 'SingleEvent.EventStatusSet', }; export const viewEventFromUrl = (data: any) => @@ -73,3 +74,6 @@ export const setAssignee = (assignee: UserFormField, eventId: string) => export const rollbackAssignee = (assignee: UserFormField, eventId: string) => actionCreator(actionTypes.ASSIGNEE_SAVE_FAILED)({ assignee, eventId }); + +export const setEventStatus = (status: string, eventId: string) => + actionCreator(actionTypes.EVENT_STATUS_SET)({ status, eventId }); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index f35e35dcbb..75c4726f9d 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -14,8 +14,9 @@ import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; import { updateEnrollmentEvent, commitEnrollmentEvent, - rollbackEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; +import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; +import { pageKeys } from '../../App/withAppUrlSync'; import { EventCompletionMenuItem } from '../../EventCompletionMenuItem'; import type { PlainProps } from './WidgetHeader.types'; @@ -59,21 +60,15 @@ const WidgetHeaderPlain = ({ const storedEvent = useSelector((state: any) => state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); - const onCompletionStatusMutate = useCallback((newStatus: string) => { + const onCompletionStatusUpdated = useCallback((newStatus: string) => { if (storedEvent) { const { completedAt, completedBy, ...eventWithoutCompletion } = storedEvent; dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); + dispatch(commitEnrollmentEvent(eventId)); } + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, storedEvent, eventId]); - const onCompletionStatusUpdated = useCallback(() => { - dispatch(commitEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - - const onCompletionStatusError = useCallback(() => { - dispatch(rollbackEnrollmentEvent(eventId)); - }, [dispatch, eventId]); - const { icon, name } = stage; return ( @@ -123,16 +118,14 @@ const WidgetHeaderPlain = ({ setActionsIsOpen(false)} /> )} {supportsChangelog && ( { setChangeLogIsOpen(true); setActionsIsOpen(false); diff --git a/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts b/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts index 079b84450f..f7ab4d7e3d 100644 --- a/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts +++ b/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts @@ -42,6 +42,27 @@ const setAssignee = (state, action) => { return newState; }; +const setEventStatus = (state, action) => { + const { status, eventId } = action.payload; + if (eventId !== state.eventId || !state.loadedValues?.eventContainer?.event) { + return state; + } + + return { + ...state, + loadedValues: { + ...state.loadedValues, + eventContainer: { + ...state.loadedValues.eventContainer, + event: { + ...state.loadedValues.eventContainer.event, + status, + }, + }, + }, + }; +}; + export const viewEventPageDesc = createReducerDescription({ [viewEventActionTypes.VIEW_EVENT_FROM_URL]: (state, action) => { const newState = { @@ -168,4 +189,5 @@ export const viewEventPageDesc = createReducerDescription({ [viewEventActionTypes.ASSIGNEE_SAVE_FAILED]: setAssignee, [enrollmentEditEventActionTypes.ASSIGNEE_SET]: setAssignee, [enrollmentEditEventActionTypes.ASSIGNEE_SAVE_FAILED]: setAssignee, + [viewEventActionTypes.EVENT_STATUS_SET]: setEventStatus, }, 'viewEventPage'); From 5f6ac26d527eaf42f9b2000e58f47125b9c416de Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:04:18 +0000 Subject: [PATCH 11/37] feat: allign pr with unify menu items pr --- i18n/en.pot | 58 +++++++++---------- .../EventCompletionMenuItem/index.ts | 1 - .../DeleteEventModal.tsx} | 0 .../DeleteMenuItem.tsx} | 8 +-- .../EventCompletionMenuItem.tsx} | 0 .../EventDetailsSection.component.tsx | 6 +- .../EventDetailsSection.types.ts | 1 - .../ViewEvent.component.tsx | 2 - .../WidgetEventEdit.container.tsx | 3 +- .../WidgetHeader/WidgetHeader.container.tsx | 6 +- .../WidgetHeader/WidgetHeader.types.ts | 1 - .../EventRow/DeleteActionButton/index.ts | 1 - .../EventRow/DeleteActionModal/index.ts | 1 - .../Stage/StageDetail/EventRow/EventRow.tsx | 4 +- 14 files changed, 42 insertions(+), 50 deletions(-) delete mode 100644 src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts rename src/core_modules/capture-core/components/{WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx => EventOverflowMenu/DeleteEventModal.tsx} (100%) rename src/core_modules/capture-core/components/{WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx => EventOverflowMenu/DeleteMenuItem.tsx} (89%) rename src/core_modules/capture-core/components/{EventCompletionMenuItem/EventCompletionMenuItem.component.tsx => EventOverflowMenu/EventCompletionMenuItem.tsx} (100%) delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts delete mode 100644 src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index 9f4e82135d..1dd9d107a9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-27T13:06:05.301Z\n" -"PO-Revision-Date: 2026-07-27T13:06:05.301Z\n" +"POT-Creation-Date: 2026-07-28T15:04:20.277Z\n" +"PO-Revision-Date: 2026-07-28T15:04:20.277Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -437,6 +437,33 @@ msgstr "" "This event has unsaved changes. Leaving this page without saving will lose " "these changes. Are you sure you want to discard unsaved changes?" +msgid "An error occurred while deleting the event" +msgstr "An error occurred while deleting the event" + +msgid "Delete event" +msgstr "Delete event" + +msgid "Deleting an event is permanent and cannot be undone." +msgstr "Deleting an event is permanent and cannot be undone." + +msgid "Are you sure you want to delete this event?" +msgstr "Are you sure you want to delete this event?" + +msgid "Yes, delete event" +msgstr "Yes, delete event" + +msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" +msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" + +msgid "This event has been completed" +msgstr "This event has been completed" + +msgid "This event is outside the edit period" +msgstr "This event is outside the edit period" + +msgid "Delete" +msgstr "Delete" + msgid "An error occurred when updating event status" msgstr "An error occurred when updating event status" @@ -581,9 +608,6 @@ msgstr "Uploading file" msgid "selected" msgstr "selected" -msgid "Delete" -msgstr "Delete" - msgid "Select file" msgstr "Select file" @@ -1101,9 +1125,6 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" -msgid "This event has been completed" -msgstr "This event has been completed" - msgid "View only - {{message}}" msgstr "View only - {{message}}" @@ -1497,18 +1518,9 @@ msgstr "Warning" msgid "stage not found in rules execution" msgstr "stage not found in rules execution" -msgid "Delete event" -msgstr "Delete event" - -msgid "Deleting an event is permanent and cannot be undone." -msgstr "Deleting an event is permanent and cannot be undone." - msgid "Are you sure you want to delete this event? " msgstr "Are you sure you want to delete this event? " -msgid "Yes, delete event" -msgstr "Yes, delete event" - msgid "Go to “Schedule” tab to reschedule this event" msgstr "Go to “Schedule” tab to reschedule this event" @@ -1757,18 +1769,6 @@ msgstr "This stage can only have one event" msgid "New {{ eventName }} event" msgstr "New {{ eventName }} event" -msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" -msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" - -msgid "This event is outside the edit period" -msgstr "This event is outside the edit period" - -msgid "An error occurred while deleting the event" -msgstr "An error occurred while deleting the event" - -msgid "Are you sure you want to delete this event?" -msgstr "Are you sure you want to delete this event?" - msgid "Unskip" msgstr "Unskip" diff --git a/src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts b/src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts deleted file mode 100644 index af2879a761..0000000000 --- a/src/core_modules/capture-core/components/EventCompletionMenuItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EventCompletionMenuItem } from './EventCompletionMenuItem.component'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx similarity index 100% rename from src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx similarity index 89% rename from src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx index cd1ee4f139..81dd971729 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/DeleteActionButton.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx @@ -5,10 +5,10 @@ import { IconDelete16, MenuItem, } from '@dhis2/ui'; -import { ConditionalTooltip } from '../../../../../../Tooltips/ConditionalTooltip'; -import { convertClientToView, convertServerToClient } from '../../../../../../../converters'; -import { dataElementTypes, type ProgramStage } from '../../../../../../../metaData'; -import { useEventEditPermissions } from '../../../../../../../hooks'; +import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; +import { convertClientToView, convertServerToClient } from '../../converters'; +import { dataElementTypes, type ProgramStage } from '../../metaData'; +import { useEventEditPermissions } from '../../hooks'; type Props = { setActionsOpen: (open: boolean) => void; diff --git a/src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx similarity index 100% rename from src/core_modules/capture-core/components/EventCompletionMenuItem/EventCompletionMenuItem.component.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 11a88e7745..7880604327 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -25,9 +25,9 @@ import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; -import { useProgramExpiryForUser } from '../../../../hooks'; +import { useProgramExpiryForUser, useEventEditPermissions } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; -import { EventCompletionMenuItem } from '../../../EventCompletionMenuItem'; +import { EventCompletionMenuItem } from '../../../EventOverflowMenu/EventCompletionMenuItem'; import { setEventStatus } from '../ViewEventComponent/viewEvent.actions'; import type { PlainProps } from './EventDetailsSection.types'; @@ -74,7 +74,6 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onBackToAllEvents, programId, showEditButton, - canChangeCompletionStatus, ...passOnProps } = props; const dispatch = useDispatch(); @@ -89,6 +88,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const { canChangeCompletionStatus } = useEventEditPermissions({ programId, stage: programStage, eventStatus }); const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; const onCompletionStatusUpdated = useCallback((newStatus: string) => { diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts index 65a66586ef..337cb058f4 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.types.ts @@ -10,5 +10,4 @@ export type PlainProps = { programId: string; onBackToAllEvents: () => void; showEditButton: boolean; - canChangeCompletionStatus: boolean; }; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index a87b271e4f..bbee70a42a 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -101,7 +101,6 @@ export const ViewEventPlain = (props: Props & WithStyles) => { isEventWithinValidPeriod, isWithinCompleteExpiry, canEditCompletedEvent, - canChangeCompletionStatus, readOnly, } = useEventEditPermissions({ programId, @@ -136,7 +135,6 @@ export const ViewEventPlain = (props: Props & WithStyles) => { onBackToViewEvent={onBackToViewEvent} onBackToAllEvents={onBackToAllEvents} showEditButton={showEditButton} - canChangeCompletionStatus={canChangeCompletionStatus} /> } noncollapsible diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 75c4726f9d..fed28fc0d4 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -5,7 +5,7 @@ import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, space import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; import { FEATURES, useFeature } from 'capture-core-utils'; -import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; +import { useEnrollmentEditEventPageMode, useEventEditPermissions } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; @@ -17,7 +17,7 @@ import { } from '../../Pages/common/EnrollmentOverviewDomain'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; -import { EventCompletionMenuItem } from '../../EventCompletionMenuItem'; +import { EventCompletionMenuItem } from '../../EventOverflowMenu/EventCompletionMenuItem'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -45,13 +45,13 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - canChangeCompletionStatus, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const { canChangeCompletionStatus } = useEventEditPermissions({ programId, stage, eventStatus }); const [actionsIsOpen, setActionsIsOpen] = useState(false); const showEditButton = !readOnly; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index d72277e368..2c7a3f76db 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -9,5 +9,4 @@ export type PlainProps = { orgUnit: OrgUnit, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, - canChangeCompletionStatus: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts deleted file mode 100644 index 84a87a5579..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DeleteActionButton } from './DeleteActionButton'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts deleted file mode 100644 index b69763984a..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { DeleteActionModal } from './DeleteActionModal'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index d473ccb6b7..c31b3167da 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -8,9 +8,9 @@ import { } from '@dhis2/ui'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; -import { DeleteActionButton } from './DeleteActionButton'; +import { DeleteActionButton } from '../../../../../EventOverflowMenu/DeleteMenuItem'; import { SkipAction } from './SkipAction'; -import { DeleteActionModal } from './DeleteActionModal'; +import { DeleteActionModal } from '../../../../../EventOverflowMenu/DeleteEventModal'; const styles: Readonly = { row: { From 4ed2084df93f7ba7897991eb352b7c7fd1092e28 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:29:56 +0000 Subject: [PATCH 12/37] feat: add onMutate and onError props to EventCompletionMenuItem --- i18n/en.pot | 4 ++-- .../EventCompletionMenuItem.tsx | 15 ++++++++++++++- .../WidgetHeader/WidgetHeader.container.tsx | 17 ++++++++++++----- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 1dd9d107a9..f64d705118 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T15:04:20.277Z\n" -"PO-Revision-Date: 2026-07-28T15:04:20.277Z\n" +"POT-Creation-Date: 2026-07-28T15:29:58.133Z\n" +"PO-Revision-Date: 2026-07-28T15:29:58.133Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx index 97c8e70cc4..bdf8266ad3 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx @@ -10,11 +10,20 @@ import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; type Props = { eventId: string; eventStatus?: string; + onMutate?: (newStatus: string) => void; onUpdated: (newStatus: string) => void; + onError?: () => void; onClose: () => void; }; -export const EventCompletionMenuItem = ({ eventId, eventStatus, onUpdated, onClose }: Props) => { +export const EventCompletionMenuItem = ({ + eventId, + eventStatus, + onMutate, + onUpdated, + onError, + onClose, +}: Props) => { const dataEngine = useDataEngine(); const { show: showError } = useAlert( ({ message }) => message, @@ -47,9 +56,13 @@ export const EventCompletionMenuItem = ({ eventId, eventStatus, onUpdated, onClo }); }, { + onMutate: () => { + onMutate?.(newStatus); + }, onError: (error: unknown) => { showError({ message: i18n.t('An error occurred when updating event status') }); log.error(errorCreator('An error occurred when updating event status')({ error, eventId, newStatus })); + onError?.(); }, onSuccess: () => { onUpdated(newStatus); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index fed28fc0d4..c9e2166eb1 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -14,9 +14,8 @@ import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; import { updateEnrollmentEvent, commitEnrollmentEvent, + rollbackEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; -import { pageKeys } from '../../App/withAppUrlSync'; import { EventCompletionMenuItem } from '../../EventOverflowMenu/EventCompletionMenuItem'; import type { PlainProps } from './WidgetHeader.types'; @@ -60,15 +59,21 @@ const WidgetHeaderPlain = ({ const storedEvent = useSelector((state: any) => state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); - const onCompletionStatusUpdated = useCallback((newStatus: string) => { + const onCompletionStatusMutate = useCallback((newStatus: string) => { if (storedEvent) { const { completedAt, completedBy, ...eventWithoutCompletion } = storedEvent; dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); - dispatch(commitEnrollmentEvent(eventId)); } - dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, storedEvent, eventId]); + const onCompletionStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + const { icon, name } = stage; return ( @@ -118,7 +123,9 @@ const WidgetHeaderPlain = ({ setActionsIsOpen(false)} /> )} From 2abdcd20cc35b6f5bfca1d1b03449376f1731c25 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:55:58 +0000 Subject: [PATCH 13/37] fix: remove atomicMode from resource query in EventCompletionMenuItem --- i18n/en.pot | 4 ++-- .../components/EventOverflowMenu/EventCompletionMenuItem.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index f64d705118..8e76cbf89d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T15:29:58.133Z\n" -"PO-Revision-Date: 2026-07-28T15:29:58.133Z\n" +"POT-Creation-Date: 2026-07-28T16:55:59.544Z\n" +"PO-Revision-Date: 2026-07-28T16:55:59.544Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx index bdf8266ad3..692737b256 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx @@ -45,7 +45,7 @@ export const EventCompletionMenuItem = ({ }, }) as any; return dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE&atomicMode=OBJECT', + resource: 'tracker?async=false&importStrategy=UPDATE', type: 'create', data: { events: [{ From 8ddf18aee2037db45b470407a8d9bb68774ac120 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:05:33 +0000 Subject: [PATCH 14/37] feat: remove feature flag for changelog --- i18n/en.pot | 4 +- .../EventDetailsSection.component.tsx | 72 +++++++++--------- .../WidgetHeader/WidgetHeader.container.tsx | 74 +++++++++---------- 3 files changed, 69 insertions(+), 81 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 8e76cbf89d..c17f7547a3 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T16:55:59.544Z\n" -"PO-Revision-Date: 2026-07-28T16:55:59.544Z\n" +"POT-Creation-Date: 2026-07-28T17:05:34.486Z\n" +"PO-Revision-Date: 2026-07-28T17:05:34.487Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 7880604327..8d56dce2cf 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -2,7 +2,6 @@ import React, { useState, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { dataEntryIds, dataEntryKeys } from 'capture-core/constants'; import { withStyles } from 'capture-core-utils/styles'; -import { FEATURES, useFeature } from 'capture-core-utils'; import { spacers, IconFileDocument24, @@ -83,7 +82,6 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); const queryClient = useQueryClient(); - const supportsChangelog = useFeature(FEATURES.changelogs); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); @@ -142,42 +140,38 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { {i18n.t('Edit event')} } - {(supportsChangelog || showCompletionAction) && ( - setActionsIsOpen(prev => !prev)} - secondary - small - icon={} - dataTest="event-program-event-overflow-button" - component={( - - {showCompletionAction && ( - setActionsIsOpen(false)} - /> - )} - {supportsChangelog && ( - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> - )} - - )} - /> - )} + setActionsIsOpen(prev => !prev)} + secondary + small + icon={} + dataTest="event-program-event-overflow-button" + component={( + + {showCompletionAction && ( + setActionsIsOpen(false)} + /> + )} + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + + )} + /> ); @@ -204,7 +198,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { /> )} - {supportsChangelog && changeLogIsOpen && ( + {changeLogIsOpen && ( inMemoryFileStore.clear, []); const dispatch = useDispatch(); - const supportsChangelog = useFeature(FEATURES.changelogs); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const { canChangeCompletionStatus } = useEventEditPermissions({ programId, stage, eventStatus }); const [actionsIsOpen, setActionsIsOpen] = useState(false); @@ -105,44 +103,40 @@ const WidgetHeaderPlain = ({ )} - {(supportsChangelog || canChangeCompletionStatus) && ( - setActionsIsOpen(prev => !prev)} - icon={} - small - secondary - dataTest={'tracker-program-event-overflow-button'} - component={ - - {canChangeCompletionStatus && ( - setActionsIsOpen(false)} - /> - )} - {supportsChangelog && ( - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> - )} - - } - /> - )} + setActionsIsOpen(prev => !prev)} + icon={} + small + secondary + dataTest={'tracker-program-event-overflow-button'} + component={ + + {canChangeCompletionStatus && ( + setActionsIsOpen(false)} + /> + )} + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} + /> + + } + /> )} From 43ea9b95a7af9c1c71b5da297912259e10f40892 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:12:27 +0000 Subject: [PATCH 15/37] fix: status needs refresh to update --- i18n/en.pot | 4 ++-- .../EventOverflowMenu/EventCompletionMenuItem.tsx | 4 ++-- .../EventDetailsSection.component.tsx | 10 ++++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c17f7547a3..73ea55c789 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T17:05:34.486Z\n" -"PO-Revision-Date: 2026-07-28T17:05:34.487Z\n" +"POT-Creation-Date: 2026-07-28T17:12:28.777Z\n" +"PO-Revision-Date: 2026-07-28T17:12:28.778Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx index 692737b256..6dc4159393 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx @@ -11,7 +11,7 @@ type Props = { eventId: string; eventStatus?: string; onMutate?: (newStatus: string) => void; - onUpdated: (newStatus: string) => void; + onUpdated?: (newStatus: string) => void; onError?: () => void; onClose: () => void; }; @@ -65,7 +65,7 @@ export const EventCompletionMenuItem = ({ onError?.(); }, onSuccess: () => { - onUpdated(newStatus); + onUpdated?.(newStatus); }, }, ); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 8d56dce2cf..9b216a1542 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -89,10 +89,15 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { canChangeCompletionStatus } = useEventEditPermissions({ programId, stage: programStage, eventStatus }); const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; - const onCompletionStatusUpdated = useCallback((newStatus: string) => { + const onCompletionStatusMutate = useCallback((newStatus: string) => { dispatch(setEventStatus(newStatus, eventId)); }, [dispatch, eventId]); + const onCompletionStatusError = useCallback(() => { + const previousStatus = eventStatus === 'COMPLETED' ? 'ACTIVE' : 'COMPLETED'; + dispatch(setEventStatus(previousStatus, eventId)); + }, [dispatch, eventId, eventStatus]); + const onSaveExternal = useCallback(() => { queryClient.removeQueries({ queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], @@ -157,7 +162,8 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { setActionsIsOpen(false)} /> )} From 46085b69e12dc71f3e7788e3171640f0e56b55f6 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:47:27 +0000 Subject: [PATCH 16/37] feat: integrate updateField action for event completion status mutation --- i18n/en.pot | 4 ++-- .../EventDetailsSection.component.tsx | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 73ea55c789..96cf94e185 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T17:12:28.777Z\n" -"PO-Revision-Date: 2026-07-28T17:12:28.778Z\n" +"POT-Creation-Date: 2026-07-28T17:47:28.719Z\n" +"PO-Revision-Date: 2026-07-28T17:47:28.719Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 9b216a1542..bd3043e7a7 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -27,6 +27,7 @@ import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramS import { useProgramExpiryForUser, useEventEditPermissions } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; import { EventCompletionMenuItem } from '../../../EventOverflowMenu/EventCompletionMenuItem'; +import { updateField } from '../../../DataEntry/actions/dataEntry.actions'; import { setEventStatus } from '../ViewEventComponent/viewEvent.actions'; import type { PlainProps } from './EventDetailsSection.types'; @@ -91,11 +92,25 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const onCompletionStatusMutate = useCallback((newStatus: string) => { dispatch(setEventStatus(newStatus, eventId)); + dispatch(updateField( + newStatus === 'COMPLETED' ? 'true' : 'false', + {}, + 'complete', + dataEntryIds.SINGLE_EVENT, + dataEntryKeys.VIEW, + )); }, [dispatch, eventId]); const onCompletionStatusError = useCallback(() => { const previousStatus = eventStatus === 'COMPLETED' ? 'ACTIVE' : 'COMPLETED'; dispatch(setEventStatus(previousStatus, eventId)); + dispatch(updateField( + previousStatus === 'COMPLETED' ? 'true' : 'false', + {}, + 'complete', + dataEntryIds.SINGLE_EVENT, + dataEntryKeys.VIEW, + )); }, [dispatch, eventId, eventStatus]); const onSaveExternal = useCallback(() => { From 2e7cd1281a7e8fb49574c80cf6c1174b5f2671ae Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:26:42 +0000 Subject: [PATCH 17/37] feat: refactor event status handling and update related actions --- i18n/en.pot | 4 +-- .../EventDetailsSection.component.tsx | 30 ++++--------------- .../ViewEventComponent/viewEvent.actions.ts | 4 --- .../viewEvent.reducerDescription.ts | 22 -------------- 4 files changed, 7 insertions(+), 53 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 96cf94e185..1517e13894 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T17:47:28.719Z\n" -"PO-Revision-Date: 2026-07-28T17:47:28.719Z\n" +"POT-Creation-Date: 2026-07-28T18:26:44.312Z\n" +"PO-Revision-Date: 2026-07-28T18:26:44.312Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index bd3043e7a7..d8bfbd7f94 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -27,8 +27,8 @@ import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramS import { useProgramExpiryForUser, useEventEditPermissions } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; import { EventCompletionMenuItem } from '../../../EventOverflowMenu/EventCompletionMenuItem'; -import { updateField } from '../../../DataEntry/actions/dataEntry.actions'; -import { setEventStatus } from '../ViewEventComponent/viewEvent.actions'; +import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; +import { pageKeys } from '../../../Breadcrumbs/EventBreadcrumb/EventBreadcrumb'; import type { PlainProps } from './EventDetailsSection.types'; const getStyles: any = () => ({ @@ -90,29 +90,10 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const { canChangeCompletionStatus } = useEventEditPermissions({ programId, stage: programStage, eventStatus }); const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; - const onCompletionStatusMutate = useCallback((newStatus: string) => { - dispatch(setEventStatus(newStatus, eventId)); - dispatch(updateField( - newStatus === 'COMPLETED' ? 'true' : 'false', - {}, - 'complete', - dataEntryIds.SINGLE_EVENT, - dataEntryKeys.VIEW, - )); + const onCompletionStatusUpdated = useCallback(() => { + dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); }, [dispatch, eventId]); - const onCompletionStatusError = useCallback(() => { - const previousStatus = eventStatus === 'COMPLETED' ? 'ACTIVE' : 'COMPLETED'; - dispatch(setEventStatus(previousStatus, eventId)); - dispatch(updateField( - previousStatus === 'COMPLETED' ? 'true' : 'false', - {}, - 'complete', - dataEntryIds.SINGLE_EVENT, - dataEntryKeys.VIEW, - )); - }, [dispatch, eventId, eventStatus]); - const onSaveExternal = useCallback(() => { queryClient.removeQueries({ queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], @@ -177,8 +158,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { setActionsIsOpen(false)} /> )} diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts index 92218c02c0..88af3a7479 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/viewEvent.actions.ts @@ -17,7 +17,6 @@ export const actionTypes = { INITIALIZE_WORKING_LISTS_ON_BACK_TO_MAIN_PAGE: 'InitializeWorkingListsOnBackToMainPage', ASSIGNEE_SET: 'SingleEvent.AssigneeSet', ASSIGNEE_SAVE_FAILED: 'SingleEvent.AssigneeSaveFailed', - EVENT_STATUS_SET: 'SingleEvent.EventStatusSet', }; export const viewEventFromUrl = (data: any) => @@ -74,6 +73,3 @@ export const setAssignee = (assignee: UserFormField, eventId: string) => export const rollbackAssignee = (assignee: UserFormField, eventId: string) => actionCreator(actionTypes.ASSIGNEE_SAVE_FAILED)({ assignee, eventId }); - -export const setEventStatus = (status: string, eventId: string) => - actionCreator(actionTypes.EVENT_STATUS_SET)({ status, eventId }); diff --git a/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts b/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts index f7ab4d7e3d..079b84450f 100644 --- a/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts +++ b/src/core_modules/capture-core/reducers/descriptions/viewEvent.reducerDescription.ts @@ -42,27 +42,6 @@ const setAssignee = (state, action) => { return newState; }; -const setEventStatus = (state, action) => { - const { status, eventId } = action.payload; - if (eventId !== state.eventId || !state.loadedValues?.eventContainer?.event) { - return state; - } - - return { - ...state, - loadedValues: { - ...state.loadedValues, - eventContainer: { - ...state.loadedValues.eventContainer, - event: { - ...state.loadedValues.eventContainer.event, - status, - }, - }, - }, - }; -}; - export const viewEventPageDesc = createReducerDescription({ [viewEventActionTypes.VIEW_EVENT_FROM_URL]: (state, action) => { const newState = { @@ -189,5 +168,4 @@ export const viewEventPageDesc = createReducerDescription({ [viewEventActionTypes.ASSIGNEE_SAVE_FAILED]: setAssignee, [enrollmentEditEventActionTypes.ASSIGNEE_SET]: setAssignee, [enrollmentEditEventActionTypes.ASSIGNEE_SAVE_FAILED]: setAssignee, - [viewEventActionTypes.EVENT_STATUS_SET]: setEventStatus, }, 'viewEventPage'); From 0f7443e337936733fa824c91666fa20b7f46f20b Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:52:41 +0000 Subject: [PATCH 18/37] feat: add event completion status mutation handling in EventRow component --- i18n/en.pot | 4 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 41 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 1517e13894..7d9e75196b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T18:26:44.312Z\n" -"PO-Revision-Date: 2026-07-28T18:26:44.312Z\n" +"POT-Creation-Date: 2026-07-28T18:52:43.603Z\n" +"PO-Revision-Date: 2026-07-28T18:52:43.603Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index c31b3167da..269d4aa6b2 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { DataTableCell, @@ -6,11 +7,18 @@ import { FlyoutMenu, IconMore16, } from '@dhis2/ui'; +import { useEventEditPermissions } from 'capture-core/hooks'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; import { DeleteActionButton } from '../../../../../EventOverflowMenu/DeleteMenuItem'; import { SkipAction } from './SkipAction'; import { DeleteActionModal } from '../../../../../EventOverflowMenu/DeleteEventModal'; +import { EventCompletionMenuItem } from '../../../../../EventOverflowMenu/EventCompletionMenuItem'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../../../../../Pages/common/EnrollmentOverviewDomain'; const styles: Readonly = { row: { @@ -46,6 +54,26 @@ const EventRowPlain = ({ }: EventRowProps & WithStyles) => { const [actionsOpen, setActionsOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const dispatch = useDispatch(); + + const { canChangeCompletionStatus } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus: eventDetails.status, + }); + + const onCompletionStatusMutate = useCallback((newStatus: string) => { + const { completedAt, ...eventWithoutCompletion } = eventDetails; + dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); + }, [dispatch, eventDetails, id]); + + const onCompletionStatusUpdated = useCallback(() => { + dispatch(commitEnrollmentEvent(id)); + }, [dispatch, id]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(id)); + }, [dispatch, id]); return ( )} + {canChangeCompletionStatus && ( + setActionsOpen(false)} + /> + )} + Date: Tue, 28 Jul 2026 19:32:26 +0000 Subject: [PATCH 19/37] feat: replace useEventEditPermissions with useCanChangeCompletionStatus in relevant components --- i18n/en.pot | 4 ++-- .../components/EventOverflowMenu/DeleteEventModal.tsx | 2 -- .../EventDetailsSection.component.tsx | 4 ++-- .../WidgetHeader/WidgetHeader.container.tsx | 4 ++-- .../Stages/Stage/StageDetail/EventRow/EventRow.tsx | 4 ++-- src/core_modules/capture-core/hooks/index.ts | 1 + .../hooks/useCanChangeCompletionStatus.ts | 11 +++++++++-- .../capture-core/hooks/useEventEditPermissions.ts | 4 ---- 8 files changed, 18 insertions(+), 16 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 7d9e75196b..2427dbca1f 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T18:52:43.603Z\n" -"PO-Revision-Date: 2026-07-28T18:52:43.603Z\n" +"POT-Creation-Date: 2026-07-28T19:32:28.140Z\n" +"PO-Revision-Date: 2026-07-28T19:32:28.140Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx index 2883536f0f..64294ce199 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx @@ -46,8 +46,6 @@ export const DeleteActionModal = ({ }), { onMutate: () => { - // Capture the event before the optimistic removal so it can be restored - // from the same source of truth (the rendered redux event) if the delete fails. const eventToRollbackOnFail = eventDetails; onDeleteEvent(eventId); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index d8bfbd7f94..00fed0d81d 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -24,7 +24,7 @@ import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; -import { useProgramExpiryForUser, useEventEditPermissions } from '../../../../hooks'; +import { useProgramExpiryForUser, useCanChangeCompletionStatus } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; import { EventCompletionMenuItem } from '../../../EventOverflowMenu/EventCompletionMenuItem'; import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; @@ -87,7 +87,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const { canChangeCompletionStatus } = useEventEditPermissions({ programId, stage: programStage, eventStatus }); + const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage: programStage, eventStatus }); const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; const onCompletionStatusUpdated = useCallback(() => { diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index c11a2c55e8..31afd603c9 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import { useEnrollmentEditEventPageMode, useEventEditPermissions } from 'capture-core/hooks'; +import { useEnrollmentEditEventPageMode, useCanChangeCompletionStatus } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; @@ -48,7 +48,7 @@ const WidgetHeaderPlain = ({ const dispatch = useDispatch(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); - const { canChangeCompletionStatus } = useEventEditPermissions({ programId, stage, eventStatus }); + const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage, eventStatus }); const [actionsIsOpen, setActionsIsOpen] = useState(false); const showEditButton = !readOnly; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 269d4aa6b2..a8f64ea924 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -7,7 +7,7 @@ import { FlyoutMenu, IconMore16, } from '@dhis2/ui'; -import { useEventEditPermissions } from 'capture-core/hooks'; +import { useCanChangeCompletionStatus } from 'capture-core/hooks'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; import { DeleteActionButton } from '../../../../../EventOverflowMenu/DeleteMenuItem'; @@ -56,7 +56,7 @@ const EventRowPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); - const { canChangeCompletionStatus } = useEventEditPermissions({ + const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage: programStage, eventStatus: eventDetails.status, diff --git a/src/core_modules/capture-core/hooks/index.ts b/src/core_modules/capture-core/hooks/index.ts index e9beab6052..1c5cdec17c 100644 --- a/src/core_modules/capture-core/hooks/index.ts +++ b/src/core_modules/capture-core/hooks/index.ts @@ -9,3 +9,4 @@ export { useProgramExpiryForUser } from './useProgramExpiryForUser'; export { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; export { useHideWidgetByRuleLocations } from './useHideWidgetByRuleLocations'; export { useEventEditPermissions } from './useEventEditPermissions'; +export { useCanChangeCompletionStatus } from './useCanChangeCompletionStatus'; diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts index 472b3e671f..19d9a9c232 100644 --- a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts +++ b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts @@ -1,13 +1,20 @@ import { useAuthorities } from '../utils/authority/useAuthorities'; +import { getProgramEventAccess, ProgramStage } from '../metaData'; import { eventStatuses } from '../components/WidgetEventEdit/constants/status.const'; type Input = { + programId: string, + stage?: ProgramStage | null, eventStatus?: string, - eventAccess: { read: boolean, write: boolean } | null, }; -export const useCanChangeCompletionStatus = ({ eventStatus, eventAccess }: Input): boolean => { +// canChangeCompletionStatus is true when ALL of the following hold: +// - Write access to the program stage (eventAccess.write is true). +// - Event status is ACTIVE, OR status is COMPLETED and the user has F_UNCOMPLETE_EVENT. + +export const useCanChangeCompletionStatus = ({ programId, stage, eventStatus }: Input): boolean => { const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); if (!eventAccess?.write) { return false; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index f662b86428..4eb20023de 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -1,6 +1,5 @@ import { useProgramExpiryForUser } from './useProgramExpiryForUser'; import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; -import { useCanChangeCompletionStatus } from './useCanChangeCompletionStatus'; import { getProgramEventAccess, ProgramStage } from '../metaData'; import { isValidPeriod, isWithinCompleteEventsExpiry } from '../utils/validation/validators/form'; import { eventStatuses } from '../components/WidgetEventEdit/constants/status.const'; @@ -20,7 +19,6 @@ type Output = { isWithinCompleteExpiry: boolean, canEditCompletedEvent: boolean, canUncompleteEvent: boolean, - canChangeCompletionStatus: boolean, expiryPeriod: ReturnType, readOnly: boolean, }; @@ -44,7 +42,6 @@ export const useEventEditPermissions = ({ const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); const { hasAuthority: canEditExpired } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ eventStatus, eventAccess }); const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); @@ -65,7 +62,6 @@ export const useEventEditPermissions = ({ isWithinCompleteExpiry, canEditCompletedEvent, canUncompleteEvent, - canChangeCompletionStatus, expiryPeriod, readOnly, }; From 42e667de8136ce93e9b9e8146ca61ac916164d27 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:45:09 +0000 Subject: [PATCH 20/37] feat: dispatch changeEventFromUrl action on completion status update --- i18n/en.pot | 4 ++-- .../WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 2427dbca1f..75f1b8a59e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T19:32:28.140Z\n" -"PO-Revision-Date: 2026-07-28T19:32:28.140Z\n" +"POT-Creation-Date: 2026-07-28T20:45:10.917Z\n" +"PO-Revision-Date: 2026-07-28T20:45:10.917Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 31afd603c9..b3030314f8 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -16,6 +16,8 @@ import { rollbackEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; import { EventCompletionMenuItem } from '../../EventOverflowMenu/EventCompletionMenuItem'; +import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; +import { pageKeys } from '../../App/withAppUrlSync'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -66,6 +68,7 @@ const WidgetHeaderPlain = ({ const onCompletionStatusUpdated = useCallback(() => { dispatch(commitEnrollmentEvent(eventId)); + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, eventId]); const onCompletionStatusError = useCallback(() => { From 206e43c00e4644c07dd8cf5df6d7de55f2bd5d57 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:43:31 +0000 Subject: [PATCH 21/37] feat: rename onUpdated to onSuccess --- i18n/en.pot | 4 ++-- .../EventOverflowMenu/EventCompletionMenuItem.tsx | 6 +++--- .../EventDetailsSection/EventDetailsSection.component.tsx | 4 ++-- .../WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx | 4 ++-- .../Stages/Stage/StageDetail/EventRow/EventRow.tsx | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 75f1b8a59e..3b12e89416 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-28T20:45:10.917Z\n" -"PO-Revision-Date: 2026-07-28T20:45:10.917Z\n" +"POT-Creation-Date: 2026-07-31T12:43:32.062Z\n" +"PO-Revision-Date: 2026-07-31T12:43:32.062Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx index 6dc4159393..eaed514678 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx @@ -11,7 +11,7 @@ type Props = { eventId: string; eventStatus?: string; onMutate?: (newStatus: string) => void; - onUpdated?: (newStatus: string) => void; + onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; }; @@ -20,7 +20,7 @@ export const EventCompletionMenuItem = ({ eventId, eventStatus, onMutate, - onUpdated, + onSuccess, onError, onClose, }: Props) => { @@ -65,7 +65,7 @@ export const EventCompletionMenuItem = ({ onError?.(); }, onSuccess: () => { - onUpdated?.(newStatus); + onSuccess?.(newStatus); }, }, ); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 00fed0d81d..29e5d02f02 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -90,7 +90,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage: programStage, eventStatus }); const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; - const onCompletionStatusUpdated = useCallback(() => { + const onCompletionStatusSuccess = useCallback(() => { dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); }, [dispatch, eventId]); @@ -158,7 +158,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { setActionsIsOpen(false)} /> )} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index b3030314f8..ba96103a2f 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -66,7 +66,7 @@ const WidgetHeaderPlain = ({ } }, [dispatch, storedEvent, eventId]); - const onCompletionStatusUpdated = useCallback(() => { + const onCompletionStatusSuccess = useCallback(() => { dispatch(commitEnrollmentEvent(eventId)); dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); }, [dispatch, eventId]); @@ -124,7 +124,7 @@ const WidgetHeaderPlain = ({ eventId={eventId} eventStatus={eventStatus} onMutate={onCompletionStatusMutate} - onUpdated={onCompletionStatusUpdated} + onSuccess={onCompletionStatusSuccess} onError={onCompletionStatusError} onClose={() => setActionsIsOpen(false)} /> diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index a8f64ea924..ce747272d5 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -67,7 +67,7 @@ const EventRowPlain = ({ dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); }, [dispatch, eventDetails, id]); - const onCompletionStatusUpdated = useCallback(() => { + const onCompletionStatusSuccess = useCallback(() => { dispatch(commitEnrollmentEvent(id)); }, [dispatch, id]); @@ -114,7 +114,7 @@ const EventRowPlain = ({ eventId={id} eventStatus={eventDetails.status} onMutate={onCompletionStatusMutate} - onUpdated={onCompletionStatusUpdated} + onSuccess={onCompletionStatusSuccess} onError={onCompletionStatusError} onClose={() => setActionsOpen(false)} /> From 4c34ad1b7e75d9df5b977ec9735b151146ce5b41 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:04:25 +0000 Subject: [PATCH 22/37] fix: roll back and devin review --- i18n/en.pot | 4 +- .../capture-core-utils/types/api-types.ts | 1 + .../EventCompletionMenuItem.tsx | 4 +- .../EventDetailsSection.component.tsx | 80 ++++++++----------- .../Stage/StageDetail/EventRow/EventRow.tsx | 2 +- 5 files changed, 38 insertions(+), 53 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3b12e89416..1d234c4ea4 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-31T12:43:32.062Z\n" -"PO-Revision-Date: 2026-07-31T12:43:32.062Z\n" +"POT-Creation-Date: 2026-07-31T15:04:26.852Z\n" +"PO-Revision-Date: 2026-07-31T15:04:26.852Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core-utils/types/api-types.ts b/src/core_modules/capture-core-utils/types/api-types.ts index fdd2df9961..3cecf966fe 100644 --- a/src/core_modules/capture-core-utils/types/api-types.ts +++ b/src/core_modules/capture-core-utils/types/api-types.ts @@ -23,6 +23,7 @@ export type ApiEnrollmentEvent = { occurredAt: string; scheduledAt: string; completedAt?: string; + completedBy?: string; updatedAt: string; dataValues: Array; notes?: Array; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx index eaed514678..1ff19eda15 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx @@ -33,7 +33,7 @@ export const EventCompletionMenuItem = ({ const isCompleted = eventStatus === eventStatuses.COMPLETED; const newStatus = isCompleted ? eventStatuses.ACTIVE : eventStatuses.COMPLETED; - const { mutate: updateCompletionStatus, isLoading } = useMutation( + const { mutate: updateCompletionStatus } = useMutation( async () => { const { event: apiEvent } = await dataEngine.query({ event: { @@ -79,7 +79,7 @@ export const EventCompletionMenuItem = ({ suffix="" onClick={() => { onClose(); - !isLoading && updateCompletionStatus(); + updateCompletionStatus(); }} /> ); diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 29e5d02f02..31ff7d7479 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -1,7 +1,8 @@ import React, { useState, useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { dataEntryIds, dataEntryKeys } from 'capture-core/constants'; import { withStyles } from 'capture-core-utils/styles'; +import { FEATURES, useFeature } from 'capture-core-utils'; import { spacers, IconFileDocument24, @@ -24,11 +25,8 @@ import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; -import { useProgramExpiryForUser, useCanChangeCompletionStatus } from '../../../../hooks'; +import { useProgramExpiryForUser } from '../../../../hooks'; import { useAuthorities } from '../../../../utils/authority/useAuthorities'; -import { EventCompletionMenuItem } from '../../../EventOverflowMenu/EventCompletionMenuItem'; -import { changeEventFromUrl } from '../ViewEventComponent/viewEvent.actions'; -import { pageKeys } from '../../../Breadcrumbs/EventBreadcrumb/EventBreadcrumb'; import type { PlainProps } from './EventDetailsSection.types'; const getStyles: any = () => ({ @@ -76,28 +74,20 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { showEditButton, ...passOnProps } = props; - const dispatch = useDispatch(); const orgUnitId = useSelector((state: any) => state.viewEventPage.loadedValues?.orgUnit?.id); - const eventStatus = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.status); const { formFoundation } = useMetadataForProgramStage({ programId }); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); const { programCategory, isLoading } = useCategoryCombinations(programId); const queryClient = useQueryClient(); + const supportsChangelog = useFeature(FEATURES.changelogs); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage: programStage, eventStatus }); - const showCompletionAction = !isEditEventPage && canChangeCompletionStatus; - - const onCompletionStatusSuccess = useCallback(() => { - dispatch(changeEventFromUrl(eventId, pageKeys.VIEW_EVENT)); - }, [dispatch, eventId]); const onSaveExternal = useCallback(() => { - queryClient.removeQueries({ - queryKey: [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId], - }); + const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; + queryClient.removeQueries(queryKey); onBackToAllEvents(); }, [eventId, queryClient, onBackToAllEvents]); @@ -141,38 +131,32 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { {i18n.t('Edit event')} } - setActionsIsOpen(prev => !prev)} - secondary - small - icon={} - dataTest="event-program-event-overflow-button" - component={( - - {showCompletionAction && ( - setActionsIsOpen(false)} + {supportsChangelog && ( + setActionsIsOpen(prev => !prev)} + secondary + small + icon={} + dataTest="event-program-event-overflow-button" + component={( + + { + setChangeLogIsOpen(true); + setActionsIsOpen(false); + }} /> - )} - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} - /> - - )} - /> + + )} + /> + )} ); @@ -199,7 +183,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { /> )} - {changeLogIsOpen && ( + {supportsChangelog && changeLogIsOpen && ( { - const { completedAt, ...eventWithoutCompletion } = eventDetails; + const { completedAt, completedBy, ...eventWithoutCompletion } = eventDetails; dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); }, [dispatch, eventDetails, id]); From dfe3c2eb883b7ce0c7440cdbb95663ad4e6ce9ac Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:14:43 +0000 Subject: [PATCH 23/37] fix: replace EventStatuses with eventStatuses constant in EventRow and SkipAction components --- i18n/en.pot | 4 ++-- .../Stages/Stage/StageDetail/EventRow/EventRow.tsx | 12 +++--------- .../StageDetail/EventRow/SkipAction/SkipAction.tsx | 10 +++++----- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 4b8acdbe65..dc2e383e78 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-31T15:04:26.852Z\n" -"PO-Revision-Date: 2026-07-31T15:04:26.852Z\n" +"POT-Creation-Date: 2026-07-31T18:14:45.124Z\n" +"PO-Revision-Date: 2026-07-31T18:14:45.125Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 6602f5e839..8427c6e9da 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -14,6 +14,7 @@ import { DeleteActionButton } from '../../../../../EventOverflowMenu/DeleteMenuI import { SkipAction } from './SkipAction'; import { DeleteActionModal } from '../../../../../EventOverflowMenu/DeleteEventModal'; import { EventCompletionMenuItem } from '../../../../../EventOverflowMenu/EventCompletionMenuItem'; +import { eventStatuses } from '../../../../../WidgetEventEdit/constants/status.const'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -32,13 +33,6 @@ const styles: Readonly = { }, }; -export const EventStatuses = { - ACTIVE: 'ACTIVE', - COMPLETED: 'COMPLETED', - SKIPPED: 'SKIPPED', - SCHEDULE: 'SCHEDULE', -}; - const EventRowPlain = ({ id, pendingApiResponse, @@ -98,8 +92,8 @@ const EventRowPlain = ({ dense dataTest={'overflow-menu'} > - {(eventDetails.status === EventStatuses.SCHEDULE || - eventDetails.status === EventStatuses.SKIPPED) && ( + {(eventDetails.status === eventStatuses.SCHEDULE || + eventDetails.status === eventStatuses.SKIPPED) && ( { - const status = EventStatuses[payload.status]; + const status = eventStatuses[payload.status]; const previousStatus = eventDetails.status; status && onUpdateEventStatus(eventId, status); @@ -68,13 +68,13 @@ export const SkipAction = ({ !pendingApiResponse && updateEventStatus({ status }); }; - if (eventDetails.status === EventStatuses.SKIPPED) { + if (eventDetails.status === eventStatuses.SKIPPED) { return ( } label={i18n.t('Unskip')} - onClick={() => handleMenuItemClick(EventStatuses.SCHEDULE)} + onClick={() => handleMenuItemClick(eventStatuses.SCHEDULE)} suffix="" /> ); @@ -85,7 +85,7 @@ export const SkipAction = ({ dense icon={} label={i18n.t('Skip')} - onClick={() => handleMenuItemClick(EventStatuses.SKIPPED)} + onClick={() => handleMenuItemClick(eventStatuses.SKIPPED)} suffix="" /> ); From 1c7b39b65f95a039523439fec3893e738acebda9 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:22:54 +0000 Subject: [PATCH 24/37] feat: add index file and manu item folder --- i18n/en.pot | 4 ++-- .../{ => MenuItems}/DeleteEventModal.tsx | 0 .../EventOverflowMenu/{ => MenuItems}/DeleteMenuItem.tsx | 8 ++++---- .../{ => MenuItems}/EventCompletionMenuItem.tsx | 2 +- .../components/EventOverflowMenu/MenuItems/index.ts | 3 +++ .../capture-core/components/EventOverflowMenu/index.ts | 1 + .../WidgetHeader/WidgetHeader.container.tsx | 2 +- .../Stages/Stage/StageDetail/EventRow/EventRow.tsx | 6 ++---- .../Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx | 2 +- .../capture-core/hooks/useCanChangeCompletionStatus.ts | 2 +- 10 files changed, 16 insertions(+), 14 deletions(-) rename src/core_modules/capture-core/components/EventOverflowMenu/{ => MenuItems}/DeleteEventModal.tsx (100%) rename src/core_modules/capture-core/components/EventOverflowMenu/{ => MenuItems}/DeleteMenuItem.tsx (90%) rename src/core_modules/capture-core/components/EventOverflowMenu/{ => MenuItems}/EventCompletionMenuItem.tsx (97%) create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts create mode 100644 src/core_modules/capture-core/components/EventOverflowMenu/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index dc2e383e78..25bbf20f95 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-07-31T18:14:45.124Z\n" -"PO-Revision-Date: 2026-07-31T18:14:45.125Z\n" +"POT-Creation-Date: 2026-08-04T13:22:55.241Z\n" +"PO-Revision-Date: 2026-08-04T13:22:55.241Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx similarity index 100% rename from src/core_modules/capture-core/components/EventOverflowMenu/DeleteEventModal.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx similarity index 90% rename from src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 81dd971729..64041e51b3 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -5,10 +5,10 @@ import { IconDelete16, MenuItem, } from '@dhis2/ui'; -import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; -import { convertClientToView, convertServerToClient } from '../../converters'; -import { dataElementTypes, type ProgramStage } from '../../metaData'; -import { useEventEditPermissions } from '../../hooks'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; +import { convertClientToView, convertServerToClient } from '../../../converters'; +import { dataElementTypes, type ProgramStage } from '../../../metaData'; +import { useEventEditPermissions } from '../../../hooks'; type Props = { setActionsOpen: (open: boolean) => void; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/EventCompletionMenuItem.tsx similarity index 97% rename from src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/EventCompletionMenuItem.tsx index 1ff19eda15..ba394cf226 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/EventCompletionMenuItem.tsx @@ -5,7 +5,7 @@ import { MenuItem, IconCheckmark16, IconUndo16 } from '@dhis2/ui'; import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; -import { eventStatuses } from '../WidgetEventEdit/constants/status.const'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; type Props = { eventId: string; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts new file mode 100644 index 0000000000..8b28641cca --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -0,0 +1,3 @@ +export { DeleteActionButton } from './DeleteMenuItem'; +export { DeleteActionModal } from './DeleteEventModal'; +export { EventCompletionMenuItem } from './EventCompletionMenuItem'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts new file mode 100644 index 0000000000..5393191a7b --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -0,0 +1 @@ +export { DeleteActionButton, DeleteActionModal, EventCompletionMenuItem } from './MenuItems'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index ba96103a2f..8b0bf4c257 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -15,7 +15,7 @@ import { commitEnrollmentEvent, rollbackEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { EventCompletionMenuItem } from '../../EventOverflowMenu/EventCompletionMenuItem'; +import { EventCompletionMenuItem } from '../../EventOverflowMenu'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import type { PlainProps } from './WidgetHeader.types'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 8427c6e9da..52822140b6 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -8,13 +8,11 @@ import { IconMore16, } from '@dhis2/ui'; import { useCanChangeCompletionStatus } from 'capture-core/hooks'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; -import { DeleteActionButton } from '../../../../../EventOverflowMenu/DeleteMenuItem'; +import { DeleteActionButton, DeleteActionModal, EventCompletionMenuItem } from '../../../../../EventOverflowMenu'; import { SkipAction } from './SkipAction'; -import { DeleteActionModal } from '../../../../../EventOverflowMenu/DeleteEventModal'; -import { EventCompletionMenuItem } from '../../../../../EventOverflowMenu/EventCompletionMenuItem'; -import { eventStatuses } from '../../../../../WidgetEventEdit/constants/status.const'; import { updateEnrollmentEvent, commitEnrollmentEvent, diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx index b9fe509316..2b0706687a 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx @@ -9,8 +9,8 @@ import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { DirectionalArrow } from '../../../../../../../utils/rtl'; -import { eventStatuses } from '../../../../../../WidgetEventEdit/constants/status.const'; type Props = { eventId: string; diff --git a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts index 19d9a9c232..48f90bf631 100644 --- a/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts +++ b/src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts @@ -1,6 +1,6 @@ import { useAuthorities } from '../utils/authority/useAuthorities'; import { getProgramEventAccess, ProgramStage } from '../metaData'; -import { eventStatuses } from '../components/WidgetEventEdit/constants/status.const'; +import { statusTypes as eventStatuses } from '../events/statusTypes'; type Input = { programId: string, From 06daabf029c61dd105361dc9ab616a5184c461cd Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:49:50 +0000 Subject: [PATCH 25/37] fix: clean up --- i18n/en.pot | 22 +++++++++---------- ...ionMenuItem.tsx => CompletionMenuItem.tsx} | 2 +- .../EventOverflowMenu/MenuItems/index.ts | 2 +- .../components/EventOverflowMenu/index.ts | 2 +- .../WidgetHeader/WidgetHeader.container.tsx | 4 ++-- .../Stage/StageDetail/EventRow/EventRow.tsx | 4 ++-- 6 files changed, 18 insertions(+), 18 deletions(-) rename src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/{EventCompletionMenuItem.tsx => CompletionMenuItem.tsx} (98%) diff --git a/i18n/en.pot b/i18n/en.pot index f765c3a923..b7bd365c4e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T13:40:13.636Z\n" -"PO-Revision-Date: 2026-08-04T13:40:13.636Z\n" +"POT-Creation-Date: 2026-08-04T13:49:52.383Z\n" +"PO-Revision-Date: 2026-08-04T13:49:52.383Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -437,6 +437,15 @@ msgstr "" "This event has unsaved changes. Leaving this page without saving will lose " "these changes. Are you sure you want to discard unsaved changes?" +msgid "An error occurred when updating event status" +msgstr "An error occurred when updating event status" + +msgid "Mark incomplete" +msgstr "Mark incomplete" + +msgid "Mark complete" +msgstr "Mark complete" + msgid "An error occurred while deleting the event" msgstr "An error occurred while deleting the event" @@ -464,15 +473,6 @@ msgstr "This event is outside the edit period" msgid "Delete" msgstr "Delete" -msgid "An error occurred when updating event status" -msgstr "An error occurred when updating event status" - -msgid "Mark incomplete" -msgstr "Mark incomplete" - -msgid "Mark complete" -msgstr "Mark complete" - msgid "No events to display" msgstr "No events to display" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/EventCompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx similarity index 98% rename from src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/EventCompletionMenuItem.tsx rename to src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index ba394cf226..b5ca3f94e7 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/EventCompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -16,7 +16,7 @@ type Props = { onClose: () => void; }; -export const EventCompletionMenuItem = ({ +export const CompletionMenuItem = ({ eventId, eventStatus, onMutate, diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts index 8b28641cca..eacbb89fff 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -1,3 +1,3 @@ export { DeleteActionButton } from './DeleteMenuItem'; export { DeleteActionModal } from './DeleteEventModal'; -export { EventCompletionMenuItem } from './EventCompletionMenuItem'; +export { CompletionMenuItem } from './CompletionMenuItem'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index 5393191a7b..d0e2d93630 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1 +1 @@ -export { DeleteActionButton, DeleteActionModal, EventCompletionMenuItem } from './MenuItems'; +export { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from './MenuItems'; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 8b0bf4c257..e6aee5c4ed 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -15,7 +15,7 @@ import { commitEnrollmentEvent, rollbackEnrollmentEvent, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { EventCompletionMenuItem } from '../../EventOverflowMenu'; +import { CompletionMenuItem } from '../../EventOverflowMenu'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; import type { PlainProps } from './WidgetHeader.types'; @@ -120,7 +120,7 @@ const WidgetHeaderPlain = ({ dataTest={'tracker-program-event-overflow-menu'} > {canChangeCompletionStatus && ( - Date: Tue, 4 Aug 2026 14:07:57 +0000 Subject: [PATCH 26/37] feat: refactor event status handling to use centralised statusTypes --- i18n/en.pot | 4 ++-- .../EnrollmentBreadcrumb.tsx | 17 ++++------------- .../EnrollmentPageDefault.types.ts | 5 ++--- .../EditEventDataEntry.component.tsx | 3 ++- .../Stages/Stage/StageDetail/hooks/helpers.ts | 4 ++-- .../capture-core/events/statusTypes.ts | 4 +++- 6 files changed, 15 insertions(+), 22 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index b7bd365c4e..0368c23e40 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T13:49:52.383Z\n" -"PO-Revision-Date: 2026-08-04T13:49:52.383Z\n" +"POT-Creation-Date: 2026-08-04T14:07:59.385Z\n" +"PO-Revision-Date: 2026-08-04T14:07:59.385Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb.tsx b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb.tsx index 2fc98e432e..6f4536e37a 100644 --- a/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb.tsx +++ b/src/core_modules/capture-core/components/Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useState, ComponentType } from 'react'; import i18n from '@dhis2/d2-i18n'; import { withStyles, WithStyles } from 'capture-core-utils/styles'; import { colors } from '@dhis2/ui'; +import { statusTypes, type StatusType } from '../../../events/statusTypes'; import { DirectionalChevron } from '../../../utils/rtl'; import { useWorkingListLabel } from './hooks/useWorkingListLabel'; import { BreadcrumbItem } from '../common/BreadcrumbItem'; @@ -13,16 +14,6 @@ import { type EnrollmentPageKeyTypes = typeof EnrollmentPageKeys[keyof typeof EnrollmentPageKeys]; -export const EventStatuses = { - ACTIVE: 'ACTIVE', - COMPLETED: 'COMPLETED', - SKIPPED: 'SKIPPED', - SCHEDULE: 'SCHEDULE', - OVERDUE: 'OVERDUE', -} as const; - -type EventStatus = typeof EventStatuses[keyof typeof EventStatuses]; - type OwnProps = { onBackToMainPage: () => void; onBackToDashboard?: () => void; @@ -30,7 +21,7 @@ type OwnProps = { displayFrontPageList: boolean; programId: string; userInteractionInProgress?: boolean; - eventStatus?: EventStatus; + eventStatus?: StatusType; page: 'mainPage' | EnrollmentPageKeyTypes; }; type WarningKey = typeof pageKeys[keyof typeof pageKeys]; @@ -50,9 +41,9 @@ const pageKeys = { ...EnrollmentPageKeys, } as const; -const eventIsScheduled = (eventStatus?: EventStatus): boolean => +const eventIsScheduled = (eventStatus?: StatusType): boolean => !!eventStatus && - ([EventStatuses.SCHEDULE, EventStatuses.OVERDUE, EventStatuses.SKIPPED] as EventStatus[]) + ([statusTypes.SCHEDULE, statusTypes.OVERDUE, statusTypes.SKIPPED] as StatusType[]) .includes(eventStatus); diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.ts b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.ts index 9f9490949f..089a2df271 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.ts +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types.ts @@ -10,10 +10,9 @@ import type { import { EnrollmentPageKeys, } from '../../common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants'; -import { EventStatuses } from '../../../Breadcrumbs/EnrollmentBreadcrumb/EnrollmentBreadcrumb'; +import type { StatusType } from '../../../../events/statusTypes'; type EnrollmentPageKeyTypes = typeof EnrollmentPageKeys[keyof typeof EnrollmentPageKeys]; -type EventStatus = typeof EventStatuses[keyof typeof EventStatuses]; export type Props = { currentPage: EnrollmentPageKeyTypes; @@ -29,7 +28,7 @@ export type Props = { onBackToDashboard?: () => void; onBackToViewEvent?: () => void; userInteractionInProgress?: boolean; - eventStatus?: EventStatus; + eventStatus?: StatusType; onDelete: () => void; onAddNew: () => void; onViewAll: (stageId: string) => void; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx index a14687d4aa..b6ff13a908 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx @@ -585,7 +585,8 @@ class EditEventDataEntryPlain extends Component { const { status, options } = getEventStatus(event); - const isPositive = [statusTypes.COMPLETED].includes(status); - const isNegative = [statusTypes.OVERDUE].includes(status); + const isPositive = status === statusTypes.COMPLETED; + const isNegative = status === statusTypes.OVERDUE; return { isNegative, isPositive, diff --git a/src/core_modules/capture-core/events/statusTypes.ts b/src/core_modules/capture-core/events/statusTypes.ts index ae750d6634..4496c2da8e 100644 --- a/src/core_modules/capture-core/events/statusTypes.ts +++ b/src/core_modules/capture-core/events/statusTypes.ts @@ -7,7 +7,9 @@ export const statusTypes = { OVERDUE: 'OVERDUE', SKIPPED: 'SKIPPED', VISITED: 'VISITED', -}; +} as const; + +export type StatusType = typeof statusTypes[keyof typeof statusTypes]; export const translatedStatusTypes = (options?: string) => ({ From 88bae47fa61bc0f6afa0873f898251104baa1b66 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:12:41 +0000 Subject: [PATCH 27/37] feat: centralise event status handling --- i18n/en.pot | 4 ++-- .../Actions/Complete/Complete.component.tsx | 3 ++- .../Complete/CompleteModal/CompleteModal.container.tsx | 3 ++- .../WidgetEnrollment/constants/status.const.ts | 9 --------- .../EditEventDataEntry/EditEventDataEntry.component.tsx | 5 ++--- .../components/WidgetEventEdit/constants/status.const.ts | 8 -------- .../capture-core/hooks/useEventEditPermissions.ts | 2 +- 7 files changed, 9 insertions(+), 25 deletions(-) delete mode 100644 src/core_modules/capture-core/components/WidgetEventEdit/constants/status.const.ts diff --git a/i18n/en.pot b/i18n/en.pot index 0368c23e40..0a1acbf97a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T14:07:59.385Z\n" -"PO-Revision-Date: 2026-08-04T14:07:59.385Z\n" +"POT-Creation-Date: 2026-08-04T14:12:42.801Z\n" +"PO-Revision-Date: 2026-08-04T14:12:42.801Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/Complete.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/Complete.component.tsx index fa4786aedd..5446d1d6eb 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/Complete.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/Complete.component.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { IconCheckmark16, MenuItem } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import type { Props } from './complete.types'; -import { plainStatus, eventStatuses } from '../../constants/status.const'; +import { plainStatus } from '../../constants/status.const'; export const Complete = ({ enrollment, events, onUpdate, setOpenCompleteModal }: Props) => { const hasActiveEvents = events?.some(event => event.status === eventStatuses.ACTIVE); diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/CompleteModal/CompleteModal.container.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/CompleteModal/CompleteModal.container.tsx index b1353e9d3f..14cc472609 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/CompleteModal/CompleteModal.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Complete/CompleteModal/CompleteModal.container.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useMemo } from 'react'; import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { formatMomentEn } from 'capture-core-utils/date'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { CompleteModalComponent } from './CompleteModal.component'; -import { eventStatuses, plainStatus } from '../../../constants/status.const'; +import { plainStatus } from '../../../constants/status.const'; import type { Props } from './completeModal.types'; export const CompleteModal = ({ enrollment, events, programStages, setOpenCompleteModal, onUpdateStatus }: Props) => { diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts b/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts index 2b0bb8939f..7b0854dce2 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/constants/status.const.ts @@ -11,12 +11,3 @@ export const translatedStatus = Object.freeze({ [plainStatus.COMPLETED]: i18n.t('Completed'), [plainStatus.CANCELLED]: i18n.t('Cancelled'), }); - -export const eventStatuses = Object.freeze({ - ACTIVE: 'ACTIVE', - COMPLETED: 'COMPLETED', - VISITED: 'VISITED', - SCHEDULE: 'SCHEDULE', - OVERDUE: 'OVERDUE', - SKIPPED: 'SKIPPED', -}); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx index b6ff13a908..e92bb54063 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx @@ -36,7 +36,6 @@ import { withConditionalTooltip, } from '../../FormFields/New'; import { statusTypes, translatedStatusTypes } from '../../../events/statusTypes'; -import { eventStatuses } from '../constants/status.const'; import labelTypeClasses from '../DataEntry/dataEntryFieldLabels.module.css'; import { withDeleteButton } from '../DataEntry/withDeleteButton'; import { withAskToCreateNew } from '../../DataEntry/withAskToCreateNew'; @@ -324,7 +323,7 @@ const buildCompleteFieldSettingsFn = () => { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const isEventCompleted = props.eventStatus === eventStatuses.COMPLETED; + const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; const canUncompleteEvent = props.canUncompleteEvent; const shouldDisable = isEventCompleted && !canUncompleteEvent; return shouldDisable @@ -341,7 +340,7 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const isEventCompleted = props.eventStatus === eventStatuses.COMPLETED; + const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; const canUncompleteEvent = props.canUncompleteEvent; const shouldDisable = isEventCompleted && !canUncompleteEvent; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/constants/status.const.ts b/src/core_modules/capture-core/components/WidgetEventEdit/constants/status.const.ts deleted file mode 100644 index e2c9f07b8b..0000000000 --- a/src/core_modules/capture-core/components/WidgetEventEdit/constants/status.const.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const eventStatuses = Object.freeze({ - ACTIVE: 'ACTIVE', - COMPLETED: 'COMPLETED', - VISITED: 'VISITED', - SCHEDULE: 'SCHEDULE', - OVERDUE: 'OVERDUE', - SKIPPED: 'SKIPPED', -}); diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 4eb20023de..a382695aef 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -2,7 +2,7 @@ import { useProgramExpiryForUser } from './useProgramExpiryForUser'; import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; import { getProgramEventAccess, ProgramStage } from '../metaData'; import { isValidPeriod, isWithinCompleteEventsExpiry } from '../utils/validation/validators/form'; -import { eventStatuses } from '../components/WidgetEventEdit/constants/status.const'; +import { statusTypes as eventStatuses } from '../events/statusTypes'; import { useAuthorities } from '../utils/authority/useAuthorities'; type Input = { From ae2f15bfb8bf10abe46821e90363c1fb330deacc Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:35:10 +0000 Subject: [PATCH 28/37] feat: enhance event row loading state with circular loader --- i18n/en.pot | 7 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 92 ++++++++++--------- .../StageDetail/StageDetail.component.tsx | 36 ++------ 3 files changed, 57 insertions(+), 78 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 0a1acbf97a..4f629b284b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-04T14:12:42.801Z\n" -"PO-Revision-Date: 2026-08-04T14:12:42.801Z\n" +"POT-Creation-Date: 2026-08-04T14:35:11.571Z\n" +"PO-Revision-Date: 2026-08-04T14:35:11.571Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1784,9 +1784,6 @@ msgstr "Unskip" msgid "Skip" msgstr "Skip" -msgid "To open this event, please wait until saving is complete" -msgstr "To open this event, please wait until saving is complete" - msgid "Show {{ rest }} more" msgstr "Show {{ rest }} more" diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 1181cd668b..739c0f676f 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useState } from 'react'; import { useDispatch } from 'react-redux'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { + CircularLoader, DataTableCell, DataTableRow, FlyoutMenu, @@ -77,53 +78,56 @@ const EventRowPlain = ({ {stageWriteAccess && ( <> - setActionsOpen(prev => !prev)} - dataTest={'overflow-button'} - secondary - small - icon={} - disabled={pendingApiResponse} - component={( - - {(eventDetails.status === eventStatuses.SCHEDULE || - eventDetails.status === eventStatuses.SKIPPED) && ( - - )} + {pendingApiResponse ? ( + + ) : ( + setActionsOpen(prev => !prev)} + dataTest={'overflow-button'} + secondary + small + icon={} + component={( + + {(eventDetails.status === eventStatuses.SCHEDULE || + eventDetails.status === eventStatuses.SKIPPED) && ( + + )} - {canChangeCompletionStatus && ( - setActionsOpen(false)} + /> + )} + + setActionsOpen(false)} + programId={programId} + programStage={programStage} /> - )} - - - - )} - /> + + )} + /> + )} {deleteModalOpen && ( ) => { .map(row => formatRowForView(row, dataElementsClient)) .map((row: any) => { const cells = headerColumns.map(({ id }) => ( - !row.pendingApiResponse && onEventClick(row.id)} > - {({ onMouseOver, onMouseOut, ref }) => ( - !row.pendingApiResponse && onEventClick(row.id)} - // @ts-expect-error - UI library expects a ref prop, but it is not defined in the types - ref={(tableCell) => { - if (tableCell) { - if (row.pendingApiResponse) { - tableCell.onmouseover = onMouseOver; - tableCell.onmouseout = onMouseOut; - ref.current = tableCell; - } else { - tableCell.onmouseover = null; - tableCell.onmouseout = null; - } - } - }} - > -
- {row[id] as React.ReactNode} -
-
- )} -
+
+ {row[id] as React.ReactNode} +
+
)); const eventDetails = events.find(event => event.event === row.id); From a9d409ed9ebe0cf73d56ef2a45f6b844ccde7e8b Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:50:59 +0000 Subject: [PATCH 29/37] feat: refactor event edit permissions --- i18n/en.pot | 4 +- .../MenuItems/DeleteMenuItem.tsx | 12 ++-- .../EnrollmentEditEventPage.container.tsx | 10 ++-- .../EventDetailsSection.component.tsx | 15 +++-- .../ViewEvent.component.tsx | 12 ++-- .../ViewEventReadOnlyBadge.component.tsx | 9 +-- .../EnrollmentAccessContext.tsx | 21 +++---- .../EnrollmentReadOnlyBadge.component.tsx | 6 +- .../ReadOnlyBadge/ReadOnlyBadge.tsx | 12 ++-- .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 6 +- .../EditEventDataEntry.component.tsx | 12 ++-- .../WidgetEventEdit.container.tsx | 11 ++-- .../WidgetHeader/WidgetHeader.container.tsx | 6 +- .../WidgetHeader/WidgetHeader.types.ts | 1 + .../Stage/StageDetail/EventRow/EventRow.tsx | 10 +++- src/core_modules/capture-core/hooks/index.ts | 1 - .../hooks/useCanChangeCompletionStatus.ts | 26 -------- .../hooks/useEventEditPermissions.ts | 59 ++++++++++--------- .../form/canChangeCompletionStatus.ts | 21 +++++++ .../validators/form/canEditExpiredEvent.ts | 12 ++++ .../utils/validation/validators/form/index.ts | 2 + 21 files changed, 135 insertions(+), 133 deletions(-) delete mode 100644 src/core_modules/capture-core/hooks/useCanChangeCompletionStatus.ts create mode 100644 src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts create mode 100644 src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts diff --git a/i18n/en.pot b/i18n/en.pot index ed250fb9d1..cedb0dac9a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-12T10:41:50.939Z\n" -"PO-Revision-Date: 2026-08-12T10:41:50.939Z\n" +"POT-Creation-Date: 2026-08-13T14:51:01.572Z\n" +"PO-Revision-Date: 2026-08-13T14:51:01.572Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 64041e51b3..370643360e 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -8,7 +8,8 @@ import { import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; import { convertClientToView, convertServerToClient } from '../../../converters'; import { dataElementTypes, type ProgramStage } from '../../../metaData'; -import { useEventEditPermissions } from '../../../hooks'; +import { useEventEditPermissions, useProgramExpiryForUser } from '../../../hooks'; +import { isValidPeriod } from '../../../utils/validation/validators/form'; type Props = { setActionsOpen: (open: boolean) => void; @@ -31,11 +32,12 @@ export const DeleteActionButton = ({ }: Props) => { const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); + const expiryPeriod = useProgramExpiryForUser(programId); + const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient, expiryPeriod ?? null); const { - isEventWithinValidPeriod, canEditCompletedEvent, - readOnly, + canEditEvent, } = useEventEditPermissions({ programId, stage: programStage, @@ -60,11 +62,11 @@ export const DeleteActionButton = ({ return ( } label={i18n.t('Delete')} dataTest="stages-and-events-delete" diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index 6339759652..bf312dc6a4 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -50,7 +50,7 @@ import { setCurrentDataEntry } from '../../DataEntry/actions/dataEntry.actions'; import { convertIsoToLocalCalendar } from '../../../utils/converters/date'; import { dataEntryHasChanges } from '../../DataEntry/common/dataEntryHasChanges'; import type { UserFormField } from '../../FormFields/UserField'; -import type { ProgramStage } from '../../../metaData'; +import { getProgramEventAccess, type ProgramStage } from '../../../metaData'; const getEventDate = (event) => { const eventDataConvertValue = convertDateWithTimeForView(event?.occurredAt ?? event?.scheduledAt); @@ -266,10 +266,9 @@ const EnrollmentEditEventPageWithContextPlain = ({ const outputEffects = useWidgetDataFromStore(dataEntryKey); + const eventAccess = getProgramEventAccess(programId, stageId ?? null); const { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, + canEditExpiredEvent, canEditCompletedEvent, } = useEventEditPermissions({ programId, @@ -313,9 +312,8 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - isEventWithinValidPeriod={isEventWithinValidPeriod} + canEditExpiredEvent={canEditExpiredEvent} canEditCompletedEvent={canEditCompletedEvent} - isWithinCompleteEventsExpiry={isWithinCompleteExpiry} > ({ @@ -81,7 +82,13 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const { canEditCompletionStatus } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus: eventData?.eventContainer?.event?.status, + occurredAtClient: convertFormToClient(eventData?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: eventData?.eventContainer?.event?.completedAt, + }); const onSaveExternal = useCallback(() => { const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; @@ -103,7 +110,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onSaveExternal={onSaveExternal} expiryPeriod={expiryPeriod} programId={programId} - canUncompleteEvent={canUncompleteEvent} + canEditCompletionStatus={canEditCompletionStatus} {...passOnProps} /> : ) => { const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); const { - isEventWithinValidPeriod, - isWithinCompleteExpiry, + canEditExpiredEvent, canEditCompletedEvent, - readOnly, + canEditEvent, } = useEventEditPermissions({ programId, stage: programStage, @@ -109,7 +108,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && !readOnly; + const showEditButton = !isEditEventPage && canEditEvent; return (
@@ -123,9 +122,8 @@ export const ViewEventPlain = (props: Props & WithStyles) => { />
@@ -138,7 +136,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { /> ( ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx index d7b31c649e..86e3b9cf8b 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx @@ -18,9 +18,8 @@ export type EnrollmentAccessContextValue = { showWidgetBadge: boolean; trackedEntityInactive: boolean; canToggleTrackedEntityStatus: boolean; - isEventWithinValidPeriod?: boolean; + canEditExpiredEvent?: boolean; canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; }; const fallback: EnrollmentAccessContextValue = { @@ -45,9 +44,8 @@ type ProviderProps = { program?: TrackerProgram; currentStageId?: string; trackedEntityInactive?: boolean; - isEventWithinValidPeriod?: boolean; + canEditExpiredEvent?: boolean; canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; children: React.ReactNode; }; @@ -66,9 +64,8 @@ const computeContextValue = ( program: TrackerProgram, currentStageId: string | undefined, trackedEntityInactive: boolean, - isEventWithinValidPeriod?: boolean, + canEditExpiredEvent?: boolean, canEditCompletedEvent?: boolean, - isWithinCompleteEventsExpiry?: boolean, ): EnrollmentAccessContextValue => { const { rawStageWriteAccessById, stageReadAccessById } = buildStageAccessMaps(program); const rawProgramWriteAccess = Boolean(program.access?.data?.write); @@ -101,9 +98,8 @@ const computeContextValue = ( showWidgetBadge: !isEventPage && !allWriteAccessMissing, trackedEntityInactive, canToggleTrackedEntityStatus: rawTrackedEntityTypeWriteAccess, - isEventWithinValidPeriod, + canEditExpiredEvent, canEditCompletedEvent, - isWithinCompleteEventsExpiry, }; }; @@ -111,9 +107,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive = false, - isEventWithinValidPeriod, + canEditExpiredEvent, canEditCompletedEvent, - isWithinCompleteEventsExpiry, children, }: ProviderProps) => { const value = useMemo( @@ -122,9 +117,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, + canEditExpiredEvent, canEditCompletedEvent, - isWithinCompleteEventsExpiry, ) : { ...fallback, @@ -142,9 +136,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, + canEditExpiredEvent, canEditCompletedEvent, - isWithinCompleteEventsExpiry, ], ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx index bb2e96cedb..97a92d9dcf 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx @@ -12,18 +12,16 @@ export const EnrollmentReadOnlyBadge = () => { anyStageReadAccess, trackedEntityTypeName, trackedEntityInactive, - isEventWithinValidPeriod, + canEditExpiredEvent, canEditCompletedEvent, - isWithinCompleteEventsExpiry, } = useEnrollmentAccessContext(); if (isEventPage) { return ( { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -46,9 +45,8 @@ const getReadOnlyMessage = ({ if (!access.program) return getProgramMessage(); if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); if (!access.programStage) return getProgramStageMessage(multipleStages); - if (!eventWithinValidPeriod) return getExpiredMessage(); + if (!canEditExpiredEvent) return getExpiredMessage(); if (!canEditCompletedEvent) return getCompletedEventMessage(); - if (!withinCompleteEventsExpiry) return getExpiredMessage(); return ''; }; @@ -56,9 +54,8 @@ const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, programStageWriteAccess = true, - eventWithinValidPeriod = true, + canEditExpiredEvent = true, canEditCompletedEvent = true, - withinCompleteEventsExpiry = true, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -74,9 +71,8 @@ const ReadOnlyBadgePlain = ({ access, trackedEntityName, multipleStages, - eventWithinValidPeriod, + canEditExpiredEvent, canEditCompletedEvent, - withinCompleteEventsExpiry, trackedEntityInactive, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index a7ada9cb08..f3f7845116 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -2,9 +2,8 @@ export type Props = { programWriteAccess?: boolean; trackedEntityTypeWriteAccess?: boolean; programStageWriteAccess?: boolean; - eventWithinValidPeriod?: boolean; + canEditExpiredEvent?: boolean; canEditCompletedEvent?: boolean; - withinCompleteEventsExpiry?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -21,8 +20,7 @@ export type ReadOnlyMessageInput = { access: Access; trackedEntityName: string | undefined; multipleStages: boolean; - eventWithinValidPeriod: boolean; + canEditExpiredEvent: boolean; canEditCompletedEvent: boolean; - withinCompleteEventsExpiry: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx index e92bb54063..782b5e5764 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx @@ -323,9 +323,7 @@ const buildCompleteFieldSettingsFn = () => { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canEditCompletionStatus; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -340,16 +338,14 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const isEventCompleted = props.eventStatus === statusTypes.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canEditCompletionStatus; return createComponentProps(props, { label: i18n.t('Complete event'), id: 'complete', disabled: shouldDisable, eventStatus: props.eventStatus, - canUncompleteEvent: props.canUncompleteEvent, + canEditCompletionStatus: props.canEditCompletionStatus, }); }, getPropName: () => 'complete', @@ -448,7 +444,7 @@ type Props = { dataEntryId: string; onCancelEditEvent?: (isScheduled: boolean) => void; eventStatus?: string; - canUncompleteEvent?: boolean; + canEditCompletionStatus?: boolean; enrollmentId: string; isCompleted?: boolean; assignee?: UserFormField | null; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index cbfcf85665..621789d8b4 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -20,6 +20,7 @@ import { useEnrollmentEditEventPageMode, useAvailableProgramStages, useEventEditPermissions, + useProgramExpiryForUser, } from '../../hooks'; import { convertFormToClient } from '../../converters'; import { dataElementTypes } from '../../metaData'; @@ -108,7 +109,8 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); - const { readOnly, expiryPeriod, canUncompleteEvent } = useEventEditPermissions({ + const expiryPeriod = useProgramExpiryForUser(programId); + const { canEditEvent, canEditCompletionStatus } = useEventEditPermissions({ programId, stage, eventStatus, @@ -139,7 +141,8 @@ const WidgetEventEditPlain = ({ programId={programId} orgUnit={orgUnit} setChangeLogIsOpen={setChangeLogIsOpen} - readOnly={readOnly} + readOnly={!canEditEvent} + canEditCompletionStatus={canEditCompletionStatus} /> } noncollapsible @@ -174,9 +177,9 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canUncompleteEvent={canUncompleteEvent} + canEditCompletionStatus={canEditCompletionStatus} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={!readOnly} + hasDeleteButton={canEditEvent} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index e6aee5c4ed..245a7534c2 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; -import { useEnrollmentEditEventPageMode, useCanChangeCompletionStatus } from 'capture-core/hooks'; +import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; @@ -45,12 +45,12 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, + canEditCompletionStatus, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ programId, stage, eventStatus }); const [actionsIsOpen, setActionsIsOpen] = useState(false); const showEditButton = !readOnly; @@ -119,7 +119,7 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > - {canChangeCompletionStatus && ( + {canEditCompletionStatus && ( void, readOnly: boolean, + canEditCompletionStatus: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 739c0f676f..2f1973f014 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -8,8 +8,10 @@ import { FlyoutMenu, IconMore16, } from '@dhis2/ui'; -import { useCanChangeCompletionStatus } from 'capture-core/hooks'; +import { useEventEditPermissions } from 'capture-core/hooks'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { convertServerToClient } from 'capture-core/converters'; +import { dataElementTypes } from 'capture-core/metaData'; import { OverflowButton } from '../../../../../Buttons'; import type { EventRowProps } from './EventRow.types'; import { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from '../../../../../EventOverflowMenu'; @@ -49,10 +51,12 @@ const EventRowPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); - const canChangeCompletionStatus = useCanChangeCompletionStatus({ + const { canEditCompletionStatus } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, + occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, }); const onCompletionStatusMutate = useCallback((newStatus: string) => { @@ -104,7 +108,7 @@ const EventRowPlain = ({ /> )} - {canChangeCompletionStatus && ( + {canEditCompletionStatus && ( { - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); - - if (!eventAccess?.write) { - return false; - } - if (eventStatus === eventStatuses.COMPLETED) { - return canUncompleteEvent; - } - return eventStatus === eventStatuses.ACTIVE; -}; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index a382695aef..59ef768e98 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -1,7 +1,12 @@ import { useProgramExpiryForUser } from './useProgramExpiryForUser'; import { useCompleteEventsExpiryForUser } from './useCompleteEventsExpiryForUser'; -import { getProgramEventAccess, ProgramStage } from '../metaData'; -import { isValidPeriod, isWithinCompleteEventsExpiry } from '../utils/validation/validators/form'; +import { getProgramEventAccess, type ProgramStage } from '../metaData'; +import { + isValidPeriod, + isWithinCompleteEventsExpiry, + canChangeCompletionStatus as computeCanChangeCompletionStatus, + canEditExpiredEvent as computeCanEditExpiredEvent, +} from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; import { useAuthorities } from '../utils/authority/useAuthorities'; @@ -14,22 +19,12 @@ type Input = { }; type Output = { - eventAccess: { read: boolean, write: boolean } | null, - isEventWithinValidPeriod: boolean, - isWithinCompleteExpiry: boolean, + canEditExpiredEvent: boolean, canEditCompletedEvent: boolean, - canUncompleteEvent: boolean, - expiryPeriod: ReturnType, - readOnly: boolean, + canEditCompletionStatus: boolean, + canEditEvent: boolean, }; -// An event is read-only when ANY of the following is true: -// - No write access to the program stage (eventAccess.write is false). -// - occurredAt is outside the program's expiry period (overridden by F_EDIT_EXPIRED). -// - The event is completed and past the completeEventsExpiryDays window (overridden by F_EDIT_EXPIRED). -// - The event is completed on a stage with blockEntryForm set (overridden by F_EDIT_EXPIRED). - - export const useEventEditPermissions = ({ programId, stage, @@ -41,28 +36,36 @@ export const useEventEditPermissions = ({ const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const { hasAuthority: canEditExpired } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); - + const { hasAuthority: hasEditExpiredAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); - const canEditCompletedEvent = canEditExpired || !( + const canEditCompletedEvent = !( stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED ); - const readOnly = !eventAccess?.write - || !isEventWithinValidPeriod - || !isWithinCompleteExpiry - || !canEditCompletedEvent; - - return { - eventAccess, + const canEditExpiredEvent = computeCanEditExpiredEvent({ + hasEditExpiredAuthority, isEventWithinValidPeriod, isWithinCompleteExpiry, - canEditCompletedEvent, + }); + + const canEditCompletionStatus = computeCanChangeCompletionStatus({ + hasWriteAccess: !!eventAccess?.write, + eventStatus, canUncompleteEvent, - expiryPeriod, - readOnly, + canEditExpiredEvent, + }); + + const canEditEvent = !!eventAccess?.write + && canEditExpiredEvent + && canEditCompletedEvent; + + return { + canEditExpiredEvent, + canEditCompletedEvent, + canEditCompletionStatus, + canEditEvent, }; }; diff --git a/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts b/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts new file mode 100644 index 0000000000..75e20ba14c --- /dev/null +++ b/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts @@ -0,0 +1,21 @@ +import { statusTypes as eventStatuses } from '../../../../events/statusTypes'; + +type Input = { + hasWriteAccess: boolean, + eventStatus?: string, + canUncompleteEvent: boolean, + canEditExpiredEvent: boolean, +}; + +export const canChangeCompletionStatus = ({ + hasWriteAccess, + eventStatus, + canUncompleteEvent, + canEditExpiredEvent, +}: Input): boolean => { + if (!hasWriteAccess) return false; + if (eventStatus === eventStatuses.COMPLETED) { + return canUncompleteEvent && canEditExpiredEvent; + } + return eventStatus === eventStatuses.ACTIVE; +}; diff --git a/src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts b/src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts new file mode 100644 index 0000000000..701f94eafa --- /dev/null +++ b/src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts @@ -0,0 +1,12 @@ +type Input = { + hasEditExpiredAuthority: boolean, + isEventWithinValidPeriod: boolean, + isWithinCompleteExpiry: boolean, +}; + +export const canEditExpiredEvent = ({ + hasEditExpiredAuthority, + isEventWithinValidPeriod, + isWithinCompleteExpiry, +}: Input): boolean => + hasEditExpiredAuthority || (isEventWithinValidPeriod && isWithinCompleteExpiry); diff --git a/src/core_modules/capture-core/utils/validation/validators/form/index.ts b/src/core_modules/capture-core/utils/validation/validators/form/index.ts index 8bf4778f2f..869c599509 100644 --- a/src/core_modules/capture-core/utils/validation/validators/form/index.ts +++ b/src/core_modules/capture-core/utils/validation/validators/form/index.ts @@ -4,6 +4,8 @@ export { isValidAge } from './ageValidator'; export { isValidNonFutureDate } from './isValidNonFutureDate'; export { isValidPeriod } from './expiredPeriod'; export { isWithinCompleteEventsExpiry } from './completeEventsExpiry'; +export { canChangeCompletionStatus } from './canChangeCompletionStatus'; +export { canEditExpiredEvent } from './canEditExpiredEvent'; export { getDateRangeValidator } from './getDateRangeValidator'; export { getDateTimeRangeValidator } from './getDateTimeRangeValidator'; export { getNumberRangeValidator } from './getNumberRangeValidator'; From da15a0cb168bf4558a78889d1146f9fdf640e936 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:34:21 +0000 Subject: [PATCH 30/37] feat: refactor event edit permissions and authority management --- i18n/en.pot | 4 +- .../MenuItems/DeleteMenuItem.tsx | 4 +- .../EnrollmentEditEventPage.container.tsx | 8 ++-- .../ViewEvent.component.tsx | 8 ++-- .../ViewEventReadOnlyBadge.component.tsx | 12 +++--- .../EnrollmentAccessContext.tsx | 28 +++++++------- .../EnrollmentReadOnlyBadge.component.tsx | 8 ++-- .../ReadOnlyBadge/ReadOnlyBadge.tsx | 16 ++++---- .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 8 ++-- .../Actions/Actions.container.tsx | 5 ++- .../OverflowMenu/OverflowMenu.container.tsx | 6 ++- .../DeleteEnrollmentsAction.tsx | 7 ++-- .../DeleteTeiAction/DeleteTeiAction.tsx | 8 ++-- .../hooks/useCompleteEventsExpiryForUser.ts | 5 ++- .../hooks/useEventEditPermissions.ts | 38 ++++++++----------- .../hooks/useProgramExpiryForUser.ts | 5 ++- .../utils/authority/authorities.ts | 8 ++++ .../utils/authority/useAuthorities.ts | 27 ------------- .../utils/authority/useAuthority.ts | 14 +++++++ .../utils/userInfo/useAuthority.ts | 25 ------------ .../form/canChangeCompletionStatus.ts | 9 +++-- .../validators/form/canEditExpiredEvent.ts | 12 ------ .../utils/validation/validators/form/index.ts | 1 - 23 files changed, 111 insertions(+), 155 deletions(-) create mode 100644 src/core_modules/capture-core/utils/authority/authorities.ts delete mode 100644 src/core_modules/capture-core/utils/authority/useAuthorities.ts create mode 100644 src/core_modules/capture-core/utils/authority/useAuthority.ts delete mode 100644 src/core_modules/capture-core/utils/userInfo/useAuthority.ts delete mode 100644 src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts diff --git a/i18n/en.pot b/i18n/en.pot index cedb0dac9a..0c06b936fd 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-13T14:51:01.572Z\n" -"PO-Revision-Date: 2026-08-13T14:51:01.572Z\n" +"POT-Creation-Date: 2026-08-14T16:34:23.443Z\n" +"PO-Revision-Date: 2026-08-14T16:34:23.443Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 370643360e..880ec3536c 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -36,7 +36,7 @@ export const DeleteActionButton = ({ const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient, expiryPeriod ?? null); const { - canEditCompletedEvent, + isFormBlockedByCompletion, canEditEvent, } = useEventEditPermissions({ programId, @@ -53,7 +53,7 @@ export const DeleteActionButton = ({ interpolation: { escapeValue: false }, }); } - if (!canEditCompletedEvent) { + if (isFormBlockedByCompletion) { return i18n.t('This event has been completed'); } return i18n.t('This event is outside the edit period'); diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index bf312dc6a4..2d0b9724c9 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -268,8 +268,8 @@ const EnrollmentEditEventPageWithContextPlain = ({ const eventAccess = getProgramEventAccess(programId, stageId ?? null); const { - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -312,8 +312,8 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - canEditExpiredEvent={canEditExpiredEvent} - canEditCompletedEvent={canEditCompletedEvent} + isEventBlockedByExpiry={isEventBlockedByExpiry} + isFormBlockedByCompletion={isFormBlockedByCompletion} > ) => { const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); const { - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, canEditEvent, } = useEventEditPermissions({ programId, @@ -122,8 +122,8 @@ export const ViewEventPlain = (props: Props & WithStyles) => { />
diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx index d418bbd827..a835c090bb 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx @@ -3,19 +3,19 @@ import { ReadOnlyBadge } from '../../../ReadOnlyBadge'; type Props = { eventAccess: { read: boolean; write: boolean }; - canEditExpiredEvent: boolean; - canEditCompletedEvent: boolean; + isEventBlockedByExpiry: boolean; + isFormBlockedByCompletion: boolean; }; export const ViewEventReadOnlyBadge = ({ eventAccess, - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, }: Props) => ( ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx index 86e3b9cf8b..6900c73737 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx @@ -18,8 +18,8 @@ export type EnrollmentAccessContextValue = { showWidgetBadge: boolean; trackedEntityInactive: boolean; canToggleTrackedEntityStatus: boolean; - canEditExpiredEvent?: boolean; - canEditCompletedEvent?: boolean; + isEventBlockedByExpiry?: boolean; + isFormBlockedByCompletion?: boolean; }; const fallback: EnrollmentAccessContextValue = { @@ -44,8 +44,8 @@ type ProviderProps = { program?: TrackerProgram; currentStageId?: string; trackedEntityInactive?: boolean; - canEditExpiredEvent?: boolean; - canEditCompletedEvent?: boolean; + isEventBlockedByExpiry?: boolean; + isFormBlockedByCompletion?: boolean; children: React.ReactNode; }; @@ -64,8 +64,8 @@ const computeContextValue = ( program: TrackerProgram, currentStageId: string | undefined, trackedEntityInactive: boolean, - canEditExpiredEvent?: boolean, - canEditCompletedEvent?: boolean, + isEventBlockedByExpiry?: boolean, + isFormBlockedByCompletion?: boolean, ): EnrollmentAccessContextValue => { const { rawStageWriteAccessById, stageReadAccessById } = buildStageAccessMaps(program); const rawProgramWriteAccess = Boolean(program.access?.data?.write); @@ -98,8 +98,8 @@ const computeContextValue = ( showWidgetBadge: !isEventPage && !allWriteAccessMissing, trackedEntityInactive, canToggleTrackedEntityStatus: rawTrackedEntityTypeWriteAccess, - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, }; }; @@ -107,8 +107,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive = false, - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, children, }: ProviderProps) => { const value = useMemo( @@ -117,8 +117,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, ) : { ...fallback, @@ -136,8 +136,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, ], ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx index 97a92d9dcf..46763fcd22 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx @@ -12,16 +12,16 @@ export const EnrollmentReadOnlyBadge = () => { anyStageReadAccess, trackedEntityTypeName, trackedEntityInactive, - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, } = useEnrollmentAccessContext(); if (isEventPage) { return ( { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -45,8 +45,8 @@ const getReadOnlyMessage = ({ if (!access.program) return getProgramMessage(); if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); if (!access.programStage) return getProgramStageMessage(multipleStages); - if (!canEditExpiredEvent) return getExpiredMessage(); - if (!canEditCompletedEvent) return getCompletedEventMessage(); + if (isEventBlockedByExpiry) return getExpiredMessage(); + if (isFormBlockedByCompletion) return getCompletedEventMessage(); return ''; }; @@ -54,8 +54,8 @@ const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, programStageWriteAccess = true, - canEditExpiredEvent = true, - canEditCompletedEvent = true, + isEventBlockedByExpiry = false, + isFormBlockedByCompletion = false, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -71,8 +71,8 @@ const ReadOnlyBadgePlain = ({ access, trackedEntityName, multipleStages, - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, trackedEntityInactive, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index f3f7845116..986d017be9 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -2,8 +2,8 @@ export type Props = { programWriteAccess?: boolean; trackedEntityTypeWriteAccess?: boolean; programStageWriteAccess?: boolean; - canEditExpiredEvent?: boolean; - canEditCompletedEvent?: boolean; + isEventBlockedByExpiry?: boolean; + isFormBlockedByCompletion?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -20,7 +20,7 @@ export type ReadOnlyMessageInput = { access: Access; trackedEntityName: string | undefined; multipleStages: boolean; - canEditExpiredEvent: boolean; - canEditCompletedEvent: boolean; + isEventBlockedByExpiry: boolean; + isFormBlockedByCompletion: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx index 833d422dd1..c95d5d8773 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx @@ -3,7 +3,8 @@ import { ActionsComponent } from './Actions.component'; import type { Props } from './actions.types'; import { useUpdateEnrollment, useDeleteEnrollment } from '../dataMutation/dataMutation'; import { useUpdateOwnership } from './Transfer/hooks'; -import { useAuthorities } from '../../../utils/authority/useAuthorities'; +import { useAuthority } from '../../../utils/authority/useAuthority'; +import { Authorities } from '../../../utils/authority/authorities'; export const Actions = ({ enrollment = {}, @@ -21,7 +22,7 @@ export const Actions = ({ }: Props) => { const { updateMutation, updateLoading } = useUpdateEnrollment(refetchEnrollment, refetchTEI, onError, onSuccess); const { deleteMutation, deleteLoading } = useDeleteEnrollment(onDelete, onError, onSuccess); - const { hasAuthority } = useAuthorities({ authorities: ['F_ENROLLMENT_CASCADE_DELETE'] }); + const { hasAuthority } = useAuthority(Authorities.ENROLLMENT_CASCADE_DELETE); const { updateEnrollmentOwnership, isTransferLoading } = useUpdateOwnership({ teiId: enrollment.trackedEntity, programId: enrollment.program, diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx index 6fa5973130..53ff29eb10 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx @@ -1,5 +1,7 @@ import React from 'react'; -import { useAuthorities } from 'capture-core/utils/authority/useAuthorities'; + +import { useAuthority } from '../../../utils/authority/useAuthority'; +import { Authorities } from '../../../utils/authority/authorities'; import type { Props } from './OverflowMenu.types'; import { OverflowMenuComponent } from './OverflowMenu.component'; @@ -18,7 +20,7 @@ export const OverflowMenu = ({ programAPI, readOnlyMode, }: Props) => { - const { hasAuthority } = useAuthorities({ authorities: ['F_TEI_CASCADE_DELETE'] }); + const { hasAuthority } = useAuthority(Authorities.TEI_CASCADE_DELETE); return ( { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + const { hasAuthority } = useAuthority(Authorities.ENROLLMENT_CASCADE_DELETE); const tooltipContent = getTooltipContent(programDataWriteAccess, bulkDataEntryIsActive); const disabled = !programDataWriteAccess || bulkDataEntryIsActive; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx index 44daa45d42..49e0bfcac2 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx @@ -1,13 +1,11 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/userInfo/useAuthority'; +import { useAuthority } from '../../../../../../utils/authority/useAuthority'; +import { Authorities } from '../../../../../../utils/authority/authorities'; import { useCascadeDeleteTei } from './hooks/useCascadeDeleteTei'; import type { PlainProps } from './DeleteTeiAction.types'; -const CASCADE_DELETE_TEI_AUTHORITY = 'F_TEI_CASCADE_DELETE'; - - // TODO - Add program and TEType access checks before adding action to prod export const DeleteTeiAction = ({ selectedRows, @@ -16,7 +14,7 @@ export const DeleteTeiAction = ({ onUpdateList, }: PlainProps) => { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const { hasAuthority } = useAuthority({ authority: CASCADE_DELETE_TEI_AUTHORITY }); + const { hasAuthority } = useAuthority(Authorities.TEI_CASCADE_DELETE); const { deleteTeis, isLoading } = useCascadeDeleteTei({ selectedRows, setIsDeleteDialogOpen, diff --git a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts index d1834547ec..eb43f6b640 100644 --- a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts @@ -1,9 +1,10 @@ import { useMemo } from 'react'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority } from '../utils/authority/useAuthority'; +import { Authorities } from '../utils/authority/authorities'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useCompleteEventsExpiryForUser = (programId: string): number | undefined => { - const { hasAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const { hasAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { program } = useProgramFromIndexedDB(programId, { enabled: !!programId }); return useMemo(() => { diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 59ef768e98..ddfe48c15f 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -5,10 +5,10 @@ import { isValidPeriod, isWithinCompleteEventsExpiry, canChangeCompletionStatus as computeCanChangeCompletionStatus, - canEditExpiredEvent as computeCanEditExpiredEvent, } from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority } from '../utils/authority/useAuthority'; +import { Authorities } from '../utils/authority/authorities'; type Input = { programId: string, @@ -19,8 +19,8 @@ type Input = { }; type Output = { - canEditExpiredEvent: boolean, - canEditCompletedEvent: boolean, + isEventBlockedByExpiry: boolean, + isFormBlockedByCompletion: boolean, canEditCompletionStatus: boolean, canEditEvent: boolean, }; @@ -35,36 +35,30 @@ export const useEventEditPermissions = ({ const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const { hasAuthority: hasEditExpiredAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); - const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const { hasAuthority: canUncompleteEvent } = useAuthority(Authorities.UNCOMPLETE_EVENT); + const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); + const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); + const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; - const canEditCompletedEvent = !( - stage?.blockEntryForm - && eventStatus === eventStatuses.COMPLETED - ); - - const canEditExpiredEvent = computeCanEditExpiredEvent({ - hasEditExpiredAuthority, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - }); + const isFormBlockedByCompletion = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; const canEditCompletionStatus = computeCanChangeCompletionStatus({ hasWriteAccess: !!eventAccess?.write, eventStatus, canUncompleteEvent, - canEditExpiredEvent, + isExpired, + hasEditExpiredAuthority, }); const canEditEvent = !!eventAccess?.write - && canEditExpiredEvent - && canEditCompletedEvent; + && !isEventBlockedByExpiry + && !isFormBlockedByCompletion; return { - canEditExpiredEvent, - canEditCompletedEvent, + isEventBlockedByExpiry, + isFormBlockedByCompletion, canEditCompletionStatus, canEditEvent, }; diff --git a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts index cb4becebe2..f4c704fba7 100644 --- a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts @@ -1,10 +1,11 @@ import { useMemo } from 'react'; import { serverToClientExpiryPeriod } from '../converters/serverToClientExpiryPeriod'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority } from '../utils/authority/useAuthority'; +import { Authorities } from '../utils/authority/authorities'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useProgramExpiryForUser = (programId: string) => { - const { hasAuthority } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const { hasAuthority } = useAuthority(Authorities.EDIT_EXPIRED); const { program } = useProgramFromIndexedDB(programId, { enabled: !!programId }); const expiryPeriod = useMemo(() => { diff --git a/src/core_modules/capture-core/utils/authority/authorities.ts b/src/core_modules/capture-core/utils/authority/authorities.ts new file mode 100644 index 0000000000..7ae1b40729 --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/authorities.ts @@ -0,0 +1,8 @@ +export const Authorities = Object.freeze({ + UNCOMPLETE_EVENT: 'F_UNCOMPLETE_EVENT', + EDIT_EXPIRED: 'F_EDIT_EXPIRED', + TEI_CASCADE_DELETE: 'F_TEI_CASCADE_DELETE', + ENROLLMENT_CASCADE_DELETE: 'F_ENROLLMENT_CASCADE_DELETE', +} as const); + +export type Authority = typeof Authorities[keyof typeof Authorities]; diff --git a/src/core_modules/capture-core/utils/authority/useAuthorities.ts b/src/core_modules/capture-core/utils/authority/useAuthorities.ts deleted file mode 100644 index be6d8ca5e7..0000000000 --- a/src/core_modules/capture-core/utils/authority/useAuthorities.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useApiMetadataQuery } from 'capture-core/utils/reactQueryHelpers'; - -const auth = Object.freeze({ - ALL: 'ALL', -}); - -export const useAuthorities = ({ authorities }: { authorities: string[] }) => { - const queryKey = ['authorities']; - const queryFn = { - resource: 'me.json', - params: { - fields: 'authorities', - }, - }; - const queryOptions = { - select: ({ authorities: userAuthorities }) => - userAuthorities && - authorities.some( - authority => userAuthorities.includes(auth.ALL) || userAuthorities.includes(authority), - ), - }; - const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); - - return { - hasAuthority: Boolean(data), - }; -}; diff --git a/src/core_modules/capture-core/utils/authority/useAuthority.ts b/src/core_modules/capture-core/utils/authority/useAuthority.ts new file mode 100644 index 0000000000..a40c1d5b4e --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/useAuthority.ts @@ -0,0 +1,14 @@ +import { useApiMetadataQuery } from 'capture-core/utils/reactQueryHelpers'; +import type { Authority } from './authorities'; + +export const useAuthority = (authority: Authority) => { + const { data } = useApiMetadataQuery( + ['authorities'], + { resource: 'me.json', params: { fields: 'authorities' } }, + { + select: ({ authorities: userAuthorities }: { authorities: string[] }) => + userAuthorities?.includes('ALL') || userAuthorities?.includes(authority), + }, + ); + return { hasAuthority: Boolean(data) }; +}; diff --git a/src/core_modules/capture-core/utils/userInfo/useAuthority.ts b/src/core_modules/capture-core/utils/userInfo/useAuthority.ts deleted file mode 100644 index fe1e883117..0000000000 --- a/src/core_modules/capture-core/utils/userInfo/useAuthority.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useApiMetadataQuery } from '../reactQueryHelpers'; - -type Props = { - authority: string; -}; - -export const useAuthority = ({ authority }: Props) => { - const queryKey = ['authorities']; - const queryFn = { - resource: 'me.json', - params: { - fields: 'authorities', - }, - }; - const queryOptions = { - select: ({ authorities }: { authorities: string[] }) => - authorities && - authorities.some(apiAuthority => apiAuthority === 'ALL' || apiAuthority === authority), - }; - const { data } = useApiMetadataQuery(queryKey, queryFn, queryOptions); - - return { - hasAuthority: Boolean(data), - }; -}; diff --git a/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts b/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts index 75e20ba14c..ba83d6d2c1 100644 --- a/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts +++ b/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts @@ -4,18 +4,21 @@ type Input = { hasWriteAccess: boolean, eventStatus?: string, canUncompleteEvent: boolean, - canEditExpiredEvent: boolean, + isExpired: boolean, + hasEditExpiredAuthority: boolean, }; export const canChangeCompletionStatus = ({ hasWriteAccess, eventStatus, canUncompleteEvent, - canEditExpiredEvent, + isExpired, + hasEditExpiredAuthority, }: Input): boolean => { if (!hasWriteAccess) return false; if (eventStatus === eventStatuses.COMPLETED) { - return canUncompleteEvent && canEditExpiredEvent; + if (isExpired && !hasEditExpiredAuthority) return false; + return canUncompleteEvent; } return eventStatus === eventStatuses.ACTIVE; }; diff --git a/src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts b/src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts deleted file mode 100644 index 701f94eafa..0000000000 --- a/src/core_modules/capture-core/utils/validation/validators/form/canEditExpiredEvent.ts +++ /dev/null @@ -1,12 +0,0 @@ -type Input = { - hasEditExpiredAuthority: boolean, - isEventWithinValidPeriod: boolean, - isWithinCompleteExpiry: boolean, -}; - -export const canEditExpiredEvent = ({ - hasEditExpiredAuthority, - isEventWithinValidPeriod, - isWithinCompleteExpiry, -}: Input): boolean => - hasEditExpiredAuthority || (isEventWithinValidPeriod && isWithinCompleteExpiry); diff --git a/src/core_modules/capture-core/utils/validation/validators/form/index.ts b/src/core_modules/capture-core/utils/validation/validators/form/index.ts index 869c599509..7532716c4b 100644 --- a/src/core_modules/capture-core/utils/validation/validators/form/index.ts +++ b/src/core_modules/capture-core/utils/validation/validators/form/index.ts @@ -5,7 +5,6 @@ export { isValidNonFutureDate } from './isValidNonFutureDate'; export { isValidPeriod } from './expiredPeriod'; export { isWithinCompleteEventsExpiry } from './completeEventsExpiry'; export { canChangeCompletionStatus } from './canChangeCompletionStatus'; -export { canEditExpiredEvent } from './canEditExpiredEvent'; export { getDateRangeValidator } from './getDateRangeValidator'; export { getDateTimeRangeValidator } from './getDateTimeRangeValidator'; export { getNumberRangeValidator } from './getNumberRangeValidator'; From 7d27954fe11080fab12eb92f1255bed7c9d53da6 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:36:12 +0000 Subject: [PATCH 31/37] feat: consolidate authority imports across components and hooks --- i18n/en.pot | 4 ++-- .../components/WidgetEnrollment/Actions/Actions.container.tsx | 3 +-- .../WidgetProfile/OverflowMenu/OverflowMenu.container.tsx | 3 +-- .../DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx | 3 +-- .../Actions/DeleteTeiAction/DeleteTeiAction.tsx | 3 +-- .../capture-core/hooks/useCompleteEventsExpiryForUser.ts | 3 +-- .../capture-core/hooks/useEventEditPermissions.ts | 3 +-- .../capture-core/hooks/useProgramExpiryForUser.ts | 3 +-- src/core_modules/capture-core/utils/authority/index.ts | 2 ++ 9 files changed, 11 insertions(+), 16 deletions(-) create mode 100644 src/core_modules/capture-core/utils/authority/index.ts diff --git a/i18n/en.pot b/i18n/en.pot index 0c06b936fd..7a1de996db 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-14T16:34:23.443Z\n" -"PO-Revision-Date: 2026-08-14T16:34:23.443Z\n" +"POT-Creation-Date: 2026-08-14T16:36:14.062Z\n" +"PO-Revision-Date: 2026-08-14T16:36:14.062Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx index c95d5d8773..bd8a45f4a4 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.container.tsx @@ -3,8 +3,7 @@ import { ActionsComponent } from './Actions.component'; import type { Props } from './actions.types'; import { useUpdateEnrollment, useDeleteEnrollment } from '../dataMutation/dataMutation'; import { useUpdateOwnership } from './Transfer/hooks'; -import { useAuthority } from '../../../utils/authority/useAuthority'; -import { Authorities } from '../../../utils/authority/authorities'; +import { useAuthority, Authorities } from '../../../utils/authority'; export const Actions = ({ enrollment = {}, diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx index 53ff29eb10..ef1263df7f 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { useAuthority } from '../../../utils/authority/useAuthority'; -import { Authorities } from '../../../utils/authority/authorities'; +import { useAuthority, Authorities } from '../../../utils/authority'; import type { Props } from './OverflowMenu.types'; import { OverflowMenuComponent } from './OverflowMenu.component'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx index 52a8f96446..3af7917a1d 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/authority/useAuthority'; -import { Authorities } from '../../../../../../utils/authority/authorities'; +import { useAuthority, Authorities } from '../../../../../../utils/authority'; import { EnrollmentDeleteModal } from './EnrollmentDeleteModal'; import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; import type { PlainProps } from './DeleteEnrollmentsAction.types'; diff --git a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx index 49e0bfcac2..f03156f92e 100644 --- a/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx +++ b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteTeiAction/DeleteTeiAction.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/authority/useAuthority'; -import { Authorities } from '../../../../../../utils/authority/authorities'; +import { useAuthority, Authorities } from '../../../../../../utils/authority'; import { useCascadeDeleteTei } from './hooks/useCascadeDeleteTei'; import type { PlainProps } from './DeleteTeiAction.types'; diff --git a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts index eb43f6b640..ba5d018b1b 100644 --- a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts @@ -1,6 +1,5 @@ import { useMemo } from 'react'; -import { useAuthority } from '../utils/authority/useAuthority'; -import { Authorities } from '../utils/authority/authorities'; +import { useAuthority, Authorities } from '../utils/authority'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useCompleteEventsExpiryForUser = (programId: string): number | undefined => { diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index ddfe48c15f..a2df75b880 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -7,8 +7,7 @@ import { canChangeCompletionStatus as computeCanChangeCompletionStatus, } from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; -import { useAuthority } from '../utils/authority/useAuthority'; -import { Authorities } from '../utils/authority/authorities'; +import { useAuthority, Authorities } from '../utils/authority'; type Input = { programId: string, diff --git a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts index f4c704fba7..4a599762ce 100644 --- a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { serverToClientExpiryPeriod } from '../converters/serverToClientExpiryPeriod'; -import { useAuthority } from '../utils/authority/useAuthority'; -import { Authorities } from '../utils/authority/authorities'; +import { useAuthority, Authorities } from '../utils/authority'; import { useProgramFromIndexedDB } from '../utils/cachedDataHooks/useProgramFromIndexedDB'; export const useProgramExpiryForUser = (programId: string) => { diff --git a/src/core_modules/capture-core/utils/authority/index.ts b/src/core_modules/capture-core/utils/authority/index.ts new file mode 100644 index 0000000000..6aaeca5eea --- /dev/null +++ b/src/core_modules/capture-core/utils/authority/index.ts @@ -0,0 +1,2 @@ +export { useAuthority } from './useAuthority'; +export { Authorities, type Authority } from './authorities'; From 25ff87e2d834fcd51e660a6b4a56fe1e2af78b18 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:05:08 +0000 Subject: [PATCH 32/37] feat: refactor event edit permissions to use isEventReadOnly flag --- i18n/en.pot | 4 ++-- .../MenuItems/DeleteMenuItem.tsx | 6 +++--- .../EventDetailsSection.component.tsx | 4 ++-- .../ViewEvent.component.tsx | 6 +++--- .../EnrollmentAccessContext.tsx | 2 ++ .../EditEventDataEntry.component.tsx | 8 ++++---- .../WidgetEventEdit.container.tsx | 10 +++++----- .../WidgetHeader/WidgetHeader.container.tsx | 4 ++-- .../WidgetHeader/WidgetHeader.types.ts | 2 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 4 ++-- .../hooks/useEventEditPermissions.ts | 20 +++++++++---------- 11 files changed, 36 insertions(+), 34 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 7a1de996db..658675bc95 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-14T16:36:14.062Z\n" -"PO-Revision-Date: 2026-08-14T16:36:14.062Z\n" +"POT-Creation-Date: 2026-08-14T17:05:09.479Z\n" +"PO-Revision-Date: 2026-08-14T17:05:09.479Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index 880ec3536c..b706eac71b 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -37,7 +37,7 @@ export const DeleteActionButton = ({ const { isFormBlockedByCompletion, - canEditEvent, + isEventReadOnly, } = useEventEditPermissions({ programId, stage: programStage, @@ -62,11 +62,11 @@ export const DeleteActionButton = ({ return ( } label={i18n.t('Delete')} dataTest="stages-and-events-delete" diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index 22485b6b13..3d0642c6dd 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -82,7 +82,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { canEditCompletionStatus } = useEventEditPermissions({ + const { canUncompleteEvent } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventData?.eventContainer?.event?.status, @@ -110,7 +110,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onSaveExternal={onSaveExternal} expiryPeriod={expiryPeriod} programId={programId} - canEditCompletionStatus={canEditCompletionStatus} + canUncompleteEvent={canUncompleteEvent} {...passOnProps} /> : ) => { const { isEventBlockedByExpiry, isFormBlockedByCompletion, - canEditEvent, + isEventReadOnly, } = useEventEditPermissions({ programId, stage: programStage, @@ -108,7 +108,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && canEditEvent; + const showEditButton = !isEditEventPage && !isEventReadOnly; return (
@@ -136,7 +136,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { /> (fallback); diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx index 782b5e5764..51720d4f92 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/EditEventDataEntry/EditEventDataEntry.component.tsx @@ -323,7 +323,7 @@ const buildCompleteFieldSettingsFn = () => { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const shouldDisable = !props.canEditCompletionStatus; + const shouldDisable = !props.canUncompleteEvent; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -338,14 +338,14 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const shouldDisable = !props.canEditCompletionStatus; + const shouldDisable = !props.canUncompleteEvent; return createComponentProps(props, { label: i18n.t('Complete event'), id: 'complete', disabled: shouldDisable, eventStatus: props.eventStatus, - canEditCompletionStatus: props.canEditCompletionStatus, + canUncompleteEvent: props.canUncompleteEvent, }); }, getPropName: () => 'complete', @@ -444,7 +444,7 @@ type Props = { dataEntryId: string; onCancelEditEvent?: (isScheduled: boolean) => void; eventStatus?: string; - canEditCompletionStatus?: boolean; + canUncompleteEvent?: boolean; enrollmentId: string; isCompleted?: boolean; assignee?: UserFormField | null; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 621789d8b4..29ae5c9bd5 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,7 +110,7 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { canEditEvent, canEditCompletionStatus } = useEventEditPermissions({ + const { isEventReadOnly, canUncompleteEvent } = useEventEditPermissions({ programId, stage, eventStatus, @@ -141,8 +141,8 @@ const WidgetEventEditPlain = ({ programId={programId} orgUnit={orgUnit} setChangeLogIsOpen={setChangeLogIsOpen} - readOnly={!canEditEvent} - canEditCompletionStatus={canEditCompletionStatus} + readOnly={isEventReadOnly} + canUncompleteEvent={canUncompleteEvent} /> } noncollapsible @@ -177,9 +177,9 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canEditCompletionStatus={canEditCompletionStatus} + canUncompleteEvent={canUncompleteEvent} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={canEditEvent} + hasDeleteButton={!isEventReadOnly} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 245a7534c2..0360e228ef 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -45,7 +45,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, - canEditCompletionStatus, + canUncompleteEvent, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -119,7 +119,7 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > - {canEditCompletionStatus && ( + {canUncompleteEvent && ( void, readOnly: boolean, - canEditCompletionStatus: boolean, + canUncompleteEvent: boolean, }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 2f1973f014..7ec245b1c0 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -51,7 +51,7 @@ const EventRowPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); - const { canEditCompletionStatus } = useEventEditPermissions({ + const { canUncompleteEvent } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, @@ -108,7 +108,7 @@ const EventRowPlain = ({ /> )} - {canEditCompletionStatus && ( + {canUncompleteEvent && ( Date: Fri, 14 Aug 2026 19:00:36 +0000 Subject: [PATCH 33/37] feat: enhance event edit permissions and streamline delete action handling --- i18n/en.pot | 19 ++--- .../MenuItems/DeleteMenuItem.tsx | 78 ++++--------------- .../Stage/StageDetail/EventRow/EventRow.tsx | 20 +++-- .../hooks/useEventEditPermissions.ts | 6 +- 4 files changed, 34 insertions(+), 89 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 658675bc95..60496fe988 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-14T17:05:09.479Z\n" -"PO-Revision-Date: 2026-08-14T17:05:09.479Z\n" +"POT-Creation-Date: 2026-08-14T19:00:38.850Z\n" +"PO-Revision-Date: 2026-08-14T19:00:38.851Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -461,15 +461,6 @@ msgstr "Are you sure you want to delete this event?" msgid "Yes, delete event" msgstr "Yes, delete event" -msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" -msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" - -msgid "This event has been completed" -msgstr "This event has been completed" - -msgid "This event is outside the edit period" -msgstr "This event is outside the edit period" - msgid "Delete" msgstr "Delete" @@ -1128,6 +1119,9 @@ msgstr "You only have view access to this program stage" msgid "This event is outside the editing period" msgstr "This event is outside the editing period" +msgid "This event has been completed" +msgstr "This event has been completed" + msgid "This {{trackedEntityName}} is deactivated" msgstr "This {{trackedEntityName}} is deactivated" @@ -1962,6 +1956,9 @@ msgstr "Program stage name" msgid "Working list could not be loaded" msgstr "Working list could not be loaded" +msgid "{{occurredAt}} belongs to an expired period. Event cannot be deleted" +msgstr "{{occurredAt}} belongs to an expired period. Event cannot be deleted" + msgid "Download data..." msgstr "Download data..." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index b706eac71b..e7b0ee71c0 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -5,77 +5,25 @@ import { IconDelete16, MenuItem, } from '@dhis2/ui'; -import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; -import { convertClientToView, convertServerToClient } from '../../../converters'; -import { dataElementTypes, type ProgramStage } from '../../../metaData'; -import { useEventEditPermissions, useProgramExpiryForUser } from '../../../hooks'; -import { isValidPeriod } from '../../../utils/validation/validators/form'; type Props = { setActionsOpen: (open: boolean) => void; setDeleteModalOpen: (open: boolean) => void; - occurredAt: string; - completedAt?: string; - eventStatus?: string; - programId: string; - programStage?: ProgramStage | null; }; export const DeleteActionButton = ({ setActionsOpen, setDeleteModalOpen, - occurredAt, - completedAt, - eventStatus, - programId, - programStage, -}: Props) => { - const occurredAtClient = convertServerToClient(occurredAt, dataElementTypes.DATE) as string; - const occurredAtClientView = convertClientToView(occurredAtClient, dataElementTypes.DATE); - const expiryPeriod = useProgramExpiryForUser(programId); - const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient, expiryPeriod ?? null); - - const { - isFormBlockedByCompletion, - isEventReadOnly, - } = useEventEditPermissions({ - programId, - stage: programStage, - eventStatus, - occurredAtClient, - completedAtClient: convertServerToClient(completedAt, dataElementTypes.DATE) as string, - }); - - const getDisabledMessage = (): string => { - if (!isEventWithinValidPeriod) { - return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be deleted', { - occurredAt: occurredAtClientView, - interpolation: { escapeValue: false }, - }); - } - if (isFormBlockedByCompletion) { - return i18n.t('This event has been completed'); - } - return i18n.t('This event is outside the edit period'); - }; - - return ( - - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - setDeleteModalOpen(true); - setActionsOpen(false); - }} - suffix="" - /> - - ); -}; +}: Props) => ( + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + setDeleteModalOpen(true); + setActionsOpen(false); + }} + suffix="" + /> +); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index 7ec245b1c0..abb9a03e84 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -22,6 +22,7 @@ import { rollbackEnrollmentEvent, } from '../../../../../Pages/common/EnrollmentOverviewDomain'; + const styles: Readonly = { row: { maxWidth: '100%', @@ -34,6 +35,9 @@ const styles: Readonly = { }, }; +const isSkippableStatus = (status?: string) => + status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + const EventRowPlain = ({ id, pendingApiResponse, @@ -51,7 +55,7 @@ const EventRowPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); - const { canUncompleteEvent } = useEventEditPermissions({ + const { canUncompleteEvent, isEventReadOnly } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, @@ -82,9 +86,9 @@ const EventRowPlain = ({ {stageWriteAccess && ( <> - {pendingApiResponse ? ( - - ) : ( + {pendingApiResponse && } + + {!pendingApiResponse && (!isEventReadOnly || canUncompleteEvent) && ( setActionsOpen(prev => !prev)} @@ -97,8 +101,7 @@ const EventRowPlain = ({ dense dataTest={'overflow-menu'} > - {(eventDetails.status === eventStatuses.SCHEDULE || - eventDetails.status === eventStatuses.SKIPPED) && ( + {isSkippableStatus(eventDetails.status) && ( )} diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index c2240f0ca8..0f95ed1ae5 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -40,7 +40,7 @@ export const useEventEditPermissions = ({ const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); const isExpired = !isWithinValidPeriod || !isWithinCompleteExpiry; - const isFormBlockedByCompletion = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; const canUncompleteEvent = computeCanChangeCompletionStatus({ @@ -51,9 +51,11 @@ export const useEventEditPermissions = ({ hasEditExpiredAuthority, }); + const isFormBlockedByCompletion = isCompletedAndBlockingForm; + const isEventReadOnly = !eventAccess?.write || isEventBlockedByExpiry - || isFormBlockedByCompletion; + || isCompletedAndBlockingForm; return { isEventBlockedByExpiry, From 98cbc604a8d8e8c1a40f17f4aeb1c18f94985358 Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:23:46 +0000 Subject: [PATCH 34/37] feat: update event completion fields and permissions handling --- i18n/en.pot | 4 ++-- .../MenuItems/CompletionMenuItem.tsx | 5 ++-- .../MenuItems/DeleteMenuItem.tsx | 2 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 5 +++- .../hooks/useBulkCompleteEvents.ts | 3 ++- .../hooks/useEventEditPermissions.ts | 24 +++++++++++++++---- .../form/canChangeCompletionStatus.ts | 24 ------------------- .../utils/validation/validators/form/index.ts | 1 - 8 files changed, 31 insertions(+), 37 deletions(-) delete mode 100644 src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts diff --git a/i18n/en.pot b/i18n/en.pot index 60496fe988..f2301c365b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-14T19:00:38.850Z\n" -"PO-Revision-Date: 2026-08-14T19:00:38.851Z\n" +"POT-Creation-Date: 2026-08-15T14:23:48.058Z\n" +"PO-Revision-Date: 2026-08-15T14:23:48.058Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx index b5ca3f94e7..061a542589 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -40,7 +40,8 @@ export const CompletionMenuItem = ({ resource: 'tracker/events', id: eventId, params: { - fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + fields: 'event,status,program,programStage,orgUnit,occurredAt,scheduledAt,' + + 'enrollment,trackedEntity,attributeOptionCombo,notes,assignedUser,geometry,followUp', }, }, }) as any; @@ -76,7 +77,7 @@ export const CompletionMenuItem = ({ dataTest={isCompleted ? 'uncomplete-event-menu-item' : 'complete-event-menu-item'} icon={isCompleted ? : } label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} - suffix="" + suffix={null} onClick={() => { onClose(); updateCompletionStatus(); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx index e7b0ee71c0..b37bfbffca 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -24,6 +24,6 @@ export const DeleteActionButton = ({ setDeleteModalOpen(true); setActionsOpen(false); }} - suffix="" + suffix={null} /> ); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index abb9a03e84..fed7452b4b 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -38,6 +38,9 @@ const styles: Readonly = { const isSkippableStatus = (status?: string) => status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; +const getRowClass = (classes: Record, disabled: boolean) => + (disabled ? classes.rowDisabled : classes.row); + const EventRowPlain = ({ id, pendingApiResponse, @@ -78,7 +81,7 @@ const EventRowPlain = ({ return ( {cells} diff --git a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts index 889127fb1b..f15e825c93 100644 --- a/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts +++ b/src/core_modules/capture-core/components/WorkingLists/EventWorkingListsCommon/EventBulkActions/Actions/CompleteAction/hooks/useBulkCompleteEvents.ts @@ -33,7 +33,8 @@ export const useBulkCompleteEvents = ({ { resource: 'tracker/events', params: () => ({ - fields: '*,!completedAt,!completedBy,!dataValues,!relationships', + fields: 'event,status,program,programStage,orgUnit,occurredAt,scheduledAt,' + + 'enrollment,trackedEntity,attributeOptionCombo,notes,assignedUser,geometry,followUp', pageSize: 100, program: programId, events: Object.keys(selectedRows).join(','), diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 0f95ed1ae5..e4e58b604a 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -4,11 +4,25 @@ import { getProgramEventAccess, type ProgramStage } from '../metaData'; import { isValidPeriod, isWithinCompleteEventsExpiry, - canChangeCompletionStatus as computeCanChangeCompletionStatus, } from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; import { useAuthority, Authorities } from '../utils/authority'; +const computeCanUncompleteEvent = ( + hasWriteAccess: boolean, + eventStatus: string | undefined, + isExpired: boolean, + hasEditExpiredAuthority: boolean, + hasUncompleteAuthority: boolean, +): boolean => { + if (!hasWriteAccess) return false; + if (eventStatus === eventStatuses.COMPLETED) { + if (isExpired && !hasEditExpiredAuthority) return false; + return hasUncompleteAuthority; + } + return eventStatus === eventStatuses.ACTIVE; +}; + type Input = { programId: string, stage?: ProgramStage | null, @@ -43,13 +57,13 @@ export const useEventEditPermissions = ({ const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; - const canUncompleteEvent = computeCanChangeCompletionStatus({ - hasWriteAccess: !!eventAccess?.write, + const canUncompleteEvent = computeCanUncompleteEvent( + !!eventAccess?.write, eventStatus, - canUncompleteEvent: hasUncompleteAuthority, isExpired, hasEditExpiredAuthority, - }); + hasUncompleteAuthority, + ); const isFormBlockedByCompletion = isCompletedAndBlockingForm; diff --git a/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts b/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts deleted file mode 100644 index ba83d6d2c1..0000000000 --- a/src/core_modules/capture-core/utils/validation/validators/form/canChangeCompletionStatus.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { statusTypes as eventStatuses } from '../../../../events/statusTypes'; - -type Input = { - hasWriteAccess: boolean, - eventStatus?: string, - canUncompleteEvent: boolean, - isExpired: boolean, - hasEditExpiredAuthority: boolean, -}; - -export const canChangeCompletionStatus = ({ - hasWriteAccess, - eventStatus, - canUncompleteEvent, - isExpired, - hasEditExpiredAuthority, -}: Input): boolean => { - if (!hasWriteAccess) return false; - if (eventStatus === eventStatuses.COMPLETED) { - if (isExpired && !hasEditExpiredAuthority) return false; - return canUncompleteEvent; - } - return eventStatus === eventStatuses.ACTIVE; -}; diff --git a/src/core_modules/capture-core/utils/validation/validators/form/index.ts b/src/core_modules/capture-core/utils/validation/validators/form/index.ts index 7532716c4b..8bf4778f2f 100644 --- a/src/core_modules/capture-core/utils/validation/validators/form/index.ts +++ b/src/core_modules/capture-core/utils/validation/validators/form/index.ts @@ -4,7 +4,6 @@ export { isValidAge } from './ageValidator'; export { isValidNonFutureDate } from './isValidNonFutureDate'; export { isValidPeriod } from './expiredPeriod'; export { isWithinCompleteEventsExpiry } from './completeEventsExpiry'; -export { canChangeCompletionStatus } from './canChangeCompletionStatus'; export { getDateRangeValidator } from './getDateRangeValidator'; export { getDateTimeRangeValidator } from './getDateTimeRangeValidator'; export { getNumberRangeValidator } from './getNumberRangeValidator'; From 3aa3a6c962a8c5f455a56f39a24b4a81caca5e6c Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:33:08 +0000 Subject: [PATCH 35/37] feat: rename form completion permission to event completion and refactor related logic --- i18n/en.pot | 4 +-- .../capture-core-utils/types/api-types.ts | 1 - .../EnrollmentEditEventPage.container.tsx | 4 +-- .../EventDetailsSection.component.tsx | 3 +- .../ViewEvent.component.tsx | 4 +-- .../ViewEventReadOnlyBadge.component.tsx | 6 ++-- .../EnrollmentAccessContext.tsx | 16 +++++----- .../EnrollmentReadOnlyBadge.component.tsx | 4 +-- .../ReadOnlyBadge/ReadOnlyBadge.tsx | 8 ++--- .../ReadOnlyBadge/ReadOnlyBadge.types.ts | 4 +-- .../WidgetEventEdit.container.tsx | 3 +- .../WidgetHeader/WidgetHeader.container.tsx | 2 +- .../Stage/StageDetail/EventRow/EventRow.tsx | 18 ++++++++--- .../hooks/computeCanUncompleteEvent.ts | 22 ++++++++++++++ .../hooks/useEventEditPermissions.ts | 30 +++++-------------- 15 files changed, 73 insertions(+), 56 deletions(-) create mode 100644 src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts diff --git a/i18n/en.pot b/i18n/en.pot index f2301c365b..c197792b10 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-15T14:23:48.058Z\n" -"PO-Revision-Date: 2026-08-15T14:23:48.058Z\n" +"POT-Creation-Date: 2026-08-15T16:33:11.739Z\n" +"PO-Revision-Date: 2026-08-15T16:33:11.739Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core-utils/types/api-types.ts b/src/core_modules/capture-core-utils/types/api-types.ts index 3cecf966fe..fdd2df9961 100644 --- a/src/core_modules/capture-core-utils/types/api-types.ts +++ b/src/core_modules/capture-core-utils/types/api-types.ts @@ -23,7 +23,6 @@ export type ApiEnrollmentEvent = { occurredAt: string; scheduledAt: string; completedAt?: string; - completedBy?: string; updatedAt: string; dataValues: Array; notes?: Array; diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index 2d0b9724c9..60fc1397d4 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx @@ -269,7 +269,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ const eventAccess = getProgramEventAccess(programId, stageId ?? null); const { isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -313,7 +313,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} isEventBlockedByExpiry={isEventBlockedByExpiry} - isFormBlockedByCompletion={isFormBlockedByCompletion} + isEventBlockedByCompletion={isEventBlockedByCompletion} > { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { canUncompleteEvent } = useEventEditPermissions({ + const { isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventData?.eventContainer?.event?.status, occurredAtClient: convertFormToClient(eventData?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, completedAtClient: eventData?.eventContainer?.event?.completedAt, }); + const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; const onSaveExternal = useCallback(() => { const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx index 73eae2640d..617a20fbb5 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventComponent/ViewEvent.component.tsx @@ -99,7 +99,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { const { isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, isEventReadOnly, } = useEventEditPermissions({ programId, @@ -123,7 +123,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => {
diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx index a835c090bb..c978fb4859 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/ViewEventReadOnlyBadge/ViewEventReadOnlyBadge.component.tsx @@ -4,18 +4,18 @@ import { ReadOnlyBadge } from '../../../ReadOnlyBadge'; type Props = { eventAccess: { read: boolean; write: boolean }; isEventBlockedByExpiry: boolean; - isFormBlockedByCompletion: boolean; + isEventBlockedByCompletion: boolean; }; export const ViewEventReadOnlyBadge = ({ eventAccess, isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, }: Props) => ( ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx index aadfdeb2f0..2b5792ad9b 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx @@ -19,7 +19,7 @@ export type EnrollmentAccessContextValue = { trackedEntityInactive: boolean; canToggleTrackedEntityStatus: boolean; isEventBlockedByExpiry?: boolean; - isFormBlockedByCompletion?: boolean; + isEventBlockedByCompletion?: boolean; }; const fallback: EnrollmentAccessContextValue = { @@ -37,7 +37,7 @@ const fallback: EnrollmentAccessContextValue = { trackedEntityInactive: false, canToggleTrackedEntityStatus: false, isEventBlockedByExpiry: false, - isFormBlockedByCompletion: false, + isEventBlockedByCompletion: false, }; const Context = createContext(fallback); @@ -47,7 +47,7 @@ type ProviderProps = { currentStageId?: string; trackedEntityInactive?: boolean; isEventBlockedByExpiry?: boolean; - isFormBlockedByCompletion?: boolean; + isEventBlockedByCompletion?: boolean; children: React.ReactNode; }; @@ -67,7 +67,7 @@ const computeContextValue = ( currentStageId: string | undefined, trackedEntityInactive: boolean, isEventBlockedByExpiry?: boolean, - isFormBlockedByCompletion?: boolean, + isEventBlockedByCompletion?: boolean, ): EnrollmentAccessContextValue => { const { rawStageWriteAccessById, stageReadAccessById } = buildStageAccessMaps(program); const rawProgramWriteAccess = Boolean(program.access?.data?.write); @@ -101,7 +101,7 @@ const computeContextValue = ( trackedEntityInactive, canToggleTrackedEntityStatus: rawTrackedEntityTypeWriteAccess, isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, }; }; @@ -110,7 +110,7 @@ export const EnrollmentAccessProvider = ({ currentStageId, trackedEntityInactive = false, isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, children, }: ProviderProps) => { const value = useMemo( @@ -120,7 +120,7 @@ export const EnrollmentAccessProvider = ({ currentStageId, trackedEntityInactive, isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, ) : { ...fallback, @@ -139,7 +139,7 @@ export const EnrollmentAccessProvider = ({ currentStageId, trackedEntityInactive, isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, ], ); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx index 46763fcd22..86685cfdae 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx @@ -13,7 +13,7 @@ export const EnrollmentReadOnlyBadge = () => { trackedEntityTypeName, trackedEntityInactive, isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, } = useEnrollmentAccessContext(); if (isEventPage) { @@ -21,7 +21,7 @@ export const EnrollmentReadOnlyBadge = () => { { if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); @@ -46,7 +46,7 @@ const getReadOnlyMessage = ({ if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); if (!access.programStage) return getProgramStageMessage(multipleStages); if (isEventBlockedByExpiry) return getExpiredMessage(); - if (isFormBlockedByCompletion) return getCompletedEventMessage(); + if (isEventBlockedByCompletion) return getCompletedEventMessage(); return ''; }; @@ -55,7 +55,7 @@ const ReadOnlyBadgePlain = ({ trackedEntityTypeWriteAccess = true, programStageWriteAccess = true, isEventBlockedByExpiry = false, - isFormBlockedByCompletion = false, + isEventBlockedByCompletion = false, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -72,7 +72,7 @@ const ReadOnlyBadgePlain = ({ trackedEntityName, multipleStages, isEventBlockedByExpiry, - isFormBlockedByCompletion, + isEventBlockedByCompletion, trackedEntityInactive, }); if (!message) return null; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts index 986d017be9..9c36730417 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -3,7 +3,7 @@ export type Props = { trackedEntityTypeWriteAccess?: boolean; programStageWriteAccess?: boolean; isEventBlockedByExpiry?: boolean; - isFormBlockedByCompletion?: boolean; + isEventBlockedByCompletion?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -21,6 +21,6 @@ export type ReadOnlyMessageInput = { trackedEntityName: string | undefined; multipleStages: boolean; isEventBlockedByExpiry: boolean; - isFormBlockedByCompletion: boolean; + isEventBlockedByCompletion: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx index 29ae5c9bd5..35d842773e 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -110,13 +110,14 @@ const WidgetEventEditPlain = ({ const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, canUncompleteEvent } = useEventEditPermissions({ + const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ programId, stage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); + const canUncompleteEvent = !isEventBlockedByCompletion && !isEventBlockedByExpiry; return orgUnit && loadedValues ? (
diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx index 0360e228ef..3ed32293ed 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.container.tsx @@ -61,7 +61,7 @@ const WidgetHeaderPlain = ({ const onCompletionStatusMutate = useCallback((newStatus: string) => { if (storedEvent) { - const { completedAt, completedBy, ...eventWithoutCompletion } = storedEvent; + const { completedAt, ...eventWithoutCompletion } = storedEvent; dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); } }, [dispatch, storedEvent, eventId]); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx index fed7452b4b..fd749589c4 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.tsx @@ -41,6 +41,11 @@ const isSkippableStatus = (status?: string) => const getRowClass = (classes: Record, disabled: boolean) => (disabled ? classes.rowDisabled : classes.row); +const isCompletionToggleable = (status: string, blockedByCompletion: boolean, blockedByExpiry: boolean) => + !blockedByCompletion + && !blockedByExpiry + && (status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED); + const EventRowPlain = ({ id, pendingApiResponse, @@ -58,16 +63,21 @@ const EventRowPlain = ({ const [deleteModalOpen, setDeleteModalOpen] = useState(false); const dispatch = useDispatch(); - const { canUncompleteEvent, isEventReadOnly } = useEventEditPermissions({ + const { isEventReadOnly, isEventBlockedByCompletion, isEventBlockedByExpiry } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, }); + const canToggleCompletion = isCompletionToggleable( + eventDetails.status, + isEventBlockedByCompletion, + isEventBlockedByExpiry, + ); const onCompletionStatusMutate = useCallback((newStatus: string) => { - const { completedAt, completedBy, ...eventWithoutCompletion } = eventDetails; + const { completedAt, ...eventWithoutCompletion } = eventDetails; dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); }, [dispatch, eventDetails, id]); @@ -91,7 +101,7 @@ const EventRowPlain = ({ <> {pendingApiResponse && } - {!pendingApiResponse && (!isEventReadOnly || canUncompleteEvent) && ( + {!pendingApiResponse && (!isEventReadOnly || canToggleCompletion) && ( setActionsOpen(prev => !prev)} @@ -114,7 +124,7 @@ const EventRowPlain = ({ /> )} - {canUncompleteEvent && ( + {canToggleCompletion && ( { + if (!hasWriteAccess) return false; + if (eventStatus === eventStatuses.COMPLETED) { + if (isExpired && !hasEditExpiredAuthority) return false; + return hasUncompleteAuthority; + } + return eventStatus === eventStatuses.ACTIVE; +}; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index e4e58b604a..1f013a17be 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -7,21 +7,7 @@ import { } from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; import { useAuthority, Authorities } from '../utils/authority'; - -const computeCanUncompleteEvent = ( - hasWriteAccess: boolean, - eventStatus: string | undefined, - isExpired: boolean, - hasEditExpiredAuthority: boolean, - hasUncompleteAuthority: boolean, -): boolean => { - if (!hasWriteAccess) return false; - if (eventStatus === eventStatuses.COMPLETED) { - if (isExpired && !hasEditExpiredAuthority) return false; - return hasUncompleteAuthority; - } - return eventStatus === eventStatuses.ACTIVE; -}; +import { computeCanUncompleteEvent } from './computeCanUncompleteEvent'; type Input = { programId: string, @@ -33,8 +19,7 @@ type Input = { type Output = { isEventBlockedByExpiry: boolean, - isFormBlockedByCompletion: boolean, - canUncompleteEvent: boolean, + isEventBlockedByCompletion: boolean, isEventReadOnly: boolean, }; @@ -57,15 +42,15 @@ export const useEventEditPermissions = ({ const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; - const canUncompleteEvent = computeCanUncompleteEvent( - !!eventAccess?.write, + const canUncompleteEvent = computeCanUncompleteEvent({ + hasWriteAccess: !!eventAccess?.write, eventStatus, isExpired, hasEditExpiredAuthority, hasUncompleteAuthority, - ); + }); - const isFormBlockedByCompletion = isCompletedAndBlockingForm; + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; const isEventReadOnly = !eventAccess?.write || isEventBlockedByExpiry @@ -73,8 +58,7 @@ export const useEventEditPermissions = ({ return { isEventBlockedByExpiry, - isFormBlockedByCompletion, - canUncompleteEvent, + isEventBlockedByCompletion, isEventReadOnly, }; }; From ce829941189ad511bce350243a11fe19c85d61da Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:31:52 +0000 Subject: [PATCH 36/37] fix: remove computeCanUncompleteEvent and integrate its logic into useEventEditPermissions --- i18n/en.pot | 4 ++-- .../hooks/computeCanUncompleteEvent.ts | 22 ------------------- .../hooks/useEventEditPermissions.ts | 22 +++++++++++++++---- 3 files changed, 20 insertions(+), 28 deletions(-) delete mode 100644 src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts diff --git a/i18n/en.pot b/i18n/en.pot index b6f9f6a140..df8ba17ba2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-17T07:55:32.419Z\n" -"PO-Revision-Date: 2026-08-17T07:55:32.419Z\n" +"POT-Creation-Date: 2026-08-17T09:31:54.769Z\n" +"PO-Revision-Date: 2026-08-17T09:31:54.769Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts b/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts deleted file mode 100644 index 79a9137480..0000000000 --- a/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { statusTypes as eventStatuses } from '../events/statusTypes'; - -export const computeCanUncompleteEvent = ({ - hasWriteAccess, - eventStatus, - isExpired, - hasEditExpiredAuthority, - hasUncompleteAuthority, -}: { - hasWriteAccess: boolean, - eventStatus: string | undefined, - isExpired: boolean, - hasEditExpiredAuthority: boolean, - hasUncompleteAuthority: boolean, -}): boolean => { - if (!hasWriteAccess) return false; - if (eventStatus === eventStatuses.COMPLETED) { - if (isExpired && !hasEditExpiredAuthority) return false; - return hasUncompleteAuthority; - } - return eventStatus === eventStatuses.ACTIVE; -}; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 1f013a17be..ab4ee048a1 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -7,7 +7,6 @@ import { } from '../utils/validation/validators/form'; import { statusTypes as eventStatuses } from '../events/statusTypes'; import { useAuthority, Authorities } from '../utils/authority'; -import { computeCanUncompleteEvent } from './computeCanUncompleteEvent'; type Input = { programId: string, @@ -23,6 +22,21 @@ type Output = { isEventReadOnly: boolean, }; +const canUncompletEvent = ( + hasWriteAccess: boolean, + eventStatus: string | undefined, + isExpired: boolean, + hasEditExpiredAuthority: boolean, + hasUncompleteAuthority: boolean, +): boolean => { + if (!hasWriteAccess) return false; + if (eventStatus === eventStatuses.COMPLETED) { + if (isExpired && !hasEditExpiredAuthority) return false; + return hasUncompleteAuthority; + } + return eventStatus === eventStatuses.ACTIVE; +}; + export const useEventEditPermissions = ({ programId, stage, @@ -42,13 +56,13 @@ export const useEventEditPermissions = ({ const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; - const canUncompleteEvent = computeCanUncompleteEvent({ - hasWriteAccess: !!eventAccess?.write, + const canUncompleteEvent = canUncompletEvent( + !!eventAccess?.write, eventStatus, isExpired, hasEditExpiredAuthority, hasUncompleteAuthority, - }); + ); const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; From 8522012292aeb03b6f4c18e86e45b39f1aa9badc Mon Sep 17 00:00:00 2001 From: henrikmv <110386561+henrikmv@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:31:15 +0000 Subject: [PATCH 37/37] fix: simplify canUncompleteEvent logic by removing unnecessary parameters --- i18n/en.pot | 4 ++-- .../capture-core/hooks/useEventEditPermissions.ts | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index df8ba17ba2..b655ce189e 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-08-17T09:31:54.769Z\n" -"PO-Revision-Date: 2026-08-17T09:31:54.769Z\n" +"POT-Creation-Date: 2026-08-18T07:31:16.406Z\n" +"PO-Revision-Date: 2026-08-18T07:31:16.406Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index ab4ee048a1..b3147bf34d 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -25,13 +25,12 @@ type Output = { const canUncompletEvent = ( hasWriteAccess: boolean, eventStatus: string | undefined, - isExpired: boolean, - hasEditExpiredAuthority: boolean, hasUncompleteAuthority: boolean, + isEventBlockedByExpiry: boolean, ): boolean => { if (!hasWriteAccess) return false; if (eventStatus === eventStatuses.COMPLETED) { - if (isExpired && !hasEditExpiredAuthority) return false; + if (isEventBlockedByExpiry) return false; return hasUncompleteAuthority; } return eventStatus === eventStatuses.ACTIVE; @@ -59,9 +58,8 @@ export const useEventEditPermissions = ({ const canUncompleteEvent = canUncompletEvent( !!eventAccess?.write, eventStatus, - isExpired, - hasEditExpiredAuthority, hasUncompleteAuthority, + isEventBlockedByExpiry, ); const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent;