diff --git a/i18n/en.pot b/i18n/en.pot index 991f100a44..a2957513fb 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-28T11:16:56.550Z\n" -"PO-Revision-Date: 2026-08-28T11:16:56.550Z\n" +"POT-Creation-Date: 2026-08-28T12:16:22.694Z\n" +"PO-Revision-Date: 2026-08-28T12:16:22.694Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -437,6 +437,9 @@ 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 "View changelog" +msgstr "View changelog" + msgid "An error occurred when updating event status" msgstr "An error occurred when updating event status" @@ -446,6 +449,9 @@ msgstr "Mark incomplete" msgid "Mark complete" msgstr "Mark complete" +msgid "Delete" +msgstr "Delete" + msgid "An error occurred while deleting the event" msgstr "An error occurred while deleting the event" @@ -461,8 +467,11 @@ msgstr "Are you sure you want to delete this event?" msgid "Yes, delete event" msgstr "Yes, delete event" -msgid "Delete" -msgstr "Delete" +msgid "Unskip" +msgstr "Unskip" + +msgid "Skip" +msgstr "Skip" msgid "No events to display" msgstr "No events to display" @@ -993,9 +1002,6 @@ msgstr "events" msgid "event" msgstr "event" -msgid "View changelog" -msgstr "View changelog" - msgid "Event details" msgstr "Event details" @@ -1098,6 +1104,12 @@ msgstr "Possible duplicates found" msgid "An error occurred loading possible duplicates" msgstr "An error occurred loading possible duplicates" +msgid "View only - {{message}}" +msgstr "View only - {{message}}" + +msgid "View only" +msgstr "View only" + msgid "You only have view access to this enrollment" msgstr "You only have view access to this enrollment" @@ -1122,18 +1134,15 @@ msgstr "This event is outside the editing period" msgid "This event has been completed" msgstr "This event has been completed" +msgid "You do not have access to uncomplete this event" +msgstr "You do not have access to uncomplete this event" + msgid "This {{trackedEntityName}} is deactivated" msgstr "This {{trackedEntityName}} is deactivated" msgid "This tracked entity is deactivated" msgstr "This tracked entity is deactivated" -msgid "View only - {{message}}" -msgstr "View only - {{message}}" - -msgid "View only" -msgstr "View only" - msgid "Add relationship" msgstr "Add relationship" @@ -1530,9 +1539,6 @@ msgstr "Go to “Schedule” tab to reschedule this event" msgid "Scheduled date cannot be changed for {{ eventStatus }} events" msgstr "Scheduled date cannot be changed for {{ eventStatus }} events" -msgid "You do not have access to uncomplete this event" -msgstr "You do not have access to uncomplete this event" - msgid "Geometry (Area)" msgstr "Geometry (Area)" @@ -1772,12 +1778,6 @@ msgstr "This stage can only have one event" msgid "New {{ eventName }} event" msgstr "New {{ eventName }} event" -msgid "Unskip" -msgstr "Unskip" - -msgid "Skip" -msgstr "Skip" - msgid "Show {{ rest }} more" msgstr "Show {{ rest }} more" diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx new file mode 100644 index 0000000000..f55c4480bf --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/EventOverflowMenu.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { FlyoutMenu, MenuDivider } from '@dhis2/ui'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { ChangelogMenuItem, SkipMenuItem, CompletionMenuItem, DeleteMenuItem } from './MenuItems'; + +const isSkippableStatus = (status?: string) => + status === eventStatuses.SCHEDULE + || status === eventStatuses.SKIPPED + || status === eventStatuses.OVERDUE; + +const isCompletableStatus = (status?: string) => + status === eventStatuses.ACTIVE || status === eventStatuses.COMPLETED; + +type Props = { + eventId: string; + eventStatus?: string; + maxWidth?: string; + dataTest?: string; + + onOpenChangelog: () => void; + onClose: () => void; + + hideMutationActions?: boolean; + isEventBlockedByExpiry: boolean; + canToggleCompletion: boolean; + readOnlyMessage: string; + + onSkipMutate: (newStatus: string) => void; + onSkipSuccess: (newStatus: string) => void; + onSkipError: () => void; + + onCompletionMutate: (newStatus: string) => void; + onCompletionSuccess: (newStatus: string) => void; + onCompletionError: () => void; + + onDeleteRequest: () => void; +}; + +export const EventOverflowMenu = ({ + eventId, + eventStatus, + maxWidth, + dataTest = 'overflow-menu', + onOpenChangelog, + onClose, + hideMutationActions = false, + isEventBlockedByExpiry, + canToggleCompletion, + readOnlyMessage, + onSkipMutate, + onSkipSuccess, + onSkipError, + onCompletionMutate, + onCompletionSuccess, + onCompletionError, + onDeleteRequest, +}: Props) => ( + + + + {!hideMutationActions && ( + <> + + + {isSkippableStatus(eventStatus) && ( + + )} + + {isCompletableStatus(eventStatus) && ( + + )} + + + + )} + +); diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx new file mode 100644 index 0000000000..576d338f02 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/ChangelogMenuItem.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { MenuItem, IconClockHistory16 } from '@dhis2/ui'; + +type Props = { + onOpenChangelog: () => void; + onClose: () => void; +}; + +export const ChangelogMenuItem = ({ onOpenChangelog, onClose }: Props) => ( + } + label={i18n.t('View changelog')} + suffix={null} + dataTest="event-overflow-changelog" + onClick={() => { + onOpenChangelog(); + onClose(); + }} + /> +); 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 ff314e0ccf..105c681d1e 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -7,6 +7,7 @@ import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { removeEventChangelogQueries } from '../../WidgetsChangelog'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; type Props = { eventId: string; @@ -15,6 +16,8 @@ type Props = { onSuccess?: (newStatus: string) => void; onError?: () => void; onClose: () => void; + canToggleCompletion: boolean; + readOnlyMessage: string; }; export const CompletionMenuItem = ({ @@ -24,6 +27,8 @@ export const CompletionMenuItem = ({ onSuccess, onError, onClose, + canToggleCompletion, + readOnlyMessage, }: Props) => { const dataEngine = useDataEngine(); const queryClient = useQueryClient(); @@ -75,16 +80,19 @@ export const CompletionMenuItem = ({ ); return ( - : } - label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} - suffix={null} - onClick={() => { - onClose(); - updateCompletionStatus(); - }} - /> + + : } + label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} + suffix={null} + onClick={() => { + onClose(); + updateCompletionStatus(); + }} + /> + ); }; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx deleted file mode 100644 index c5731ba439..0000000000 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteEventModal.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import log from 'loglevel'; -import i18n from '@dhis2/d2-i18n'; -import { Button, ButtonStrip, Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2/ui'; -import { useAlert, useDataEngine } from '@dhis2/app-runtime'; -import { useMutation } from '@tanstack/react-query'; -import { errorCreator } from 'capture-core-utils'; -import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; - -type Props = { - eventId: string; - pendingApiResponse: boolean; - eventDetails: ApiEnrollmentEvent; - onDeleteEvent: (eventId: string) => void; - onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; - setDeleteModalOpen: (open: boolean) => void; -}; - -export const DeleteEventModal = ({ - setDeleteModalOpen, - pendingApiResponse, - eventId, - eventDetails, - onDeleteEvent, - onRollbackDeleteEvent, -}: Props) => { - const { show: showError } = useAlert( - ({ message }) => message, - { - critical: true, - }, - ); - const dataEngine = useDataEngine(); - - const { mutate } = useMutation( - () => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=DELETE', - type: 'create', - data: { - events: [ - { - event: eventId, - }, - ], - }, - }), - { - onMutate: () => { - const eventToRollbackOnFail = eventDetails; - - onDeleteEvent(eventId); - return eventToRollbackOnFail; - }, - onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { - showError({ message: i18n.t('An error occurred while deleting the event') }); - log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); - - if (eventToRollbackOnFail) { - onRollbackDeleteEvent(eventToRollbackOnFail); - } - }, - }, - ); - - return ( - setDeleteModalOpen(false)} - small - > - - {i18n.t('Delete event')} - - -

