diff --git a/i18n/en.pot b/i18n/en.pot index 290dc3804b..28de816a18 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-09-14T12:18:16.706Z\n" -"PO-Revision-Date: 2026-09-14T12:18:16.707Z\n" +"POT-Creation-Date: 2026-09-15T13:58:33.293Z\n" +"PO-Revision-Date: 2026-09-15T13:58:33.293Z\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..8bdca033f7 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/CompletionMenuItem.tsx @@ -0,0 +1,184 @@ +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 { useDispatch } from 'react-redux'; +import { errorCreator } from 'capture-core-utils'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; +import { CompleteModal } from '../../DataEntries/common/trackerEvent/withAskToCompleteEnrollment/CompleteModal'; +import { statusTypes as enrollmentStatuses } from '../../../enrollment'; +import { + updateEnrollmentAndEvents, + commitEnrollmentAndEvents, + rollbackEnrollmentAndEvents, + setExternalEnrollmentStatus, +} from '../../Pages/common/EnrollmentOverviewDomain'; + +const updateEventStatus = async ( + dataEngine: any, + eventId: string, + targetStatus: string, + extra?: { additionalEvents?: any[]; enrollment?: any }, +) => { + 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: targetStatus }, ...(extra?.additionalEvents ?? [])], + ...(extra?.enrollment && { enrollments: [extra.enrollment] }), + }, + }); +}; + +type MenuItemProps = { + eventId: string; + eventStatus?: string; + onMutate?: (newStatus: string) => void; + onSuccess?: (newStatus: string) => void; + onError?: () => void; + onClose: () => void; + askCompleteEnrollmentOnEventComplete?: boolean; + onAskCompleteEnrollment?: () => void; +}; + +export const CompletionMenuItem = ({ + eventId, + eventStatus, + onMutate, + onSuccess, + onError, + onClose, + askCompleteEnrollmentOnEventComplete, + onAskCompleteEnrollment, +}: MenuItemProps) => { + 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( + () => updateEventStatus(dataEngine, eventId, newStatus), + { + onMutate: () => onMutate?.(newStatus), + onError: (error) => { + 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(); + if (!isCompleted && askCompleteEnrollmentOnEventComplete && onAskCompleteEnrollment) { + onAskCompleteEnrollment(); + } else { + updateCompletionStatus(); + } + }} + /> + ); +}; + +type ModalProps = { + eventId: string; + enrollment: any; + programStageName?: string; + onClose: () => void; + onMutate?: (newStatus: string) => void; + onSuccess?: (newStatus: string) => void; + onError?: () => void; +}; + +export const CompleteMenuItemModal = ({ + eventId, + enrollment, + programStageName, + onClose, + onMutate, + onSuccess, + onError, +}: ModalProps) => { + const dataEngine = useDataEngine(); + const dispatch = useDispatch(); + const { show: showError } = useAlert(({ message }) => message, { critical: true }); + + const handleError = (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 })); + }; + + const { mutate: completeEventOnly } = useMutation( + () => updateEventStatus(dataEngine, eventId, eventStatuses.COMPLETED), + { + onMutate: () => onMutate?.(eventStatuses.COMPLETED), + onError: (error) => { handleError(error); onError?.(); }, + onSuccess: () => onSuccess?.(eventStatuses.COMPLETED), + }, + ); + + const { mutate: completeEventAndEnrollment } = useMutation( + (updatedEnrollment: any) => { + const { events: additionalEvents = [], ...enrollmentPayload } = updatedEnrollment; + return updateEventStatus(dataEngine, eventId, eventStatuses.COMPLETED, { + additionalEvents, + enrollment: enrollmentPayload, + }); + }, + { + onMutate: (updatedEnrollment) => { + const currentEvent = enrollment.events?.find((e: any) => e.event === eventId) ?? {}; + dispatch(setExternalEnrollmentStatus(enrollmentStatuses.COMPLETED)); + dispatch(updateEnrollmentAndEvents({ + ...updatedEnrollment, + events: [ + { ...currentEvent, event: eventId, status: eventStatuses.COMPLETED }, + ...(updatedEnrollment.events ?? []), + ], + })); + }, + onError: (error) => { + handleError(error); + dispatch(rollbackEnrollmentAndEvents()); + }, + onSuccess: () => { + dispatch(commitEnrollmentAndEvents()); + }, + }, + ); + + const events = enrollment.events ?? []; + return ( + event.status === eventStatuses.ACTIVE)} + programStageName={programStageName ?? ''} + onCancel={() => { onClose(); completeEventOnly(); }} + onCompleteEnrollment={(updated: any) => { onClose(); completeEventAndEnrollment(updated); }} + /> + ); +}; 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 93% 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..c5731ba439 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 @@ -16,7 +16,7 @@ type Props = { setDeleteModalOpen: (open: boolean) => void; }; -export const DeleteActionModal = ({ +export const DeleteEventModal = ({ setDeleteModalOpen, pendingApiResponse, eventId, @@ -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..3360cdaf3f --- /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 DeleteMenuItem = ({ + 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..503f53cea8 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/MenuItems/index.ts @@ -0,0 +1,3 @@ +export { DeleteMenuItem } from './DeleteMenuItem'; +export { DeleteEventModal } from './DeleteEventModal'; +export { CompletionMenuItem, CompleteMenuItemModal } 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..d07b5cdbe1 --- /dev/null +++ b/src/core_modules/capture-core/components/EventOverflowMenu/index.ts @@ -0,0 +1 @@ +export { DeleteMenuItem, DeleteEventModal, CompletionMenuItem, CompleteMenuItemModal } 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 c19b3f034e..9e7af510c8 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 @@ -9,10 +9,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; @@ -28,7 +27,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.component.tsx b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.component.tsx index 7dbf6e1daa..7cc2e1e799 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.component.tsx +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/EnrollmentEditEventPage.component.tsx @@ -48,6 +48,7 @@ export const EnrollmentEditEventPageComponent = ({ onUpdateEnrollmentStatusError, onUpdateEnrollmentStatusSuccess, onSaveAndCompleteEnrollment, + widgetEnrollmentStatus, onCancelEditEvent, onHandleScheduleSave, onSaveExternal, @@ -113,6 +114,7 @@ export const EnrollmentEditEventPageComponent = ({ onUpdateEnrollmentStatusError={onUpdateEnrollmentStatusError} onUpdateEnrollmentStatusSuccess={onUpdateEnrollmentStatusSuccess} onSaveAndCompleteEnrollment={onSaveAndCompleteEnrollment} + widgetEnrollmentStatus={widgetEnrollmentStatus} onSaveAndCompleteEnrollmentSuccessActionType={actionTypes.EVENT_SAVE_ENROLLMENT_COMPLETE_SUCCESS} onSaveAndCompleteEnrollmentErrorActionType={actionTypes.EVENT_SAVE_ENROLLMENT_COMPLETE_ERROR} events={events} 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 510613469f..2bf7cf62df 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 @@ -52,7 +52,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); @@ -144,6 +144,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ const { navigate } = useNavigate(); const dispatch = useDispatch(); const queryClient = useQueryClient(); + const { status: widgetEnrollmentStatus } = useSelector(({ widgetEnrollment }: any) => widgetEnrollment); const { pageLayout, isLoading } = useEnrollmentPageLayout({ selectedScopeId: programId, dataStoreKey: DataStoreKeyByPage.ENROLLMENT_EVENT_EDIT, @@ -279,11 +280,10 @@ const EnrollmentEditEventPageWithContextPlain = ({ const outputEffects = useWidgetDataFromStore(dataEntryKey); + const eventAccess = getProgramEventAccess(programId, stageId ?? null); const { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, + isEventBlockedByExpiry, + isEventBlockedByCompletion, } = useEventEditPermissions({ programId, stage: programStage, @@ -326,9 +326,8 @@ const EnrollmentEditEventPageWithContextPlain = ({ program={program} currentStageId={stageId} trackedEntityInactive={trackedEntityInactive} - isEventWithinValidPeriod={isEventWithinValidPeriod} - canEditCompletedEvent={canEditCompletedEvent} - isWithinCompleteEventsExpiry={isWithinCompleteExpiry} + isEventBlockedByExpiry={isEventBlockedByExpiry} + isEventBlockedByCompletion={isEventBlockedByCompletion} > void; pageStatus: string; eventStatus?: string; + widgetEnrollmentStatus?: { value: string | null }; eventAccess: { read: boolean; write: boolean; diff --git a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx index c33d38c51f..3dd42407a5 100644 --- a/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx +++ b/src/core_modules/capture-core/components/Pages/ViewEvent/EventDetailsSection/EventDetailsSection.component.tsx @@ -24,8 +24,9 @@ import { ReactQueryAppNamespace } from '../../../../utils/reactQueryHelpers'; import { CHANGELOG_ENTITY_TYPES } from '../../../WidgetsChangelog'; import { useCategoryCombinations } from '../../../DataEntryDhis2Helpers/AOC/useCategoryCombinations'; import { useMetadataForProgramStage } from '../../../DataEntries/common/ProgramStage/useMetadataForProgramStage'; -import { useProgramExpiryForUser } from '../../../../hooks'; -import { useAuthorities } from '../../../../utils/authority/useAuthorities'; +import { useProgramExpiryForUser, useEventEditPermissions } from '../../../../hooks'; +import { convertFormToClient } from '../../../../converters'; +import { dataElementTypes } from '../../../../metaData'; import type { PlainProps } from './EventDetailsSection.types'; const getStyles: any = () => ({ @@ -81,7 +82,13 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { const [changeLogIsOpen, setChangeLogIsOpen] = useState(false); const [actionsIsOpen, setActionsIsOpen] = useState(false); const expiryPeriod = useProgramExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const { canToggleCompletion } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus: eventData?.eventContainer?.event?.status, + occurredAtClient: convertFormToClient(eventData?.dataEntryValues?.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: eventData?.eventContainer?.event?.completedAt, + }); const onSaveExternal = useCallback(() => { const queryKey = [ReactQueryAppNamespace, 'changelog', CHANGELOG_ENTITY_TYPES.EVENT, eventId]; @@ -103,7 +110,7 @@ const EventDetailsSectionPlain = (props: PlainProps & { classes: any }) => { onSaveExternal={onSaveExternal} expiryPeriod={expiryPeriod} programId={programId} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} {...passOnProps} /> : ) => { 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,10 @@ export const ViewEventPlain = (props: Props & WithStyles) => { occurredAtClient: convertFormToClient(occurredAt, dataElementTypes.DATE) as string, completedAtClient: completedAt, }); - const showEditButton = !isEditEventPage && !readOnly; + // TODO: Restore `!isEventReadOnly` when DHIS2-21921 lands. + // Until then, single-event uncomplete still goes through the "Edit event" button, + // so the completion factor of isEventReadOnly must not hide the button. + const showEditButton = !isEditEventPage && !isEventBlockedByExpiry && eventAccess.write; return (
@@ -123,9 +125,8 @@ export const ViewEventPlain = (props: Props & WithStyles) => { />
@@ -138,7 +139,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 a7aa2760e5..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,11 +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', - 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..eb8d78a668 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.canToggleCompletion; return shouldDisable ? i18n.t('You do not have access to uncomplete this event') : undefined; @@ -341,16 +338,14 @@ 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.canToggleCompletion; return createComponentProps(props, { label: i18n.t('Complete event'), id: 'complete', disabled: shouldDisable, eventStatus: props.eventStatus, - canUncompleteEvent: props.canUncompleteEvent, + canToggleCompletion: props.canToggleCompletion, }); }, getPropName: () => 'complete', @@ -449,7 +444,7 @@ type Props = { dataEntryId: string; onCancelEditEvent?: (isScheduled: boolean) => void; eventStatus?: string; - canUncompleteEvent?: boolean; + canToggleCompletion?: boolean; enrollmentId: string; isCompleted?: boolean; assignee?: UserFormField | null; @@ -585,7 +580,8 @@ class EditEventDataEntryPlain extends Component } noncollapsible @@ -173,9 +177,9 @@ const WidgetEventEditPlain = ({ expiryPeriod={expiryPeriod} eventId={eventId} eventStatus={eventStatus} - canUncompleteEvent={canUncompleteEvent} + canToggleCompletion={canToggleCompletion} 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..7db3940c43 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, CompleteMenuItemModal } 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,16 +45,37 @@ const WidgetHeaderPlain = ({ setChangeLogIsOpen, classes, readOnly, + canToggleCompletion, }: Props) => { useEffect(() => inMemoryFileStore.clear, []); const dispatch = useDispatch(); const { currentPageMode } = useEnrollmentEditEventPageMode(eventStatus); const [actionsIsOpen, setActionsIsOpen] = useState(false); + const [completeModalOpen, setCompleteModalOpen] = useState(false); const showEditButton = !readOnly; const { programCategory } = useCategoryCombinations(programId); + const enrollment = useSelector((state: any) => state.enrollmentDomain?.enrollment); + const storedEvent = enrollment?.events?.find((event: { event: string }) => 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 +120,18 @@ const WidgetHeaderPlain = ({ maxWidth="250px" dataTest={'tracker-program-event-overflow-menu'} > + {canToggleCompletion && ( + setActionsIsOpen(false)} + askCompleteEnrollmentOnEventComplete={stage.askCompleteEnrollmentOnEventComplete} + onAskCompleteEnrollment={() => setCompleteModalOpen(true)} + /> + )} )}
+ {completeModalOpen && enrollment && ( + setCompleteModalOpen(false)} + onMutate={onCompletionStatusMutate} + onSuccess={onCompletionStatusSuccess} + onError={onCompletionStatusError} + /> + )} ); }; diff --git a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts index c33bee7127..ac4b97af76 100644 --- a/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts +++ b/src/core_modules/capture-core/components/WidgetEventEdit/WidgetHeader/WidgetHeader.types.ts @@ -2,10 +2,12 @@ import type { OrgUnit } from '@dhis2/rules-engine-javascript'; import { ProgramStage } from '../../../metaData'; export type PlainProps = { + eventId: string, eventStatus?: string, stage: ProgramStage, programId: string, orgUnit: OrgUnit, setChangeLogIsOpen: (toggle: boolean) => void, readOnly: boolean, + canToggleCompletion: 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 cc8a0a5c4a..0000000000 --- a/src/core_modules/capture-core/components/WidgetEventEdit/constants/status.const.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const eventStatuses = Object.freeze({ - ACTIVE: 'ACTIVE', - COMPLETED: 'COMPLETED', - 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..073ce26247 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,33 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { cx } from '@emotion/css'; 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 { + DeleteMenuItem, + DeleteEventModal, + CompletionMenuItem, + CompleteMenuItemModal, +} from '../../../../../EventOverflowMenu'; import { SkipAction } from './SkipAction'; -import { DeleteActionModal } from './DeleteActionModal'; +import { + updateEnrollmentEvent, + commitEnrollmentEvent, + rollbackEnrollmentEvent, +} from '../../../../../Pages/common/EnrollmentOverviewDomain'; + const styles: Readonly = { row: { @@ -24,12 +41,8 @@ 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 EventRowPlain = ({ id, @@ -46,10 +59,49 @@ const EventRowPlain = ({ }: EventRowProps & WithStyles) => { const [actionsOpen, setActionsOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [completeModalOpen, setCompleteModalOpen] = useState(false); + const dispatch = useDispatch(); + const enrollment = useSelector((state: any) => state.enrollmentDomain?.enrollment); + + const { isEventReadOnly, canToggleCompletion } = useEventEditPermissions({ + programId, + stage: programStage, + eventStatus: eventDetails.status, + occurredAtClient: convertServerToClient(eventDetails.occurredAt, dataElementTypes.DATE) as string, + completedAtClient: convertServerToClient(eventDetails.completedAt, dataElementTypes.DATE) as string, + }); + + const onCompletionStatusMutate = useCallback((newStatus: string) => { + 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]); + + const renderCompleteMenuItemModal = () => { + if (!completeModalOpen || !enrollment) return null; + return ( + setCompleteModalOpen(false)} + onMutate={onCompletionStatusMutate} + onSuccess={onCompletionStatusSuccess} + onError={onCompletionStatusError} + /> + ); + }; return ( {cells} @@ -57,45 +109,57 @@ 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)} + askCompleteEnrollmentOnEventComplete={ + programStage?.askCompleteEnrollmentOnEventComplete + } + onAskCompleteEnrollment={() => setCompleteModalOpen(true)} + /> + )} + + - )} - - - - )} - /> + + )} + /> + )} {deleteModalOpen && ( - )} + + {renderCompleteMenuItemModal()} )} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx index 322c291ec1..2b0706687a 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageDetail/EventRow/SkipAction/SkipAction.tsx @@ -9,8 +9,8 @@ import { useMutation } from '@tanstack/react-query'; import { useAlert, useDataEngine } from '@dhis2/app-runtime'; import { errorCreator } from 'capture-core-utils'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; +import { statusTypes as eventStatuses } from 'capture-core/events/statusTypes'; import { DirectionalArrow } from '../../../../../../../utils/rtl'; -import { EventStatuses } from '../EventRow'; type Props = { eventId: string; @@ -48,7 +48,7 @@ export const SkipAction = ({ }), { onMutate: (payload: { status: string }) => { - 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 a7608b6e70..6be93404c8 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 @@ -51,8 +51,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 40d315f7cd..669164d7e4 100644 --- a/src/core_modules/capture-core/events/statusTypes.ts +++ b/src/core_modules/capture-core/events/statusTypes.ts @@ -6,7 +6,9 @@ export const statusTypes = { COMPLETED: 'COMPLETED', OVERDUE: 'OVERDUE', SKIPPED: 'SKIPPED', -}; +} as const; + +export type StatusType = typeof statusTypes[keyof typeof statusTypes]; export const translatedStatusTypes = (options?: string) => ({ 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..22317ad8ab 100644 --- a/src/core_modules/capture-core/hooks/useEventEditPermissions.ts +++ b/src/core_modules/capture-core/hooks/useEventEditPermissions.ts @@ -1,9 +1,12 @@ 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'; type Input = { programId: string, @@ -14,21 +17,39 @@ 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, + canToggleCompletion: 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). +const computeExpiryBlocked = ( + isWithinValidPeriod: boolean, + isWithinCompleteExpiry: boolean, + hasEditExpiredAuthority: boolean, +): boolean => (!isWithinValidPeriod || !isWithinCompleteExpiry) && !hasEditExpiredAuthority; + +const computeCanToggleCompletion = ( + canEditProgramStage: boolean, + isEventBlockedByExpiry: boolean, + hasUncompleteAuthority: boolean, + eventStatus?: string, +): boolean => + canEditProgramStage + && !isEventBlockedByExpiry + && ( + eventStatus === eventStatuses.ACTIVE + || (eventStatus === eventStatuses.COMPLETED && hasUncompleteAuthority) + ); +const computeIsEventReadOnly = ( + canEditProgramStage: boolean, + isEventBlockedByExpiry: boolean, + isCompletedAndBlockingForm: boolean, +): boolean => + !canEditProgramStage + || isEventBlockedByExpiry + || isCompletedAndBlockingForm; export const useEventEditPermissions = ({ programId, @@ -40,29 +61,30 @@ export const useEventEditPermissions = ({ const eventAccess = getProgramEventAccess(programId, stage?.id ?? null); const expiryPeriod = useProgramExpiryForUser(programId); const completeEventsExpiryDays = useCompleteEventsExpiryForUser(programId); - const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); - const { hasAuthority: canEditExpired } = useAuthorities({ authorities: ['F_EDIT_EXPIRED'] }); + const { hasAuthority: hasUncompleteAuthority } = useAuthority(Authorities.UNCOMPLETE_EVENT); + const { hasAuthority: hasEditExpiredAuthority } = useAuthority(Authorities.EDIT_EXPIRED); - const { isWithinValidPeriod: isEventWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); + const { isWithinValidPeriod } = isValidPeriod(occurredAtClient ?? '', expiryPeriod ?? null); const isWithinCompleteExpiry = isWithinCompleteEventsExpiry(completedAtClient, completeEventsExpiryDays); - - const canEditCompletedEvent = canEditExpired || !( - stage?.blockEntryForm - && eventStatus === eventStatuses.COMPLETED + const isEventBlockedByExpiry = computeExpiryBlocked( + isWithinValidPeriod, isWithinCompleteExpiry, hasEditExpiredAuthority, ); - const readOnly = !eventAccess?.write - || !isEventWithinValidPeriod - || !isWithinCompleteExpiry - || !canEditCompletedEvent; + const canEditProgramStage = !!eventAccess?.write; + const isCompletedAndBlockingForm = !!(stage?.blockEntryForm && eventStatus === eventStatuses.COMPLETED); + + const canToggleCompletion = computeCanToggleCompletion( + canEditProgramStage, isEventBlockedByExpiry, hasUncompleteAuthority, eventStatus, + ); + const isEventBlockedByCompletion = isCompletedAndBlockingForm && !hasUncompleteAuthority; + const isEventReadOnly = computeIsEventReadOnly( + canEditProgramStage, isEventBlockedByExpiry, isCompletedAndBlockingForm, + ); return { - eventAccess, - isEventWithinValidPeriod, - isWithinCompleteExpiry, - canEditCompletedEvent, - canUncompleteEvent, - expiryPeriod, - readOnly, + isEventBlockedByExpiry, + isEventBlockedByCompletion, + isEventReadOnly, + canToggleCompletion, }; }; 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), - }; -};