diff --git a/i18n/en.pot b/i18n/en.pot index 33b83a6757..80273eb220 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-12T11:21:02.033Z\n" -"PO-Revision-Date: 2026-08-12T11:21:02.033Z\n" +"POT-Creation-Date: 2026-08-15T20:00:40.436Z\n" +"PO-Revision-Date: 2026-08-15T20:00:40.436Z\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 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" + +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 "Delete" +msgstr "Delete" + msgid "No events to display" msgstr "No events to display" @@ -572,9 +599,6 @@ msgstr "Uploading file" msgid "selected" msgstr "selected" -msgid "Delete" -msgstr "Delete" - msgid "Select file" msgstr "Select file" @@ -1356,9 +1380,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" @@ -1500,18 +1521,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" @@ -1760,30 +1772,12 @@ 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 "An error occurred when updating event status" -msgstr "An error occurred when updating event status" - msgid "Unskip" 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" @@ -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/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/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx new file mode 100644 index 0000000000..061a542589 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import log from 'loglevel'; +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 { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; + +type Props = { + eventId: string; + eventStatus?: string; + onMutate?: (newStatus: string) => void; + onSuccess?: (newStatus: string) => void; + onError?: () => void; + onClose: () => void; +}; + +export const CompletionMenuItem = ({ + eventId, + eventStatus, + onMutate, + onSuccess, + onError, + onClose, +}: Props) => { + const dataEngine = useDataEngine(); + const { show: showError } = useAlert( + ({ message }) => message, + { critical: true }, + ); + + const isCompleted = eventStatus === eventStatuses.COMPLETED; + const newStatus = isCompleted ? eventStatuses.ACTIVE : eventStatuses.COMPLETED; + + const { mutate: updateCompletionStatus } = useMutation( + async () => { + const { event: apiEvent } = await dataEngine.query({ + event: { + resource: 'tracker/events', + id: eventId, + params: { + fields: 'event,status,program,programStage,orgUnit,occurredAt,scheduledAt,' + + 'enrollment,trackedEntity,attributeOptionCombo,notes,assignedUser,geometry,followUp', + }, + }, + }) 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: () => { + onSuccess?.(newStatus); + }, + }, + ); + + return ( + : } + label={isCompleted ? i18n.t('Mark incomplete') : i18n.t('Mark complete')} + suffix={null} + onClick={() => { + onClose(); + updateCompletionStatus(); + }} + /> + ); +}; 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/MenuItems/DeleteEventModal.tsx similarity index 94% 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/MenuItems/DeleteEventModal.tsx index 2883536f0f..64294ce199 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/DeleteActionModal/DeleteActionModal.tsx +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/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/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx new file mode 100644 index 0000000000..b37bfbffca --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/DeleteMenuItem.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { + colors, + IconDelete16, + MenuItem, +} from '@dhis2/ui'; + +type Props = { + setActionsOpen: (open: boolean) => void; + setDeleteModalOpen: (open: boolean) => void; +}; + +export const DeleteActionButton = ({ + setActionsOpen, + setDeleteModalOpen, +}: Props) => ( + } + label={i18n.t('Delete')} + dataTest="stages-and-events-delete" + onClick={() => { + setDeleteModalOpen(true); + setActionsOpen(false); + }} + suffix={null} + /> +); 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..eacbb89fff --- /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 { 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 new file mode 100644 index 0000000000..d0e2d93630 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -0,0 +1 @@ +export { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from './MenuItems'; 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/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.container.tsx index 6339759652..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 @@ -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,11 +266,10 @@ const EnrollmentEditEventPageWithContextPlain = ({ const outputEffects = useWidgetDataFromStore(dataEntryKey); + const eventAccess = getProgramEventAccess(programId, stageId ?? null); const { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, + isEventBlockedByExpiry, + isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -313,9 +312,8 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - isEventWithinValidPeriod={isEventWithinValidPeriod} - canEditCompletedEvent={canEditCompletedEvent} - isWithinCompleteEventsExpiry={isWithinCompleteExpiry} + isEventBlockedByExpiry={isEventBlockedByExpiry} + isEventBlockedByCompletion={isEventBlockedByCompletion} > ({ @@ -81,7 +82,14 @@ 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 { 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 bbee70a42a..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 @@ -98,10 +98,9 @@ export const ViewEventPlain = (props: Props & WithStyles) => { const completedAt = useSelector((state: any) => state.viewEventPage.loadedValues?.eventContainer?.event?.completedAt); const { - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, } = 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 && !isEventReadOnly; 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..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 @@ -18,9 +18,8 @@ export type EnrollmentAccessContextValue = { showWidgetBadge: boolean; trackedEntityInactive: boolean; canToggleTrackedEntityStatus: boolean; - isEventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; }; const fallback: EnrollmentAccessContextValue = { @@ -37,6 +36,8 @@ const fallback: EnrollmentAccessContextValue = { showWidgetBadge: true, trackedEntityInactive: false, canToggleTrackedEntityStatus: false, + isEventBlockedByExpiry: false, + isEventBlockedByCompletion: false, }; const Context = createContext(fallback); @@ -45,9 +46,8 @@ type ProviderProps = { program?: TrackerProgram; currentStageId?: string; trackedEntityInactive?: boolean; - isEventWithinValidPeriod?: boolean; - canEditCompletedEvent?: boolean; - isWithinCompleteEventsExpiry?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; children: React.ReactNode; }; @@ -66,9 +66,8 @@ const computeContextValue = ( program: TrackerProgram, currentStageId: string | undefined, trackedEntityInactive: boolean, - isEventWithinValidPeriod?: boolean, - canEditCompletedEvent?: boolean, - isWithinCompleteEventsExpiry?: boolean, + isEventBlockedByExpiry?: boolean, + isEventBlockedByCompletion?: boolean, ): EnrollmentAccessContextValue => { const { rawStageWriteAccessById, stageReadAccessById } = buildStageAccessMaps(program); const rawProgramWriteAccess = Boolean(program.access?.data?.write); @@ -101,9 +100,8 @@ const computeContextValue = ( showWidgetBadge: !isEventPage && !allWriteAccessMissing, trackedEntityInactive, canToggleTrackedEntityStatus: rawTrackedEntityTypeWriteAccess, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, }; }; @@ -111,9 +109,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive = false, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, children, }: ProviderProps) => { const value = useMemo( @@ -122,9 +119,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, ) : { ...fallback, @@ -142,9 +138,8 @@ export const EnrollmentAccessProvider = ({ program, currentStageId, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + 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 bb2e96cedb..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 @@ -12,18 +12,16 @@ export const EnrollmentReadOnlyBadge = () => { anyStageReadAccess, trackedEntityTypeName, trackedEntityInactive, - isEventWithinValidPeriod, - canEditCompletedEvent, - isWithinCompleteEventsExpiry, + isEventBlockedByExpiry, + isEventBlockedByCompletion, } = 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 (!canEditCompletedEvent) return getCompletedEventMessage(); - if (!withinCompleteEventsExpiry) return getExpiredMessage(); + if (isEventBlockedByExpiry) return getExpiredMessage(); + if (isEventBlockedByCompletion) return getCompletedEventMessage(); return ''; }; @@ -56,9 +54,8 @@ const ReadOnlyBadgePlain = ({ programWriteAccess = true, trackedEntityTypeWriteAccess = true, programStageWriteAccess = true, - eventWithinValidPeriod = true, - canEditCompletedEvent = true, - withinCompleteEventsExpiry = true, + isEventBlockedByExpiry = false, + isEventBlockedByCompletion = false, multipleStages = false, trackedEntityName, trackedEntityInactive = false, @@ -74,9 +71,8 @@ const ReadOnlyBadgePlain = ({ access, trackedEntityName, multipleStages, - eventWithinValidPeriod, - canEditCompletedEvent, - withinCompleteEventsExpiry, + isEventBlockedByExpiry, + 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 a7ada9cb08..9c36730417 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; - canEditCompletedEvent?: boolean; - withinCompleteEventsExpiry?: boolean; + isEventBlockedByExpiry?: boolean; + isEventBlockedByCompletion?: boolean; multipleStages?: boolean; trackedEntityName?: string; trackedEntityInactive?: boolean; @@ -21,8 +20,7 @@ export type ReadOnlyMessageInput = { access: Access; trackedEntityName: string | undefined; multipleStages: boolean; - eventWithinValidPeriod: boolean; - canEditCompletedEvent: boolean; - withinCompleteEventsExpiry: boolean; + isEventBlockedByExpiry: boolean; + isEventBlockedByCompletion: 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..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,7 +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 { useAuthorities } from '../../../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../../../utils/authority'; export const Actions = ({ enrollment = {}, @@ -21,7 +21,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/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 a14687d4aa..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 @@ -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,9 +323,7 @@ const buildCompleteFieldSettingsFn = () => { withDisplayMessages()( withInternalChangeHandler()( withConditionalTooltip((props: any) => { - const isEventCompleted = props.eventStatus === eventStatuses.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canUncompleteEvent; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -341,9 +338,7 @@ const buildCompleteFieldSettingsFn = () => { const completeSettings = { getComponent: () => completeComponent, getComponentProps: (props: any) => { - const isEventCompleted = props.eventStatus === eventStatuses.COMPLETED; - const canUncompleteEvent = props.canUncompleteEvent; - const shouldDisable = isEventCompleted && !canUncompleteEvent; + const shouldDisable = !props.canUncompleteEvent; return createComponentProps(props, { label: i18n.t('Complete event'), @@ -585,7 +580,8 @@ class EditEventDataEntryPlain extends Component @@ -133,12 +136,14 @@ const WidgetEventEditPlain = ({ } noncollapsible @@ -175,7 +180,7 @@ const WidgetEventEditPlain = ({ eventStatus={eventStatus} canUncompleteEvent={canUncompleteEvent} onCancelEditEvent={onCancelEditEvent} - hasDeleteButton={!readOnly} + 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 086660bb88..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 @@ -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'; @@ -10,6 +10,14 @@ import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; import { useCategoryCombinations } from '../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { OverflowButton } from '../../Buttons'; import { inMemoryFileStore } from '../../DataEntry/file/inMemoryFileStore'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../../Pages/common/EnrollmentOverviewDomain'; +import { CompletionMenuItem } from '../../EventOverflowMenu'; +import { changeEventFromUrl } from '../../Pages/ViewEvent/ViewEventComponent/viewEvent.actions'; +import { pageKeys } from '../../App/withAppUrlSync'; import type { PlainProps } from './WidgetHeader.types'; const styles: Readonly = { @@ -29,6 +37,7 @@ const styles: Readonly = { type Props = PlainProps & WithStyles; const WidgetHeaderPlain = ({ + eventId, eventStatus, stage, programId, @@ -36,6 +45,7 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, + canUncompleteEvent, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); @@ -46,6 +56,25 @@ const WidgetHeaderPlain = ({ const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); + const storedEvent = useSelector((state: any) => + state.enrollmentDomain?.enrollment?.events?.find((event: any) => event.event === eventId)); + + const onCompletionStatusMutate = useCallback((newStatus: string) => { + if (storedEvent) { + const { completedAt, ...eventWithoutCompletion } = storedEvent; + dispatch(updateEnrollmentEvent(eventId, { ...eventWithoutCompletion, status: newStatus })); + } + }, [dispatch, storedEvent, eventId]); + + const onCompletionStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(eventId)); + dispatch(changeEventFromUrl(eventId, pageKeys.ENROLLMENT_EVENT)); + }, [dispatch, eventId]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(eventId)); + }, [dispatch, eventId]); + const { icon, name } = stage; return ( @@ -90,6 +119,16 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > + {canUncompleteEvent && ( + setActionsIsOpen(false)} + /> + )} void, readOnly: boolean, + canUncompleteEvent: boolean, }; 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/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/OverflowMenu.container.tsx index 6fa5973130..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,5 +1,6 @@ import React from 'react'; -import { useAuthorities } from 'capture-core/utils/authority/useAuthorities'; + +import { useAuthority, Authorities } from '../../../utils/authority'; import type { Props } from './OverflowMenu.types'; import { OverflowMenuComponent } from './OverflowMenu.component'; @@ -18,7 +19,7 @@ export const OverflowMenu = ({ programAPI, readOnlyMode, }: Props) => { - const { hasAuthority } = useAuthorities({ authorities: ['F_TEI_CASCADE_DELETE'] }); + const { hasAuthority } = useAuthority(Authorities.TEI_CASCADE_DELETE); return ( 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 { - isEventWithinValidPeriod, - canEditCompletedEvent, - readOnly, - } = 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 (!canEditCompletedEvent) { - 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="" - /> - - ); -}; 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..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 @@ -1,16 +1,27 @@ -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 { + 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 { DeleteActionButton } from './DeleteActionButton'; +import { DeleteActionButton, DeleteActionModal, CompletionMenuItem } from '../../../../../EventOverflowMenu'; import { SkipAction } from './SkipAction'; -import { DeleteActionModal } from './DeleteActionModal'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../../../../../Pages/common/EnrollmentOverviewDomain'; + const styles: Readonly = { row: { @@ -24,12 +35,16 @@ const styles: Readonly = { }, }; -export const EventStatuses = { - ACTIVE: 'ACTIVE', - COMPLETED: 'COMPLETED', - SKIPPED: 'SKIPPED', - SCHEDULE: 'SCHEDULE', -}; +const isSkippableStatus = (status?: string) => + status === eventStatuses.SCHEDULE || status === eventStatuses.SKIPPED; + +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, @@ -46,10 +61,37 @@ const EventRowPlain = ({ }: EventRowProps & WithStyles) => { const [actionsOpen, setActionsOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const dispatch = useDispatch(); + + 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, ...eventWithoutCompletion } = eventDetails; + dispatch(updateEnrollmentEvent(id, { ...eventWithoutCompletion, status: newStatus })); + }, [dispatch, eventDetails, id]); + + const onCompletionStatusSuccess = useCallback(() => { + dispatch(commitEnrollmentEvent(id)); + }, [dispatch, id]); + + const onCompletionStatusError = useCallback(() => { + dispatch(rollbackEnrollmentEvent(id)); + }, [dispatch, id]); return ( {cells} @@ -57,42 +99,50 @@ const EventRowPlain = ({ {stageWriteAccess && ( <> - setActionsOpen(prev => !prev)} - dataTest={'overflow-button'} - secondary - small - icon={} - disabled={pendingApiResponse} - component={( - - {(eventDetails.status === EventStatuses.SCHEDULE || - eventDetails.status === EventStatuses.SKIPPED) && ( - } + + {!pendingApiResponse && (!isEventReadOnly || canToggleCompletion) && ( + setActionsOpen(prev => !prev)} + dataTest={'overflow-button'} + secondary + small + icon={} + component={( + + {isSkippableStatus(eventDetails.status) && ( + + )} + + {canToggleCompletion && ( + setActionsOpen(false)} + /> + )} + + - )} - - - - )} - /> + + )} + /> + )} {deleteModalOpen && ( { - 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="" /> ); 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 ada5786b24..919e35251b 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 @@ -14,7 +14,6 @@ import { DataTableCell, DataTableColumnHeader, Button, - Tooltip, } from '@dhis2/ui'; import log from 'loglevel'; import { errorCreator } from 'capture-core-utils'; @@ -178,35 +177,14 @@ const StageDetailPlain = (props: Props & WithStyles) => { .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); diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/helpers.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/helpers.ts index 511dc5dcab..32c630b3ca 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/helpers.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/hooks/helpers.ts @@ -55,8 +55,8 @@ const getValueByKeyFromEvent = (event: ApiEnrollmentEvent, { id, resolveValue }: const convertStatusForView = (event: ApiEnrollmentEvent) => { 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/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/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx b/src/core_modules/capture-core/components/WorkingLists/TrackerWorkingLists/TrackedEntityBulkActions/Actions/DeleteEnrollmentsAction/DeleteEnrollmentsAction.tsx index f6d1f5051a..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,7 +1,7 @@ import React, { useState } from 'react'; import i18n from '@dhis2/d2-i18n'; import { Button } from '@dhis2/ui'; -import { useAuthority } from '../../../../../../utils/userInfo/useAuthority'; +import { useAuthority, Authorities } from '../../../../../../utils/authority'; import { EnrollmentDeleteModal } from './EnrollmentDeleteModal'; import { ConditionalTooltip } from '../../../../../Tooltips/ConditionalTooltip'; import type { PlainProps } from './DeleteEnrollmentsAction.types'; @@ -16,8 +16,6 @@ const getTooltipContent = (programDataWriteAccess: boolean, bulkDataEntryIsActiv return ''; }; -const CASCADE_DELETE_TEI_AUTHORITY = 'F_ENROLLMENT_CASCADE_DELETE'; - export const DeleteEnrollmentsAction = ({ selectedRows, programDataWriteAccess, @@ -26,7 +24,7 @@ export const DeleteEnrollmentsAction = ({ bulkDataEntryIsActive, }: PlainProps) => { 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..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,13 +1,10 @@ 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, Authorities } from '../../../../../../utils/authority'; 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 +13,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/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) => ({ diff --git a/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts b/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts new file mode 100644 index 0000000000..79a9137480 --- /dev/null +++ b/src/core_modules/capture-core/hooks/computeCanUncompleteEvent.ts @@ -0,0 +1,22 @@ +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/useCompleteEventsExpiryForUser.ts b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts index d1834547ec..ba5d018b1b 100644 --- a/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useCompleteEventsExpiryForUser.ts @@ -1,9 +1,9 @@ import { useMemo } from 'react'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; 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 4eb20023de..1f013a17be 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -1,9 +1,13 @@ 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 { useAuthorities } from '../utils/authority/useAuthorities'; +import { getProgramEventAccess, type ProgramStage } from '../metaData'; +import { + isValidPeriod, + isWithinCompleteEventsExpiry, +} 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, @@ -14,22 +18,11 @@ type Input = { }; type Output = { - eventAccess: { read: boolean, write: boolean } | null, - isEventWithinValidPeriod: boolean, - isWithinCompleteExpiry: boolean, - canEditCompletedEvent: boolean, - canUncompleteEvent: boolean, - expiryPeriod: ReturnType, - readOnly: boolean, + isEventBlockedByExpiry: boolean, + isEventBlockedByCompletion: boolean, + isEventReadOnly: 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, @@ -40,29 +33,32 @@ 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: canEditExpired } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); - - const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const { hasAuthority: hasUncompleteAuthority } = 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 isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + const isEventBlockedByExpiry = isExpired && !hasEditExpiredAuthority; + + const canUncompleteEvent = computeCanUncompleteEvent({ + hasWriteAccess: !!eventAccess?.write, + eventStatus, + isExpired, + hasEditExpiredAuthority, + hasUncompleteAuthority, + }); - const canEditCompletedEvent = canEditExpired || !( - stage?.blockEntryForm - && eventStatus === eventStatuses.COMPLETED - ); + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !canUncompleteEvent; - const readOnly = !eventAccess?.write - || !isEventWithinValidPeriod - || !isWithinCompleteExpiry - || !canEditCompletedEvent; + const isEventReadOnly = !eventAccess?.write + || isEventBlockedByExpiry + || isCompletedAndBlockingForm; return { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - canUncompleteEvent, - expiryPeriod, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, }; }; diff --git a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts index cb4becebe2..4a599762ce 100644 --- a/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts +++ b/src/core_modules/capture-core/hooks/useProgramExpiryForUser.ts @@ -1,10 +1,10 @@ import { useMemo } from 'react'; import { serverToClientExpiryPeriod } from '../converters/serverToClientExpiryPeriod'; -import { useAuthorities } from '../utils/authority/useAuthorities'; +import { useAuthority, Authorities } from '../utils/authority'; 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/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'; 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), - }; -};