diff --git a/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.feature b/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.feature index 0e93937044..da55e54d74 100644 --- a/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.feature +++ b/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.feature @@ -19,4 +19,4 @@ Feature: Program stage selector when navigating to EnrollmentEventNew without st @user:trackerAutoTestRestricted Scenario: Stages buttons should not be displayed when no data write access Given user lands on the Enrollment dashboard page by typing #/enrollmentEventNew?enrollmentId=X7g83OFRALm&orgUnitId=DiszpKrYNg8&programId=WSGAb5XwJ3Y&teiId=YsKjdOcl9Cd - Then the New event quick action button is disabled + Then the quick actions widget is not visible diff --git a/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.js b/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.js index c28e147c37..7b3ef8265c 100644 --- a/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.js +++ b/cypress/e2e/EnrollmentAddEventPage/ProgramStageSelector/ProgramStageSelector.js @@ -33,6 +33,6 @@ Then('only three program stages are displayed in the stage selector widget', () cy.get('[data-test=program-stage-selector-button]').should('have.length', 3); }); -Then('the New event quick action button is disabled', () => { - cy.get('[data-test=quick-action-button-report]').should('be.disabled'); +Then('the quick actions widget is not visible', () => { + cy.get('[data-test=quick-action-button-container]').should('not.exist'); }); diff --git a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature index b0eb978b21..7a0fe7295d 100644 --- a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature +++ b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.feature @@ -103,10 +103,10 @@ Scenario: User can see disabled scheduled date for active event Then the user see the schedule date field with tooltip: Scheduled date cannot be changed for Active events @user:trackerAutoTestRestricted -Scenario: The user cannot enter edit mode for completed events +Scenario: The edit button is hidden for users with read-only access Given you land on the enrollment event page with selected Person by having typed /#/enrollmentEventEdit?eventId=nUVwTLuQ6FT&orgUnitId=DiszpKrYNg8 And the view enrollment event form is in view mode - Then the edit button should be disabled + Then the edit button should not be visible Scenario: User can edit the event and complete the enrollment Given you land on the enrollment event page with selected Malaria Entity by having typed #/enrollmentEventEdit?eventId=MHR4Zj6KLz0&orgUnitId=DiszpKrYNg8 diff --git a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js index 7bc499c242..4d6bb465b8 100644 --- a/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js +++ b/cypress/e2e/EnrollmentEditEventPage/EnrollmentEditEventPageForm/EnrollmentEditEventPageForm.js @@ -207,10 +207,10 @@ And('you open the Birth stage event', () => { }); }); -Then('the edit button should be disabled', () => { +Then('the edit button should not be visible', () => { cy.get('[data-test="widget-enrollment-event"]') .find('[data-test="widget-enrollment-event-edit-button"]') - .should('be.disabled'); + .should('not.exist'); }); And('the add event form is displayed', () => { diff --git a/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.feature b/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.feature index ea20b12fea..2e60db7c13 100644 --- a/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.feature +++ b/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.feature @@ -14,7 +14,7 @@ Feature: User interacts with the quick actions-menu Then you should be navigated to the schedule tab @user:trackerAutoTestRestricted - Scenario: The create new quick actions button should be disabled if no available stages + Scenario: The quick actions widget is hidden for users with read-only access Given you open the enrollment page by typing #enrollment?enrollmentId=WKPoiZxZxNG&orgUnitId=DiszpKrYNg8&programId=WSGAb5XwJ3Y&teiId=PgmUFEQYZdt - Then the quick action buttons should be disabled + Then the quick action buttons should not be visible diff --git a/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js b/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js index ff2c76281f..7aa0657aa8 100644 --- a/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js +++ b/cypress/e2e/EnrollmentPage/EnrollmentQuickActions/EnrollmentQuickActions.js @@ -34,8 +34,7 @@ Then('the buttons should be disabled', () => { }); }); -Then('the quick action buttons should be disabled', () => { - cy.get('[data-test="quick-action-button-container"]') - .find('button') - .should('be.disabled'); +Then('the quick action buttons should not be visible', () => { + cy.get('[data-test="enrollment-overview-page"]').should('exist'); + cy.get('[data-test="quick-action-button-container"]').should('not.exist'); }); diff --git a/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.feature b/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.feature index a18e595f6b..2c4a7fa670 100644 --- a/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.feature +++ b/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.feature @@ -83,6 +83,6 @@ Feature: User interacts with Stages and Events Widget Then the Care at birth program stage should be hidden @user:trackerAutoTestRestricted - Scenario: Create new event button is disabled if no data write access + Scenario: Create new event buttons are hidden for users with read-only access Given you open the enrollment page by typing #enrollment?enrollmentId=WKPoiZxZxNG&orgUnitId=DiszpKrYNg8&programId=WSGAb5XwJ3Y&teiId=PgmUFEQYZdt - Then you should see the disabled button New Previous deliveries event + Then create new event button should not be visible diff --git a/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.js b/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.js index 1966adca4a..2575432141 100644 --- a/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.js +++ b/cypress/e2e/EnrollmentPage/StagesAndEventsWidget/StagesAndEventsWidget.js @@ -186,6 +186,11 @@ Then(/^you should see the disabled button (.*)$/, (stageName) => { .should('be.disabled'); }); +Then('create new event button should not be visible', () => { + cy.get('[data-test="stages-and-events-widget"]').should('exist'); + cy.get('[data-test="create-new-button"]').should('not.exist'); +}); + Then('the Care at birth program stage should be hidden', () => { cy.contains('[data-test="stages-and-events-widget"]', 'Postpartum care visit').should('exist'); cy.contains('[data-test="stages-and-events-widget"]', 'Care at birth').should('not.exist'); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js index a2bd1da20a..943619c0cd 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetEnrollment/index.js @@ -60,6 +60,18 @@ Then('the enrollment widget should be opened', () => { }); }); +Then('the enrollment actions button is not visible', () => { + cy.get('[data-test="widget-enrollment"]').within(() => { + cy.get('[data-test="widget-enrollment-actions-button"]').should('not.exist'); + }); +}); + +Then('the enrollment date edit buttons are not visible', () => { + cy.get('[data-test="widget-enrollment"]').within(() => { + cy.get('[data-test="widget-enrollment-icon-edit-date"]').should('not.exist'); + }); +}); + Then('the user sees the enrollment date', () => { cy.get('[data-test="widget-enrollment-enrollment-date"]').within(() => { cy.get('[data-test="widget-enrollment-icon-calendar"]').should('exist'); diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.feature b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.feature index f4dfa1cc08..edb46a6ca5 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.feature +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentAddEventPage/WidgetsForEnrollmentAddEventPage.feature @@ -38,12 +38,11 @@ Feature: The user interacts with the widgets on the enrollment add event page And the user sees the owner organisation unit And the user sees the last update date - Scenario: User can open the delete modal + Scenario: The enrollment widget is in read-only mode Given you land on the enrollment add event page by having typed #/enrollmentEventNew?programId=IpHINAT79UW&orgUnitId=DiszpKrYNg8&teiId=EaOyKGOIGRp&enrollmentId=wBU0RAsYjKE&stageId=A03MvHHogjR Then the enrollment widget should be opened - When the user opens the enrollment actions menu - And the user clicks on the delete action - Then the user sees the delete enrollment modal + And the enrollment actions button is not visible + And the enrollment date edit buttons are not visible Scenario: User switch tab in add event page Given you land on the enrollment add event page by having typed #/enrollmentEventNew?programId=IpHINAT79UW&orgUnitId=DiszpKrYNg8&teiId=EaOyKGOIGRp&enrollmentId=wBU0RAsYjKE&stageId=A03MvHHogjR diff --git a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature index 2603f14633..896acfb495 100644 --- a/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature +++ b/cypress/e2e/WidgetsForEnrollmentPages/WidgetsForEnrollmentEditEvent/WidgetsForEnrollmentEditEvent.feature @@ -38,12 +38,11 @@ Feature: The user interacts with the widgets on the enrollment edit event And the user sees the owner organisation unit And the user sees the last update date - Scenario: User can open the delete modal + Scenario: The enrollment widget is in read-only mode Given you land on the enrollment edit event page by having typed /#/enrollmentEventEdit?eventId=XGLkLlOXgmE&orgUnitId=DiszpKrYNg8 Then the enrollment widget should be opened - When the user opens the enrollment actions menu - And the user clicks on the delete action - Then the user sees the delete enrollment modal + And the enrollment actions button is not visible + And the enrollment date edit buttons are not visible Scenario: User can add note on edit event page view mode Given you land on the enrollment edit event page by having typed /#/enrollmentEventEdit?eventId=XGLkLlOXgmE&orgUnitId=DiszpKrYNg8 @@ -72,13 +71,11 @@ Feature: The user interacts with the widgets on the enrollment edit event When you remove the assigned user Then the event has no assignd user - Scenario: User can complete the enrollment and the active events + Scenario: The enrollment widget is in read-only mode on enrollment event page Given you land on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=PyXThVzWJzL&orgUnitId=RzgSFJ9E46G And the enrollment widget should be opened - And the user sees the enrollment status and the Baby Postnatal event status is active - And the user opens the enrollment actions menu - When the user completes the enrollment and the active events - Then the user sees the enrollment status and the Baby Postnatal event status is completed + Then the enrollment actions button is not visible + And the enrollment date edit buttons are not visible Scenario: User can see the enrollment minimap Given you land on the enrollment edit event page by having typed #/enrollmentEventEdit?eventId=W1uHdJEjZUI&orgUnitId=DiszpKrYNg8 diff --git a/i18n/en.pot b/i18n/en.pot index 58ea419d37..beeca74ad7 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-05-26T07:35:44.230Z\n" -"PO-Revision-Date: 2026-05-26T07:35:44.230Z\n" +"POT-Creation-Date: 2026-05-24T11:43:30.545Z\n" +"PO-Revision-Date: 2026-05-24T11:43:30.545Z\n" msgid "The application could not be loaded." msgstr "The application could not be loaded." @@ -1071,6 +1071,36 @@ msgstr "Possible duplicates found" msgid "An error occurred loading possible duplicates" msgstr "An error occurred loading possible duplicates" +msgid "You only have view access to this enrollment" +msgstr "You only have view access to this enrollment" + +msgid "You only have view access to this program" +msgstr "You only have view access to this program" + +msgid "You only have view access to this {{trackedEntityName}}" +msgstr "You only have view access to this {{trackedEntityName}}" + +msgid "You only have view access to this tracked entity type" +msgstr "You only have view access to this tracked entity type" + +msgid "You only have view access to these program stages" +msgstr "You only have view access to these program stages" + +msgid "You only have view access to this program stage" +msgstr "You only have view access to this program stage" + +msgid "This event is outside the valid editing period" +msgstr "This event is outside the valid editing period" + +msgid "This event has been completed" +msgstr "This event has been completed" + +msgid "View only - {{message}}" +msgstr "View only - {{message}}" + +msgid "View only" +msgstr "View only" + msgid "You don't have access to delete this relationship" msgstr "You don't have access to delete this relationship" @@ -1311,9 +1341,6 @@ msgstr "Unsaved changes" msgid "Continue data entry" msgstr "Continue data entry" -msgid "You do not have access to modify this enrollment" -msgstr "You do not have access to modify this enrollment" - msgid "Enrollment actions" msgstr "Enrollment actions" @@ -1512,9 +1539,6 @@ msgstr "No polygon captured" msgid "Event completed" msgstr "Event completed" -msgid "The event cannot be edited after it has been completed" -msgstr "The event cannot be edited after it has been completed" - msgid "Notes about this event" msgstr "Notes about this event" @@ -1593,14 +1617,17 @@ msgstr "Save note" msgid "Edit {{trackedEntityName}}" msgstr "Edit {{trackedEntityName}}" +msgid "Save changes" +msgstr "Save changes" + msgid "Change information about this {{trackedEntityName}} here." msgstr "Change information about this {{trackedEntityName}} here." msgid "Information about this enrollment can be edited in the Enrollment widget." msgstr "Information about this enrollment can be edited in the Enrollment widget." -msgid "Save changes" -msgstr "Save changes" +msgid "{{trackedEntityName}} profile" +msgstr "{{trackedEntityName}} profile" msgid "Profile" msgstr "Profile" @@ -1641,6 +1668,9 @@ msgstr "There was a problem deleting the {{trackedEntityTypeName}}" msgid "Yes, delete {{trackedEntityTypeName}}" msgstr "Yes, delete {{trackedEntityTypeName}}" +msgid "View profile" +msgstr "View profile" + msgid "Profile widget could not be loaded. Please try again later" msgstr "Profile widget could not be loaded. Please try again later" @@ -1701,9 +1731,6 @@ msgstr "Please enter a date" msgid "Please select a valid event" msgstr "Please select a valid event" -msgid "You do not have access to create events in this stage" -msgstr "You do not have access to create events in this stage" - msgid "This stage can only have one event" msgstr "This stage can only have one event" @@ -1751,6 +1778,9 @@ msgstr "{{ overdueEvents }} overdue" msgid "{{ scheduledEvents }} scheduled" msgstr "{{ scheduledEvents }} scheduled" +msgid "No stages found in this program" +msgstr "No stages found in this program" + msgid "Stages and Events" msgstr "Stages and Events" @@ -1891,6 +1921,9 @@ msgstr "An error occurred while deleting the relationship." msgid "To open this relationship, please wait until saving is complete" msgstr "To open this relationship, please wait until saving is complete" +msgid "This enrollment doesn't have any relationships" +msgstr "This enrollment doesn't have any relationships" + msgid "Type" msgstr "Type" diff --git a/src/core_modules/capture-core/components/DataEntry/DataEntry.component.tsx b/src/core_modules/capture-core/components/DataEntry/DataEntry.component.tsx index f6c9003adc..7e23cda0b4 100644 --- a/src/core_modules/capture-core/components/DataEntry/DataEntry.component.tsx +++ b/src/core_modules/capture-core/components/DataEntry/DataEntry.component.tsx @@ -142,6 +142,7 @@ export type DataEntryOutputProps = { onGetValidationContext?: () => any, orgUnitId?: string, pluginContext?: PluginContext, + viewMode?: boolean, }; type OwnProps = DataEntryOutputProps & { diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx index 4189a37995..ea486c8df5 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.container.tsx @@ -8,6 +8,7 @@ import { useTimeZoneConversion } from '@dhis2/app-runtime'; import type { ApiEnrollmentEvent } from 'capture-core-utils/types/api-types'; import { commitEnrollmentAndEvents, + EnrollmentAccessProvider, rollbackEnrollmentAndEvents, showEnrollmentError, updateEnrollmentAndEvents, @@ -191,42 +192,43 @@ export const EnrollmentPageDefault = () => { } return ( - + + + ); }; diff --git a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentQuickActions/EnrollmentQuickActions.component.tsx b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentQuickActions/EnrollmentQuickActions.component.tsx index 3ce36927d4..6bb4b27e7d 100644 --- a/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentQuickActions/EnrollmentQuickActions.component.tsx +++ b/src/core_modules/capture-core/components/Pages/Enrollment/EnrollmentPageDefault/EnrollmentQuickActions/EnrollmentQuickActions.component.tsx @@ -7,6 +7,7 @@ import { Widget } from '../../../../Widget'; import { QuickActionButton } from './QuickActionButton/QuickActionButton'; import { tabMode } from '../../../EnrollmentAddEvent/NewEventWorkspace/newEventWorkspace.constants'; import { useNavigate, buildUrlQueryString, useLocationQuery } from '../../../../../utils/routing'; +import { useEnrollmentAccessContext } from '../../../common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import { OwnProps, ProgramStage, EventCount } from './EnrollmentQuickActions.types'; const styles = { @@ -28,6 +29,7 @@ const EnrollmentQuickActionsComponentPlain = ({ const [open, setOpen] = useState(true); const { navigate } = useNavigate(); const { enrollmentId, programId, teiId, orgUnitId } = useLocationQuery(); + const { anyStageWriteAccess } = useEnrollmentAccessContext(); const stagesWithEventCount = useMemo(() => stages.map((stage) => { const mutatedStage = { ...stage }; @@ -61,6 +63,8 @@ const EnrollmentQuickActionsComponentPlain = ({ const ready: boolean = events !== undefined && stages !== undefined; + if (!anyStageWriteAccess) return null; + return ( = ({ typography }: any) => ({ container: { @@ -67,28 +69,33 @@ const EnrollmentAddEventPagePain = ({ return null; } return ( -
- -
+ +
+ +
+
); }; diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/PageLayout/DefaultPageLayout.constants.ts b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/PageLayout/DefaultPageLayout.constants.ts index 88c5c90724..5028187fef 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/PageLayout/DefaultPageLayout.constants.ts +++ b/src/core_modules/capture-core/components/Pages/EnrollmentAddEvent/PageLayout/DefaultPageLayout.constants.ts @@ -24,6 +24,7 @@ export const DefaultPageLayout: PageLayoutConfig = { { type: WidgetTypes.COMPONENT, name: 'TrackedEntityRelationship', + settings: { readOnlyMode: true }, }, { type: WidgetTypes.COMPONENT, 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 3f5bc27ebd..7e32fe94f8 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 @@ -3,10 +3,14 @@ import type { ProgramRule } from '@dhis2/rules-engine-javascript'; import { useQueryClient } from '@tanstack/react-query'; import { useDispatch, useSelector } from 'react-redux'; import { dataEntryIds } from 'capture-core/constants'; -import { useEnrollmentEditEventPageMode, useHideWidgetByRuleLocations } from '../../../hooks'; +import { useEnrollmentEditEventPageMode, useHideWidgetByRuleLocations, useProgramExpiryForUser } from '../../../hooks'; +import { isValidPeriod } from '../../../utils/validation/validators/form/expiredPeriod'; +import { useAuthorities } from '../../../utils/authority/useAuthorities'; +import { eventStatuses } from '../../WidgetEventEdit/constants/status.const'; import type { ReduxState } from '../../App/withAppUrlSync.types'; import { commitEnrollmentAndEvents, + EnrollmentAccessProvider, rollbackEnrollmentAndEvents, setExternalEnrollmentStatus, showEnrollmentError, @@ -20,7 +24,7 @@ import { rollbackEnrollmentEvents, } from '../common/EnrollmentOverviewDomain'; import { useTeiDisplayName } from '../common/EnrollmentOverviewDomain/useTeiDisplayName'; -import { useProgramInfo } from '../../../hooks/useProgramInfo'; +import { useTrackerProgram } from '../../../hooks/useTrackerProgram'; import { pageStatuses } from './EnrollmentEditEventPage.constants'; import { EnrollmentEditEventPageComponent } from './EnrollmentEditEventPage.component'; import { useWidgetDataFromStore } from '../EnrollmentAddEvent/hooks'; @@ -39,9 +43,9 @@ import { pageKeys } from '../../App/withAppUrlSync'; import { withErrorMessageHandler } from '../../../HOC'; import { DataStoreKeyByPage, useEnrollmentPageLayout } from '../common/EnrollmentOverviewDomain/EnrollmentPageLayout'; import { DefaultPageLayout } from './PageLayout/DefaultPageLayout.constants'; -import { getProgramEventAccess, TrackerProgram } from '../../../metaData'; +import { getProgramEventAccess } from '../../../metaData'; import { rollbackAssignee, setAssignee } from './EnrollmentEditEventPage.actions'; -import { convertClientToServer } from '../../../converters'; +import { convertClientToServer, convertServerToClient } from '../../../converters'; import { CHANGELOG_ENTITY_TYPES } from '../../WidgetsChangelog'; import { ReactQueryAppNamespace } from '../../../utils/reactQueryHelpers'; import { statusTypes } from '../../../enrollment'; @@ -148,10 +152,10 @@ const EnrollmentEditEventPageWithContextPlain = ({ dispatch(cleanUpDataEntry(dataEntryIds.ENROLLMENT_EVENT)); }, [dispatch]); - const { program } = useProgramInfo(programId); - const programStage = [...program?.stages?.values() ?? []].find((item: any) => item.id === stageId); + const program = useTrackerProgram(programId); + const programStage = [...program.stages.values()].find((item: any) => item.id === stageId); const hideWidgets = useHideWidgetByRuleLocations( - program?.programRules.concat(programStage?.programRules as ProgramRule[]), + program.programRules.concat(programStage?.programRules as ProgramRule[]), ); const onDeleteTrackedEntitySuccess = useCallback(() => { @@ -253,8 +257,7 @@ const EnrollmentEditEventPageWithContextPlain = ({ }; const { teiDisplayName } = useTeiDisplayName(teiId, programId); - const trackedEntityType = (program && program instanceof TrackerProgram) ? program.trackedEntityType : undefined; - const { name: trackedEntityName = '', id: trackedEntityTypeId = '' } = trackedEntityType ?? {}; + const { name: trackedEntityName = '', id: trackedEntityTypeId = '' } = program.trackedEntityType ?? {}; const enrollmentsAsOptions = buildEnrollmentsAsOptions([enrollmentSite ?? {}], programId); const eventDate = getEventDate(event); const scheduleDate = getEventScheduleDate(event); @@ -265,6 +268,15 @@ const EnrollmentEditEventPageWithContextPlain = ({ const outputEffects = useWidgetDataFromStore(dataEntryKey); const eventAccess = getProgramEventAccess(programId, programStage?.id ?? null); + const expiryPeriod = useProgramExpiryForUser(programId); + const occurredAtClient = convertServerToClient(event?.occurredAt, dataElementTypes.DATE) as string; + const isEventWithinValidPeriod = isValidPeriod(occurredAtClient, expiryPeriod).isWithinValidPeriod; + + const { hasAuthority: canUncompleteEvent } = useAuthorities({ authorities: ['F_UNCOMPLETE_EVENT'] }); + const canEditCompletedEvent = !(programStage?.blockEntryForm + && !canUncompleteEvent + && event?.status === eventStatuses.COMPLETED); + const pageStatus = getPageStatus({ orgUnitId, @@ -296,57 +308,64 @@ const EnrollmentEditEventPageWithContextPlain = ({ } return ( - + currentStageId={stageId} + isEventWithinValidPeriod={isEventWithinValidPeriod} + canEditCompletedEvent={canEditCompletedEvent} + > + + ); }; diff --git a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.ts b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.ts index a005529792..262150b907 100644 --- a/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.ts +++ b/src/core_modules/capture-core/components/Pages/EnrollmentEditEvent/PageLayout/DefaultPageLayout.constants.ts @@ -60,6 +60,7 @@ export const DefaultPageLayout: PageLayoutConfig = { { type: WidgetTypes.COMPONENT, name: 'TrackedEntityRelationship', + settings: { readOnlyMode: true }, }, { type: WidgetTypes.COMPONENT, 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 new file mode 100644 index 0000000000..2c5396b0cc --- /dev/null +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/EnrollmentAccessContext.tsx @@ -0,0 +1,99 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import type { TrackerProgram } from '../../../../../metaData'; +import type { Access } from '../../../../../metaData/Access'; + +export type EnrollmentAccessContextValue = { + programWriteAccess: boolean; + trackedEntityTypeWriteAccess: boolean; + anyStageWriteAccess: boolean; + anyStageReadAccess: boolean; + stageWriteAccessById: Record; + stageReadAccessById: Record; + trackedEntityTypeName?: string; + currentStageId?: string; + currentStageWriteAccess: boolean; + isEventPage: boolean; + multipleStages: boolean; + allWriteAccessMissing: boolean; + showWidgetBadge: boolean; + isEventWithinValidPeriod?: boolean; + canEditCompletedEvent?: boolean; +}; + +const fallback: EnrollmentAccessContextValue = { + programWriteAccess: true, + trackedEntityTypeWriteAccess: true, + anyStageWriteAccess: true, + anyStageReadAccess: true, + stageWriteAccessById: {}, + stageReadAccessById: {}, + currentStageWriteAccess: true, + isEventPage: false, + multipleStages: false, + allWriteAccessMissing: false, + showWidgetBadge: true, +}; + +const Context = createContext(fallback); + +type ProviderProps = { + program?: TrackerProgram; + currentStageId?: string; + isEventWithinValidPeriod?: boolean; + canEditCompletedEvent?: boolean; + children: React.ReactNode; +}; + +export const EnrollmentAccessProvider = ({ + program, + currentStageId, + isEventWithinValidPeriod, + canEditCompletedEvent, + children, +}: ProviderProps) => { + const value = useMemo(() => { + if (!program) return fallback; + + const stages = Array.from(program.stages.values()); + const stageWriteAccessById: Record = {}; + const stageReadAccessById: Record = {}; + for (const stage of stages) { + const access = stage.access as Access | undefined; + stageWriteAccessById[stage.id] = Boolean(access?.data?.write); + stageReadAccessById[stage.id] = Boolean(access?.data?.read); + } + const programWriteAccess = Boolean(program.access?.data?.write); + const trackedEntityTypeWriteAccess = Boolean(program.trackedEntityType?.access?.data?.write); + const anyStageWriteAccess = Object.values(stageWriteAccessById).some(Boolean); + const anyStageReadAccess = Object.values(stageReadAccessById).some(Boolean); + const isEventPage = Boolean(currentStageId); + const currentStageWriteAccess = currentStageId + ? (stageWriteAccessById[currentStageId] ?? false) + : true; + const allWriteAccessMissing = !programWriteAccess + && !trackedEntityTypeWriteAccess + && !anyStageWriteAccess; + + return { + programWriteAccess, + trackedEntityTypeWriteAccess, + anyStageWriteAccess, + anyStageReadAccess, + stageWriteAccessById, + stageReadAccessById, + trackedEntityTypeName: program.trackedEntityType?.name, + currentStageId, + currentStageWriteAccess, + isEventPage, + multipleStages: program.stages.size > 1, + allWriteAccessMissing, + showWidgetBadge: !isEventPage && !allWriteAccessMissing, + isEventWithinValidPeriod, + canEditCompletedEvent, + }; + }, [program, currentStageId, isEventWithinValidPeriod, canEditCompletedEvent]); + + return {children}; +}; + +export const useEnrollmentAccessContext = (): EnrollmentAccessContextValue => useContext(Context); diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/index.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/index.ts new file mode 100644 index 0000000000..dc0884c162 --- /dev/null +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext/index.ts @@ -0,0 +1,5 @@ +export { + EnrollmentAccessProvider, + useEnrollmentAccessContext, +} from './EnrollmentAccessContext'; +export type { EnrollmentAccessContextValue } from './EnrollmentAccessContext'; diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.tsx b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.tsx index c5e036e078..69ad18dab5 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.tsx +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentPageLayout.tsx @@ -5,6 +5,7 @@ import { useWidgetColumns } from './hooks/useWidgetColumns'; import { AddRelationshipRefWrapper } from './AddRelationshipRefWrapper'; import type { Props as EnrollmentPageProps } from '../../../Enrollment/EnrollmentPageDefault/EnrollmentPageDefault.types'; import { EnrollmentBreadcrumb } from '../../../../Breadcrumbs/EnrollmentBreadcrumb'; +import { EnrollmentReadOnlyBadge } from './EnrollmentReadOnlyBadge'; import './enrollmentPageLayout.css'; const getEnrollmentPageStyles: Readonly = () => ({ @@ -49,6 +50,11 @@ const getEnrollmentPageStyles: Readonly = () => ({ fontWeight: 500, paddingTop: spacersNum.dp8, }, + breadcrumbRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, }); const isValidHex = (color: string) => /^#[0-9A-F]{6}$/i.test(color); @@ -115,7 +121,7 @@ const EnrollmentPageLayoutPlain = ({ className={classes.contentContainer} style={!mainContentVisible ? { display: 'none' } : undefined} > -
+
+
{pageLayout.leftColumn && !!leftColumnWidgets?.length && ( 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 new file mode 100644 index 0000000000..a106516b60 --- /dev/null +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/EnrollmentReadOnlyBadge.component.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ReadOnlyBadge } from '../../../../../ReadOnlyBadge'; +import { useEnrollmentAccessContext } from '../../EnrollmentAccessContext'; + +export const EnrollmentReadOnlyBadge = () => { + const { + isEventPage, + currentStageWriteAccess, + programWriteAccess, + trackedEntityTypeWriteAccess, + anyStageWriteAccess, + anyStageReadAccess, + trackedEntityTypeName, + isEventWithinValidPeriod, + canEditCompletedEvent, + } = useEnrollmentAccessContext(); + + if (isEventPage) { + return ( + + ); + } + + const stagesEffectivelyReadOnly = !anyStageWriteAccess && anyStageReadAccess; + const showAllMissing = !programWriteAccess && !trackedEntityTypeWriteAccess && stagesEffectivelyReadOnly; + if (!showAllMissing) return null; + + return ( + + ); +}; diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/index.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/index.ts new file mode 100644 index 0000000000..30131d8301 --- /dev/null +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/EnrollmentReadOnlyBadge/index.ts @@ -0,0 +1 @@ +export { EnrollmentReadOnlyBadge } from './EnrollmentReadOnlyBadge.component'; diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts index 893c0cc188..3e17e1fc32 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/LayoutComponentConfig/LayoutComponentConfig.ts @@ -76,6 +76,9 @@ export const StagesAndEvents: WidgetConfig = { export const TrackedEntityRelationship: WidgetConfig = { Component: TrackedEntityRelationshipsWrapper, shouldHideWidget: ({ addRelationShipContainerElement }: any) => !addRelationShipContainerElement, + getCustomSettings: ({ readOnlyMode }: any) => ({ + readOnlyMode, + }), getProps: ({ program, orgUnitId, diff --git a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts index f8a7d316da..fba2ed7c0a 100644 --- a/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts +++ b/src/core_modules/capture-core/components/Pages/common/EnrollmentOverviewDomain/index.ts @@ -21,3 +21,7 @@ export { } from './enrollment.actions'; export { useCommonEnrollmentDomainData } from './useCommonEnrollmentDomainData'; export { useRuleEffects } from './useRuleEffects'; +export { + EnrollmentAccessProvider, + useEnrollmentAccessContext, +} from './EnrollmentAccessContext'; diff --git a/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.component.tsx b/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.component.tsx index 32b1717811..720bd26f98 100644 --- a/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.component.tsx +++ b/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.component.tsx @@ -15,6 +15,7 @@ import { import { ResultsPageSizeContext } from '../../../shared-contexts'; import { RegisterTei } from '../RegisterTei'; import { useCoreOrgUnit } from '../../../../../metadataRetrieval/coreOrgUnit'; +import { useEnrollmentAccessContext } from '../../EnrollmentOverviewDomain/EnrollmentAccessContext'; const createResultsView = (onLinkToTrackedEntityFromSearch: any) => (viewProps: any) => ( { + const { + trackedEntityTypeWriteAccess, + allWriteAccessMissing, + showWidgetBadge, + } = useEnrollmentAccessContext(); + const accessReadOnly = !trackedEntityTypeWriteAccess; + const effectiveReadOnly = readOnlyMode || accessReadOnly; const dispatch = useDispatch(); const { relationshipTypes, isError } = useTEIRelationshipsWidgetMetadata(); const { orgUnit } = useCoreOrgUnit(orgUnitId); @@ -74,6 +83,10 @@ export const TrackedEntityRelationshipsWrapper = ({ onCloseAddRelationship={onCloseAddRelationship} onSelectFindMode={onSelectFindMode} relationshipTypes={relationshipTypes} + readOnly={effectiveReadOnly} + accessReadOnly={accessReadOnly} + hideButton={accessReadOnly || allWriteAccessMissing} + hideReadOnlyBadge={!showWidgetBadge} renderTrackedEntityRegistration={( selectedTrackedEntityTypeId, suggestedProgramId, diff --git a/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.types.ts b/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.types.ts index 1a5672e517..96636b7c10 100644 --- a/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.types.ts +++ b/src/core_modules/capture-core/components/Pages/common/TEIRelationshipsWidget/TrackedEntityRelationshipsWrapper/TrackedEntityRelationshipsWrapper.types.ts @@ -9,4 +9,5 @@ export type Props = { onOpenAddRelationship: () => void; onCloseAddRelationship: () => void; onLinkedRecordClick: LinkedRecordClick; + readOnlyMode?: boolean; }; diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx new file mode 100644 index 0000000000..bad84d8dfc --- /dev/null +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/ReadOnlyBadge.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { IconInfo16, Tag } from '@dhis2/ui'; +import i18n from '@dhis2/d2-i18n'; +import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { ConditionalTooltip } from '../Tooltips/ConditionalTooltip'; + +const styles = { + label: { + fontWeight: 500, + }, +} as const; + +type Props = { + programWriteAccess?: boolean; + trackedEntityTypeWriteAccess?: boolean; + programStageWriteAccess?: boolean; + eventWithinValidPeriod?: boolean; + canEditCompletedEvent?: boolean; + multipleStages?: boolean; + trackedEntityName?: string; + inlineLabel?: boolean; +}; + +type Access = { + program: boolean; + trackedEntityType: boolean; + programStage: boolean; +}; + +const getEnrollmentMessage = (): string => i18n.t('You only have view access to this enrollment'); + +const getProgramMessage = (): string => i18n.t('You only have view access to this program'); + +const getTrackedEntityMessage = (trackedEntityName: string | undefined): string => (trackedEntityName + ? i18n.t('You only have view access to this {{trackedEntityName}}', { trackedEntityName, escapeValue: false }) + : i18n.t('You only have view access to this tracked entity type')); + +const getProgramStageMessage = (multipleStages: boolean): string => (multipleStages + ? i18n.t('You only have view access to these program stages') + : i18n.t('You only have view access to this program stage')); + +const getExpiredPeriodMessage = (): string => i18n.t('This event is outside the valid editing period'); + +const getCompletedEventMessage = (): string => i18n.t('This event has been completed'); + +const getReadOnlyMessage = ( + access: Access, + trackedEntityName: string | undefined, + multipleStages: boolean, + eventWithinValidPeriod: boolean, + canEditCompletedEvent: boolean, +): string => { + if (!access.program && !access.trackedEntityType && !access.programStage) return getEnrollmentMessage(); + if (!access.program) return getProgramMessage(); + if (!access.trackedEntityType) return getTrackedEntityMessage(trackedEntityName); + if (!access.programStage) return getProgramStageMessage(multipleStages); + if (!eventWithinValidPeriod) return getExpiredPeriodMessage(); + if (!canEditCompletedEvent) return getCompletedEventMessage(); + return ''; +}; + +const ReadOnlyBadgePlain = ({ + programWriteAccess = true, + trackedEntityTypeWriteAccess = true, + programStageWriteAccess = true, + eventWithinValidPeriod = true, + canEditCompletedEvent = true, + multipleStages = false, + trackedEntityName, + inlineLabel = false, + classes, +}: Props & WithStyles) => { + const access: Access = { + program: programWriteAccess, + trackedEntityType: trackedEntityTypeWriteAccess, + programStage: programStageWriteAccess, + }; + const message = getReadOnlyMessage( + access, + trackedEntityName, + multipleStages, + eventWithinValidPeriod, + canEditCompletedEvent, + ); + if (!message) return null; + + const labelText = inlineLabel + ? i18n.t('View only - {{message}}', { message, escapeValue: false }) + : i18n.t('View only'); + + return ( + + }> + {labelText} + + + ); +}; + +export const ReadOnlyBadge = withStyles(styles)(ReadOnlyBadgePlain); diff --git a/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts new file mode 100644 index 0000000000..12396a40ff --- /dev/null +++ b/src/core_modules/capture-core/components/ReadOnlyBadge/index.ts @@ -0,0 +1 @@ +export { ReadOnlyBadge } from './ReadOnlyBadge'; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.tsx index 969e716153..cb3a9f8bbd 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Actions/Actions.component.tsx @@ -10,7 +10,6 @@ import { AddNew } from './AddNew'; import { AddLocation } from './AddLocation'; import type { PlainProps } from './actions.types'; import { LoadingMaskForButton } from '../../LoadingMasks'; -import { ConditionalTooltip } from '../../Tooltips/ConditionalTooltip'; import { MapModal } from '../MapModal'; import { Transfer } from './Transfer'; import { TransferModal } from '../TransferModal'; @@ -35,7 +34,6 @@ const ActionsPlain = ({ ownerOrgUnitId, tetName, canAddNew, - programDataWriteAccess, onUpdateStatus, onUpdate, onDelete, @@ -67,69 +65,64 @@ const ActionsPlain = ({ return ( <> - setOpenActions(prev => !prev)} + component={ + + + { + setOpenCompleteModal(modalState); + setOpenActions(!modalState); + }} + /> + + { + setOpenTransfer(true); + setOpenActions(false); + }} + /> + { + setOpenMap(true); + setOpenActions(false); + }} + /> + + + + + } > - setOpenActions(prev => !prev)} - component={ - - - { - setOpenCompleteModal(modalState); - setOpenActions(!modalState); - }} - /> - - { - setOpenTransfer(true); - setOpenActions(false); - }} - /> - { - setOpenMap(true); - setOpenActions(false); - }} - /> - - - - - } - > - {i18n.t('Enrollment actions')} - - + {i18n.t('Enrollment actions')} + {loading && (
@@ -141,6 +134,7 @@ const ActionsPlain = ({ enrollment={enrollment} onUpdate={handleOnUpdate} setOpenMap={setOpenMap} + readOnly={false} />} {isOpenTransfer && ( { const { updateMutation, updateLoading } = useUpdateEnrollment(refetchEnrollment, refetchTEI, onError, onSuccess); @@ -47,6 +48,8 @@ export const Actions = ({ [updateStatusMutation, onUpdateEnrollmentStatus, changeRedirect], ); + if (readOnly) return null; + return ( void; onSuccess?: () => void; canAddNew: boolean; - programDataWriteAccess: boolean; onlyEnrollOnce: boolean; tetName: string; onAccessLostFromTransfer?: () => void; + readOnly?: boolean; }; export type PlainProps = { @@ -36,7 +36,6 @@ export type PlainProps = { isTransferLoading: boolean; loading: boolean; canAddNew: boolean; - programDataWriteAccess: boolean; onlyEnrollOnce: boolean; tetName: string; }; diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/Date/Date.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollment/Date/Date.component.tsx index b84d797d48..b7aa937c9d 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/Date/Date.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollment/Date/Date.component.tsx @@ -23,7 +23,7 @@ type OwnProps = { date: string; dateLabel: string; locale: string; - editEnabled: boolean; + readOnly: boolean; displayAutoGeneratedEventWarning: boolean; onSave: (date: string) => void; allowFutureDate?: boolean; @@ -91,11 +91,11 @@ const DateComponentPlain = ({ date, dateLabel, locale, - editEnabled, + readOnly, displayAutoGeneratedEventWarning, onSave, - classes, allowFutureDate, + classes, }: Props) => { const [editMode, setEditMode] = useState(false); const [selectedDate, setSelectedDate] = useState(); @@ -209,8 +209,9 @@ const DateComponentPlain = ({ {displayDate} - {editEnabled && ( + {!readOnly && (
)} +const useError = (errorEnrollment: any, errorProgram: any, errorOwnerOrgUnit: any, errorOrgUnit: any, errorLocale: any) => useMemo( - () => errorEnrollment ?? errorProgram ?? errorOwnerOrgUnit ?? errorOrgUnit, - [errorEnrollment, errorProgram, errorOwnerOrgUnit, errorOrgUnit], + () => errorEnrollment ?? errorProgram ?? errorOwnerOrgUnit ?? errorOrgUnit ?? errorLocale, + [errorEnrollment, errorProgram, errorOwnerOrgUnit, errorOrgUnit, errorLocale], ); const useContainsAutoGeneratedEvent = (program: any) => @@ -73,10 +73,10 @@ export const WidgetEnrollment = ({ const { error: errorOrgUnit, displayName } = useOrgUnitNameWithAncestors( typeof ownerOrgUnit === 'string' ? ownerOrgUnit : undefined, ); - const locale = CurrentUser.get().uiLocale; + const { error: errorLocale, locale } = useUserLocale(); const canAddNew = useCanAddNew(enrollments, programId, program?.trackedEntityType.access); const containsAutoGeneratedEvent = useContainsAutoGeneratedEvent(program); - const error = useError(errorEnrollment, errorProgram, errorOwnerOrgUnit, errorOrgUnit); + const error = useError(errorEnrollment, errorProgram, errorOwnerOrgUnit, errorOrgUnit, errorLocale); const events = useEnrollmentEvents(externalData); if (error) { @@ -89,7 +89,6 @@ export const WidgetEnrollment = ({ events={events} canAddNew={canAddNew} readOnlyMode={readOnlyMode} - programDataWriteAccess={program?.access.data.write} displayAutoGeneratedEventWarning={containsAutoGeneratedEvent} program={program} refetchEnrollment={refetchEnrollment} diff --git a/src/core_modules/capture-core/components/WidgetEnrollment/enrollment.types.ts b/src/core_modules/capture-core/components/WidgetEnrollment/enrollment.types.ts index 21ed405b80..373f7bb312 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollment/enrollment.types.ts +++ b/src/core_modules/capture-core/components/WidgetEnrollment/enrollment.types.ts @@ -54,7 +54,6 @@ export type PlainProps = { loading: boolean; canAddNew: boolean; readOnlyMode: boolean; - programDataWriteAccess: boolean; displayAutoGeneratedEventWarning: boolean; updateEnrollmentDate: (enrollmentDate: string) => void; updateIncidentDate: (incidentDate: string) => void; diff --git a/src/core_modules/capture-core/components/WidgetEnrollmentNote/WidgetEnrollmentNote.component.tsx b/src/core_modules/capture-core/components/WidgetEnrollmentNote/WidgetEnrollmentNote.component.tsx index 84f5e8e897..bc1d4e6f1d 100644 --- a/src/core_modules/capture-core/components/WidgetEnrollmentNote/WidgetEnrollmentNote.component.tsx +++ b/src/core_modules/capture-core/components/WidgetEnrollmentNote/WidgetEnrollmentNote.component.tsx @@ -3,6 +3,8 @@ import i18n from '@dhis2/d2-i18n'; import { useDispatch, useSelector } from 'react-redux'; import { requestAddNoteForEnrollment } from './WidgetEnrollmentNote.actions'; import { WidgetNote } from '../WidgetNote'; +import { ReadOnlyBadge } from '../ReadOnlyBadge'; +import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import { useLocationQuery } from '../../utils/routing'; export const WidgetEnrollmentNote = () => { @@ -10,6 +12,11 @@ export const WidgetEnrollmentNote = () => { const { enrollmentId } = useLocationQuery(); const notes = useSelector(({ enrollmentDomain }: { enrollmentDomain?: { enrollment?: { notes?: Array } } }) => enrollmentDomain?.enrollment?.notes ?? []); + const { + programWriteAccess, + trackedEntityTypeName, + showWidgetBadge, + } = useEnrollmentAccessContext(); const onAddNote = (newNoteValue: string) => { dispatch(requestAddNoteForEnrollment(enrollmentId, newNoteValue)); @@ -22,6 +29,13 @@ export const WidgetEnrollmentNote = () => { placeholder={i18n.t('Write a note about this enrollment')} emptyNoteMessage={i18n.t('This enrollment doesn\'t have any notes')} notes={notes} + readOnly={!programWriteAccess} + badge={showWidgetBadge ? ( + + ) : null} onAddNote={onAddNote} />
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 0bf662be38..d7e2513009 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,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { dataEntryKeys } from 'capture-core/constants'; import { useDispatch } from 'react-redux'; import { spacersNum, Button, IconEdit24, IconMore16, FlyoutMenu, MenuItem, spacers } from '@dhis2/ui'; @@ -6,7 +6,6 @@ import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import i18n from '@dhis2/d2-i18n'; import { FEATURES, useFeature } from 'capture-core-utils'; import { useAuthorities } from 'capture-core/utils/authority/useAuthorities'; -import { ConditionalTooltip } from 'capture-core/components/Tooltips/ConditionalTooltip'; import { useEnrollmentEditEventPageMode, useProgramExpiryForUser } from 'capture-core/hooks'; import { startShowEditEventDataEntry } from '../WidgetEventEdit.actions'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; @@ -31,9 +30,6 @@ const styles: Readonly = { alignItems: 'center', gap: spacers.dp4, }, - tooltip: { - display: 'inline-flex', - }, }; type Props = PlainProps & WithStyles; @@ -62,22 +58,7 @@ const WidgetHeaderPlain = ({ const occurredAtClient = convertFormToClient(occurredAt, dataElementTypes.DATE) as string; const { isWithinValidPeriod } = isValidPeriod(occurredAtClient, expiryPeriod); - const disableEdit = !eventAccess?.write || blockEntryForm || !isWithinValidPeriod; - const tooltipContent = useMemo(() => { - if (blockEntryForm) { - return i18n.t('The event cannot be edited after it has been completed'); - } - if (!eventAccess?.write) { - return i18n.t('You don\'t have access to edit this event'); - } - if (!isWithinValidPeriod) { - return i18n.t('{{occurredAt}} belongs to an expired period. Event cannot be edited', { - occurredAt, - interpolation: { escapeValue: false }, - }); - } - return ''; - }, [blockEntryForm, eventAccess?.write, isWithinValidPeriod, occurredAt]); + const showEditButton = eventAccess?.write && isWithinValidPeriod && !blockEntryForm; const { programCategory } = useCategoryCombinations(programId); @@ -100,22 +81,17 @@ const WidgetHeaderPlain = ({
{currentPageMode === dataEntryKeys.VIEW && (
- + {showEditButton && ( - + )} {supportsChangelog && ( { const dispatch = useDispatch(); const notes = useSelector(({ dataEntriesNotes }: { dataEntriesNotes: Record }) => dataEntriesNotes[`${dataEntryId}-${dataEntryKey}`] ?? []); + const { + currentStageWriteAccess, + trackedEntityTypeName, + showWidgetBadge, + } = useEnrollmentAccessContext(); const onAddNote = (newNoteValue: string) => { dispatch(requestAddNoteForEvent(dataEntryKey, dataEntryId, newNoteValue)); @@ -21,6 +28,13 @@ export const WidgetEventNote = ({ dataEntryKey, dataEntryId }: Props) => { placeholder={i18n.t('Write a note about this event')} emptyNoteMessage={i18n.t('This event doesn\'t have any notes')} notes={notes} + readOnly={!currentStageWriteAccess} + badge={showWidgetBadge ? ( + + ) : null} onAddNote={onAddNote} />
diff --git a/src/core_modules/capture-core/components/WidgetNote/NoteSection/NoteSection.tsx b/src/core_modules/capture-core/components/WidgetNote/NoteSection/NoteSection.tsx index eff700343a..4c1af78407 100644 --- a/src/core_modules/capture-core/components/WidgetNote/NoteSection/NoteSection.tsx +++ b/src/core_modules/capture-core/components/WidgetNote/NoteSection/NoteSection.tsx @@ -70,6 +70,7 @@ const NoteSectionPlain = ({ emptyNoteMessage, notes, handleAddNote, + readOnly, classes, }: Props) => { const [isEditing, setEditing] = useState(false); @@ -130,7 +131,7 @@ const NoteSectionPlain = ({
}
-
+ {!readOnly &&
-
+
} - {isEditing &&
+ {!readOnly && isEditing &&
@@ -59,13 +39,34 @@ export const DataEntryComponent = ({ {i18n.t('Loading...')} )} - {(modalState === TEI_MODAL_STATE.OPEN || modalState === TEI_MODAL_STATE.OPEN_ERROR) && ( )} - - - + + } + > + {i18n.t( + 'Change information about this {{trackedEntityName}} here.', + { trackedEntityName, interpolation: { escapeValue: false } }, + )} + {' '} + {i18n.t('Information about this enrollment can be edited in the Enrollment widget.')} + + + ); diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntry.container.tsx b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntry.container.tsx index 6a4ae8911d..5c547e8a55 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntry.container.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntry.container.tsx @@ -9,6 +9,7 @@ import { dataElementTypes } from 'capture-core/metaData'; import { makeQuerySingleResource } from 'capture-core/utils/api'; import type { Props } from './dataEntry.types'; import { DataEntryComponent } from './DataEntry.component'; +import { DataEntryReadOnlyComponent } from './DataEntryReadOnly.component'; import { useLifecycle, useFormValidations } from './hooks'; import { getUpdateFieldActions, updateTeiRequest, setTeiModalError } from './dataEntry.actions'; import { startRunRulesPostUpdateField } from '../../DataEntry'; @@ -32,6 +33,8 @@ export const DataEntry = ({ geometry, trackedEntityName, dataEntryFormConfig, + readOnly, + accessReadOnly, }: Props) => { const dataEntryId = 'trackedEntityProfile'; const itemId = 'edit'; @@ -145,25 +148,45 @@ export const DataEntry = ({ onDisable, ]); - return ( - Object.entries(formFoundation).length > 0 && ( - - ) + ); + } + + return ( + ); }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntryModalWrapper.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntryModalWrapper.component.tsx new file mode 100644 index 0000000000..c768612d9d --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntryModalWrapper.component.tsx @@ -0,0 +1,51 @@ +import React, { type ReactNode } from 'react'; +import { Modal, ModalTitle, ModalContent, ModalActions, ButtonStrip, spacersNum } from '@dhis2/ui'; +import { withStyles, type WithStyles } from 'capture-core-utils/styles'; +import { ReadOnlyBadge } from '../../ReadOnlyBadge'; + +const styles = { + title: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: `${spacersNum.dp8}px`, + }, +}; + +type Props = { + title: ReactNode; + actions: ReactNode; + onClose: () => void; + trackedEntityName: string; + accessReadOnly?: boolean; + children: ReactNode; +}; + +const DataEntryModalWrapperPlain = ({ + classes, + title, + actions, + onClose, + trackedEntityName, + accessReadOnly, + children, +}: Props & WithStyles) => ( + + +
+ {title} + +
+
+ {children} + + {actions} + +
+); + +export const DataEntryModalWrapper = withStyles(styles)(DataEntryModalWrapperPlain); diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntryReadOnly.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntryReadOnly.component.tsx new file mode 100644 index 0000000000..910e8ab1f5 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/DataEntryReadOnly.component.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Button } from '@dhis2/ui'; +import i18n from '@dhis2/d2-i18n'; +import { DataEntry } from '../../DataEntry'; +import { DataEntryModalWrapper } from './DataEntryModalWrapper.component'; +import type { PluginContext } from '../../D2Form/FormFieldPlugin/FormFieldPlugin.types'; + +type Props = { + dataEntryId: string; + trackedEntityName: string; + saveAttempted: boolean; + formFoundation: any; + onCancel: () => void; + onUpdateFormField: (innerAction: any) => void; + onUpdateFormFieldAsync: (innerAction: any) => void; + onGetValidationContext: () => Record; + orgUnitId: string; + pluginContext?: PluginContext; + accessReadOnly?: boolean; +}; + +export const DataEntryReadOnlyComponent = ({ + dataEntryId, + onCancel, + saveAttempted, + onUpdateFormField, + onUpdateFormFieldAsync, + trackedEntityName, + formFoundation, + onGetValidationContext, + orgUnitId, + pluginContext, + accessReadOnly, +}: Props) => ( + + {i18n.t('Close')} + + } + > + + +); diff --git a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.types.ts b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.types.ts index c7f33e3d05..e7bbe7c132 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.types.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/DataEntry/dataEntry.types.ts @@ -6,7 +6,6 @@ import type { PluginContext } from '../../D2Form/FormFieldPlugin/FormFieldPlugin export type PlainProps = { dataEntryId: string; - itemId: string; trackedEntityName: string; saveAttempted: boolean; formFoundation: any; @@ -18,9 +17,23 @@ export type PlainProps = { modalState: string; errorsMessages: Array<{ id: string; message: string }>; warningsMessages: Array<{ id: string; message: string }>; - center?: Array; orgUnitId: string; pluginContext?: PluginContext; + accessReadOnly?: boolean; +}; + +export type ReadOnlyPlainProps = { + dataEntryId: string; + trackedEntityName: string; + saveAttempted: boolean; + formFoundation: any; + onCancel: () => void; + onUpdateFormField: (innerAction: any) => void; + onUpdateFormFieldAsync: (innerAction: any) => void; + onGetValidationContext: () => Record; + orgUnitId: string; + pluginContext?: PluginContext; + accessReadOnly?: boolean; }; export type Props = { @@ -39,4 +52,6 @@ export type Props = { geometry?: Geometry; userRoles: Array; trackedEntityName: string; + readOnly?: boolean; + accessReadOnly?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.component.tsx b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.component.tsx index 3c8b8eae25..b3d954d244 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.component.tsx +++ b/src/core_modules/capture-core/components/WidgetProfile/OverflowMenu/Delete/DeleteMenuItem/DeleteMenuItem.component.tsx @@ -24,6 +24,10 @@ export const DeleteMenuItem = ({ const disabled = useMemo(() => !canWriteData || !canCascadeDeleteTei, [canWriteData, canCascadeDeleteTei]); const tooltipContent = getTooltipContent(disabled, trackedEntityTypeName); + if (!canWriteData) { + return null; + } + return ( = { header: { @@ -56,13 +57,15 @@ const showEditModal = (loading: boolean, error: any, showEdit: boolean, modalSta const computeLoadingState = ( programsLoading: boolean, trackedEntityInstancesLoading: boolean, + userRolesLoading: boolean, configIsFetched: boolean, -) => programsLoading || trackedEntityInstancesLoading || !configIsFetched; +) => programsLoading || trackedEntityInstancesLoading || userRolesLoading || !configIsFetched; const computeError = ( programsError: any, trackedEntityInstancesError: any, -) => programsError || trackedEntityInstancesError; + userRolesError: any, +) => programsError || trackedEntityInstancesError || userRolesError; const WidgetProfilePlain = ({ teiId, @@ -89,22 +92,35 @@ const WidgetProfilePlain = ({ error: trackedEntityInstancesError, trackedEntity, trackedEntityInstanceAttributes, - trackedEntityTypeName, - trackedEntityTypeAccess, geometry, } = useTrackedEntityInstances(teiId, programId, storedAttributeValues, storedGeometry); - const userRoles = CurrentUser.get().userRoles; + const { + programWriteAccess, + trackedEntityTypeWriteAccess, + } = useEnrollmentAccessContext(); + const { + loading: userRolesLoading, + error: userRolesError, + userRoles, + } = useUserRoles(); + const trackedEntityTypeName = program?.trackedEntityType?.displayName; const hasNoAttributes = !program?.programTrackedEntityAttributes?.length; - const isEditable = useMemo(() => - !hasNoAttributes && - trackedEntityTypeAccess?.data?.write && - !readOnlyMode, - [hasNoAttributes, readOnlyMode, trackedEntityTypeAccess]); + const isEditable = useMemo( + () => !hasNoAttributes && trackedEntityTypeWriteAccess && !readOnlyMode, + [hasNoAttributes, trackedEntityTypeWriteAccess, readOnlyMode], + ); + + const profileButtonLabel = useMemo(() => { + if (readOnlyMode || hasNoAttributes) return null; + if (!isEditable) return i18n.t('View profile'); + if (isEditable) return i18n.t('Edit'); + return null; + }, [isEditable, readOnlyMode, hasNoAttributes]); - const loading = computeLoadingState(programsLoading, trackedEntityInstancesLoading, configIsFetched); - const error = computeError(programsError, trackedEntityInstancesError); + const loading = computeLoadingState(programsLoading, trackedEntityInstancesLoading, userRolesLoading, configIsFetched); + const error = computeError(programsError, trackedEntityInstancesError, userRolesError); const clientAttributesWithSubvalues = useClientAttributesWithSubvalues( teiId, program as any, @@ -139,8 +155,8 @@ const WidgetProfilePlain = ({ }, [storedAttributeValues, onUpdateTeiAttributeValues, teiDisplayName]); const canWriteData = useMemo( - () => trackedEntityTypeAccess?.data?.write && program?.access?.data?.write, - [trackedEntityTypeAccess, program], + () => trackedEntityTypeWriteAccess && programWriteAccess, + [trackedEntityTypeWriteAccess, programWriteAccess], ); const renderProfile = () => { @@ -174,52 +190,61 @@ const WidgetProfilePlain = ({
); }; + const handleOnDisable = useCallback(() => setTeiModalState(TEI_MODAL_STATE.OPEN_DISABLE), [setTeiModalState]); const handleOnEnable = useCallback(() => setTeiModalState(TEI_MODAL_STATE.OPEN), [setTeiModalState]); + const handleOpen = useCallback(() => setOpenStatus(true), [setOpenStatus]); + const handleClose = useCallback(() => setOpenStatus(false), [setOpenStatus]); + + const isEmptyList = !loading && !error && !hasNoAttributes && displayInListAttributes.length === 0; + + const trackedEntityProp = useMemo(() => ({ + trackedEntity: trackedEntity ? (trackedEntity.trackedEntity || teiId) : teiId, + }), [trackedEntity, teiId]); + + const widgetHeader = ( +
+
+ {trackedEntityTypeName + ? i18n.t('{{trackedEntityTypeName}} profile', { + trackedEntityTypeName, + interpolation: { escapeValue: false }, + }) + : i18n.t('Profile')} +
+
+ {profileButtonLabel && ( + + )} + +
+
+ ); return (
-
- {trackedEntityTypeName - ? i18n.t('{{trackedEntityTypeName}} profile', { - trackedEntityTypeName, - interpolation: { escapeValue: false }, - }) - : i18n.t('Profile')} -
-
- {isEditable && ( - - )} - -
-
- } - onOpen={useCallback(() => setOpenStatus(true), [setOpenStatus])} - onClose={useCallback(() => setOpenStatus(false), [setOpenStatus])} + header={widgetHeader} + onOpen={handleOpen} + onClose={handleClose} open={open} + noncollapsible={isEmptyList} > {renderProfile()} - {showEditModal(loading, error, isEditable, modalState, program) && ( + {showEditModal(loading, error, Boolean(profileButtonLabel), modalState, program) && ( <> setTeiModalState(TEI_MODAL_STATE.CLOSE)} @@ -237,6 +262,8 @@ const WidgetProfilePlain = ({ modalState={modalState} geometry={geometry} trackedEntityName={trackedEntityTypeName} + readOnly={!isEditable} + accessReadOnly={!trackedEntityTypeWriteAccess} /> diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/index.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/index.ts index d5fc613397..9796657555 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/index.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/index.ts @@ -1,4 +1,5 @@ export { useProgram } from './useProgram'; export { useTrackedEntityInstances } from './useTrackedEntityInstances'; export { useClientAttributesWithSubvalues } from './useClientAttributesWithSubvalues'; +export { useUserRoles } from './useUserRoles'; export { useTeiDisplayName } from './useTeiDisplayName'; diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.ts index 368f94b5b7..6de9d9a339 100644 --- a/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.ts +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useTrackedEntityInstances.ts @@ -9,13 +9,6 @@ type TrackedEntityInstance = { [key: string]: any; }; -type TetData = { - trackedEntityType?: { - displayName?: string; - access?: any; - }; -}; - type QueryData = { trackedEntityInstance?: TrackedEntityInstance; }; @@ -44,22 +37,6 @@ export const useTrackedEntityInstances = ( ), ); - const { loading: tetLoading, data: tetData, refetch: refetchTET } = useDataQuery( - useMemo( - () => ({ - trackedEntityType: { - resource: 'trackedEntityTypes', - id: ({ variables }: any) => variables.tetId, - params: { - fields: 'displayName,access', - }, - }, - }), - [], - ), - { lazy: true }, - ); - useEffect(() => { const attributes = data?.trackedEntityInstance?.attributes; if (attributes && attributes.length > 0) { @@ -84,12 +61,6 @@ export const useTrackedEntityInstances = ( } }, [storedAttributeValues]); - useEffect(() => { - if (data?.trackedEntityInstance?.trackedEntityType) { - refetchTET({ variables: { tetId: data?.trackedEntityInstance?.trackedEntityType } }); - } - }, [data?.trackedEntityInstance?.trackedEntityType, refetchTET]); - useEffect(() => { if (storedGeometry !== undefined) { setGeometry(storedGeometry); @@ -101,8 +72,6 @@ export const useTrackedEntityInstances = ( loading, trackedEntity: !loading && data?.trackedEntityInstance, trackedEntityInstanceAttributes: !loading && trackedEntityInstanceAttributes, - trackedEntityTypeName: tetLoading ? undefined : (tetData?.trackedEntityType as any)?.displayName, - trackedEntityTypeAccess: !tetLoading && (tetData?.trackedEntityType as any)?.access, geometry, }; }; diff --git a/src/core_modules/capture-core/components/WidgetProfile/hooks/useUserRoles.ts b/src/core_modules/capture-core/components/WidgetProfile/hooks/useUserRoles.ts new file mode 100644 index 0000000000..4a5e9d10f3 --- /dev/null +++ b/src/core_modules/capture-core/components/WidgetProfile/hooks/useUserRoles.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import { useDataQuery } from '@dhis2/app-runtime'; + +const fields = 'userCredentials[userRoles]'; + +export const useUserRoles = () => { + const { error, loading, data } = useDataQuery( + useMemo( + () => ({ + userData: { + resource: 'me.json', + params: { + fields, + }, + }, + }), + [], + ), + ); + + const userRoles = useMemo( + () => { + if (!loading && data?.userData) { + const userData = data.userData as { + userCredentials?: { + userRoles?: Array<{ id: string }> + } + }; + return userData.userCredentials?.userRoles?.map(({ id }) => id) ?? []; + } + return []; + }, + [loading, data], + ); + return { error, loading, userRoles }; +}; diff --git a/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx b/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx index 087b2a5081..eeed2858bf 100644 --- a/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx +++ b/src/core_modules/capture-core/components/WidgetRelatedStages/WidgetRelatedStages.container.tsx @@ -14,6 +14,7 @@ import { } from './hooks'; import { relatedStageStatus } from './constants'; import { useCommonEnrollmentDomainData } from '../Pages/common/EnrollmentOverviewDomain'; +import { useEnrollmentAccessContext } from '../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { RequestEvent } from '../DataEntries'; const styles = { @@ -48,7 +49,11 @@ export const WidgetRelatedStagesPlain = ({ }: Props) => { const [isLinking, setIsLinking] = useState(false); const { enrollment } = useCommonEnrollmentDomainData(teiId, enrollmentId, programId); - const { currentRelatedStagesStatus } = useRelatedStages({ programStageId, programId }); + const { currentRelatedStagesStatus, constraint } = useRelatedStages({ programStageId, programId }); + const { stageWriteAccessById } = useEnrollmentAccessContext(); + const stageWriteAccess = Boolean(stageWriteAccessById[programStageId]); + const linkedStageId = constraint?.programStage?.id; + const linkedStageWriteAccess = linkedStageId ? Boolean(stageWriteAccessById[linkedStageId]) : false; const { linkedEvent, isLoading: isLinkedEventLoading, @@ -106,6 +111,10 @@ export const WidgetRelatedStagesPlain = ({ return null; } + if (!stageWriteAccess || !linkedStageWriteAccess) { + return null; + } + return ( setOpenStatus(true), [setOpenStatus]); const handleClose = useCallback(() => setOpenStatus(false), [setOpenStatus]); @@ -49,6 +52,7 @@ export const StagePlain = ({ icon={icon} description={description} events={events} + stageWriteAccess={effectiveStageWriteAccess} />} onOpen={handleOpen} onClose={handleClose} @@ -65,11 +69,10 @@ export const StagePlain = ({ onCreateNew={onCreateNew} hiddenProgramStage={preventAddingNewEvents} {...passOnProps} - /> : ( + /> : effectiveStageWriteAccess && (
onCreateNew(id)} - stageWriteAccess={stage.dataAccess.write} eventCount={events.length} repeatable={repeatable} preventAddingEventActionInEffect={preventAddingNewEvents} diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx index 0a503dc2dd..891650a162 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageCreateNewButton/StageCreateNewButton.tsx @@ -5,7 +5,6 @@ import { ConditionalTooltip } from '../../../../Tooltips/ConditionalTooltip'; type Props = { onCreateNew: () => void; - stageWriteAccess?: boolean; eventCount: number; repeatable?: boolean; preventAddingEventActionInEffect?: boolean; @@ -14,22 +13,12 @@ type Props = { export const StageCreateNewButton = ({ onCreateNew, - stageWriteAccess, eventCount, repeatable, preventAddingEventActionInEffect, eventName, }: Props) => { const { isDisabled, tooltipContent } = useMemo(() => { - if (!stageWriteAccess) { - return ({ - isDisabled: true, - tooltipContent: i18n.t('You do not have access to create events in this stage', { - programStageName: eventName, - interpolation: { escapeValue: false }, - }), - }); - } if (preventAddingEventActionInEffect) { return { isDisabled: true, @@ -49,7 +38,7 @@ export const StageCreateNewButton = ({ isDisabled: false, tooltipContent: '', }; - }, [eventCount, eventName, preventAddingEventActionInEffect, repeatable, stageWriteAccess]); + }, [eventCount, eventName, preventAddingEventActionInEffect, repeatable]); return ( - <> - setActionsOpen(prev => !prev)} - dataTest={'overflow-button'} - secondary - small - icon={} - disabled={pendingApiResponse || !stageWriteAccess} - component={( - - {(eventDetails.status === EventStatuses.SCHEDULE || - eventDetails.status === EventStatuses.SKIPPED) && ( - + setActionsOpen(prev => !prev)} + dataTest={'overflow-button'} + secondary + small + icon={} + disabled={pendingApiResponse} + component={( + + {(eventDetails.status === EventStatuses.SCHEDULE || + eventDetails.status === EventStatuses.SKIPPED) && ( + + )} + + - )} + + )} + /> - - + {deleteModalOpen && ( + )} - /> - - {deleteModalOpen && ( - - )} - + + )} ); 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 2e4b4a63f2..f6feb0ce38 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 @@ -26,6 +26,7 @@ import { getProgramAndStageForProgram } from '../../../../../metaData/helpers'; import type { Props } from './stageDetail.types'; import { EventRow } from './EventRow'; import { useClientDataElements } from './hooks/useClientDataElements'; +import { useEnrollmentAccessContext } from '../../../../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; const styles: Readonly = { @@ -106,6 +107,8 @@ const StageDetailPlain = (props: Props & WithStyles) => { sortDirection: SORT_DIRECTION.DESC, }; const { stage } = getProgramAndStageForProgram(programId, stageId); + const { stageWriteAccessById } = useEnrollmentAccessContext(); + const stageWriteAccess = stageWriteAccessById[stageId] ?? stage?.access?.data?.write; const headerColumns = useComputeHeaderColumn(dataElements, hideDueDate, enableUserAssignment, stage?.stageForm); const dataElementsClient = useClientDataElements(dataElements); const { loading, value: dataSource, error } = useComputeDataFromEvent(dataElementsClient, events); @@ -219,7 +222,7 @@ const StageDetailPlain = (props: Props & WithStyles) => { pendingApiResponse={row.pendingApiResponse as boolean} eventDetails={eventDetails} teiId={eventDetails.trackedEntity} - stageWriteAccess={stage?.access?.data?.write} + stageWriteAccess={stageWriteAccess} programId={programId} enrollmentId={eventDetails.enrollment} cells={cells} @@ -254,18 +257,17 @@ const StageDetailPlain = (props: Props & WithStyles) => { onClick={handleViewAll} >{i18n.t('Go to full {{ eventName }}', { eventName, interpolation: { escapeValue: false } })} : null); - const renderCreateNewButton = () => ( + const renderCreateNewButton = () => (stageWriteAccess ? (
- ); + ) : null); return (
@@ -284,6 +286,10 @@ const StageDetailPlain = (props: Props & WithStyles) => {
); } + const showMoreVisible = Boolean(dataSource && !loading + && events.length > DEFAULT_NUMBER_OF_ROW + && displayedRowNumber < events.length); + const showFooter = showMoreVisible || stageWriteAccess; return (
@@ -298,11 +304,13 @@ const StageDetailPlain = (props: Props & WithStyles) => {
- -
- {renderFooter()} -
-
+ {showFooter && ( + +
+ {renderFooter()} +
+
+ )}
); }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/StageOverview.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/StageOverview.component.tsx index f0cb8dbbc3..d87ef71abc 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/StageOverview.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/StageOverview.component.tsx @@ -10,6 +10,8 @@ import i18n from '@dhis2/d2-i18n'; import moment from 'moment'; import { statusTypes } from 'capture-core/events/statusTypes'; import { NonBundledDhis2Icon } from '../../../../NonBundledDhis2Icon'; +import { ReadOnlyBadge } from '../../../../ReadOnlyBadge'; +import { useEnrollmentAccessContext } from '../../../../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; import type { Props } from './stageOverview.types'; import { isEventOverdue } from '../StageDetail/hooks/helpers'; import { convertValue as convertValueClientToView } from '../../../../../converters/clientToView'; @@ -90,8 +92,12 @@ const getLastUpdatedAt = (events: Array, fromServerDate: (da return null; }; -export const StageOverviewPlain = ({ title, icon, description, events, classes }: Props & WithStyles) => { +export const StageOverviewPlain = ({ + title, icon, description, events, stageWriteAccess = true, classes, +}: Props & WithStyles) => { const { fromServerDate } = useTimeZoneConversion(); + const { anyStageWriteAccess, showWidgetBadge } = useEnrollmentAccessContext(); + const showStageBadge = showWidgetBadge && anyStageWriteAccess; const totalEvents = events.length; const overdueEvents = events.filter(isEventOverdue).length; const scheduledEvents = events.filter(event => event.status === statusTypes.SCHEDULE).length; @@ -154,6 +160,11 @@ export const StageOverviewPlain = ({ title, icon, description, events, classes }
{getLastUpdatedAt(events, fromServerDate)}
} + {showStageBadge && ( + + )} ); }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/stageOverview.types.ts b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/stageOverview.types.ts index 7fceabadc9..8889520a25 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/stageOverview.types.ts +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stage/StageOverview/stageOverview.types.ts @@ -6,4 +6,5 @@ export type Props = { events: Array; icon?: Icon; description?: string | null; + stageWriteAccess?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx index 03190695a3..6ee363c119 100644 --- a/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx +++ b/src/core_modules/capture-core/components/WidgetStagesAndEvents/Stages/Stages.component.tsx @@ -1,10 +1,32 @@ import React, { type ComponentType, useMemo } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { colors, spacersNum } from '@dhis2/ui'; import { compose } from 'redux'; import { Stage } from './Stage'; import type { PlainProps, InputProps } from './stages.types'; import { withLoadingIndicator } from '../../../HOC'; +import { useEnrollmentAccessContext } from '../../Pages/common/EnrollmentOverviewDomain/EnrollmentAccessContext'; -export const StagesPlain = ({ stages, events, ...passOnProps }: PlainProps) => { +const emptyStateStyle = { + padding: `0 ${spacersNum.dp12}px`, + color: colors.grey600, + fontWeight: 400, + fontSize: '14px', + lineHeight: '19px', + margin: 0, + marginBottom: spacersNum.dp12, +}; + +export const StagesPlain = ({ + stages, + events, + ...passOnProps +}: PlainProps) => { + const { stageReadAccessById } = useEnrollmentAccessContext(); + const readableStages = useMemo( + () => stages.filter(stage => stageReadAccessById[stage.id] ?? stage.dataAccess.read), + [stages, stageReadAccessById], + ); const eventsByStage = useMemo( () => stages.reduce( (acc, stage) => { @@ -27,10 +49,17 @@ export const StagesPlain = ({ stages, events, ...passOnProps }: PlainProps) => { [stages, events], ); + if (!readableStages.length) { + return ( +

+ {i18n.t('No stages found in this program')} +

+ ); + } + return (<> { - stages - .filter(stage => stage.dataAccess.read) + readableStages .map(stage => ( { +const styles = { + header: { + display: 'flex', + alignItems: 'center', + gap: `${spacersNum.dp8}px`, + flex: 1, + }, + badge: { + marginInlineStart: 'auto', + }, +}; + +const WidgetStagesAndEventsPlain = ({ + classes, + className, + stages, + events, + programId, + ...passOnProps +}: Props & WithStyles) => { const [open, setOpenStatus] = useState(true); + const { + anyStageWriteAccess, + anyStageReadAccess, + multipleStages, + showWidgetBadge, + } = useEnrollmentAccessContext(); + return (
+ {i18n.t('Stages and Events')} + {showWidgetBadge && ( +
+ +
+ )} +
+ } onOpen={useCallback(() => setOpenStatus(true), [setOpenStatus])} onClose={useCallback(() => setOpenStatus(false), [setOpenStatus])} open={open} @@ -21,9 +63,12 @@ export const WidgetStagesAndEvents = ({ className, stages, events, ...passOnProp stages={stages} ready={events !== undefined && stages !== undefined} events={events} + programId={programId} {...passOnProps} />
); }; + +export const WidgetStagesAndEvents = withStyles(styles)(WidgetStagesAndEventsPlain); diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx index 6db237113c..11f07eb94b 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/OverflowMenu/OverflowMenu.component.tsx @@ -68,12 +68,12 @@ export const OverflowMenuComponent = ({ } - disabled={!relationshipTypeWriteAccess} + disabled={!stageWriteAccess || !relationshipTypeWriteAccess} dense dataTest="event-overflow-unlink-event" onClick={handleUnlinkEvent} diff --git a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx index d608c52b05..89f4b6200d 100644 --- a/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx +++ b/src/core_modules/capture-core/components/WidgetTwoEventWorkspace/WidgetHeader/WidgetHeader.container.tsx @@ -4,6 +4,7 @@ import { withStyles, type WithStyles } from 'capture-core-utils/styles'; import { EnrollmentPageKeys } from '../../Pages/common/EnrollmentOverviewDomain/EnrollmentPageLayout/DefaultEnrollmentLayout.constants'; import { NonBundledDhis2Icon } from '../../NonBundledDhis2Icon'; +import { ReadOnlyBadge } from '../../ReadOnlyBadge'; import type { PlainProps } from './WidgetHeader.types'; import { OverflowMenuComponent } from '../OverflowMenu'; @@ -14,6 +15,9 @@ export const styles: Readonly = { icon: { marginInlineEnd: spacersNum.dp8, }, + badge: { + marginInlineStart: spacersNum.dp8, + }, }; const WidgetHeaderPlain = ({ @@ -30,6 +34,7 @@ const WidgetHeaderPlain = ({ onDeleteEventRelationship, }: PlainProps & WithStyles) => { const { icon } = linkedStage; + const linkedStageWriteAccess = Boolean(linkedStage?.access?.data?.write); return ( <> {icon && ( @@ -44,6 +49,11 @@ const WidgetHeaderPlain = ({ )} {linkedStage.name} + {!linkedStageWriteAccess && ( +
+ +
+ )} {currentPage === EnrollmentPageKeys.VIEW_EVENT && (
+ Boolean(program?.programStages?.find((s: any) => s.id === id)?.access?.data?.read); + +const useTwoEventWorkspaceData = (eventId: string, programId: string, fallbackStageId: string | undefined) => { + const { program } = useProgram(programId); + + const linkedEventQuery = useLinkedEventByOriginId({ originEventId: eventId }); + const { linkedEvent, dataValues } = linkedEventQuery; + + const metadataQuery = useMetadataForProgramStage({ + programId, + stageId: linkedEvent?.programStage, + }); + const { formFoundation, stage: linkedStage } = metadataQuery; + + const clientValuesQuery = useClientDataValues({ + linkedEventId: linkedEvent?.event, + dataValues, + formFoundation, + }); + + const isLoading = linkedEventQuery.isLoading || metadataQuery.isLoading || clientValuesQuery.isLoading; + const isError = linkedEventQuery.isError || metadataQuery.isError || clientValuesQuery.isError; + const missingData = !linkedEvent || !formFoundation || !linkedStage; + const accessBlocked = Boolean(program) + && (!stageHasReadAccess(program, fallbackStageId) || !stageHasReadAccess(program, linkedEvent?.programStage)); + + return { + program, + linkedEvent, + linkedStage, + formFoundation, + relationship: linkedEventQuery.relationship, + relationshipType: linkedEventQuery.relationshipType, + clientValuesWithSubValues: clientValuesQuery.clientValuesWithSubValues, + isLoading, + isError, + missingData, + accessBlocked, + }; +}; export const WidgetTwoEventWorkspace = ({ eventId, programId, orgUnitId, currentPage, + stageId, stage, type, onDeleteEvent, @@ -21,48 +65,26 @@ export const WidgetTwoEventWorkspace = ({ }: Props) => { const { linkedEvent, - dataValues, + linkedStage, + formFoundation, relationship, relationshipType, - isError: isLinkedEventError, - isLoading: isLinkedEventLoading, - } = useLinkedEventByOriginId({ originEventId: eventId }); - - const { - formFoundation, - stage: linkedStage, - isLoading: isLoadingMetadata, - isError: isMetadataError, - } = useMetadataForProgramStage({ - programId, - stageId: linkedEvent?.programStage, - }); - - const { clientValuesWithSubValues, - isLoading: isLoadingClientValues, - isError: isClientValuesError, - } = useClientDataValues({ - linkedEventId: linkedEvent?.event, - dataValues, - formFoundation, - }); + isLoading, + isError, + missingData, + accessBlocked, + } = useTwoEventWorkspaceData(eventId, programId, stageId ?? stage?.id); - if (isLinkedEventLoading || isLoadingMetadata || isLoadingClientValues) { - return null; - } - - if (isLinkedEventError || isMetadataError || isClientValuesError) { + if (isLoading) return null; + if (isError) { return (
{i18n.t('An error occurred while loading the widget.')}
); } - - if (!linkedEvent || !formFoundation || !linkedStage) { - return null; - } + if (missingData || accessBlocked) return null; return ( ) => { const [addWizardVisible, setAddWizardVisible] = useState(false); @@ -40,13 +42,15 @@ export const NewTrackedEntityRelationshipPlain = ({ return (
- + {!hideButton && !readOnly && ( + + )} { addWizardVisible && ( diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.types.ts b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.types.ts index 5b87f3d76b..4e051ba0ae 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.types.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/NewTrackedEntityRelationship/NewTrackedEntityRelationship.types.ts @@ -33,6 +33,8 @@ export type ContainerProps = Readonly<{ onCloseAddRelationship?: () => void; onOpenAddRelationship?: () => void; onSelectFindMode?: OnSelectFindMode; + readOnly?: boolean; + hideButton?: boolean; }>; diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.component.tsx b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.component.tsx index 67426794c1..34b9048387 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.component.tsx +++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.component.tsx @@ -20,6 +20,10 @@ export const WidgetTrackedEntityRelationship = ({ onSelectFindMode, renderTrackedEntitySearch, renderTrackedEntityRegistration, + readOnly, + hideButton, + accessReadOnly, + hideReadOnlyBadge, }: WidgetTrackedEntityRelationshipProps) => { const { data: relationshipTypes } = useRelationshipTypes(cachedRelationshipTypes); const { data: trackedEntityTypeName, isLoading: isLoadingTEType } = useTrackedEntityTypeName(trackedEntityTypeId); @@ -60,6 +64,10 @@ export const WidgetTrackedEntityRelationship = ({ relationshipTypes={relationshipTypes} sourceId={teiId} onLinkedRecordClick={onLinkedRecordClick} + readOnly={readOnly} + accessReadOnly={accessReadOnly} + hideReadOnlyBadge={hideReadOnlyBadge} + trackedEntityName={trackedEntityTypeName} > ); diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.types.ts b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.types.ts index 84fcf8923e..a76eef799f 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.types.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/WidgetTrackedEntityRelationship/WidgetTrackedEntityRelationship.types.ts @@ -44,4 +44,8 @@ export type WidgetTrackedEntityRelationshipProps = { programId: string, onLinkToTrackedEntityFromSearch: OnLinkToTrackedEntityFromSearch, ) => React.ReactElement; + readOnly?: boolean; + hideButton?: boolean; + accessReadOnly?: boolean; + hideReadOnlyBadge?: boolean; }; diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntitiesViewer.component.tsx b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntitiesViewer.component.tsx index d33f38adf8..e731b5988d 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntitiesViewer.component.tsx +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/LinkedEntitiesViewer.component.tsx @@ -13,7 +13,7 @@ const styles = { fontWeight: 500, fontSize: 14, color: colors.grey900, - paddingBottom: spacersNum.dp8, + paddingBottom: spacersNum.dp4, }, wrapper: { paddingBottom: spacersNum.dp16, diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/RelationshipsWidget.component.tsx b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/RelationshipsWidget.component.tsx index 7e75048551..7948fbb2ae 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/RelationshipsWidget.component.tsx +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/RelationshipsWidget.component.tsx @@ -1,7 +1,10 @@ import React, { type ComponentType, useState } from 'react'; +import i18n from '@dhis2/d2-i18n'; +import { colors, spacersNum } from '@dhis2/ui'; import { withStyles } from 'capture-core-utils/styles'; import type { WithStyles } from 'capture-core-utils/styles'; import { Widget, WidgetHeaderCountBadge } from '../../../Widget'; +import { ReadOnlyBadge } from '../../../ReadOnlyBadge'; import { useGroupedLinkedEntities } from './useGroupedLinkedEntities'; import { LinkedEntitiesViewer } from './LinkedEntitiesViewer.component'; import type { Props } from './relationshipsWidget.types'; @@ -10,6 +13,12 @@ import { useDeleteRelationship } from './DeleteRelationship/useDeleteRelationshi const styles = { header: {}, + emptyMessage: { + padding: `0 ${spacersNum.dp12}px`, + color: colors.grey600, + fontSize: 14, + lineHeight: '19px', + }, }; const RelationshipsWidgetPlain = ({ @@ -20,10 +29,14 @@ const RelationshipsWidgetPlain = ({ relationshipTypes, onLinkedRecordClick, children, + readOnly, + accessReadOnly, + hideReadOnlyBadge, + trackedEntityName, classes, }: Props & WithStyles) => { const [open, setOpenStatus] = useState(true); - const groupedLinkedEntities = useGroupedLinkedEntities(sourceId, relationshipTypes, relationships); + const groupedLinkedEntities = useGroupedLinkedEntities(sourceId, relationshipTypes, relationships, readOnly); const { onDeleteRelationship } = useDeleteRelationship({ sourceId }); if (isLoading) { @@ -49,11 +62,19 @@ const RelationshipsWidgetPlain = ({ > +
{title} {(relationships?.length ?? 0) > 0 && ( )} + {!hideReadOnlyBadge && ( +
+ +
+ )}
)} onOpen={() => setOpenStatus(true)} @@ -69,6 +90,11 @@ const RelationshipsWidgetPlain = ({ /> ) } + {(relationships?.length ?? 0) === 0 && ( +
+ {i18n.t("This enrollment doesn't have any relationships")} +
+ )} {children}
diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/relationshipsWidget.types.ts b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/relationshipsWidget.types.ts index 56aad27cad..b2dd7f28a8 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/relationshipsWidget.types.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/relationshipsWidget.types.ts @@ -10,4 +10,8 @@ export type Props = Readonly<{ sourceId: string; onLinkedRecordClick: LinkedRecordClick; children: ReactNode; + readOnly?: boolean; + accessReadOnly?: boolean; + hideReadOnlyBadge?: boolean; + trackedEntityName?: string; }>; diff --git a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts index 8c0d0abd20..bcd69e09cb 100644 --- a/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts +++ b/src/core_modules/capture-core/components/WidgetsRelationship/common/RelationshipsWidget/useGroupedLinkedEntities.ts @@ -50,7 +50,11 @@ const getColumns = ({ relationshipEntity, trackerDataView }: any) => { return fields; }; -const getContext = ({ relationshipEntity, program, programStage, trackedEntityType }: any, access: any) => { +const getContext = ( + { relationshipEntity, program, programStage, trackedEntityType }: any, + access: any, + readOnly?: boolean, +) => { if (relationshipEntity === RELATIONSHIP_ENTITIES.TRACKED_ENTITY_INSTANCE) { return { navigation: { @@ -58,7 +62,7 @@ const getContext = ({ relationshipEntity, program, programStage, trackedEntityTy }, display: { trackedEntityTypeName: trackedEntityType.name, - showDeleteButton: access.data.write, + showDeleteButton: !readOnly && access.data.write, }, }; } @@ -68,7 +72,7 @@ const getContext = ({ relationshipEntity, program, programStage, trackedEntityTy navigation: {}, display: { programStageName: programStage.name, - showDeleteButton: access.data.write && false, + showDeleteButton: false, }, }; } @@ -164,6 +168,7 @@ export const useGroupedLinkedEntities = ( sourceId: string, relationshipTypes: RelationshipTypes | null | undefined, relationships?: Array, + readOnly?: boolean, ): GroupedLinkedEntities => useMemo(() => { if (!relationships?.length || !relationshipTypes?.length) { return []; @@ -220,7 +225,7 @@ export const useGroupedLinkedEntities = ( { constraint: relationshipType.toConstraint, name: relationshipType.fromToName }; const columns = getColumns(constraint); - const context = getContext(constraint, relationshipType.access); + const context = getContext(constraint, relationshipType.access, readOnly); accGroupedLinkedEntities.push({ id: groupId, @@ -233,4 +238,4 @@ export const useGroupedLinkedEntities = ( return accGroupedLinkedEntities; }, [] as any); -}, [relationships, relationshipTypes, sourceId]); +}, [relationships, relationshipTypes, sourceId, readOnly]); diff --git a/src/core_modules/capture-core/utils/localeData/useUserLocale.ts b/src/core_modules/capture-core/utils/localeData/useUserLocale.ts new file mode 100644 index 0000000000..902fcc507a --- /dev/null +++ b/src/core_modules/capture-core/utils/localeData/useUserLocale.ts @@ -0,0 +1,29 @@ +import { useDataEngine } from '@dhis2/app-runtime'; +import { useQuery } from '@tanstack/react-query'; + +export const useUserLocale = (): { + locale: any; + isLoading: boolean; + isError: boolean; + error: unknown; +} => { + const dataEngine = useDataEngine(); + + const { data, isInitialLoading, isError, error } = useQuery( + ['userLocale'], + () => dataEngine.query({ + userSettings: { + resource: 'me', + params: { + fields: 'settings[keyUiLocale]', + }, + }, + })); + + return { + locale: (data as any)?.userSettings?.settings?.keyUiLocale, + isLoading: isInitialLoading, + isError, + error, + }; +};