- {i18n.t('Deleting an event is permanent and cannot be undone.')} - {' '} - {i18n.t('Are you sure you want to delete this event?')} -

-
- - - - - - -
- ); -}; 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 3360cdaf3f..b24d229107 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -1,29 +1,114 @@ import React from 'react'; +import log from 'loglevel'; import i18n from '@dhis2/d2-i18n'; import { + Button, + ButtonStrip, colors, IconDelete16, MenuItem, + Modal, + ModalActions, ModalContent, + ModalTitle, } from '@dhis2/ui'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { useMutation } from '@tanstack/react-query'; +import { errorCreator } from 'capture-core-utils'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; -type Props = { - setActionsOpen: (open: boolean) => void; - setDeleteModalOpen: (open: boolean) => void; +type DeleteMenuItemProps = { + onDeleteRequest: () => void; + onClose: () => void; + isEventBlockedByExpiry: boolean; + readOnlyMessage: string; }; export const DeleteMenuItem = ({ - setActionsOpen, - setDeleteModalOpen, -}: Props) => ( - } - label={i18n.t('Delete')} - dataTest="stages-and-events-delete" - onClick={() => { - setDeleteModalOpen(true); - setActionsOpen(false); - }} - suffix={null} - /> + onDeleteRequest, + onClose, + isEventBlockedByExpiry, + readOnlyMessage, +}: DeleteMenuItemProps) => ( + + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + onDeleteRequest(); + onClose(); + }} + suffix={null} + /> + ); + +type DeleteMenuItemModalProps = { + eventId: string; + eventDetails: ApiEnrollmentEvent; + onDeleteEvent: (eventId: string) => void; + onRollbackDeleteEvent: (eventToRollbackOnFail: ApiEnrollmentEvent) => void; + setDeleteModalOpen: (open: boolean) => void; +}; + +export const DeleteMenuItemModal = ({ + setDeleteModalOpen, + eventId, + eventDetails, + onDeleteEvent, + onRollbackDeleteEvent, +}: DeleteMenuItemModalProps) => { + const { show: showError } = useAlert( + ({ message }) => message, + { critical: true }, + ); + const dataEngine = useDataEngine(); + + const { mutate, isLoading } = useMutation( + () => dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=DELETE', + type: 'create', + data: { events: [{ event: eventId }] }, + }), + { + onMutate: () => { + const eventToRollbackOnFail = eventDetails; + onDeleteEvent(eventId); + return eventToRollbackOnFail; + }, + onError: (apiError: unknown, payload: unknown, eventToRollbackOnFail?: ApiEnrollmentEvent) => { + showError({ message: i18n.t('An error occurred while deleting the event') }); + log.error(errorCreator('An error occurred while deleting the event')({ apiError, payload })); + if (eventToRollbackOnFail) { + onRollbackDeleteEvent(eventToRollbackOnFail); + } + }, + }, + ); + + return ( + setDeleteModalOpen(false)} small> + {i18n.t('Delete event')} + +

+ {i18n.t('Deleting an event is permanent and cannot be undone.')} + {' '} + {i18n.t('Are you sure you want to delete this event?')} +

+
+ + + + + + +
+ ); +}; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx new file mode 100644 index 0000000000..3c59b73bc0 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/SkipMenuItem.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; +import { MenuItem } from '@dhis2/ui'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAlert, useDataEngine } from '@dhis2/app-runtime'; +import { errorCreator } from 'capture-core-utils'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { removeEventChangelogQueries } from '../../WidgetsChangelog'; +import { DirectionalArrow } from '../../../utils/rtl'; +import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; + +type Props = { + eventId: string; + eventStatus?: string; + onMutate?: (newStatus: string) => void; + onSuccess?: (newStatus: string) => void; + onError?: () => void; + onClose: () => void; + isEventBlockedByExpiry: boolean; + readOnlyMessage: string; +}; + +export const SkipMenuItem = ({ + eventId, + eventStatus, + onMutate, + onSuccess, + onError, + onClose, + isEventBlockedByExpiry, + readOnlyMessage, +}: Props) => { + const dataEngine = useDataEngine(); + const queryClient = useQueryClient(); + const { show: showError } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const isSkipped = eventStatus === eventStatuses.SKIPPED; + const newStatus = isSkipped ? eventStatuses.SCHEDULE : eventStatuses.SKIPPED; + + const { mutate: updateEventStatus } = useMutation( + async () => { + const { event: apiEvent } = await dataEngine.query({ + event: { + resource: 'tracker/events', + id: eventId, + params: { + fields: '*,!dataValues,!relationships', + }, + }, + }) as any; + + return dataEngine.mutate({ + resource: 'tracker?async=false&importStrategy=UPDATE', + type: 'create', + data: { + events: [{ + ...apiEvent, + status: newStatus, + }], + }, + }); + }, + { + 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: () => { + removeEventChangelogQueries(queryClient, eventId); + onSuccess?.(newStatus); + }, + }, + ); + + return ( + + : } + label={isSkipped ? i18n.t('Unskip') : i18n.t('Skip')} + suffix={null} + onClick={() => { + onClose(); + updateEventStatus(); + }} + /> + + ); +}; 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 d5ca991e6e..bb1a1131c0 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,4 @@ -export { DeleteMenuItem } from './DeleteMenuItem'; -export { DeleteEventModal } from './DeleteEventModal'; +export { DeleteMenuItem, DeleteMenuItemModal } from './DeleteMenuItem'; export { CompletionMenuItem } from './CompletionMenuItem'; +export { ChangelogMenuItem } from './ChangelogMenuItem'; +export { SkipMenuItem } from './SkipMenuItem'; diff --git a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts index 138a30a279..b2aabf540b 100644 --- a/src/core_modules/capture-core/components/EventOverflowMenu/index.ts +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -1 +1,8 @@ -export { DeleteMenuItem, DeleteEventModal, CompletionMenuItem } from './MenuItems'; +export { + DeleteMenuItem, + DeleteMenuItemModal, + CompletionMenuItem, + ChangelogMenuItem, + SkipMenuItem, +} from './MenuItems'; +export { EventOverflowMenu } from './EventOverflowMenu'; diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index 0b2af5d2a9..f2b8f63833 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -2,9 +2,7 @@ import React, { useCallback } from 'react'; import i18n from '@dhis2/d2-i18n'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; -import { formatMomentEn } from 'capture-core-utils/date'; import { useDispatch, useSelector } from 'react-redux'; -import { useTimeZoneConversion } from '@dhis2/app-runtime'; import { useQueryClient } from '@tanstack/react-query'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { removeEventChangelogQueries } from '../../../WidgetsChangelog'; @@ -44,7 +42,6 @@ import { addPersistedEnrollmentEvents, deleteEnrollmentEvent, setTrackedEntityInactiveStatus, - updateEnrollmentEventStatus, } from '../../common/EnrollmentOverviewDomain/enrollment.actions'; import { useHideWidgetByRuleLocations } from '../../../../hooks'; @@ -53,7 +50,6 @@ export const EnrollmentPageDefault = () => { const { navigate } = useNavigate(); const dispatch = useDispatch(); const queryClient = useQueryClient(); - const { fromClientDate } = useTimeZoneConversion(); const { status: widgetEnrollmentStatus } = useSelector(({ widgetEnrollment }: any) => widgetEnrollment); const { enrollmentId, programId, teiId, orgUnitId } = useLocationQuery(); const { orgUnit, error } = useCoreOrgUnit(orgUnitId); @@ -154,14 +150,6 @@ export const EnrollmentPageDefault = () => { dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); }, [dispatch]); - const onUpdateEventStatus = useCallback((eventId: string, status: string) => { - const nowClient = fromClientDate(new Date()); - const nowServer = new Date(nowClient.getServerZonedISOString()); - const updatedAt = formatMomentEn(nowServer, 'YYYY-MM-DDTHH:mm:ss'); - - dispatch(updateEnrollmentEventStatus(eventId, status, updatedAt)); - }, [dispatch, fromClientDate]); - const onAddNew = () => { navigate(`/new?${buildUrlQueryString({ orgUnitId, programId, teiId })}`); }; @@ -224,7 +212,6 @@ export const EnrollmentPageDefault = () => { hideWidgets={hideWidgets} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} - onUpdateEventStatus={onUpdateEventStatus} onRollbackDeleteEvent={onRollbackDeleteEvent} onLinkedRecordClick={onLinkedRecordClick} onUpdateTeiAttributeValues={onUpdateTeiAttributeValues} 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 d096550858..d6b548bb83 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 @@ -258,7 +258,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ const enrollmentsAsOptions = buildEnrollmentsAsOptions([enrollmentSite ?? {}], programId); const eventDate = getEventDate(event); const scheduleDate = getEventScheduleDate(event); - const { currentPageMode } = useEnrollmentEditEventPageMode(event?.status); + const { currentPageMode } = useEnrollmentEditEventPageMode(event?.status, event?.event); const dataEntryKey = `${dataEntryIds.ENROLLMENT_EVENT}-${currentPageMode}`; const userInteractionInProgress = useSelector(state => dataEntryHasChanges(state, dataEntryKey)); @@ -268,12 +268,14 @@ const EnrollmentEditEventPageWithContextPlain = ({ const { isEventBlockedByExpiry, isEventBlockedByCompletion, + isEventOverdueOrScheduled, } = useEventEditPermissions({ programId, stage: programStage, eventStatus: event?.status, occurredAtClient: convertServerToClient(event?.occurredAt, dataElementTypes.DATE) as string, completedAtClient: convertServerToClient(event?.completedAt, dataElementTypes.DATE) as string, + scheduledAtClient: convertServerToClient(event?.scheduledAt, dataElementTypes.DATE) as string, }); const pageStatus = getPageStatus({ @@ -310,7 +312,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - isEventBlockedByExpiry={isEventBlockedByExpiry} + isEventBlockedByExpiry={!isEventOverdueOrScheduled && isEventBlockedByExpiry} isEventBlockedByCompletion={isEventBlockedByCompletion} > { eventStatus: loadedValues?.eventContainer?.event?.status, occurredAtClient: convertFormToClient(loadedValues?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, completedAtClient: loadedValues?.eventContainer?.event?.completedAt, + scheduledAtClient: loadedValues?.eventContainer?.event?.scheduledAt, }); - const onSaveExternal = useCallback(() => { removeEventChangelogQueries(queryClient, eventId); onBackToAllEvents(); @@ -148,13 +148,9 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { maxWidth="250px" dataTest="event-program-event-overflow-menu" > - { - setChangeLogIsOpen(true); - setActionsIsOpen(false); - }} + setChangeLogIsOpen(true)} + onClose={() => setActionsIsOpen(false)} /> )} 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 617a20fbb5..d3700a4037 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 @@ -96,6 +96,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { const occurredAt = useSelector((state: any) => state.viewEventPage.loadedValues?.dataEntryValues?.occurredAt); const eventStatus = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.status); const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); + const scheduledAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.scheduledAt); const { isEventBlockedByExpiry, @@ -107,6 +108,7 @@ export const ViewEventPlain = (props: Props & WithStyles) => { eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, + scheduledAtClient: scheduledAt, }); const showEditButton = !isEditEventPage && !isEventReadOnly; diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts index 3b64fb4f55..821583d7e7 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts @@ -55,7 +55,6 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onUpdateEventStatus, onRollbackDeleteEvent, onEventClick, ruleEffects, @@ -66,7 +65,6 @@ export const StagesAndEvents: WidgetConfig = { onViewAll, onCreateNew, onDeleteEvent, - onUpdateEventStatus, onRollbackDeleteEvent, onEventClick, ruleEffects, diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts index fba2ed7c0a..d360edd75e 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts @@ -16,6 +16,7 @@ export { setExternalEnrollmentStatus, deleteEnrollmentEvent, deleteEnrollmentEventRelationship, + addPersistedEnrollmentEvents, commitEnrollmentEvents, rollbackEnrollmentEvents, } from './enrollment.actions'; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx index 95180a7b6d..c4a7b51ad5 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx @@ -3,7 +3,8 @@ import { IconInfo16, Tag } from '@dhis2/ui'; import i18n from '@dhis2/d2-i18n'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; -import type { Props, Access, ReadOnlyMessageInput } from './ReadOnlyBadge.types'; +import { getReadOnlyMessage } from './getReadOnlyMessage'; +import type { Props, Access } from './ReadOnlyBadge.types'; const styles = { label: { @@ -11,45 +12,6 @@ const styles = { }, } as const; -const getEnrollmentMessage = (): string => i18n.t('You only have view access to this enrollment'); - -const getProgramMessage = (): string => i18n.t('You only have view access to this program'); - -const getTrackedEntityMessage = (trackedEntityName: string | undefined): string => (trackedEntityName - ? i18n.t('You only have view access to this {{trackedEntityName}}', { trackedEntityName, escapeValue: false }) - : i18n.t('You only have view access to this tracked entity type')); - -const getProgramStageMessage = (multipleStages: boolean): string => (multipleStages - ? i18n.t('You only have view access to these program stages') - : i18n.t('You only have view access to this program stage')); - -const getExpiredMessage = (): string => i18n.t('This event is outside the editing period'); - -const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); - -const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName - ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) - : i18n.t('This tracked entity is deactivated')); - -// eslint-disable-next-line complexity -const getReadOnlyMessage = ({ - access, - trackedEntityName, - multipleStages, - isEventBlockedByExpiry, - isEventBlockedByCompletion, - trackedEntityInactive, -}: ReadOnlyMessageInput): string => { - if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); - if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); - if (!access.program) return getProgramMessage(); - if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); - if (!access.programStage) return getProgramStageMessage(multipleStages); - if (isEventBlockedByExpiry) return getExpiredMessage(); - if (isEventBlockedByCompletion) return getCompletedEventMessage(); - return ''; -}; - const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, 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 9c36730417..ed9f24dafa 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.types.ts @@ -22,5 +22,7 @@ export type ReadOnlyMessageInput = { multipleStages: boolean; isEventBlockedByExpiry: boolean; isEventBlockedByCompletion: boolean; + isEventCompleted?: boolean; + canToggleCompletion?: boolean; trackedEntityInactive: boolean; }; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts new file mode 100644 index 0000000000..f8bec3264a --- /dev/null +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/getReadOnlyMessage.ts @@ -0,0 +1,46 @@ +import i18n from '@dhis2/d2-i18n'; +import type { ReadOnlyMessageInput } from './ReadOnlyBadge.types'; + +const getEnrollmentMessage = (): string => i18n.t('You only have view access to this enrollment'); + +const getProgramMessage = (): string => i18n.t('You only have view access to this program'); + +const getTrackedEntityMessage = (trackedEntityName: string | undefined): string => (trackedEntityName + ? i18n.t('You only have view access to this {{trackedEntityName}}', { trackedEntityName, escapeValue: false }) + : i18n.t('You only have view access to this tracked entity type')); + +const getProgramStageMessage = (multipleStages: boolean): string => (multipleStages + ? i18n.t('You only have view access to these program stages') + : i18n.t('You only have view access to this program stage')); + +const getExpiredMessage = (): string => i18n.t('This event is outside the editing period'); + +const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); + +const getUncompleteAuthorityMessage = (): string => i18n.t('You do not have access to uncomplete this event'); + +const getDeactivatedMessage = (trackedEntityName: string | undefined): string => (trackedEntityName + ? i18n.t('This {{trackedEntityName}} is deactivated', { trackedEntityName, escapeValue: false }) + : i18n.t('This tracked entity is deactivated')); + +// eslint-disable-next-line complexity +export const getReadOnlyMessage = ({ + access, + trackedEntityName, + multipleStages, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventCompleted, + canToggleCompletion, + trackedEntityInactive, +}: ReadOnlyMessageInput): string => { + if (trackedEntityInactive) return getDeactivatedMessage(trackedEntityName); + if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); + if (!access.program) return getProgramMessage(); + if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); + if (!access.programStage) return getProgramStageMessage(multipleStages); + if (isEventBlockedByExpiry) return getExpiredMessage(); + if (isEventBlockedByCompletion) return getCompletedEventMessage(); + if (isEventCompleted && !canToggleCompletion) return getUncompleteAuthorityMessage(); + return ''; +}; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts index 12396a40ff..5962b380c8 100644 --- a/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts @@ -1 +1,2 @@ export { ReadOnlyBadge } from './ReadOnlyBadge'; +export { getReadOnlyMessage } from './getReadOnlyMessage'; 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 8cbdf41980..ff441c6e40 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetEventEdit.container.tsx @@ -16,6 +16,7 @@ import { EventChangelogWrapper } from './EventChangelogWrapper'; import { inMemoryFileStore } from '../DataEntry/file/inMemoryFileStore'; import { WidgetHeader } from './WidgetHeader'; import { WidgetTwoEventWorkspace, WidgetTwoEventWorkspaceWrapperTypes } from '../WidgetTwoEventWorkspace'; +import { getReadOnlyMessage } from '../ReadOnlyBadge'; import { useEnrollmentEditEventPageMode, useAvailableProgramStages, @@ -99,23 +100,38 @@ const WidgetEventEditPlain = ({ }: Props) => { useEffect(() => inMemoryFileStore.clear, []); - const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus, eventId); const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); // "Edit event"-button depends on loadedValues. Delay rendering component until loadedValues has been initialized. const loadedValues = useSelector((state: any) => state.viewEventPage.loadedValues); const orgUnit = loadedValues?.orgUnit; const occurredAt = loadedValues?.dataEntryValues?.occurredAt; const completedAt = loadedValues?.eventContainer?.event?.completedAt; + const scheduledAt = loadedValues?.eventContainer?.event?.scheduledAt; const availableProgramStages = useAvailableProgramStages(stage, teiId, enrollmentId, programId); const expiryPeriod = useProgramExpiryForUser(programId); - const { isEventReadOnly, canToggleCompletion } = useEventEditPermissions({ + const { + isEventReadOnly, canToggleCompletion, canEditProgramStage, + isEventBlockedByExpiry, isEventBlockedByCompletion, isEventCompleted, + } = useEventEditPermissions({ programId, stage, eventStatus, occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, + scheduledAtClient: scheduledAt, + }); + const readOnlyMessage = getReadOnlyMessage({ + access: { program: true, trackedEntityType: true, programStage: true }, + trackedEntityName: undefined, + multipleStages: false, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventCompleted, + canToggleCompletion, + trackedEntityInactive: false, }); return orgUnit && loadedValues ? ( @@ -140,9 +156,14 @@ const WidgetEventEditPlain = ({ stage={stage} programId={programId} orgUnit={orgUnit} + teiId={teiId} + enrollmentId={enrollmentId} setChangeLogIsOpen={setChangeLogIsOpen} readOnly={isEventReadOnly} + isEventBlockedByExpiry={isEventBlockedByExpiry} canToggleCompletion={canToggleCompletion} + canEditProgramStage={canEditProgramStage} + readOnlyMessage={readOnlyMessage} /> } noncollapsible @@ -179,7 +200,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} canToggleCompletion={canToggleCompletion} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={!isEventReadOnly} + hasDeleteButton={!isEventBlockedByExpiry} onHandleScheduleSave={onHandleScheduleSave} onSaveExternal={onSaveExternal} initialScheduleDate={initialScheduleDate} @@ -189,7 +210,9 @@ const WidgetEventEditPlain = ({ hideDueDate={stage.hideDueDate} assignee={assignee} onSaveAndCompleteEnrollmentExternal={onSaveAndCompleteEnrollment} - onSaveAndCompleteEnrollmentErrorActionType={onSaveAndCompleteEnrollmentErrorActionType} + onSaveAndCompleteEnrollmentErrorActionType={ + onSaveAndCompleteEnrollmentErrorActionType + } onSaveAndCompleteEnrollmentSuccessActionType={ onSaveAndCompleteEnrollmentSuccessActionType } 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 7e1ea458cf..7e3f4d87b9 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,8 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useState } 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, CircularLoader, IconEdit24, IconMore16, spacers } from '@dhis2/ui'; import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import i18n from '@dhis2/d2-i18n'; import { useEnrollmentEditEventPageMode } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; @@ -14,10 +15,13 @@ import { updateEnrollmentEvent, commitEnrollmentEvent, rollbackEnrollmentEvent, + deleteEnrollmentEvent, + addPersistedEnrollmentEvents, } from '../../Pages/common/EnrollmentOverviewDomain'; -import { CompletionMenuItem } from '../../EventOverflowMenu'; +import { EventOverflowMenu, DeleteMenuItemModal } from '../../EventOverflowMenu'; import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; import { pageKeys } from '../../App/withAppUrlSync'; +import { useNavigate, buildUrlQueryString } from '../../../utils/routing'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -42,16 +46,23 @@ const WidgetHeaderPlain = ({ stage, programId, orgUnit, + teiId, + enrollmentId, setChangeLogIsOpen, classes, readOnly, + isEventBlockedByExpiry, canToggleCompletion, + canEditProgramStage, + readOnlyMessage, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); + const { navigate } = useNavigate(); - const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); + const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus, eventId); const [actionsIsOpen, setActionsIsOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); @@ -75,7 +86,32 @@ const WidgetHeaderPlain = ({ dispatch(rollbackEnrollmentEvent(eventId)); }, [dispatch, eventId]); + const onSkipStatusMutate = useCallback((newStatus: string) => { + if (storedEvent) { + dispatch(updateEnrollmentEvent(eventId, { ...storedEvent, status: newStatus })); + } + }, [dispatch, storedEvent, eventId]); + + const onSkipStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); + }, [dispatch, eventId]); + + const onSkipStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + + const onDeleteEvent = useCallback((eventToDeleteId: string) => { + dispatch(deleteEnrollmentEvent(eventToDeleteId)); + navigate(`/enrollment?${buildUrlQueryString({ orgUnitId: orgUnit.id, teiId, enrollmentId })}`); + }, [dispatch, navigate, orgUnit.id, teiId, enrollmentId]); + + const onRollbackDeleteEvent = useCallback((eventDetails: ApiEnrollmentEvent) => { + dispatch(addPersistedEnrollmentEvents({ events: [eventDetails] })); + }, [dispatch]); + const { icon, name } = stage; + const pendingApiResponse = !!storedEvent?.pendingApiResponse; return ( <> @@ -94,7 +130,7 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
- {showEditButton && ( + {showEditButton && !pendingApiResponse && (
)}
+ {deleteModalOpen && storedEvent && ( + + )} ); }; 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 ac4b97af76..96e70f07a1 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 @@ -7,7 +7,12 @@ export type PlainProps = { stage: ProgramStage, programId: string, orgUnit: OrgUnit, + teiId: string, + enrollmentId: string, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, + isEventBlockedByExpiry: boolean, canToggleCompletion: boolean, + canEditProgramStage: boolean, + readOnlyMessage: string, }; 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..30059f35c1 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, IconClockHistory16, 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,6 +23,8 @@ const MenuFlyout = ({ {displayChangelog && ( } label={i18n.t('View changelog')} onClick={() => { setChangelogIsOpen(true); 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 c640b2e75d..78b58c4058 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 @@ -5,17 +5,16 @@ import { CircularLoader, DataTableCell, DataTableRow, - FlyoutMenu, IconMore16, } from '@dhis2/ui'; 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 { DeleteMenuItem, DeleteEventModal, CompletionMenuItem } from '../../../../../EventOverflowMenu'; -import { SkipAction } from './SkipAction'; +import { EventOverflowMenu, DeleteMenuItemModal } from '../../../../../EventOverflowMenu'; +import { EventChangelogWrapper } from '../../../../../WidgetEventEdit/EventChangelogWrapper'; +import { getReadOnlyMessage } from '../../../../../ReadOnlyBadge'; import { updateEnrollmentEvent, commitEnrollmentEvent, @@ -35,9 +34,6 @@ const styles: Readonly = { }, }; -const isSkippableStatus = (status?: string) => - status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; - const getRowClass = (classes: Record, disabled: boolean) => (disabled ? classes.rowDisabled : classes.row); @@ -46,24 +42,40 @@ const EventRowPlain = ({ pendingApiResponse, eventDetails, cells, - stageWriteAccess, programStage, onDeleteEvent, onRollbackDeleteEvent, - onUpdateEventStatus, programId, classes, }: EventRowProps & WithStyles) => { const [actionsOpen, setActionsOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [changelogOpen, setChangelogOpen] = useState(false); const dispatch = useDispatch(); - const { isEventReadOnly, canToggleCompletion } = useEventEditPermissions({ + const { + canToggleCompletion, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventCompleted, + canEditProgramStage, + } = useEventEditPermissions({ programId, stage: programStage, eventStatus: eventDetails.status, occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, + scheduledAtClient: convertServerToClient(eventDetails.scheduledAt, dataElementTypes.DATE) as string, + }); + const readOnlyMessage = getReadOnlyMessage({ + access: { program: true, trackedEntityType: true, programStage: canEditProgramStage }, + trackedEntityName: undefined, + multipleStages: false, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventCompleted, + canToggleCompletion, + trackedEntityInactive: false, }); const onCompletionStatusMutate = useCallback((newStatus: string) => { @@ -79,6 +91,18 @@ const EventRowPlain = ({ dispatch(rollbackEnrollmentEvent(id)); }, [dispatch, id]); + const onSkipStatusMutate = useCallback((newStatus: string) => { + dispatch(updateEnrollmentEvent(id, { ...eventDetails, status: newStatus })); + }, [dispatch, eventDetails, id]); + + const onSkipStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(id)); + }, [dispatch, id]); + + const onSkipStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(id)); + }, [dispatch, id]); + return ( - {stageWriteAccess && ( - <> - {pendingApiResponse && } - - {!pendingApiResponse && (!isEventReadOnly || canToggleCompletion) && ( - setActionsOpen(prev => !prev)} - dataTest={'overflow-button'} - secondary - small - icon={} - component={( - - {isSkippableStatus(eventDetails.status) && ( - - )} - - {canToggleCompletion && ( - setActionsOpen(false)} - /> - )} - - - - )} - /> - )} - - {deleteModalOpen && ( - - )} - - )} + <> + {pendingApiResponse && } + + {!pendingApiResponse && ( + setActionsOpen(prev => !prev)} + dataTest={'overflow-button'} + secondary + small + icon={} + component={( + setChangelogOpen(true)} + onClose={() => setActionsOpen(false)} + hideMutationActions={!canEditProgramStage} + onSkipMutate={onSkipStatusMutate} + onSkipSuccess={onSkipStatusSuccess} + onSkipError={onSkipStatusError} + onCompletionMutate={onCompletionStatusMutate} + onCompletionSuccess={onCompletionStatusSuccess} + onCompletionError={onCompletionStatusError} + onDeleteRequest={() => setDeleteModalOpen(true)} + isEventBlockedByExpiry={isEventBlockedByExpiry} + canToggleCompletion={canToggleCompletion} + readOnlyMessage={readOnlyMessage} + /> + )} + /> + )} + {deleteModalOpen && ( + + )} + {changelogOpen && programStage?.stageForm && ( + + )} + ); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts index cb2a728e23..250a4cdb49 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/EventRow.types.ts @@ -9,9 +9,7 @@ export type EventRowProps = { cells: Array; onEventClick: (id: string, options?: Record) => void; onDeleteEvent: (id: string) => void; - onUpdateEventStatus: (id: string, status: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; - stageWriteAccess: boolean; programStage?: ProgramStage | null; programId: string; }; 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 deleted file mode 100644 index c769d62db2..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import i18n from '@dhis2/d2-i18n'; -import log from 'loglevel'; -import { - MenuItem, - IconRedo16, -} from '@dhis2/ui'; -import { useMutation, useQueryClient } 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 { removeEventChangelogQueries } from '../../../../../../WidgetsChangelog'; -import { DirectionalArrow } from '../../../../../../../utils/rtl'; - -type Props = { - eventId: string; - eventDetails: ApiEnrollmentEvent; - pendingApiResponse: boolean; - onUpdateEventStatus: (eventId: string, status: string) => void; - setActionsOpen: (open: boolean) => void; -}; - -export const SkipAction = ({ - eventId, - eventDetails, - pendingApiResponse, - setActionsOpen, - onUpdateEventStatus, -}: Props) => { - const dataEngine = useDataEngine(); - const queryClient = useQueryClient(); - const { show: showError } = useAlert( - ({ message }) => message, - { critical: true }, - ); - const { mutate: updateEventStatus } = useMutation( - ({ status }: { status: string }) => dataEngine.mutate({ - resource: 'tracker?async=false&importStrategy=UPDATE', - type: 'create', - data: { - events: [ - { - ...eventDetails, - event: eventId, - status, - }, - ], - }, - }), - { - onMutate: (payload: { status: string }) => { - const status = eventStatuses[payload.status]; - const previousStatus = eventDetails.status; - - status && onUpdateEventStatus(eventId, status); - - return { previousStatus }; - }, - onSuccess: () => { - removeEventChangelogQueries(queryClient, eventId); - }, - onError: (error: unknown, payload: { status: string }, context?: { previousStatus: string }) => { - showError({ message: i18n.t('An error occurred when updating event status') }); - log.error(errorCreator('An error occurred when updating event status')({ error, payload, context })); - context && onUpdateEventStatus(eventId, context.previousStatus); - }, - }, - ); - - const handleMenuItemClick = (status: string) => { - setActionsOpen(false); - !pendingApiResponse && updateEventStatus({ status }); - }; - - if (eventDetails.status === eventStatuses.SKIPPED) { - return ( - } - label={i18n.t('Unskip')} - onClick={() => handleMenuItemClick(eventStatuses.SCHEDULE)} - suffix={null} - /> - ); - } - - return ( - } - label={i18n.t('Skip')} - onClick={() => handleMenuItemClick(eventStatuses.SKIPPED)} - suffix={null} - /> - ); -}; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts deleted file mode 100644 index f10a0f33ca..0000000000 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SkipAction } from './SkipAction'; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx index 919e35251b..360317ecfb 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/StageDetail.component.tsx @@ -94,7 +94,6 @@ const StageDetailPlain = (props: Props & WithStyles) => { enableUserAssignment = false, onEventClick, onDeleteEvent, - onUpdateEventStatus, onRollbackDeleteEvent, onViewAll, onCreateNew, @@ -199,14 +198,12 @@ const StageDetailPlain = (props: Props & WithStyles) => { id={row.id as string} pendingApiResponse={row.pendingApiResponse as boolean} eventDetails={eventDetails} - stageWriteAccess={stageWriteAccess} programStage={stage} programId={programId} cells={cells} onEventClick={onEventClick} onDeleteEvent={onDeleteEvent} onRollbackDeleteEvent={onRollbackDeleteEvent} - onUpdateEventStatus={onUpdateEventStatus} /> ); }); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts index aa93f0fbcb..8ef248e23d 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/stageDetail.types.ts @@ -11,7 +11,6 @@ type ExtractedProps = { stageId: string; onCreateNew: (stageId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (event: ApiEnrollmentEvent) => void; hiddenProgramStage?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts index f1f0b30787..e72b18aaf0 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/stage.types.ts @@ -7,7 +7,6 @@ type ExtractedProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts index 581b141919..5b7e783b17 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/stages.types.ts @@ -6,7 +6,6 @@ export type PlainProps = { events: Array; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; @@ -15,6 +14,5 @@ export type InputProps = { events?: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; } & StageCommonProps; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts index ac9dc3e5c2..9f080f5965 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/stagesAndEvents.types.ts @@ -6,7 +6,6 @@ type ExtractedProps = { events: Array | null; onEventClick: (eventId: string) => void; onDeleteEvent: (eventId: string) => void; - onUpdateEventStatus: (eventId: string, status: string) => void; onRollbackDeleteEvent: (eventId: ApiEnrollmentEvent) => void; className?: string; }; diff --git a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts index a0359b28e8..b355798dd9 100644 --- a/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts +++ b/src/core_modules/capture-core/hooks/useEnrollmentEditEventPageMode.ts @@ -1,21 +1,29 @@ import { useSelector } from 'react-redux'; -import { useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { statusTypes } from '../events/statusTypes'; import { useLocationQuery } from '../utils/routing'; -export const useEnrollmentEditEventPageMode = (eventStatus?: string) => { +const isScheduledStatus = (status?: string) => + status === statusTypes.SCHEDULE || status === statusTypes.OVERDUE; + +export const useEnrollmentEditEventPageMode = (eventStatus?: string, eventId?: string) => { const showEditEvent = useSelector(({ viewEventPage }: any) => viewEventPage?.eventDetailsSection?.showEditEvent); const { initMode } = useLocationQuery(); + const initialStatusRef = useRef<{ eventId?: string; status?: string }>({}); + if (eventId && eventStatus !== undefined && initialStatusRef.current.eventId !== eventId) { + initialStatusRef.current = { eventId, status: eventStatus }; + } + const landedAsSkipped = + initialStatusRef.current.status === statusTypes.SKIPPED + && eventStatus === statusTypes.SKIPPED; + return useMemo(() => { - if (initMode) { - return { currentPageMode: initMode }; - } + if (initMode) return { currentPageMode: initMode }; + + const shouldEdit = showEditEvent || (isScheduledStatus(eventStatus) && !landedAsSkipped); - if (eventStatus === statusTypes.SCHEDULE || eventStatus === statusTypes.OVERDUE) { - return { currentPageMode: dataEntryKeys.EDIT }; - } - return { currentPageMode: showEditEvent ? dataEntryKeys.EDIT : dataEntryKeys.VIEW }; - }, [initMode, showEditEvent, eventStatus]); + return { currentPageMode: shouldEdit ? dataEntryKeys.EDIT : dataEntryKeys.VIEW }; + }, [initMode, showEditEvent, eventStatus, landedAsSkipped]); }; diff --git a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts index 22317ad8ab..e073ced286 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -14,14 +14,14 @@ type Input = { eventStatus?: string, occurredAtClient?: string, completedAtClient?: string, + scheduledAtClient?: string, }; -type Output = { - isEventBlockedByExpiry: boolean, - isEventBlockedByCompletion: boolean, - isEventReadOnly: boolean, - canToggleCompletion: boolean, -}; +const checkWithinValidPeriod = ( + referenceDate: string | undefined, + expiryPeriod: any, +): boolean => + (referenceDate ? isValidPeriod(referenceDate, expiryPeriod).isWithinValidPeriod : true); const computeExpiryBlocked = ( isWithinValidPeriod: boolean, @@ -46,10 +46,12 @@ const computeIsEventReadOnly = ( canEditProgramStage: boolean, isEventBlockedByExpiry: boolean, isCompletedAndBlockingForm: boolean, + eventStatus?: string, ): boolean => !canEditProgramStage || isEventBlockedByExpiry - || isCompletedAndBlockingForm; + || isCompletedAndBlockingForm + || eventStatus === eventStatuses.SKIPPED; export const useEventEditPermissions = ({ programId, @@ -57,34 +59,53 @@ export const useEventEditPermissions = ({ eventStatus, occurredAtClient, completedAtClient, -}: Input): Output => { - const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); + scheduledAtClient, +}: Input) => { + // Expiry const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); - const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); - - const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const expiryReferenceDate = occurredAtClient || scheduledAtClient; + const isWithinValidPeriod = checkWithinValidPeriod(expiryReferenceDate, expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); const isEventBlockedByExpiry = computeExpiryBlocked( isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); - const canEditProgramStage = !!eventAccess?.write; - const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + // Program stage Access + const stageAccess = getProgramEventAccess(programId, stage?.id ?? null); + const canEditProgramStage = !!stageAccess?.write; + // Status + const isEventCompleted = eventStatus === eventStatuses.COMPLETED; + const isEventOverdueOrScheduled = + eventStatus === eventStatuses.OVERDUE || eventStatus === eventStatuses.SCHEDULE; + const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && isEventCompleted); + + // Completion + const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); const canToggleCompletion = computeCanToggleCompletion( canEditProgramStage, isEventBlockedByExpiry, hasUncompleteAuthority, eventStatus, ); const isEventBlockedByCompletion = isCompletedAndBlockingForm && !hasUncompleteAuthority; + + // Overall const isEventReadOnly = computeIsEventReadOnly( - canEditProgramStage, isEventBlockedByExpiry, isCompletedAndBlockingForm, + canEditProgramStage, isEventBlockedByExpiry, isCompletedAndBlockingForm, eventStatus, ); return { + // Program stage Access + canEditProgramStage, + // Expiry isEventBlockedByExpiry, + // Completion + canToggleCompletion, isEventBlockedByCompletion, + // Status + isEventCompleted, + isEventOverdueOrScheduled, + // Overall isEventReadOnly, - canToggleCompletion, }; }; diff --git a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx index b2ac6a0190..c7ec78cdeb 100644 --- a/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx +++ b/src/core_modules/capture-core/utils/rtl/DirectionalArrow.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { IconArrowRight16, IconArrowLeft16, type IconProps } from '@dhis2/ui'; import { systemSettingsStore } from '../../metaDataMemoryStores/systemSettings/systemSettings.store'; -export const DirectionalArrow = (props: IconProps): React.ReactElement => { +type Props = IconProps & { reverse?: boolean }; + +export const DirectionalArrow = ({ reverse, ...props }: Props): React.ReactElement => { const rtl = systemSettingsStore.get()?.dir === 'rtl'; + const pointLeft = reverse ? !rtl : rtl; - if (rtl) { - return ; - } - return ; + return pointLeft ? : ; };