From 66f545f4db5f6f7037d846b8ec2f2de71a6dbc24 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 11:45:22 +0200 Subject: [PATCH 01/10] Add IM team builder page, add tree component, add repos, entities and use cases needed --- .../IncidentManagementTeamD2Repository.ts | 254 +++++++++++++++++ src/data/repositories/RoleD2Repository.ts | 52 ++++ .../IncidentManagementTeamBuilderConstants.ts | 47 ++++ .../IncidentManagementTeamTestRepository.ts | 55 ++++ .../repositories/test/RoleTestRepository.ts | 11 + .../utils/IncidentManagementTeamMapper.ts | 202 ++++++++++++++ src/data/repositories/utils/helpers.ts | 23 ++ .../entities/incident-management-team/Role.ts | 3 + .../IncidentManagementTeamRepository.ts | 23 ++ src/domain/repositories/RoleRepository.ts | 6 + ...IncidentManagementTeamMemberRoleUseCase.ts | 24 ++ .../GetIncidentManagementTeamByIdUseCase.ts | 24 ++ .../GetIncidentManagementTeamById.ts | 16 ++ .../GetIncidentManagementTeamWithOptions.ts | 66 +++++ .../im-team-hierarchy/IMTeamHierarchyItem.tsx | 138 +++++++++ .../im-team-hierarchy/IMTeamHierarchyView.tsx | 83 ++++++ .../im-team-hierarchy/TeamMemberProfile.tsx | 80 ++++++ .../components/simple-modal/SimpleModal.tsx | 82 ++++++ ...tManagementTeamMemberToInitialFormState.ts | 133 +++++++++ .../useIMTeamBuilder.ts | 264 ++++++++++++++++++ 20 files changed, 1586 insertions(+) create mode 100644 src/data/repositories/IncidentManagementTeamD2Repository.ts create mode 100644 src/data/repositories/RoleD2Repository.ts create mode 100644 src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts create mode 100644 src/data/repositories/test/IncidentManagementTeamTestRepository.ts create mode 100644 src/data/repositories/test/RoleTestRepository.ts create mode 100644 src/data/repositories/utils/IncidentManagementTeamMapper.ts create mode 100644 src/data/repositories/utils/helpers.ts create mode 100644 src/domain/entities/incident-management-team/Role.ts create mode 100644 src/domain/repositories/IncidentManagementTeamRepository.ts create mode 100644 src/domain/repositories/RoleRepository.ts create mode 100644 src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts create mode 100644 src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts create mode 100644 src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts create mode 100644 src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts create mode 100644 src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx create mode 100644 src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx create mode 100644 src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx create mode 100644 src/webapp/components/simple-modal/SimpleModal.tsx create mode 100644 src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts create mode 100644 src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts diff --git a/src/data/repositories/IncidentManagementTeamD2Repository.ts b/src/data/repositories/IncidentManagementTeamD2Repository.ts new file mode 100644 index 00000000..bea23b44 --- /dev/null +++ b/src/data/repositories/IncidentManagementTeamD2Repository.ts @@ -0,0 +1,254 @@ +import { D2TrackerEvent } from "@eyeseetea/d2-api/api/trackerEvents"; + +import { D2Api, MetadataPick } from "../../types/d2-api"; +import { apiToFuture, FutureData } from "../api-futures"; +import { Future } from "../../domain/entities/generic/Future"; +import { + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + RTSL_ZEBRA_ORG_UNIT_ID, + RTSL_ZEBRA_PROGRAM_ID, +} from "./consts/DiseaseOutbreakConstants"; +import { Maybe } from "../../utils/ts-utils"; +import { IncidentManagementTeam } from "../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { IncidentManagementTeamRepository } from "../../domain/repositories/IncidentManagementTeamRepository"; +import { Id } from "../../domain/entities/Ref"; +import { + getTeamMemberIncidentManagementTeamRoles, + mapD2EventsToIncidentManagementTeam, + mapIncidentManagementTeamMemberToD2Event, +} from "./utils/IncidentManagementTeamMapper"; +import { TeamMember, TeamRole } from "../../domain/entities/incident-management-team/TeamMember"; +import { getProgramStage } from "./utils/MetadataHelper"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; +import { assertOrError } from "./utils/AssertOrError"; + +export class IncidentManagementTeamD2Repository implements IncidentManagementTeamRepository { + constructor(private api: D2Api) {} + + get( + diseaseOutbreakId: Id, + teamMembers: TeamMember[] + ): FutureData> { + return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( + ({ dataElementRoles, events }) => { + const maybeIncidentManagementTeam: Maybe = + mapD2EventsToIncidentManagementTeam(events, dataElementRoles, teamMembers); + return Future.success(maybeIncidentManagementTeam); + } + ); + } + + getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData { + return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( + ({ dataElementRoles, events }) => { + return apiToFuture( + this.api.metadata.get({ + users: { + fields: d2UserFields, + filter: { username: { eq: username } }, + }, + }) + ) + .flatMap(response => + assertOrError(response.users[0], "Incident Management Team Member") + ) + .map(d2User => + this.mapUserToIncidentManagementTeamMember( + d2User as D2UserFix, + events, + dataElementRoles + ) + ); + } + ); + } + + private mapUserToIncidentManagementTeamMember( + d2User: D2UserFix, + events: D2TrackerEvent[], + dataElementRoles: D2DataElement[] + ): TeamMember { + const avatarId = d2User?.avatar?.id; + const photoUrlString = avatarId + ? `${this.api.baseUrl}/api/fileResources/${avatarId}/data` + : undefined; + + const teamMember = new TeamMember({ + id: d2User.id, + username: d2User.username, + name: d2User.name, + email: d2User.email, + phone: d2User.phoneNumber, + status: "Available", // TODO: Get status when defined + photo: + photoUrlString && TeamMember.isValidPhotoUrl(photoUrlString) + ? new URL(photoUrlString) + : undefined, + teamRoles: undefined, + workPosition: undefined, // TODO: Get workPosition when defined + }); + + const teamRoles = getTeamMemberIncidentManagementTeamRoles( + teamMember, + events, + dataElementRoles + ); + + return new TeamMember({ + ...teamMember, + teamRoles: teamRoles.length > 0 ? teamRoles : undefined, + }); + } + + saveIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData { + return this.saveOrDeleteIncidentManagementTeamMember( + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + "CREATE_AND_UPDATE" + ); + } + + deleteIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData { + return this.saveOrDeleteIncidentManagementTeamMember( + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + "DELETE" + ); + } + + private getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId: Id): FutureData<{ + dataElementRoles: D2DataElement[]; + events: D2TrackerEvent[]; + }> { + return Future.joinObj( + { + dataElementRoles: apiToFuture( + this.api.models.dataElements.get({ + fields: dataElementFields, + paging: false, + filter: { + id: { + in: Object.values( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS + ), + }, + }, + }) + ), + events: apiToFuture( + this.api.tracker.events.get({ + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + trackedEntity: diseaseOutbreakId, + programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + fields: { + dataValues: { + dataElement: { id: true, code: true }, + value: true, + }, + trackedEntity: true, + event: true, + }, + }) + ), + }, + { concurrency: 2 } + ).flatMap(({ dataElementRoles, events }) => { + return Future.success({ + dataElementRoles: dataElementRoles.objects, + events: events.instances, + }); + }); + } + + private saveOrDeleteIncidentManagementTeamMember( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id, + importStrategy: "CREATE_AND_UPDATE" | "DELETE" + ): FutureData { + return getProgramStage( + this.api, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID + ).flatMap(incidentManagementTeamBuilderResponse => { + const incidentManagementTeamBuilderDataElements = + incidentManagementTeamBuilderResponse.objects[0]?.programStageDataElements; + + if (!incidentManagementTeamBuilderDataElements) + return Future.error( + new Error(`Incident Management Team Builder Program Stage metadata not found`) + ); + + return apiToFuture( + this.api.tracker.enrollments.get({ + fields: { + enrollment: true, + }, + trackedEntity: diseaseOutbreakId, + enrolledBefore: new Date().toISOString(), + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + }) + ).flatMap(enrollmentResponse => { + const enrollmentId = enrollmentResponse.instances[0]?.enrollment; + if (!enrollmentId) { + return Future.error(new Error(`Enrollment not found for Disease Outbreak`)); + } + const d2Event: D2TrackerEvent = mapIncidentManagementTeamMemberToD2Event( + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + enrollmentId, + incidentManagementTeamBuilderDataElements + ); + + return apiToFuture( + this.api.tracker.post({ importStrategy: importStrategy }, { events: [d2Event] }) + ).flatMap(saveResponse => { + if (saveResponse.status === "ERROR" || !diseaseOutbreakId) { + return Future.error( + new Error(`Error with Incident Management Team Member`) + ); + } else { + return Future.success(undefined); + } + }); + }); + }); + } +} + +const d2UserFields = { + id: true, + name: true, + email: true, + phoneNumber: true, + username: true, + avatar: true, +} as const; + +type D2User = MetadataPick<{ + users: { fields: typeof d2UserFields }; +}>["users"][number]; + +type D2UserFix = D2User & { username: string }; + +const dataElementFields = { + id: true, + code: true, + name: true, +} as const; + +export type D2DataElement = MetadataPick<{ + dataElements: { fields: typeof dataElementFields }; +}>["dataElements"][number]; diff --git a/src/data/repositories/RoleD2Repository.ts b/src/data/repositories/RoleD2Repository.ts new file mode 100644 index 00000000..90584f6c --- /dev/null +++ b/src/data/repositories/RoleD2Repository.ts @@ -0,0 +1,52 @@ +import { D2Api, MetadataPick } from "../../types/d2-api"; +import { apiToFuture, FutureData } from "../api-futures"; +import { assertOrError } from "./utils/AssertOrError"; +import { Future } from "../../domain/entities/generic/Future"; +import { Role } from "../../domain/entities/incident-management-team/Role"; +import { RoleRepository } from "../../domain/repositories/RoleRepository"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; + +export class RoleD2Repository implements RoleRepository { + constructor(private api: D2Api) {} + + getAll(): FutureData { + return apiToFuture( + this.api.models.dataElements.get({ + fields: dataElementFields, + paging: false, + filter: { + id: { in: Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS) }, + }, + }) + ) + .flatMap(response => assertOrError(response.objects, `Roles not found`)) + .flatMap(d2DataElementRoles => { + if (d2DataElementRoles.length === 0) + return Future.error(new Error(`Roles not found`)); + else + return Future.success( + d2DataElementRoles.map(d2DataElementRole => + this.mapDataElementToRole(d2DataElementRole) + ) + ); + }); + } + + private mapDataElementToRole(d2DataElementRole: D2DataElement): Role { + return { + id: d2DataElementRole.id, + code: d2DataElementRole.code, + name: d2DataElementRole.name, + }; + } +} + +const dataElementFields = { + id: true, + code: true, + name: true, +} as const; + +type D2DataElement = MetadataPick<{ + dataElements: { fields: typeof dataElementFields }; +}>["dataElements"][number]; diff --git a/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts new file mode 100644 index 00000000..c43a079d --- /dev/null +++ b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts @@ -0,0 +1,47 @@ +import { GetValue } from "../../../utils/ts-utils"; + +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS = { + incidentManagerRole: "fnZ7EcG5CCV", + caseManagementRole: "Ci2TwQIVR2x", + ipcUnitLeadRole: "NARFizS9nsk", + labUnitLeadRole: "SsXKTkPrJt9", + operationalSectionLeadRole: "lO197QfYLBc", + surveillanceUnitLeadRole: "EnmRCZYjSV6", + vaccineUnitRole: "RMqPVOnz8ja", +} as const; + +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS = { + teamMemberAssigned: "iodfsSspCov", + reportsToUsername: "TFIPHJyXN6H", + incidentManagerRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + caseManagementRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole, + ipcUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole, + labUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole, + operationalSectionLeadRole: + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole, + surveillanceUnitLeadRole: + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole, + vaccineUnitRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole, +} as const; + +export const incidentManagementTeamBuilderCodes = { + teamMemberAssigned: "RTSL_ZEB_DET_IMB_TMA", + reportsToUsername: "RTSL_ZEB_DET_IMB_REPORTS", + incidentManagerRole: "RTSL_ZEB_DET_IMB_INCIDENT_MANAGER", + caseManagementRole: "RTSL_ZEB_DET_IMB_CASE_MANAGMENT", + ipcUnitLeadRole: "RTSL_ZEB_DET_IMB_IPC_LEAD", + labUnitLeadRole: "RTSL_ZEB_DET_IMB_LAB_LEAD", + operationalSectionLeadRole: "RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD", + surveillanceUnitLeadRole: "RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD", + vaccineUnitRole: "RTSL_ZEB_DET_IMB_VACCINE_UNIT", +} as const; + +export type IncidentManagementTeamBuilderCodes = GetValue< + typeof incidentManagementTeamBuilderCodes +>; + +export function isStringInIncidentManagementTeamBuilderCodes( + code: string +): code is IncidentManagementTeamBuilderCodes { + return (Object.values(incidentManagementTeamBuilderCodes) as string[]).includes(code); +} diff --git a/src/data/repositories/test/IncidentManagementTeamTestRepository.ts b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts new file mode 100644 index 00000000..ed2db170 --- /dev/null +++ b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts @@ -0,0 +1,55 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; +import { Id } from "../../../domain/entities/Ref"; +import { IncidentManagementTeamRepository } from "../../../domain/repositories/IncidentManagementTeamRepository"; +import { Maybe } from "../../../utils/ts-utils"; +import { FutureData } from "../../api-futures"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../consts/IncidentManagementTeamBuilderConstants"; + +export class IncidentManagementTeamTestRepository implements IncidentManagementTeamRepository { + get( + _diseaseOutbreakId: Id, + _teamMembers: TeamMember[] + ): FutureData> { + return Future.success(undefined); + } + + saveIncidentManagementTeamMemberRole( + _teamMemberRole: TeamRole, + _incidentManagementTeamMember: TeamMember, + _diseaseOutbreakId: Id + ): FutureData { + return Future.success(undefined); + } + + deleteIncidentManagementTeamMemberRole( + _teamMemberRole: TeamRole, + _incidentManagementTeamMember: TeamMember, + _diseaseOutbreakId: Id + ): FutureData { + return Future.success(undefined); + } + + getIncidentManagementTeamMember(username: Id, _diseaseOutbreakId: Id): FutureData { + const teamMember: TeamMember = new TeamMember({ + id: username, + username: username, + name: `Team Member Name ${username}`, + email: `email@email.com`, + phone: `121-1234`, + teamRoles: [ + { + id: "role", + name: "role", + roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + reportsToUsername: "reportsToUsername", + }, + ], + status: "Available", + photo: new URL("https://www.example.com"), + workPosition: "workPosition", + }); + return Future.success(teamMember); + } +} diff --git a/src/data/repositories/test/RoleTestRepository.ts b/src/data/repositories/test/RoleTestRepository.ts new file mode 100644 index 00000000..ad8ef9ca --- /dev/null +++ b/src/data/repositories/test/RoleTestRepository.ts @@ -0,0 +1,11 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { Role } from "../../../domain/entities/incident-management-team/Role"; +import { RoleRepository } from "../../../domain/repositories/RoleRepository"; +import { FutureData } from "../../api-futures"; + +export class RoleTestRepository implements RoleRepository { + getAll(): FutureData { + const roles: Role[] = []; + return Future.success(roles); + } +} diff --git a/src/data/repositories/utils/IncidentManagementTeamMapper.ts b/src/data/repositories/utils/IncidentManagementTeamMapper.ts new file mode 100644 index 00000000..3bafec72 --- /dev/null +++ b/src/data/repositories/utils/IncidentManagementTeamMapper.ts @@ -0,0 +1,202 @@ +import { D2TrackerEvent, DataValue } from "@eyeseetea/d2-api/api/trackerEvents"; + +import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { getPopulatedDataElement, getValueById } from "./helpers"; +import { + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + RTSL_ZEBRA_ORG_UNIT_ID, + RTSL_ZEBRA_PROGRAM_ID, +} from "../consts/DiseaseOutbreakConstants"; +import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; +import { Maybe } from "../../../utils/ts-utils"; +import { D2DataElement } from "../IncidentManagementTeamD2Repository"; +import _c from "../../../domain/entities/generic/Collection"; +import { Id } from "../../../domain/entities/Ref"; +import { SelectedPick } from "@eyeseetea/d2-api/api"; +import { D2DataElementSchema } from "@eyeseetea/d2-api/2.36"; +import { + IncidentManagementTeamBuilderCodes, + isStringInIncidentManagementTeamBuilderCodes, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, +} from "../consts/IncidentManagementTeamBuilderConstants"; + +export function mapD2EventsToIncidentManagementTeam( + events: D2TrackerEvent[], + dataElementRoles: D2DataElement[], + teamMembers: TeamMember[] +): Maybe { + const teamHierarchy: TeamMember[] = teamMembers.reduce( + (acc: TeamMember[], teamMember: TeamMember) => { + const memberRoleEvents = events.filter(event => { + const teamMemberAssignedUsername = getValueById( + event.dataValues, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + ); + return teamMemberAssignedUsername === teamMember.username; + }); + + if (memberRoleEvents.length === 0) { + return acc; + } else { + const teamRoles = getTeamMemberIncidentManagementTeamRoles( + teamMember, + memberRoleEvents, + dataElementRoles + ); + return teamRoles.length === 0 + ? acc + : [...acc, new TeamMember({ ...teamMember, teamRoles: teamRoles })]; + } + }, + [] + ); + + return new IncidentManagementTeam({ + teamHierarchy: teamHierarchy, + }); +} + +export function getTeamMemberIncidentManagementTeamRoles( + teamMemberAssigned: TeamMember, + events: D2TrackerEvent[], + dataElementRoles: D2DataElement[] +): TeamRole[] { + return events.reduce((acc: TeamRole[], event: D2TrackerEvent) => { + if ( + teamMemberAssigned.username === + getValueById( + event.dataValues, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + ) + ) { + const teamRole = getTeamRole(event.event, event.dataValues, dataElementRoles); + return teamRole ? [...acc, teamRole] : acc; + } + return acc; + }, []); +} + +function getTeamRole( + eventId: Id, + dataValues: DataValue[], + dataElementRoles: D2DataElement[] +): Maybe { + const roleIds = Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS); + + const selectedRoleId = roleIds.find(roleId => { + const role = getValueById(dataValues, roleId); + return role === "true"; + }); + + const roleDataElement = dataElementRoles.find(dataElement => dataElement.id === selectedRoleId); + + const reportsToUsername = getValueById( + dataValues, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.reportsToUsername + ); + + if (selectedRoleId && roleDataElement) { + return { + id: eventId, + roleId: selectedRoleId, + name: roleDataElement?.name, + reportsToUsername: reportsToUsername, + }; + } +} + +type D2ProgramStageDataElementsMetadata = { + dataElement: SelectedPick< + D2DataElementSchema, + { + id: true; + valueType: true; + code: true; + } + >; +}; + +export function mapIncidentManagementTeamMemberToD2Event( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + teiId: Id, + enrollmentId: Id, + programStageDataElementsMetadata: D2ProgramStageDataElementsMetadata[] +): D2TrackerEvent { + const dataElementValues: Record = + getValueFromIncidentManagementTeamMember( + incidentManagementTeamMember.username, + teamMemberRole + ); + + const dataValues: DataValue[] = programStageDataElementsMetadata.map(programStage => { + if (!isStringInIncidentManagementTeamBuilderCodes(programStage.dataElement.code)) { + throw new Error("DataElement code not found in IncidentManagementTeamBuilderCodes"); + } + const typedCode: IncidentManagementTeamBuilderCodes = programStage.dataElement.code; + return getPopulatedDataElement(programStage.dataElement.id, dataElementValues[typedCode]); + }); + + const d2IncidentManagementTeam: D2TrackerEvent = { + event: teamMemberRole.id ?? "", + status: "ACTIVE", + program: RTSL_ZEBRA_PROGRAM_ID, + programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + enrollment: enrollmentId, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + occurredAt: new Date().toISOString(), + dataValues: dataValues, + trackedEntity: teiId, + }; + + return d2IncidentManagementTeam; +} + +export function getValueFromIncidentManagementTeamMember( + incidentManagementTeamMemberUsername: string, + teamRoleAssigned: TeamRole +): Record { + const checkRoleSelected = (roleId: string): boolean => + (teamRoleAssigned?.roleId || "") === roleId; + + return { + RTSL_ZEB_DET_IMB_TMA: incidentManagementTeamMemberUsername, + RTSL_ZEB_DET_IMB_REPORTS: teamRoleAssigned?.reportsToUsername ?? "", + RTSL_ZEB_DET_IMB_INCIDENT_MANAGER: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_CASE_MANAGMENT: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_IPC_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_LAB_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_VACCINE_UNIT: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole + ) + ? "true" + : "", + }; +} diff --git a/src/data/repositories/utils/helpers.ts b/src/data/repositories/utils/helpers.ts new file mode 100644 index 00000000..edb6fd18 --- /dev/null +++ b/src/data/repositories/utils/helpers.ts @@ -0,0 +1,23 @@ +import { DataValue } from "@eyeseetea/d2-api/api/trackerEvents"; +import { Maybe } from "../../../utils/ts-utils"; +import { Id } from "../../../domain/entities/Ref"; + +export function getValueById(dataValues: DataValue[], dataElement: string): Maybe { + return dataValues.find(dataValue => dataValue.dataElement === dataElement)?.value; +} + +export function getDataValueById(dataValues: DataValue[], dataElement: string): Maybe { + return dataValues.find(dataValue => dataValue.dataElement === dataElement); +} + +export function getPopulatedDataElement(dataElement: Id, value: Maybe): DataValue { + const populatedDataElement: DataValue = { + dataElement: dataElement, + value: value ?? "", + updatedAt: new Date().toISOString(), + storedBy: "", + createdAt: new Date().toISOString(), + providedElsewhere: false, + }; + return populatedDataElement; +} diff --git a/src/domain/entities/incident-management-team/Role.ts b/src/domain/entities/incident-management-team/Role.ts new file mode 100644 index 00000000..f9bb7b9a --- /dev/null +++ b/src/domain/entities/incident-management-team/Role.ts @@ -0,0 +1,3 @@ +import { CodedNamedRef } from "../Ref"; + +export type Role = CodedNamedRef; diff --git a/src/domain/repositories/IncidentManagementTeamRepository.ts b/src/domain/repositories/IncidentManagementTeamRepository.ts new file mode 100644 index 00000000..fe710da6 --- /dev/null +++ b/src/domain/repositories/IncidentManagementTeamRepository.ts @@ -0,0 +1,23 @@ +import { FutureData } from "../../data/api-futures"; +import { Maybe } from "../../utils/ts-utils"; +import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; +import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; +import { Id } from "../entities/Ref"; + +export interface IncidentManagementTeamRepository { + get( + diseaseOutbreakId: Id, + teamMembers: TeamMember[] + ): FutureData>; + saveIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData; + deleteIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData; + getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData; +} diff --git a/src/domain/repositories/RoleRepository.ts b/src/domain/repositories/RoleRepository.ts new file mode 100644 index 00000000..9720a272 --- /dev/null +++ b/src/domain/repositories/RoleRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../data/api-futures"; +import { Role } from "../entities/incident-management-team/Role"; + +export interface RoleRepository { + getAll(): FutureData; +} diff --git a/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts new file mode 100644 index 00000000..fe5c9823 --- /dev/null +++ b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts @@ -0,0 +1,24 @@ +import { FutureData } from "../../data/api-futures"; +import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; +import { Id } from "../entities/Ref"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; + +export class DeleteIncidentManagementTeamMemberRoleUseCase { + constructor( + private options: { + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } + ) {} + + public execute( + teamMemberRole: TeamRole, + incidentManagementTeam: TeamMember, + diseaseOutbreakId: Id + ): FutureData { + return this.options.incidentManagementTeamRepository.deleteIncidentManagementTeamMemberRole( + teamMemberRole, + incidentManagementTeam, + diseaseOutbreakId + ); + } +} diff --git a/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts new file mode 100644 index 00000000..2f994a94 --- /dev/null +++ b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts @@ -0,0 +1,24 @@ +import { FutureData } from "../../data/api-futures"; +import { Maybe } from "../../utils/ts-utils"; +import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; +import { Id } from "../entities/Ref"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; +import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; +import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; + +export class GetIncidentManagementTeamByIdUseCase { + constructor( + private options: { + teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } + ) {} + + public execute(diseaseOutbreakEventId: Id): FutureData> { + return getIncidentManagementTeamById( + diseaseOutbreakEventId, + this.options.incidentManagementTeamRepository, + this.options.teamMemberRepository + ); + } +} diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts new file mode 100644 index 00000000..be105f1f --- /dev/null +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts @@ -0,0 +1,16 @@ +import { FutureData } from "../../../../data/api-futures"; +import { Maybe } from "../../../../utils/ts-utils"; +import { IncidentManagementTeam } from "../../../entities/incident-management-team/IncidentManagementTeam"; +import { Id } from "../../../entities/Ref"; +import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; + +export function getIncidentManagementTeamById( + diseaseOutbreakId: Id, + incidentManagementTeamRepository: IncidentManagementTeamRepository, + teamMemberRepository: TeamMemberRepository +): FutureData> { + return teamMemberRepository.getAll().flatMap(teamMembers => { + return incidentManagementTeamRepository.get(diseaseOutbreakId, teamMembers); + }); +} diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts new file mode 100644 index 00000000..4b39c6d4 --- /dev/null +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts @@ -0,0 +1,66 @@ +import { FutureData } from "../../../../data/api-futures"; +import { incidentManagementTeamBuilderCodes } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { Maybe } from "../../../../utils/ts-utils"; +import { SECTION_IDS } from "../../../../webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; +import { IncidentManagementTeamMemberFormData } from "../../../entities/ConfigurableForm"; +import { DiseaseOutbreakEvent } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Future } from "../../../entities/generic/Future"; +import { Id } from "../../../entities/Ref"; +import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../../../repositories/RoleRepository"; +import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; +import { getIncidentManagementTeamById } from "./GetIncidentManagementTeamById"; + +export function getIncidentManagementTeamWithOptions( + incidentManagementTeamRoleId: Maybe, + eventTrackerDetails: DiseaseOutbreakEvent, + repositories: { + roleRepository: RoleRepository; + teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } +): FutureData { + return Future.joinObj({ + roles: repositories.roleRepository.getAll(), + teamMembers: repositories.teamMemberRepository.getForIncidentManagementTeamMembers(), + incidentManagers: repositories.teamMemberRepository.getIncidentManagers(), + incidentManagementTeam: getIncidentManagementTeamById( + eventTrackerDetails.id, + repositories.incidentManagementTeamRepository, + repositories.teamMemberRepository + ), + }).flatMap(({ roles, teamMembers, incidentManagers, incidentManagementTeam }) => { + const teamMemberSelected = incidentManagementTeam?.teamHierarchy.find(teamMember => + teamMember.teamRoles?.some(teamRole => teamRole.id === incidentManagementTeamRoleId) + ); + + const incidentManagementTeamMemberFormData: IncidentManagementTeamMemberFormData = { + type: "incident-management-team-member-assignment", + eventTrackerDetails: eventTrackerDetails, + entity: teamMemberSelected, + incidentManagementTeamRoleId: incidentManagementTeamRoleId, + currentIncidentManagementTeam: incidentManagementTeam, + options: { + roles: roles, + teamMembers: teamMembers.filter(teamMembers => teamMembers.status === "Available"), + incidentManagers: incidentManagers.filter( + teamMembers => teamMembers.status === "Available" + ), + }, + labels: { + errors: { + field_is_required: "This field is required", + }, + }, + rules: [ + { + type: "disableFieldOptionWithSameFieldValue", + fieldId: incidentManagementTeamBuilderCodes.teamMemberAssigned, + fieldIdsToDisableOption: [incidentManagementTeamBuilderCodes.reportsToUsername], + sectionsWithFieldsToDisableOption: [SECTION_IDS.reportsTo], + }, + ], + }; + return Future.success(incidentManagementTeamMemberFormData); + }); +} diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx new file mode 100644 index 00000000..ca04b388 --- /dev/null +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx @@ -0,0 +1,138 @@ +import { TreeItem as TreeItemMUI } from "@material-ui/lab"; +import React from "react"; +import styled from "styled-components"; +import { IconUser24 } from "@dhis2/ui"; + +import { Maybe } from "../../../utils/ts-utils"; +import { Checkbox } from "../checkbox/Checkbox"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { TeamMemberProfile } from "./TeamMemberProfile"; + +type IMTeamHierarchyItemProps = { + nodeId: string; + teamRole: string; + member: Maybe; + selected: boolean; + disabled?: boolean; + onSelectedChange: (nodeId: string, selected: boolean) => void; + children?: React.ReactNode; + diseaseOutbreakEventName: string; +}; + +export const IMTeamHierarchyItem: React.FC = React.memo(props => { + const { + nodeId, + teamRole, + member, + disabled = false, + onSelectedChange, + selected, + children, + diseaseOutbreakEventName, + } = props; + + const [openMemberProfile, setOpenMemberProfile] = React.useState(false); + + const onCheckboxChange = React.useCallback( + (isChecked: boolean) => { + !disabled && onSelectedChange(nodeId, isChecked); + }, + [disabled, nodeId, onSelectedChange] + ); + + const onTeamRoleClick = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + !disabled && onSelectedChange(nodeId, !selected); + }, + [disabled, nodeId, onSelectedChange, selected] + ); + + const onMemberClick = React.useCallback( + (event: React.MouseEvent) => { + if (member) { + event.preventDefault(); + setOpenMemberProfile(true); + } + }, + [member] + ); + + return ( + <> + + + + + + + {teamRole}: + + + {member ? member.name : "TBD"} + + + + } + > + {children} + + + {member && ( + + )} + + ); +}); + +const StyledIMTeamHierarchyItem = styled(TreeItemMUI)` + .MuiTreeItem-label { + padding-left: 0; + height: 30px; + span.MuiButtonBase-root.MuiCheckbox-root { + padding: 9px 2px; + } + } +`; + +const LabelWrapper = styled.div` + display: flex; + align-items: center; +`; + +const RoleAndMemberWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; + svg { + color: ${props => props.theme.palette.common.grey700}; + height: 20px; + width: 20px; + } +`; + +const RoleWrapper = styled.div` + font-weight: 700; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; + +const MemberWrapper = styled.div` + font-weight: 400; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx new file mode 100644 index 00000000..a2ce3b9b --- /dev/null +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx @@ -0,0 +1,83 @@ +import { TreeView as TreeViewMUI } from "@material-ui/lab"; +import React from "react"; +import styled from "styled-components"; +import { ArrowDropDown, ArrowRight } from "@material-ui/icons"; + +import { Maybe } from "../../../utils/ts-utils"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { IMTeamHierarchyItem } from "./IMTeamHierarchyItem"; +import { Id } from "../../../domain/entities/Ref"; + +export type IMTeamHierarchyOption = { + id: Id; + teamRole: string; + teamRoleId: Id; + member: Maybe; + parents: { id: Id; name: string }[]; + children: IMTeamHierarchyOption[]; +}; + +type IMTeamHierarchyViewProps = { + items: IMTeamHierarchyOption[]; + selectedItemId: Id; + onSelectedItemChange: (nodeId: Id, selected: boolean) => void; + diseaseOutbreakEventName: string; +}; + +export const IMTeamHierarchyView: React.FC = React.memo(props => { + const { onSelectedItemChange, items, selectedItemId, diseaseOutbreakEventName } = props; + + return ( + + } + defaultExpandIcon={} + > + {items.map(item => ( + + {item.children && + item.children.map(child => ( + + ))} + + ))} + + + ); +}); + +const IMTeamHierarchyViewContainer = styled.div` + border: 1px solid ${props => props.theme.palette.common.grey500}; + background-color: ${props => props.theme.palette.common.white}; + padding: 8px; + @media (max-width: 800px) { + } +`; + +const StyledIMTeamHierarchyView = styled(TreeViewMUI)` + .MuiTreeItem-group { + margin-left: 8px; + border-left: 1px solid ${props => props.theme.palette.common.grey400}; + } + + .MuiTreeItem-content { + align-items: baseline; + } +`; diff --git a/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx new file mode 100644 index 00000000..172e9936 --- /dev/null +++ b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; +import { Link } from "@material-ui/core"; + +import i18n from "../../../utils/i18n"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { ProfileModal } from "../profile-modal/ProfileModal"; + +type TeamMemberProfileProps = { + open: boolean; + setOpen: (open: boolean) => void; + member: TeamMember; + diseaseOutbreakEventName: string; +}; + +export const TeamMemberProfile: React.FC = React.memo(props => { + const { open, setOpen, member, diseaseOutbreakEventName } = props; + + const teamRolesNames = useMemo( + () => member.teamRoles?.map(role => role.name).join(", "), + [member.teamRoles] + ); + + return ( + setOpen(false)} + name={member.name} + src={member.photo?.toString()} + alt={member.photo ? `Photo of ${member.name}` : undefined} + > + + {member.phone} + + {member.email} + + {teamRolesNames && {teamRolesNames}} + + + {i18n.t("Currently assigned:", { nsSeparator: false })} + + {diseaseOutbreakEventName} + + + + ); +}); + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const AssignContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const TextBold = styled.span` + color: ${props => props.theme.palette.common.black}; + font-size: 0.875rem; + font-weight: 700; +`; + +const Text = styled.span` + color: ${props => props.theme.palette.common.black}; + font-size: 0.875rem; + font-weight: 400; +`; + +const StyledLink = styled(Link)` + &.MuiTypography-colorPrimary { + font-size: 0.875rem; + font-weight: 400; + text-decoration: underline; + color: ${props => props.theme.palette.common.black}; + } +`; diff --git a/src/webapp/components/simple-modal/SimpleModal.tsx b/src/webapp/components/simple-modal/SimpleModal.tsx new file mode 100644 index 00000000..a8706b33 --- /dev/null +++ b/src/webapp/components/simple-modal/SimpleModal.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { Modal, CardContent, Card } from "@material-ui/core"; +import styled from "styled-components"; + +import i18n from "../../../utils/i18n"; +import { Button } from "../button/Button"; + +type SimpleModalProps = { + title: string; + children: React.ReactNode; + footerButtons?: React.ReactNode; + open: boolean; + onClose: () => void; + closeLabel?: string; +}; + +export const SimpleModal: React.FC = React.memo( + ({ children, footerButtons, closeLabel, open = false, onClose, title }) => { + return ( + + + {title} + + + {children} + + +
+ {footerButtons ?? null} + +
+
+
+ ); + } +); + +const Content = styled.div` + display: flex; + @media (max-width: 700px) { + flex-direction: column; + } +`; + +const Title = styled.span` + color: ${props => props.theme.palette.common.black}; + font-size: 1.25rem; + font-weight: 500; +`; + +const Footer = styled.div` + display: flex; + margin-block-start: 16px; + gap: 8px; +`; + +const StyledCard = styled(Card)` + width: 500px; + @media (max-width: 700px) { + width: 300px; + } + display: flex; + flex-direction: column; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 24px; + box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.1); +`; + +const StyledCardContent = styled(CardContent)` + width: 100%; +`; diff --git a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts new file mode 100644 index 00000000..96b5643a --- /dev/null +++ b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts @@ -0,0 +1,133 @@ +import { IncidentManagementTeamMemberFormData } from "../../../../domain/entities/ConfigurableForm"; +import { FormState } from "../../../components/form/FormState"; +import { mapTeamMemberToUser, mapToPresentationOptions } from "../mapEntityToFormState"; +import { Option as UIOption } from "../../../components/utils/option"; +import { User } from "../../../components/user-selector/UserSelector"; +import { + incidentManagementTeamBuilderCodes, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, +} from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; + +export const TEAM_ROLE_FIELD_ID = "team-role-field"; +export const SECTION_IDS = { + teamRole: "team-role-section", + teamMemberAssigned: `${incidentManagementTeamBuilderCodes.teamMemberAssigned}-section`, + reportsTo: `${incidentManagementTeamBuilderCodes.reportsToUsername}-section`, +}; + +export function mapIncidentManagementTeamMemberToInitialFormState( + formData: IncidentManagementTeamMemberFormData +): FormState { + const { + entity: incidentManagementTeamMember, + eventTrackerDetails, + options, + incidentManagementTeamRoleId, + currentIncidentManagementTeam, + } = formData; + + const { roles, teamMembers, incidentManagers } = options; + + const roleOptions: UIOption[] = mapToPresentationOptions(roles); + const roleOptionsWithoutIncidentManager: UIOption[] = mapToPresentationOptions( + roles.filter( + role => + role.id !== RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ) + ); + const teamMemberOptions: User[] = teamMembers.map(tm => mapTeamMemberToUser(tm)); + const incidentManagerOptions: User[] = incidentManagers.map(tm => mapTeamMemberToUser(tm)); + const currentIncidentManagementTeamOptions: User[] = ( + currentIncidentManagementTeam?.teamHierarchy || [] + ).map(tm => mapTeamMemberToUser(tm)); + const teamRoleToAssing = incidentManagementTeamMember?.teamRoles?.find( + teamRole => teamRole.id === incidentManagementTeamRoleId + ); + + return { + id: incidentManagementTeamMember?.id || "", + title: "Incident Management Team Builder", + subtitle: eventTrackerDetails.name, + saveButtonLabel: "Save Assignment", + isValid: false, + sections: [ + { + title: "Role", + id: SECTION_IDS.teamRole, + isVisible: true, + required: true, + fields: [ + { + id: TEAM_ROLE_FIELD_ID, + placeholder: "Select a role", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: + teamRoleToAssing?.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ? roleOptions + : roleOptionsWithoutIncidentManager, + value: teamRoleToAssing?.roleId || "", + required: true, + showIsRequired: true, + disabled: + teamRoleToAssing?.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + }, + ], + }, + { + title: "Team member assigned", + id: SECTION_IDS.teamMemberAssigned, + isVisible: true, + required: true, + fields: [ + { + id: incidentManagementTeamBuilderCodes.teamMemberAssigned, + placeholder: "Select a team member", + helperText: "Only available team members are shown", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: + teamRoleToAssing?.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ? incidentManagerOptions + : teamMemberOptions, + value: incidentManagementTeamMember?.username || "", + required: true, + showIsRequired: true, + disabled: false, + }, + ], + }, + { + title: "Reports to...", + id: SECTION_IDS.reportsTo, + isVisible: true, + required: false, + fields: [ + { + id: incidentManagementTeamBuilderCodes.reportsToUsername, + placeholder: "Select a team member", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: currentIncidentManagementTeamOptions.map(user => ({ + ...user, + disabled: user.value === incidentManagementTeamMember?.username, + })), + value: teamRoleToAssing?.reportsToUsername || "", + required: false, + showIsRequired: false, + disabled: false, + }, + ], + }, + ], + }; +} diff --git a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts new file mode 100644 index 00000000..576a3ceb --- /dev/null +++ b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Id } from "../../../domain/entities/Ref"; +import { Maybe } from "../../../utils/ts-utils"; +import { useAppContext } from "../../contexts/app-context"; +import { getDateAsLocaleDateTimeString } from "../../../data/repositories/utils/DateTimeHelper"; +import { User } from "../../components/user-selector/UserSelector"; +import { mapTeamMemberToUser } from "../form-page/mapEntityToFormState"; +import { IMTeamHierarchyOption } from "../../components/im-team-hierarchy/IMTeamHierarchyView"; +import { RouteName, useRoutes } from "../../hooks/useRoutes"; +import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; + +type GlobalMessage = { + text: string; + type: "warning" | "success" | "error"; +}; + +type State = { + globalMessage: Maybe; + incidentManagementTeamHierarchyItems: Maybe; + selectedHierarchyItemId: string; + onSelectHierarchyItem: (nodeId: string, selected: boolean) => void; + goToIncidentManagementTeamRole: () => void; + onDeleteIncidentManagementTeamMember: () => void; + incidentManagerUser: Maybe; + lastUpdated: string; + openDeleteModalData: IMTeamHierarchyOption | undefined; + onOpenDeleteModalData: (selectedHierarchyItemId: Id | undefined) => void; + disableDeletion: boolean; +}; + +export function useIMTeamBuilder(id: Id): State { + const { compositionRoot } = useAppContext(); + const { goTo } = useRoutes(); + + const [globalMessage, setGlobalMessage] = useState>(); + const [incidentManagementTeamHierarchyItems, setIncidentManagementTeamHierarchyItems] = + useState(); + const [incidentManagementTeam, setIncidentManagementTeam] = useState< + IncidentManagementTeam | undefined + >(); + const [selectedHierarchyItemId, setSelectedHierarchyItemId] = useState(""); + const [disableDeletion, setDisableDeletion] = useState(false); + const [openDeleteModalData, setOpenDeleteModalData] = useState< + IMTeamHierarchyOption | undefined + >(undefined); + + useEffect(() => { + compositionRoot.incidentManagementTeam.get.execute(id).run( + incidentManagementTeam => { + setIncidentManagementTeam(incidentManagementTeam); + setIncidentManagementTeamHierarchyItems( + mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( + incidentManagementTeam + ) + ); + }, + err => { + console.debug(err); + setGlobalMessage({ + text: `Error loading current Incident Management Team`, + type: "error", + }); + } + ); + }, [compositionRoot.incidentManagementTeam.get, id]); + + const goToIncidentManagementTeamRole = useCallback(() => { + if (selectedHierarchyItemId) { + goTo(RouteName.EDIT_FORM, { + formType: "incident-management-team-member-assignment", + id: selectedHierarchyItemId, + }); + } else { + goTo(RouteName.CREATE_FORM, { + formType: "incident-management-team-member-assignment", + }); + } + }, [goTo, selectedHierarchyItemId]); + + const onSelectHierarchyItem = useCallback( + (nodeId: string, selected: boolean) => { + const selection = selected ? nodeId : ""; + const incidentManagementTeamItemSelected = selection + ? incidentManagementTeamHierarchyItems?.find(item => item.id === selection) + : undefined; + + const isIncidentManagerRoleSelected = + incidentManagementTeamItemSelected?.teamRoleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole; + + setSelectedHierarchyItemId(selection); + setDisableDeletion(isIncidentManagerRoleSelected); + }, + [incidentManagementTeamHierarchyItems] + ); + + const onOpenDeleteModalData = useCallback( + (selectedHierarchyItemId: Id | undefined) => { + if (!selectedHierarchyItemId) { + setOpenDeleteModalData(undefined); + } else { + const incidentManagementTeamItem = incidentManagementTeamHierarchyItems?.find( + item => item.id === selectedHierarchyItemId + ); + + if (incidentManagementTeamItem) { + setOpenDeleteModalData(incidentManagementTeamItem); + } + } + }, + [incidentManagementTeamHierarchyItems] + ); + + const onDeleteIncidentManagementTeamMember = useCallback(() => { + if (disableDeletion) return; + + const teamMember = incidentManagementTeamHierarchyItems?.find( + item => item.id === selectedHierarchyItemId + )?.member; + + const teamRoleToDelete = teamMember?.teamRoles?.find( + teamRole => teamRole.id === selectedHierarchyItemId + ); + + if (teamMember && teamRoleToDelete) { + compositionRoot.incidentManagementTeam.deleteIncidentManagementTeamMemberRole + .execute(teamRoleToDelete, teamMember, id) + .run( + () => { + setGlobalMessage({ + text: `${teamMember.name} deleted from Incident Management Team`, + type: "success", + }); + }, + err => { + console.debug(err); + setGlobalMessage({ + text: `Error deleting ${teamMember.name} from Incident Management Team`, + type: "error", + }); + } + ); + } else { + setGlobalMessage({ + text: `Error deleting team member from Incident Management Team`, + type: "error", + }); + } + }, [ + compositionRoot.incidentManagementTeam.deleteIncidentManagementTeamMemberRole, + disableDeletion, + id, + incidentManagementTeamHierarchyItems, + selectedHierarchyItemId, + ]); + + const incidentManagerUser = useMemo(() => { + const incidentManagerTeamMember = incidentManagementTeam?.teamHierarchy.find(member => { + return member.teamRoles?.some( + role => + role.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ); + }); + if (incidentManagerTeamMember) { + return mapTeamMemberToUser(incidentManagerTeamMember); + } + }, [incidentManagementTeam?.teamHierarchy]); + + const lastUpdated = getDateAsLocaleDateTimeString(new Date()); //TO DO : Fetch sync time from datastore once implemented + + return { + globalMessage, + incidentManagementTeamHierarchyItems, + selectedHierarchyItemId, + onSelectHierarchyItem, + goToIncidentManagementTeamRole, + incidentManagerUser, + lastUpdated, + onDeleteIncidentManagementTeamMember, + openDeleteModalData, + onOpenDeleteModalData, + disableDeletion, + }; +} + +function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( + incidentManagementTeam: Maybe +): IMTeamHierarchyOption[] { + if (incidentManagementTeam?.teamHierarchy) { + const createHierarchyItem = ( + item: TeamMember, + teamRole: TeamRole + ): IMTeamHierarchyOption => ({ + id: teamRole.id, + teamRole: teamRole.name, + teamRoleId: teamRole.roleId, + member: new TeamMember({ + id: item.id, + name: item.name, + username: item.username, + phone: item.phone, + email: item.email, + status: item.status, + photo: item.photo, + teamRoles: item.teamRoles, + workPosition: item.workPosition, + }), + parents: [], + children: [], + }); + + const teamMap = incidentManagementTeam?.teamHierarchy.reduce< + Record + >((map, item) => { + const hierarchyItems = item.teamRoles?.map(teamRole => + createHierarchyItem(item, teamRole) + ); + + return !hierarchyItems || hierarchyItems?.length === 0 + ? map + : hierarchyItems.reduce( + (acc, hierarchyItem) => ({ + ...acc, + [hierarchyItem.id]: hierarchyItem, + }), + map + ); + }, {}); + + return incidentManagementTeam.teamHierarchy.reduce((acc, item) => { + return item.teamRoles + ? item.teamRoles.reduce((innerAcc, teamRole) => { + const hierarchyItem = teamMap[teamRole.id]; + if (!hierarchyItem) return innerAcc; + + const reportsToUsername = teamRole.reportsToUsername; + if (reportsToUsername) { + const parentItem = Object.values(teamMap).find( + teamItem => teamItem.member?.username === reportsToUsername + ); + + if (parentItem) { + parentItem.children = [...(parentItem.children || []), hierarchyItem]; + hierarchyItem.parents = [ + ...hierarchyItem.parents, + { id: parentItem.id, name: parentItem.teamRole }, + ]; + } + } + + return hierarchyItem.parents.length === 0 + ? [...innerAcc, hierarchyItem] + : innerAcc; + }, acc) + : acc; + }, []); + } else { + return []; + } +} From d302181483b2d66a45d329996a0566c3943e4fca Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 11:45:55 +0200 Subject: [PATCH 02/10] Add incident management team builder logic --- src/CompositionRoot.ts | 24 ++- .../repositories/TeamMemberD2Repository.ts | 8 +- .../consts/DiseaseOutbreakConstants.ts | 1 + .../test/TeamMemberTestRepository.ts | 28 +++- .../utils/RiskAssessmentMapper.ts | 17 +- src/domain/entities/ConfigurableForm.ts | 21 ++- src/domain/entities/Rule.ts | 20 ++- .../incident-management-team/TeamMember.ts | 6 +- .../repositories/TeamMemberRepository.ts | 1 + .../usecases/GetDiseaseOutbreakByIdUseCase.ts | 48 +++--- .../usecases/GetEntityWithOptionsUseCase.ts | 18 +++ src/domain/usecases/SaveEntityUseCase.ts | 79 ++++++++- .../disease-outbreak/SaveDiseaseOutbreak.ts | 151 +++++++++++++++++- src/types/d2-ui.d.ts | 2 + src/webapp/components/form/Form.tsx | 1 - src/webapp/components/form/FormFieldsState.ts | 28 ++++ .../components/form/FormSectionsState.ts | 88 ++++++++++ .../components/form/__tests__/Form.spec.tsx | 4 +- .../layout/side-bar/SideBarContent.tsx | 17 +- .../components/user-selector/UserCard.tsx | 25 +-- .../components/user-selector/UserSelector.tsx | 1 + src/webapp/hooks/useRoutes.ts | 5 +- src/webapp/pages/form-page/FormPage.tsx | 3 +- ...pDiseaseOutbreakEventToInitialFormState.ts | 4 +- .../utils/applyRulesInFormState.ts | 24 ++- .../updateDiseaseOutbreakEventFormState.ts | 2 + .../pages/form-page/mapEntityToFormState.ts | 7 +- .../form-page/mapFormStateToEntityData.ts | 64 ++++++++ src/webapp/pages/form-page/useForm.ts | 48 +++++- .../IMTeamBuilderPage.tsx | 136 +++++++++++++++- 30 files changed, 792 insertions(+), 89 deletions(-) diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 39bed03e..dd7bf83b 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -41,6 +41,14 @@ import { AlertSyncDataStoreTestRepository } from "./data/repositories/test/Alert import { AlertSyncRepository } from "./domain/repositories/AlertSyncRepository"; import { DataStoreClient } from "./data/DataStoreClient"; import { GetTotalCardCountsUseCase } from "./domain/usecases/GetTotalCardCountsUseCase"; +import { RoleRepository } from "./domain/repositories/RoleRepository"; +import { RoleD2Repository } from "./data/repositories/RoleD2Repository"; +import { RoleTestRepository } from "./data/repositories/test/RoleTestRepository"; +import { IncidentManagementTeamTestRepository } from "./data/repositories/test/IncidentManagementTeamTestRepository"; +import { IncidentManagementTeamD2Repository } from "./data/repositories/IncidentManagementTeamD2Repository"; +import { IncidentManagementTeamRepository } from "./domain/repositories/IncidentManagementTeamRepository"; +import { GetIncidentManagementTeamByIdUseCase } from "./domain/usecases/GetIncidentManagementTeamByIdUseCase"; +import { DeleteIncidentManagementTeamMemberRoleUseCase } from "./domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase"; export type CompositionRoot = ReturnType; @@ -55,15 +63,14 @@ type Repositories = { riskAssessmentRepository: RiskAssessmentRepository; mapConfigRepository: MapConfigRepository; performanceOverviewRepository: PerformanceOverviewRepository; + roleRepository: RoleRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; }; function getCompositionRoot(repositories: Repositories) { return { getWithOptions: new GetEntityWithOptionsUseCase(repositories), - save: new SaveEntityUseCase( - repositories.diseaseOutbreakEventRepository, - repositories.riskAssessmentRepository - ), + save: new SaveEntityUseCase(repositories), users: { getCurrent: new GetCurrentUserUseCase(repositories.usersRepository), }, @@ -76,6 +83,11 @@ function getCompositionRoot(repositories: Repositories) { repositories.optionsRepository ), }, + incidentManagementTeam: { + get: new GetIncidentManagementTeamByIdUseCase(repositories), + deleteIncidentManagementTeamMemberRole: + new DeleteIncidentManagementTeamMemberRoleUseCase(repositories), + }, performanceOverview: { getPerformanceOverviewMetrics: new GetAllPerformanceOverviewMetricsUseCase( repositories @@ -105,6 +117,8 @@ export function getWebappCompositionRoot(api: D2Api) { riskAssessmentRepository: new RiskAssessmentD2Repository(api), mapConfigRepository: new MapConfigD2Repository(api), performanceOverviewRepository: new PerformanceOverviewD2Repository(api, dataStoreClient), + roleRepository: new RoleD2Repository(api), + incidentManagementTeamRepository: new IncidentManagementTeamD2Repository(api), }; return getCompositionRoot(repositories); @@ -122,6 +136,8 @@ export function getTestCompositionRoot() { riskAssessmentRepository: new RiskAssessmentTestRepository(), mapConfigRepository: new MapConfigTestRepository(), performanceOverviewRepository: new PerformanceOverviewTestRepository(), + roleRepository: new RoleTestRepository(), + incidentManagementTeamRepository: new IncidentManagementTeamTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/TeamMemberD2Repository.ts b/src/data/repositories/TeamMemberD2Repository.ts index 169e5f11..8913f1e4 100644 --- a/src/data/repositories/TeamMemberD2Repository.ts +++ b/src/data/repositories/TeamMemberD2Repository.ts @@ -8,6 +8,7 @@ import { Future } from "../../domain/entities/generic/Future"; const RTSL_ZEBRA_INCIDENTMANAGER = "RTSL_ZEBRA_INCIDENTMANAGER"; const RTSL_ZEBRA_RISKASSESSOR = "RTSL_ZEBRA_RISKASSESSOR"; +const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_MEMBERS = "RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_MEMBERS"; export class TeamMemberD2Repository implements TeamMemberRepository { constructor(private api: D2Api) {} @@ -37,6 +38,10 @@ export class TeamMemberD2Repository implements TeamMemberRepository { return this.getTeamMembersByUserGroup(RTSL_ZEBRA_RISKASSESSOR); } + getForIncidentManagementTeamMembers(): FutureData { + return this.getTeamMembersByUserGroup(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_MEMBERS); + } + private getTeamMembersByUserGroup(userGroupCode: string): FutureData { return apiToFuture( this.api.metadata.get({ @@ -85,11 +90,12 @@ export class TeamMemberD2Repository implements TeamMemberRepository { email: user.email, phone: user.phoneNumber, status: "Available", // TODO: Get status when defined - role: { id: "1", name: "Incident Manager" }, // TODO: Get role when defined photo: photoUrlString && TeamMember.isValidPhotoUrl(photoUrlString) ? new URL(photoUrlString) : undefined, + teamRoles: undefined, + workPosition: undefined, // TODO: Get workPosition when defined }); } } diff --git a/src/data/repositories/consts/DiseaseOutbreakConstants.ts b/src/data/repositories/consts/DiseaseOutbreakConstants.ts index 3bcb78af..6478bffa 100644 --- a/src/data/repositories/consts/DiseaseOutbreakConstants.ts +++ b/src/data/repositories/consts/DiseaseOutbreakConstants.ts @@ -15,6 +15,7 @@ export const RTSL_ZEBRA_RISK_ASSESSMENT_GRADING_PROGRAM_STAGE_ID = "swh2ZukmkDk" export const RTSL_ZEBRA_RISK_ASSESSMENT_SUMMARY_PROGRAM_STAGE_ID = "jBjvgjSgf9d"; export const RTSL_ZEBRA_RISK_ASSESSMENT_QUESTIONNAIRE_PROGRAM_STAGE_ID = "Ltmf2awDAkS"; export const RTSL_ZEBRA_RISK_ASSESSMENT_QUESTIONNAIRE_CUSTOM_PROGRAM_STAGE_ID = "LpB1gNXEbEV"; +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID = "DwEOpUBGgOp"; export const RTSL_ZEBRA_ALERTS_PROGRAM_ID = "MQtbs8UkBxy"; export const RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID = "Pq1drzz2HJk"; diff --git a/src/data/repositories/test/TeamMemberTestRepository.ts b/src/data/repositories/test/TeamMemberTestRepository.ts index 91c4d178..38e86470 100644 --- a/src/data/repositories/test/TeamMemberTestRepository.ts +++ b/src/data/repositories/test/TeamMemberTestRepository.ts @@ -12,9 +12,10 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name test`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success([teamMember]); @@ -26,9 +27,26 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name test`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", + }); + + return Future.success([teamMember]); + } + + getForIncidentManagementTeamMembers(): FutureData { + const teamMember: TeamMember = new TeamMember({ + id: "incidentManagementTeamMember", + username: "incidentManagementTeamMember", + name: `Team Member Name test`, + email: `email@email.com`, + phone: `121-1234`, + teamRoles: undefined, + status: "Available", + photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success([teamMember]); @@ -41,9 +59,10 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name test`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success([teamMember]); @@ -56,9 +75,10 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name ${id}`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success(teamMember); diff --git a/src/data/repositories/utils/RiskAssessmentMapper.ts b/src/data/repositories/utils/RiskAssessmentMapper.ts index ae433941..8ce97f62 100644 --- a/src/data/repositories/utils/RiskAssessmentMapper.ts +++ b/src/data/repositories/utils/RiskAssessmentMapper.ts @@ -43,6 +43,7 @@ import { RiskAssessmentSummaryFormData, } from "../../../domain/entities/ConfigurableForm"; import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; +import { getPopulatedDataElement, getValueById } from "./helpers"; type D2ProgramStageDataElementsMetadata = { dataElement: SelectedPick< @@ -220,18 +221,6 @@ function mapRiskAssessmentQuestionnaireToDataElements( } } -function getPopulatedDataElement(dataElement: Id, value: Maybe): DataValue { - const populatedDataElement: DataValue = { - dataElement: dataElement, - value: value ?? "", - updatedAt: new Date().toISOString(), - storedBy: "", - createdAt: new Date().toISOString(), - providedElsewhere: false, - }; - return populatedDataElement; -} - function getRiskAssessmentTrackerEvent( programStageId: Id, id: Maybe, @@ -416,7 +405,3 @@ export function mapDataElementsToCustomRiskAssessmentQuestionnaire( return customQuestion; } - -function getValueById(dataValues: DataValue[], dataElement: string): Maybe { - return dataValues.find(dataValue => dataValue.dataElement === dataElement)?.value; -} diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index 8bcaf1f6..beefb3b1 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -1,6 +1,6 @@ import { Maybe } from "../../utils/ts-utils"; import { TeamMember } from "./incident-management-team/TeamMember"; -import { Option } from "./Ref"; +import { Id, Option } from "./Ref"; import { Rule } from "./Rule"; import { DiseaseOutbreakEvent, @@ -10,6 +10,7 @@ import { FormType } from "../../webapp/pages/form-page/FormPage"; import { RiskAssessmentGrading } from "./risk-assessment/RiskAssessmentGrading"; import { RiskAssessmentSummary } from "./risk-assessment/RiskAssessmentSummary"; import { RiskAssessmentQuestionnaire } from "./risk-assessment/RiskAssessmentQuestionnaire"; +import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; export type DiseaseOutbreakEventOptions = { dataSources: Option[]; @@ -84,8 +85,24 @@ export type RiskAssessmentQuestionnaireFormData = BaseFormData & { options: RiskAssessmentQuestionnaireOptions; }; +export type IncidentManagementTeamRoleOptions = { + roles: Option[]; + teamMembers: TeamMember[]; + incidentManagers: TeamMember[]; +}; + +export type IncidentManagementTeamMemberFormData = BaseFormData & { + type: "incident-management-team-member-assignment"; + eventTrackerDetails: DiseaseOutbreakEvent; + entity: Maybe; + incidentManagementTeamRoleId: Maybe; + currentIncidentManagementTeam: Maybe; + options: IncidentManagementTeamRoleOptions; +}; + export type ConfigurableForm = | DiseaseOutbreakEventFormData | RiskAssessmentGradingFormData | RiskAssessmentSummaryFormData - | RiskAssessmentQuestionnaireFormData; + | RiskAssessmentQuestionnaireFormData + | IncidentManagementTeamMemberFormData; diff --git a/src/domain/entities/Rule.ts b/src/domain/entities/Rule.ts index 539f0450..1c9ef85e 100644 --- a/src/domain/entities/Rule.ts +++ b/src/domain/entities/Rule.ts @@ -1,6 +1,9 @@ import { Maybe } from "purify-ts"; -export type Rule = RuleToggleSectionsVisibilityByFieldValue; +export type Rule = + | RuleToggleSectionsVisibilityByFieldValue + | RuleDisableFieldByFieldValue + | RuleDisableFieldOptionWithSameFieldValue; type RuleToggleSectionsVisibilityByFieldValue = { type: "toggleSectionsVisibilityByFieldValue"; @@ -8,3 +11,18 @@ type RuleToggleSectionsVisibilityByFieldValue = { fieldValue: string | boolean | string[] | Date | Maybe | null; sectionIds: string[]; }; + +type RuleDisableFieldByFieldValue = { + type: "disableFieldsByFieldValue"; + fieldId: string; + fieldValue: string | boolean | string[] | Date | Maybe | null; + disableFieldIds: string[]; + sectionIdsWithDisableFields: string[]; +}; + +type RuleDisableFieldOptionWithSameFieldValue = { + type: "disableFieldOptionWithSameFieldValue"; + fieldId: string; + fieldIdsToDisableOption: string[]; + sectionsWithFieldsToDisableOption: string[]; +}; diff --git a/src/domain/entities/incident-management-team/TeamMember.ts b/src/domain/entities/incident-management-team/TeamMember.ts index 54382767..b32c0a62 100644 --- a/src/domain/entities/incident-management-team/TeamMember.ts +++ b/src/domain/entities/incident-management-team/TeamMember.ts @@ -7,7 +7,8 @@ type Email = string; type IncidentManagerStatus = "Available" | "Unavailable"; export type TeamRole = NamedRef & { - level?: number; + roleId: string; + reportsToUsername: Maybe; }; interface TeamMemberAttrs extends NamedRef { @@ -16,7 +17,8 @@ interface TeamMemberAttrs extends NamedRef { email: Maybe; status: Maybe; photo: Maybe; - role: Maybe; + teamRoles: Maybe; + workPosition: Maybe; } export class TeamMember extends Struct() { diff --git a/src/domain/repositories/TeamMemberRepository.ts b/src/domain/repositories/TeamMemberRepository.ts index 1ff60092..26f94769 100644 --- a/src/domain/repositories/TeamMemberRepository.ts +++ b/src/domain/repositories/TeamMemberRepository.ts @@ -7,4 +7,5 @@ export interface TeamMemberRepository { get(id: Id): FutureData; getIncidentManagers(): FutureData; getRiskAssessors(): FutureData; + getForIncidentManagementTeamMembers(): FutureData; } diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 44bf45ca..5c420427 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -3,10 +3,12 @@ import { DiseaseOutbreakEvent } from "../entities/disease-outbreak-event/Disease import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; import { OptionsRepository } from "../repositories/OptionsRepository"; import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; +import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; import { getAll } from "./utils/risk-assessment/GetRiskAssessmentById"; export class GetDiseaseOutbreakByIdUseCase { @@ -17,6 +19,7 @@ export class GetDiseaseOutbreakByIdUseCase { teamMemberRepository: TeamMemberRepository; orgUnitRepository: OrgUnitRepository; riskAssessmentRepository: RiskAssessmentRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; } ) {} @@ -43,9 +46,6 @@ export class GetDiseaseOutbreakByIdUseCase { this.options.optionsRepository.getNotificationSource( notificationSourceCode ), - incidentManager: incidentManagerName - ? this.options.teamMemberRepository.get(incidentManagerName) - : Future.success(undefined), areasAffectedProvinces: this.options.orgUnitRepository.get(areasAffectedProvinceIds), areasAffectedDistricts: @@ -56,32 +56,40 @@ export class GetDiseaseOutbreakByIdUseCase { this.options.optionsRepository, this.options.teamMemberRepository ), + incidentManagementTeam: getIncidentManagementTeamById( + id, + this.options.incidentManagementTeamRepository, + this.options.teamMemberRepository + ), }).flatMap( ({ mainSyndrome, suspectedDisease, notificationSource, - incidentManager, areasAffectedProvinces, areasAffectedDistricts, riskAssessment, + incidentManagementTeam, }) => { - const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent( - { - ...diseaseOutbreakEventBase, - createdBy: undefined, //TO DO : FIXME populate once metadata change is done. - mainSyndrome: mainSyndrome, - suspectedDisease: suspectedDisease, - notificationSource: notificationSource, - areasAffectedProvinces: areasAffectedProvinces, - areasAffectedDistricts: areasAffectedDistricts, - incidentManager: incidentManager, - riskAssessment: riskAssessment, - incidentActionPlan: undefined, //TO DO : FIXME populate once incidentActionPlan repo is implemented - incidentManagementTeam: undefined, //TO DO : FIXME populate once incidentManagementTeam repo is implemented - } - ); - return Future.success(diseaseOutbreakEvent); + return this.options.incidentManagementTeamRepository + .getIncidentManagementTeamMember(incidentManagerName, id) + .flatMap(incidentManager => { + const diseaseOutbreakEvent: DiseaseOutbreakEvent = + new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + createdBy: undefined, //TO DO : FIXME populate once metadata change is done. + mainSyndrome: mainSyndrome, + suspectedDisease: suspectedDisease, + notificationSource: notificationSource, + areasAffectedProvinces: areasAffectedProvinces, + areasAffectedDistricts: areasAffectedDistricts, + incidentManager: incidentManager, + riskAssessment: riskAssessment, + incidentActionPlan: undefined, //TO DO : FIXME populate once incidentActionPlan repo is implemented + incidentManagementTeam: incidentManagementTeam, + }); + return Future.success(diseaseOutbreakEvent); + }); } ); }); diff --git a/src/domain/usecases/GetEntityWithOptionsUseCase.ts b/src/domain/usecases/GetEntityWithOptionsUseCase.ts index ba83468c..eec1f885 100644 --- a/src/domain/usecases/GetEntityWithOptionsUseCase.ts +++ b/src/domain/usecases/GetEntityWithOptionsUseCase.ts @@ -6,9 +6,12 @@ import { DiseaseOutbreakEvent } from "../entities/disease-outbreak-event/Disease import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; import { OptionsRepository } from "../repositories/OptionsRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { getDiseaseOutbreakWithEventOptions } from "./utils/disease-outbreak/GetDiseaseOutbreakWithOptions"; +import { getIncidentManagementTeamWithOptions } from "./utils/incident-management-team/GetIncidentManagementTeamWithOptions"; import { getRiskAssessmentGradingWithOptions, getRiskAssessmentQuestionnaireWithOptions, @@ -20,7 +23,9 @@ export class GetEntityWithOptionsUseCase { private options: { diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; optionsRepository: OptionsRepository; + roleRepository: RoleRepository; teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; } ) {} @@ -61,6 +66,19 @@ export class GetEntityWithOptionsUseCase { this.options.optionsRepository ); + case "incident-management-team-member-assignment": + if (!eventTrackerDetails) + return Future.error( + new Error( + "Disease outbreak id is required for incident management team member builder" + ) + ); + + return getIncidentManagementTeamWithOptions(id, eventTrackerDetails, { + roleRepository: this.options.roleRepository, + teamMemberRepository: this.options.teamMemberRepository, + incidentManagementTeamRepository: this.options.incidentManagementTeamRepository, + }); default: return Future.error(new Error("Form type not supported")); } diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 20eadae4..377c27ab 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -1,30 +1,101 @@ import { FutureData } from "../../data/api-futures"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { ConfigurableForm } from "../entities/ConfigurableForm"; +import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; +import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { saveDiseaseOutbreak } from "./utils/disease-outbreak/SaveDiseaseOutbreak"; export class SaveEntityUseCase { constructor( - private diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository, - private riskAssessmentRepository: RiskAssessmentRepository + private options: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + riskAssessmentRepository: RiskAssessmentRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + } ) {} public execute(formData: ConfigurableForm): FutureData { if (!formData || !formData.entity) return Future.error(new Error("No form data found")); switch (formData.type) { case "disease-outbreak-event": - return saveDiseaseOutbreak(this.diseaseOutbreakEventRepository, formData.entity); + return saveDiseaseOutbreak( + { + diseaseOutbreakEventRepository: this.options.diseaseOutbreakEventRepository, + incidentManagementTeamRepository: + this.options.incidentManagementTeamRepository, + teamMemberRepository: this.options.teamMemberRepository, + }, + formData.entity + ); case "risk-assessment-grading": case "risk-assessment-summary": case "risk-assessment-questionnaire": - return this.riskAssessmentRepository.saveRiskAssessment( + return this.options.riskAssessmentRepository.saveRiskAssessment( formData, formData.eventTrackerDetails.id ); + case "incident-management-team-member-assignment": { + const isIncidentManager = formData.entity.teamRoles?.find( + role => + role.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ); + + const hasIncidentManagerChanged = + formData.eventTrackerDetails.incidentManagerName !== formData.entity.username; + + if (isIncidentManager && hasIncidentManagerChanged) { + const updatedIncidentManager = formData.entity.username; + return this.options.diseaseOutbreakEventRepository + .get(formData.eventTrackerDetails.id) + .flatMap(diseaseOutbreakEventBase => { + if ( + diseaseOutbreakEventBase.incidentManagerName !== + updatedIncidentManager + ) { + const updatedDiseaseOutbreakEvent: DiseaseOutbreakEventBaseAttrs = { + ...diseaseOutbreakEventBase, + lastUpdated: new Date(), + incidentManagerName: updatedIncidentManager, + }; + + return saveDiseaseOutbreak( + { + diseaseOutbreakEventRepository: + this.options.diseaseOutbreakEventRepository, + incidentManagementTeamRepository: + this.options.incidentManagementTeamRepository, + teamMemberRepository: this.options.teamMemberRepository, + }, + updatedDiseaseOutbreakEvent + ); + } else { + return Future.success(undefined); + } + }); + } else { + const teamRoleToSave = formData.entity.teamRoles?.find( + role => role.id === formData.incidentManagementTeamRoleId || role.id === "" + ); + + if (!teamRoleToSave) { + return Future.error(new Error("No team role to save found")); + } + + return this.options.incidentManagementTeamRepository.saveIncidentManagementTeamMemberRole( + teamRoleToSave, + formData.entity, + formData.eventTrackerDetails.id + ); + } + } default: return Future.error(new Error("Form type not supported")); } diff --git a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts index 5e2e1a59..a82543d5 100644 --- a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts +++ b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts @@ -1,16 +1,159 @@ import { FutureData } from "../../../../data/api-futures"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { DiseaseOutbreakEventBaseAttrs } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../entities/generic/Future"; +import { TeamMember, TeamRole } from "../../../entities/incident-management-team/TeamMember"; import { Id } from "../../../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; export function saveDiseaseOutbreak( - diseaseOutbreakRepository: DiseaseOutbreakEventRepository, + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs ): FutureData { - return diseaseOutbreakRepository + return repositories.diseaseOutbreakEventRepository .save(diseaseOutbreakEventBaseAttrs) - .flatMap(diseaseOutbreakEventId => { - return Future.success(diseaseOutbreakEventId); + .flatMap(() => { + return saveIncidentManagerTeamMemberRole(repositories, diseaseOutbreakEventBaseAttrs); }); } + +function saveIncidentManagerTeamMemberRole( + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, + diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs +): FutureData { + return repositories.teamMemberRepository.getAll().flatMap(teamMembers => { + return repositories.incidentManagementTeamRepository + .get(diseaseOutbreakEventBaseAttrs.id, teamMembers) + .flatMap(incidentManagementTeam => { + const incidentManagerTeamMemberFound = incidentManagementTeam?.teamHierarchy?.find( + teamMember => + teamMember.teamRoles?.some( + teamRole => + teamRole.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ) + ); + + const incidentManagerTeamRole = incidentManagerTeamMemberFound?.teamRoles?.find( + teamRole => + teamRole.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ); + + if ( + incidentManagerTeamMemberFound && + incidentManagerTeamMemberFound.username !== + diseaseOutbreakEventBaseAttrs.incidentManagerName && + incidentManagerTeamRole + ) { + return changeIncidentManager( + repositories, + diseaseOutbreakEventBaseAttrs, + incidentManagerTeamMemberFound, + incidentManagerTeamRole, + teamMembers + ); + } else { + return createNewIncidentManager( + repositories, + diseaseOutbreakEventBaseAttrs, + teamMembers + ); + } + }); + }); +} + +function changeIncidentManager( + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, + diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, + oldIncidentManager: TeamMember, + oldIncidentManagerTeamRole: TeamRole, + teamMembers: TeamMember[] +): FutureData { + if (oldIncidentManager.username !== diseaseOutbreakEventBaseAttrs.incidentManagerName) { + const newIncidentManager = teamMembers.find( + teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName + ); + if (!newIncidentManager) { + return Future.error( + new Error( + `Incident manager with username ${diseaseOutbreakEventBaseAttrs.incidentManagerName} not found` + ) + ); + } + const newIncidentManagerTeamRole: TeamRole = { + id: "", + name: "", + roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + reportsToUsername: undefined, + }; + return repositories.incidentManagementTeamRepository + .deleteIncidentManagementTeamMemberRole( + oldIncidentManagerTeamRole, + oldIncidentManager, + diseaseOutbreakEventBaseAttrs.id + ) + .flatMap(() => { + return repositories.incidentManagementTeamRepository + .saveIncidentManagementTeamMemberRole( + newIncidentManagerTeamRole, + newIncidentManager, + diseaseOutbreakEventBaseAttrs.id + ) + .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); + }); + } else { + return Future.success(diseaseOutbreakEventBaseAttrs.id); + } +} + +function createNewIncidentManager( + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, + diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, + teamMembers: TeamMember[] +): FutureData { + const newIncidentManager = teamMembers.find( + teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName + ); + + if (!newIncidentManager) { + return Future.error( + new Error( + `Incident manager with username ${diseaseOutbreakEventBaseAttrs.incidentManagerName} not found` + ) + ); + } + + const incidentManagerTeamRole: TeamRole = { + id: "", + name: "", + roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + reportsToUsername: undefined, + }; + return repositories.incidentManagementTeamRepository + .saveIncidentManagementTeamMemberRole( + incidentManagerTeamRole, + newIncidentManager, + diseaseOutbreakEventBaseAttrs.id + ) + .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); +} diff --git a/src/types/d2-ui.d.ts b/src/types/d2-ui.d.ts index 539ca0bc..d11a6526 100644 --- a/src/types/d2-ui.d.ts +++ b/src/types/d2-ui.d.ts @@ -9,8 +9,10 @@ declare module "@dhis2/ui" { export function IconArrowRight24(props: { color?: string }): React.ReactElement; export function IconCalendar24(props: { color?: string }): React.ReactElement; export function IconChevronDown24(props: { color?: string }): React.ReactElement; + export function IconChevronRight24(props: { color?: string }): React.ReactElement; export function IconCross24(props: { color?: string }): React.ReactElement; export function IconCross16(props: { color?: string }): React.ReactElement; export function IconSearch24(props: { color?: string }): React.ReactElement; export function IconInfo24(props: { color?: string }): React.ReactElement; + export function IconEditItems24(props: { color?: string }): React.ReactElement; } diff --git a/src/webapp/components/form/Form.tsx b/src/webapp/components/form/Form.tsx index fc8d710d..52632974 100644 --- a/src/webapp/components/form/Form.tsx +++ b/src/webapp/components/form/Form.tsx @@ -18,7 +18,6 @@ export type FormProps = { export const Form: React.FC = React.memo(props => { const { formState, onFormChange, onSave, onCancel, errorLabels, handleAddNew } = props; - const { formLocalState, handleUpdateFormField } = useLocalForm(formState, onFormChange); return ( diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 695a046c..2ce46fd8 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -4,6 +4,7 @@ import { User } from "../user-selector/UserSelector"; import { Option } from "../utils/option"; import { ValidationError, ValidationErrorKey } from "../../../domain/entities/ValidationError"; import { FormSectionState } from "./FormSectionsState"; +import { Rule } from "../../../domain/entities/Rule"; export type FieldType = "text" | "boolean" | "select" | "radio" | "date" | "user" | "addNew"; @@ -205,3 +206,30 @@ export function validateField( export function hideFieldsAndSetToEmpty(fields: FormFieldState[]): FormFieldState[] { return fields.map(field => ({ ...getFieldWithEmptyValue(field), isVisible: false })); } + +export function applyRulesInUpdatedField( + updatedField: FormFieldState, + formRules: Rule[] +): FormFieldState { + const filteredRulesByFieldId = formRules.filter(rule => rule.fieldId === updatedField.id); + + if (filteredRulesByFieldId.length === 0) { + return updatedField; + } + + const formStateWithRulesApplied = filteredRulesByFieldId.reduce((currentUpdatedField, rule) => { + switch (rule.type) { + case "disableFieldsByFieldValue": + return rule.disableFieldIds.includes(currentUpdatedField.id) + ? { + ...currentUpdatedField, + disabled: currentUpdatedField.value === rule.fieldValue, + } + : currentUpdatedField; + default: + return currentUpdatedField; + } + }, updatedField); + + return formStateWithRulesApplied; +} diff --git a/src/webapp/components/form/FormSectionsState.ts b/src/webapp/components/form/FormSectionsState.ts index 732378ba..e006b722 100644 --- a/src/webapp/components/form/FormSectionsState.ts +++ b/src/webapp/components/form/FormSectionsState.ts @@ -166,6 +166,8 @@ export function toggleSectionVisibilityByFieldValue( fieldValue: FormFieldState["value"], rule: Rule ): FormSectionState { + if (rule.type !== "toggleSectionsVisibilityByFieldValue") return section; + if (rule.sectionIds.includes(section.id)) { const subsections = section.subsections?.map(subsection => { return toggleSectionVisibilityByFieldValue(subsection, fieldValue, rule); @@ -201,3 +203,89 @@ export function toggleSectionVisibilityByFieldValue( }; } } + +export function disableFieldsByFieldValueInSection( + section: FormSectionState, + fieldValue: FormFieldState["value"], + rule: Rule +): FormSectionState { + if (rule.type !== "disableFieldsByFieldValue") return section; + + if (rule.sectionIdsWithDisableFields.includes(section.id)) { + const subsections = section.subsections?.map(subsection => { + return disableFieldsByFieldValueInSection(subsection, fieldValue, rule); + }); + + const fieldsInSection: FormFieldState[] = section.fields.map(field => { + return rule.disableFieldIds.includes(field.id) + ? { + ...field, + disabled: fieldValue === rule.fieldValue, + } + : field; + }); + + return section.subsections + ? { + ...section, + fields: fieldsInSection, + subsections: subsections, + } + : { + ...section, + fields: fieldsInSection, + }; + } else { + return { + ...section, + subsections: section.subsections?.map(subsection => + disableFieldsByFieldValueInSection(subsection, fieldValue, rule) + ), + }; + } +} + +export function disableFieldOptionWithSameFieldValueInSection( + section: FormSectionState, + fieldValue: FormFieldState["value"], + rule: Rule +): FormSectionState { + if (rule.type !== "disableFieldOptionWithSameFieldValue") return section; + + if (rule.sectionsWithFieldsToDisableOption.includes(section.id)) { + const subsections = section.subsections?.map(subsection => { + return disableFieldOptionWithSameFieldValueInSection(subsection, fieldValue, rule); + }); + + const fieldsInSection: FormFieldState[] = section.fields.map(field => { + return rule.fieldIdsToDisableOption.includes(field.id) && + (field.type === "select" || field.type === "user" || field.type === "radio") + ? ({ + ...field, + options: field.options?.map(option => ({ + ...option, + disabled: option.value === fieldValue, + })), + } as FormFieldState) + : field; + }); + + return section.subsections + ? { + ...section, + fields: fieldsInSection, + subsections: subsections, + } + : { + ...section, + fields: fieldsInSection, + }; + } else { + return { + ...section, + subsections: section.subsections?.map(subsection => + disableFieldOptionWithSameFieldValueInSection(subsection, fieldValue, rule) + ), + }; + } +} diff --git a/src/webapp/components/form/__tests__/Form.spec.tsx b/src/webapp/components/form/__tests__/Form.spec.tsx index 5b923e9a..95ae764a 100644 --- a/src/webapp/components/form/__tests__/Form.spec.tsx +++ b/src/webapp/components/form/__tests__/Form.spec.tsx @@ -449,7 +449,7 @@ function givenFormProps(): FormProps { { value: "1", label: "user 1", - workPosition: "Postion", + workPosition: "workPosition", phone: "PhoneNumber", email: "Email", status: "Available", @@ -458,7 +458,7 @@ function givenFormProps(): FormProps { { value: "2", label: "user 2", - workPosition: "Postion", + workPosition: "workPosition", phone: "PhoneNumber", email: "Email", status: "Unavailable", diff --git a/src/webapp/components/layout/side-bar/SideBarContent.tsx b/src/webapp/components/layout/side-bar/SideBarContent.tsx index 21717a9d..6d1e7fc1 100644 --- a/src/webapp/components/layout/side-bar/SideBarContent.tsx +++ b/src/webapp/components/layout/side-bar/SideBarContent.tsx @@ -7,6 +7,7 @@ import { AddCircleOutline } from "@material-ui/icons"; import i18n from "../../../../utils/i18n"; import { Button } from "../../button/Button"; import { RouteName, routes, useRoutes } from "../../../hooks/useRoutes"; +import { useCurrentEventTracker } from "../../../contexts/current-event-tracker-context"; type SideBarContentProps = { children?: React.ReactNode; @@ -45,6 +46,7 @@ const DEFAULT_SIDEBAR_OPTIONS: SideBarOption[] = [ export const SideBarContent: React.FC = React.memo( ({ children, hideOptions = false, showCreateEvent = false }) => { const { goTo } = useRoutes(); + const { getCurrentEventTracker } = useCurrentEventTracker(); const goToCreateEvent = useCallback(() => { goTo(RouteName.CREATE_FORM, { formType: "disease-outbreak-event" }); @@ -63,7 +65,20 @@ export const SideBarContent: React.FC = React.memo( ) : ( {DEFAULT_SIDEBAR_OPTIONS.map(({ text, value }) => ( - + ))} diff --git a/src/webapp/components/user-selector/UserCard.tsx b/src/webapp/components/user-selector/UserCard.tsx index 3b70d134..a8cc7011 100644 --- a/src/webapp/components/user-selector/UserCard.tsx +++ b/src/webapp/components/user-selector/UserCard.tsx @@ -10,6 +10,7 @@ type UserCardProps = { }; export const UserCard: React.FC = React.memo(props => { const { selectedUser } = props; + return ( @@ -18,21 +19,23 @@ export const UserCard: React.FC = React.memo(props => { {selectedUser?.label} {selectedUser?.workPosition && {selectedUser?.workPosition}} - - - - {selectedUser?.phone} - - {selectedUser?.email} - + {selectedUser?.teamRoles && {selectedUser?.teamRoles}} + {selectedUser?.phone && {selectedUser?.phone}} + {selectedUser?.email && ( + + {selectedUser?.email} + + )} -
- {i18n.t("Status: ", { nsSeparator: false })} + {selectedUser?.status && ( +
+ {i18n.t("Status: ", { nsSeparator: false })} - {selectedUser?.status && {selectedUser?.status}} -
+ {selectedUser?.status && {selectedUser?.status}} +
+ )}
diff --git a/src/webapp/components/user-selector/UserSelector.tsx b/src/webapp/components/user-selector/UserSelector.tsx index d2c3db9c..0bb3b118 100644 --- a/src/webapp/components/user-selector/UserSelector.tsx +++ b/src/webapp/components/user-selector/UserSelector.tsx @@ -13,6 +13,7 @@ export type User = { status?: string; src?: string; alt?: string; + teamRoles?: string; }; type UserSelectorProps = { diff --git a/src/webapp/hooks/useRoutes.ts b/src/webapp/hooks/useRoutes.ts index f7285bd8..c21da7df 100644 --- a/src/webapp/hooks/useRoutes.ts +++ b/src/webapp/hooks/useRoutes.ts @@ -19,6 +19,7 @@ const formTypes = [ "risk-assessment-grading", "risk-assessment-summary", "risk-assessment-questionnaire", + "incident-management-team-member-assignment", ] as const satisfies FormType[]; const formType = `:formType(${join(formTypes, "|")})` as const; @@ -27,7 +28,7 @@ export const routes: Record = { [RouteName.CREATE_FORM]: `/create/${formType}`, [RouteName.EDIT_FORM]: `/edit/${formType}/:id`, [RouteName.EVENT_TRACKER]: "/event-tracker/:id", - [RouteName.IM_TEAM_BUILDER]: "/incident-management-team-builder", + [RouteName.IM_TEAM_BUILDER]: "/incident-management-team-builder/:id", [RouteName.INCIDENT_ACTION_PLAN]: "/incident-action-plan", [RouteName.RESOURCES]: "/resources", [RouteName.DASHBOARD]: "/", @@ -37,7 +38,7 @@ type RouteParams = { [RouteName.CREATE_FORM]: { formType: FormType }; [RouteName.EDIT_FORM]: { formType: FormType; id: string }; [RouteName.EVENT_TRACKER]: { id: string }; - [RouteName.IM_TEAM_BUILDER]: undefined; + [RouteName.IM_TEAM_BUILDER]: { id: string }; [RouteName.INCIDENT_ACTION_PLAN]: undefined; [RouteName.RESOURCES]: undefined; [RouteName.DASHBOARD]: undefined; diff --git a/src/webapp/pages/form-page/FormPage.tsx b/src/webapp/pages/form-page/FormPage.tsx index d336fe65..881cc25a 100644 --- a/src/webapp/pages/form-page/FormPage.tsx +++ b/src/webapp/pages/form-page/FormPage.tsx @@ -11,7 +11,8 @@ export type FormType = | "disease-outbreak-event" | "risk-assessment-grading" | "risk-assessment-questionnaire" - | "risk-assessment-summary"; + | "risk-assessment-summary" + | "incident-management-team-member-assignment"; export const FormPage: React.FC = React.memo(() => { const { formType, id } = useParams<{ diff --git a/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts index 93fb3c1f..95392ca7 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts @@ -42,10 +42,12 @@ export const diseaseOutbreakEventFieldIds = { } as const; export function mapTeamMemberToUser(teamMember: TeamMember): User { + const teamRoles = teamMember.teamRoles?.map(role => role.name).join(", "); return { value: teamMember.username, label: teamMember.name, - workPosition: teamMember.role?.name || "", + workPosition: teamMember.workPosition || "", + teamRoles: teamRoles || "", phone: teamMember.phone || "", email: teamMember.email || "", status: teamMember.status || "", diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts index c817105f..522a297c 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts @@ -1,6 +1,10 @@ import { Rule } from "../../../../../domain/entities/Rule"; import { FormFieldState } from "../../../../components/form/FormFieldsState"; -import { toggleSectionVisibilityByFieldValue } from "../../../../components/form/FormSectionsState"; +import { + disableFieldOptionWithSameFieldValueInSection, + disableFieldsByFieldValueInSection, + toggleSectionVisibilityByFieldValue, +} from "../../../../components/form/FormSectionsState"; import { FormState } from "../../../../components/form/FormState"; export function applyRulesInFormState( @@ -23,6 +27,24 @@ export function applyRulesInFormState( toggleSectionVisibilityByFieldValue(section, updatedField.value, rule) ), }; + case "disableFieldsByFieldValue": + return { + ...formState, + sections: formState.sections.map(section => + disableFieldsByFieldValueInSection(section, updatedField.value, rule) + ), + }; + case "disableFieldOptionWithSameFieldValue": + return { + ...formState, + sections: formState.sections.map(section => + disableFieldOptionWithSameFieldValueInSection( + section, + updatedField.value, + rule + ) + ), + }; } }, currentFormState); diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts index 5cbac64c..67783f96 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts @@ -63,6 +63,8 @@ function validateFormState( break; case "risk-assessment-questionnaire": break; + case "incident-management-team-member-assignment": + break; } return [...formValidationErrors, ...entityValidationErrors]; diff --git a/src/webapp/pages/form-page/mapEntityToFormState.ts b/src/webapp/pages/form-page/mapEntityToFormState.ts index 125e68b5..76293370 100644 --- a/src/webapp/pages/form-page/mapEntityToFormState.ts +++ b/src/webapp/pages/form-page/mapEntityToFormState.ts @@ -5,6 +5,7 @@ import { FormState } from "../../components/form/FormState"; import { User } from "../../components/user-selector/UserSelector"; import { Option as PresentationOption } from "../../components/utils/option"; import { mapDiseaseOutbreakEventToInitialFormState } from "./disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState"; +import { mapIncidentManagementTeamMemberToInitialFormState } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { mapRiskAssessmentQuestionnaireToInitialFormState, mapRiskAssessmentSummaryToInitialFormState, @@ -24,6 +25,8 @@ export function mapEntityToFormState( return mapRiskAssessmentSummaryToInitialFormState(configurableForm); case "risk-assessment-questionnaire": return mapRiskAssessmentQuestionnaireToInitialFormState(configurableForm); + case "incident-management-team-member-assignment": + return mapIncidentManagementTeamMemberToInitialFormState(configurableForm); } } @@ -37,10 +40,12 @@ export function mapToPresentationOptions(options: Option[]): PresentationOption[ } export function mapTeamMemberToUser(teamMember: TeamMember): User { + const teamRoles = teamMember.teamRoles?.map(role => role.name).join(", "); return { value: teamMember.username, label: teamMember.name, - workPosition: teamMember.role?.name || "", + workPosition: teamMember.workPosition || "", + teamRoles: teamRoles || "", phone: teamMember.phone || "", email: teamMember.email || "", status: teamMember.status || "", diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 546fecbe..46742ebe 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -17,6 +17,7 @@ import { import { ConfigurableForm, DiseaseOutbreakEventFormData, + IncidentManagementTeamMemberFormData, RiskAssessmentGradingFormData, RiskAssessmentQuestionnaireFormData, RiskAssessmentQuestionnaireOptions, @@ -34,6 +35,9 @@ import { RiskAssessmentQuestion, RiskAssessmentQuestionnaire, } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; +import { incidentManagementTeamBuilderCodes } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; export function mapFormStateToEntityData( formState: FormState, @@ -82,6 +86,16 @@ export function mapFormStateToEntityData( return riskQuestionnaireForm; } + case "incident-management-team-member-assignment": { + const incidentManagementTeamMember: TeamMember = + mapFormStateToIncidentManagementTeamMember(formState, formData); + const incidentManagementTeamMemberForm: IncidentManagementTeamMemberFormData = { + ...formData, + entity: incidentManagementTeamMember, + }; + return incidentManagementTeamMemberForm; + } + default: return formData; } @@ -466,3 +480,53 @@ function getRiskAssessmentQuestionsWithOption( return { likelihoodOption, consequencesOption, riskOption }; } + +function mapFormStateToIncidentManagementTeamMember( + formState: FormState, + formData: IncidentManagementTeamMemberFormData +): TeamMember { + const { options, incidentManagementTeamRoleId } = formData; + const { roles, teamMembers } = options; + + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + const getStringFieldValueById = (id: string): string => getStringFieldValue(id, allFields); + + const teamRoleSelected = roles.find( + role => role.id === getStringFieldValueById(TEAM_ROLE_FIELD_ID) + ); + const teamMemberAssigned = teamMembers.find(teamMember => { + return ( + teamMember.username === + getStringFieldValueById(incidentManagementTeamBuilderCodes.teamMemberAssigned) + ); + }); + + const reportsToUserNameSelected = + getStringFieldValueById(incidentManagementTeamBuilderCodes.reportsToUsername) || ""; + + const filteredTeamMemberAssignedRoles = teamMemberAssigned?.teamRoles?.filter( + teamRole => teamRole.id !== incidentManagementTeamRoleId + ); + + const newTeamMemberAssignedRoles = [ + ...(filteredTeamMemberAssignedRoles || []), + { + id: incidentManagementTeamRoleId || "", + roleId: teamRoleSelected?.id || "", + name: teamRoleSelected?.name || "", + reportsToUsername: reportsToUserNameSelected, + }, + ]; + + return new TeamMember({ + id: teamMemberAssigned?.id || "", + teamRoles: teamRoleSelected ? newTeamMemberAssignedRoles : undefined, + username: teamMemberAssigned?.username || "", + name: teamMemberAssigned?.name || "", + phone: teamMemberAssigned?.phone, + email: teamMemberAssigned?.email, + photo: teamMemberAssigned?.photo, + workPosition: teamMemberAssigned?.workPosition, + status: teamMemberAssigned?.status, + }); +} diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index cf4e1bcb..6c9c0d8d 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -16,6 +16,7 @@ import { addNewCustomQuestionSection, getAnotherOptionSection, } from "./risk-assessment/mapRiskAssessmentToInitialFormState"; +import { DiseaseOutbreakEvent } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; export type GlobalMessage = { text: string; @@ -57,11 +58,15 @@ export function useForm(formType: FormType, id?: Id): State { const [globalMessage, setGlobalMessage] = useState>(); const [formState, setFormState] = useState({ kind: "loading" }); const [configurableForm, setConfigurableForm] = useState(); + const [currentEventTrackerState, setCurrentEventTrackerState] = + useState>(); const [formLabels, setFormLabels] = useState(); const [isLoading, setIsLoading] = useState(false); const currentEventTracker = getCurrentEventTracker(); useEffect(() => { + if (currentEventTrackerState?.id === currentEventTracker?.id) return; + compositionRoot.getWithOptions.execute(formType, currentEventTracker, id).run( formData => { setConfigurableForm(formData); @@ -70,6 +75,7 @@ export function useForm(formType: FormType, id?: Id): State { kind: "loaded", data: mapEntityToFormState(formData, !!id), }); + setCurrentEventTrackerState(currentEventTracker); }, error => { setFormState({ @@ -82,9 +88,16 @@ export function useForm(formType: FormType, id?: Id): State { ), type: "error", }); + setCurrentEventTrackerState(currentEventTracker); } ); - }, [compositionRoot.getWithOptions, currentEventTracker, formType, id]); + }, [ + compositionRoot.getWithOptions, + formType, + id, + currentEventTracker, + currentEventTrackerState?.id, + ]); const handleAddNew = useCallback(() => { if (formState.kind !== "loaded" || !configurableForm) return; @@ -222,6 +235,17 @@ export function useForm(formType: FormType, id?: Id): State { type: "success", }); break; + + case "incident-management-team-member-assignment": + if (currentEventTracker?.id) + goTo(RouteName.IM_TEAM_BUILDER, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Incident Management Team Member saved successfully`), + type: "success", + }); + break; } }, err => { @@ -241,12 +265,22 @@ export function useForm(formType: FormType, id?: Id): State { ]); const onCancelForm = useCallback(() => { - if (currentEventTracker) - goTo(RouteName.EVENT_TRACKER, { - id: currentEventTracker.id, - }); - else goTo(RouteName.DASHBOARD); - }, [currentEventTracker, goTo]); + if (currentEventTracker) { + switch (formType) { + case "incident-management-team-member-assignment": + goTo(RouteName.IM_TEAM_BUILDER, { + id: currentEventTracker.id, + }); + break; + default: + goTo(RouteName.EVENT_TRACKER, { + id: currentEventTracker.id, + }); + } + } else { + goTo(RouteName.DASHBOARD); + } + }, [currentEventTracker, goTo, formType]); return { formLabels, diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 37b10744..9f70c01e 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -1,13 +1,143 @@ -import React from "react"; +import React, { useEffect } from "react"; +import styled from "styled-components"; +import { IconUser24, IconEditItems24 } from "@dhis2/ui"; +import { useParams } from "react-router-dom"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import { DeleteOutline } from "@material-ui/icons"; import { Layout } from "../../components/layout/Layout"; import i18n from "../../../utils/i18n"; +import LoaderContainer from "../../components/loader/LoaderContainer"; +import { UserCard } from "../../components/user-selector/UserCard"; +import { Section } from "../../components/section/Section"; +import { Button } from "../../components/button/Button"; +import { IMTeamHierarchyView } from "../../components/im-team-hierarchy/IMTeamHierarchyView"; +import { useIMTeamBuilder } from "./useIMTeamBuilder"; +import { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; +import { SimpleModal } from "../../components/simple-modal/SimpleModal"; export const IMTeamBuilderPage: React.FC = React.memo(() => { + const { id } = useParams<{ + id: string; + }>(); + const snackbar = useSnackbar(); + const { getCurrentEventTracker } = useCurrentEventTracker(); + const { + globalMessage, + incidentManagerUser, + lastUpdated, + incidentManagementTeamHierarchyItems, + selectedHierarchyItemId, + openDeleteModalData, + disableDeletion, + onSelectHierarchyItem, + goToIncidentManagementTeamRole, + onDeleteIncidentManagementTeamMember, + onOpenDeleteModalData, + } = useIMTeamBuilder(id); + + useEffect(() => { + if (!globalMessage) return; + + snackbar[globalMessage.type](globalMessage.text); + }, [globalMessage, snackbar]); + return ( + subtitle={getCurrentEventTracker()?.name || ""} + > + + + {incidentManagerUser && } + + +
+ + + {selectedHierarchyItemId && ( + + )} + + } + > + + + onOpenDeleteModalData(undefined)} + title={i18n.t("Delete team role")} + closeLabel={i18n.t("Cancel")} + footerButtons={ + + } + > + {openDeleteModalData && ( + + {openDeleteModalData.teamRole}: + {openDeleteModalData.member?.name} + + )} + +
+
+ ); }); + +const UserCardContainer = styled.div` + width: fit-content; + margin-block-end: 48px; +`; + +const ButtonsContainer = styled.div` + display: flex; + gap: 8px; +`; + +const RoleAndMemberWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const RoleWrapper = styled.div` + font-weight: 700; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; + +const MemberWrapper = styled.div` + font-weight: 400; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; From 214ec57e7fccb8d0e329efe269dbf5ea5ac4367a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 11:49:54 +0200 Subject: [PATCH 03/10] Add translations --- i18n/en.pot | 21 ++++++++++++++++++--- i18n/es.po | 23 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 495e3a38..6944d7db 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: 2024-09-30T07:39:32.843Z\n" -"PO-Revision-Date: 2024-09-30T07:39:32.843Z\n" +"POT-Creation-Date: 2024-10-09T09:46:34.412Z\n" +"PO-Revision-Date: 2024-10-09T09:46:34.412Z\n" msgid "Low" msgstr "" @@ -90,7 +90,7 @@ msgstr "" msgid "Notes" msgstr "" -msgid "Notes" +msgid "Currently assigned:" msgstr "" msgid "Create Event" @@ -174,6 +174,9 @@ msgstr "" msgid "Risk Assessment Questionnaire saved successfully" msgstr "" +msgid "Incident Management Team Member saved successfully" +msgstr "" + msgid "Incident Action Plan" msgstr "" @@ -183,5 +186,17 @@ msgstr "" msgid "Incident Management Team Builder" msgstr "" +msgid "Edit Role" +msgstr "" + +msgid "Assign Role" +msgstr "" + +msgid "Delete Role" +msgstr "" + +msgid "Delete team role" +msgstr "" + msgid "Resources" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index e9452c28..74942176 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T14:10:04.460Z\n" +"POT-Creation-Date: 2024-10-09T09:46:34.412Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -86,6 +86,12 @@ msgstr "" msgid "Edit Details" msgstr "" +msgid "Notes" +msgstr "" + +msgid "Currently assigned:" +msgstr "" + msgid "Create Event" msgstr "" @@ -167,6 +173,9 @@ msgstr "" msgid "Risk Assessment Questionnaire saved successfully" msgstr "" +msgid "Incident Management Team Member saved successfully" +msgstr "" + msgid "Incident Action Plan" msgstr "" @@ -176,6 +185,18 @@ msgstr "" msgid "Incident Management Team Builder" msgstr "" +msgid "Edit Role" +msgstr "" + +msgid "Assign Role" +msgstr "" + +msgid "Delete Role" +msgstr "" + +msgid "Delete team role" +msgstr "" + msgid "Resources" msgstr "" From a183db7287a016dbdbe71e88ab3f3376766b44bb Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:01:08 +0200 Subject: [PATCH 04/10] Improve UI --- .../components/im-team-hierarchy/IMTeamHierarchyItem.tsx | 7 ++++--- .../incident-management-team-builder/IMTeamBuilderPage.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx index ca04b388..7913f315 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx @@ -127,12 +127,13 @@ const RoleAndMemberWrapper = styled.div` const RoleWrapper = styled.div` font-weight: 700; - font-size: 14px; + font-size: 0.875rem; color: ${props => props.theme.palette.common.grey900}; `; const MemberWrapper = styled.div` font-weight: 400; - font-size: 14px; - color: ${props => props.theme.palette.common.grey900}; + font-size: 0.875rem; + text-decoration: underline; + color: ${props => props.theme.palette.common.blue800}; `; diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 9f70c01e..0f515cb9 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -132,12 +132,12 @@ const RoleAndMemberWrapper = styled.div` const RoleWrapper = styled.div` font-weight: 700; - font-size: 14px; + font-size: 0.875rem; color: ${props => props.theme.palette.common.grey900}; `; const MemberWrapper = styled.div` font-weight: 400; - font-size: 14px; + font-size: 0.875rem; color: ${props => props.theme.palette.common.grey900}; `; From bf0e4027d19f2138d2422a83096a2e151899e375 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:28:00 +0200 Subject: [PATCH 05/10] Add edit profile button --- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- .../im-team-hierarchy/TeamMemberProfile.tsx | 20 ++++++++++++++++++- .../components/profile-modal/ProfileModal.tsx | 6 ++++-- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 6944d7db..ae5f2314 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: 2024-10-09T09:46:34.412Z\n" -"PO-Revision-Date: 2024-10-09T09:46:34.412Z\n" +"POT-Creation-Date: 2024-10-09T10:17:08.902Z\n" +"PO-Revision-Date: 2024-10-09T10:17:08.902Z\n" msgid "Low" msgstr "" @@ -90,6 +90,9 @@ msgstr "" msgid "Notes" msgstr "" +msgid "Edit Profile" +msgstr "" + msgid "Currently assigned:" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 74942176..dea17a7c 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-09T09:46:34.412Z\n" +"POT-Creation-Date: 2024-10-09T10:17:08.902Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -89,6 +89,9 @@ msgstr "" msgid "Notes" msgstr "" +msgid "Edit Profile" +msgstr "" + msgid "Currently assigned:" msgstr "" diff --git a/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx index 172e9936..0e378c96 100644 --- a/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx +++ b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx @@ -1,10 +1,13 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import styled from "styled-components"; import { Link } from "@material-ui/core"; +import { IconEditItems24 } from "@dhis2/ui"; import i18n from "../../../utils/i18n"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { ProfileModal } from "../profile-modal/ProfileModal"; +import { useAppContext } from "../../contexts/app-context"; +import { Button } from "../button/Button"; type TeamMemberProfileProps = { open: boolean; @@ -15,12 +18,17 @@ type TeamMemberProfileProps = { export const TeamMemberProfile: React.FC = React.memo(props => { const { open, setOpen, member, diseaseOutbreakEventName } = props; + const { api } = useAppContext(); const teamRolesNames = useMemo( () => member.teamRoles?.map(role => role.name).join(", "), [member.teamRoles] ); + const onRedirectToProfile = useCallback(() => { + window.open(`${api.baseUrl}/dhis-web-user/index.html#/users/edit/${member.id}`, "_blank"); + }, [api.baseUrl, member.id]); + return ( = React.memo(pr name={member.name} src={member.photo?.toString()} alt={member.photo ? `Photo of ${member.name}` : undefined} + footerButtons={ + + } > {member.phone} diff --git a/src/webapp/components/profile-modal/ProfileModal.tsx b/src/webapp/components/profile-modal/ProfileModal.tsx index 9e58e38d..bf952cd9 100644 --- a/src/webapp/components/profile-modal/ProfileModal.tsx +++ b/src/webapp/components/profile-modal/ProfileModal.tsx @@ -8,7 +8,7 @@ import { Button } from "../button/Button"; type ProfileModalProps = { name: string; children: React.ReactNode; - avatarSize?: "small" | "medium"; + footerButtons?: React.ReactNode; alt?: string; src?: string; open: boolean; @@ -16,7 +16,7 @@ type ProfileModalProps = { }; export const ProfileModal: React.FC = React.memo( - ({ children, src, alt, open = false, onClose, name }) => { + ({ children, footerButtons, src, alt, open = false, onClose, name }) => { return ( = React.memo(
+ {footerButtons ?? null}
@@ -61,6 +62,7 @@ const Name = styled.span` const Footer = styled.div` display: flex; margin-block-start: 16px; + gap: 16px; `; const StyledCard = styled(Card)` From ddd8e233fd02553320025b41f262170dd678cfc6 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:52:01 +0200 Subject: [PATCH 06/10] Fix delete members inside tree --- .../IMTeamBuilderPage.tsx | 6 +- .../useIMTeamBuilder.ts | 64 +++++++++++++------ 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 0f515cb9..5f9f4082 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -103,8 +103,10 @@ export const IMTeamBuilderPage: React.FC = React.memo(() => { > {openDeleteModalData && ( - {openDeleteModalData.teamRole}: - {openDeleteModalData.member?.name} + {openDeleteModalData.teamRole.name}: + + {openDeleteModalData.teamMember.name}{" "} + )} diff --git a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts index 576a3ceb..cb159af9 100644 --- a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts +++ b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts @@ -17,6 +17,11 @@ type GlobalMessage = { type: "warning" | "success" | "error"; }; +export type ProfileModalData = { + teamMember: TeamMember; + teamRole: TeamRole; +}; + type State = { globalMessage: Maybe; incidentManagementTeamHierarchyItems: Maybe; @@ -26,7 +31,7 @@ type State = { onDeleteIncidentManagementTeamMember: () => void; incidentManagerUser: Maybe; lastUpdated: string; - openDeleteModalData: IMTeamHierarchyOption | undefined; + openDeleteModalData: ProfileModalData | undefined; onOpenDeleteModalData: (selectedHierarchyItemId: Id | undefined) => void; disableDeletion: boolean; }; @@ -43,11 +48,11 @@ export function useIMTeamBuilder(id: Id): State { >(); const [selectedHierarchyItemId, setSelectedHierarchyItemId] = useState(""); const [disableDeletion, setDisableDeletion] = useState(false); - const [openDeleteModalData, setOpenDeleteModalData] = useState< - IMTeamHierarchyOption | undefined - >(undefined); + const [openDeleteModalData, setOpenDeleteModalData] = useState( + undefined + ); - useEffect(() => { + const getIncidentManagementTeam = useCallback(() => { compositionRoot.incidentManagementTeam.get.execute(id).run( incidentManagementTeam => { setIncidentManagementTeam(incidentManagementTeam); @@ -67,6 +72,10 @@ export function useIMTeamBuilder(id: Id): State { ); }, [compositionRoot.incidentManagementTeam.get, id]); + useEffect(() => { + getIncidentManagementTeam(); + }, [getIncidentManagementTeam]); + const goToIncidentManagementTeamRole = useCallback(() => { if (selectedHierarchyItemId) { goTo(RouteName.EDIT_FORM, { @@ -84,17 +93,23 @@ export function useIMTeamBuilder(id: Id): State { (nodeId: string, selected: boolean) => { const selection = selected ? nodeId : ""; const incidentManagementTeamItemSelected = selection - ? incidentManagementTeamHierarchyItems?.find(item => item.id === selection) + ? incidentManagementTeam?.teamHierarchy.find(teamMember => + teamMember.teamRoles?.some(role => role.id === selection) + ) : undefined; + const selectedRole = incidentManagementTeamItemSelected?.teamRoles?.find( + role => role.id === selection + ); + const isIncidentManagerRoleSelected = - incidentManagementTeamItemSelected?.teamRoleId === + selectedRole?.roleId === RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole; setSelectedHierarchyItemId(selection); setDisableDeletion(isIncidentManagerRoleSelected); }, - [incidentManagementTeamHierarchyItems] + [incidentManagementTeam?.teamHierarchy] ); const onOpenDeleteModalData = useCallback( @@ -102,27 +117,35 @@ export function useIMTeamBuilder(id: Id): State { if (!selectedHierarchyItemId) { setOpenDeleteModalData(undefined); } else { - const incidentManagementTeamItem = incidentManagementTeamHierarchyItems?.find( - item => item.id === selectedHierarchyItemId + const incidentManagementTeamItem = incidentManagementTeam?.teamHierarchy.find( + teamMember => + teamMember.teamRoles?.some(role => role.id === selectedHierarchyItemId) + ); + + const selectedRole = incidentManagementTeamItem?.teamRoles?.find( + role => role.id === selectedHierarchyItemId ); - if (incidentManagementTeamItem) { - setOpenDeleteModalData(incidentManagementTeamItem); + if (incidentManagementTeamItem && selectedRole) { + setOpenDeleteModalData({ + teamRole: selectedRole, + teamMember: incidentManagementTeamItem, + }); } } }, - [incidentManagementTeamHierarchyItems] + [incidentManagementTeam?.teamHierarchy] ); const onDeleteIncidentManagementTeamMember = useCallback(() => { if (disableDeletion) return; - const teamMember = incidentManagementTeamHierarchyItems?.find( - item => item.id === selectedHierarchyItemId - )?.member; + const teamMember = incidentManagementTeam?.teamHierarchy.find(teamMember => + teamMember.teamRoles?.some(role => role.id === selectedHierarchyItemId) + ); const teamRoleToDelete = teamMember?.teamRoles?.find( - teamRole => teamRole.id === selectedHierarchyItemId + role => role.id === selectedHierarchyItemId ); if (teamMember && teamRoleToDelete) { @@ -134,6 +157,8 @@ export function useIMTeamBuilder(id: Id): State { text: `${teamMember.name} deleted from Incident Management Team`, type: "success", }); + getIncidentManagementTeam(); + onOpenDeleteModalData(undefined); }, err => { console.debug(err); @@ -141,6 +166,7 @@ export function useIMTeamBuilder(id: Id): State { text: `Error deleting ${teamMember.name} from Incident Management Team`, type: "error", }); + onOpenDeleteModalData(undefined); } ); } else { @@ -152,8 +178,10 @@ export function useIMTeamBuilder(id: Id): State { }, [ compositionRoot.incidentManagementTeam.deleteIncidentManagementTeamMemberRole, disableDeletion, + getIncidentManagementTeam, id, - incidentManagementTeamHierarchyItems, + incidentManagementTeam?.teamHierarchy, + onOpenDeleteModalData, selectedHierarchyItemId, ]); From b82d19f93e753eed6510a0c9760041727cfed58d Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:56:12 +0200 Subject: [PATCH 07/10] Improve messages to user --- src/webapp/pages/form-page/useForm.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index 6c9c0d8d..0aeca5ee 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -80,12 +80,10 @@ export function useForm(formType: FormType, id?: Id): State { error => { setFormState({ kind: "error", - message: i18n.t(`Create Event form cannot be loaded`), + message: i18n.t(`Form cannot be loaded`), }); setGlobalMessage({ - text: i18n.t( - `An error occurred while loading Create Event form: ${error.message}` - ), + text: i18n.t(`An error occurred while loading form: ${error.message}`), type: "error", }); setCurrentEventTrackerState(currentEventTracker); From f0f21dc8f90931ca12f14e740531b87684ce7713 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:56:34 +0200 Subject: [PATCH 08/10] Improve messages to user --- i18n/en.pot | 6 +++--- i18n/es.po | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index ae5f2314..8786dc3f 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: 2024-10-09T10:17:08.902Z\n" -"PO-Revision-Date: 2024-10-09T10:17:08.902Z\n" +"POT-Creation-Date: 2024-10-09T10:56:24.909Z\n" +"PO-Revision-Date: 2024-10-09T10:56:24.909Z\n" msgid "Low" msgstr "" @@ -162,7 +162,7 @@ msgstr "" msgid "Add another" msgstr "" -msgid "Create Event form cannot be loaded" +msgid "Form cannot be loaded" msgstr "" msgid "Disease Outbreak saved successfully" diff --git a/i18n/es.po b/i18n/es.po index dea17a7c..12778856 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-10-09T10:17:08.902Z\n" +"POT-Creation-Date: 2024-10-09T10:56:24.909Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -161,7 +161,7 @@ msgstr "" msgid "Add another" msgstr "" -msgid "Create Event form cannot be loaded" +msgid "Form cannot be loaded" msgstr "" msgid "Disease Outbreak saved successfully" From 8f40b4387bfd7cc65606d29f482e83852f0c981f Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 10 Oct 2024 11:15:22 +0200 Subject: [PATCH 09/10] Fix build tree and add search bar --- .../im-team-hierarchy/IMTeamHierarchyItem.tsx | 29 +++-- .../im-team-hierarchy/IMTeamHierarchyView.tsx | 33 +++-- .../IMTeamBuilderPage.tsx | 4 + .../useIMTeamBuilder.ts | 113 +++++++++++++----- 4 files changed, 122 insertions(+), 57 deletions(-) diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx index 7913f315..a80300c9 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx @@ -7,16 +7,18 @@ import { Maybe } from "../../../utils/ts-utils"; import { Checkbox } from "../checkbox/Checkbox"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TeamMemberProfile } from "./TeamMemberProfile"; +import { IMTeamHierarchyOption } from "./IMTeamHierarchyView"; +import { Id } from "../../../domain/entities/Ref"; type IMTeamHierarchyItemProps = { nodeId: string; teamRole: string; member: Maybe; - selected: boolean; disabled?: boolean; onSelectedChange: (nodeId: string, selected: boolean) => void; - children?: React.ReactNode; + subChildren: IMTeamHierarchyOption[]; diseaseOutbreakEventName: string; + selectedItemId: Id; }; export const IMTeamHierarchyItem: React.FC = React.memo(props => { @@ -26,9 +28,9 @@ export const IMTeamHierarchyItem: React.FC = React.mem member, disabled = false, onSelectedChange, - selected, - children, + subChildren, diseaseOutbreakEventName, + selectedItemId, } = props; const [openMemberProfile, setOpenMemberProfile] = React.useState(false); @@ -43,9 +45,9 @@ export const IMTeamHierarchyItem: React.FC = React.mem const onTeamRoleClick = React.useCallback( (event: React.MouseEvent) => { event.preventDefault(); - !disabled && onSelectedChange(nodeId, !selected); + !disabled && onSelectedChange(nodeId, !(selectedItemId === nodeId)); }, - [disabled, nodeId, onSelectedChange, selected] + [disabled, nodeId, onSelectedChange, selectedItemId] ); const onMemberClick = React.useCallback( @@ -67,7 +69,7 @@ export const IMTeamHierarchyItem: React.FC = React.mem @@ -84,7 +86,18 @@ export const IMTeamHierarchyItem: React.FC = React.mem } > - {children} + {subChildren.map(child => ( + + ))} {member && ( diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx index a2ce3b9b..fad574f6 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx @@ -7,13 +7,14 @@ import { Maybe } from "../../../utils/ts-utils"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { IMTeamHierarchyItem } from "./IMTeamHierarchyItem"; import { Id } from "../../../domain/entities/Ref"; +import { SearchInput } from "../search-input/SearchInput"; export type IMTeamHierarchyOption = { id: Id; teamRole: string; teamRoleId: Id; member: Maybe; - parents: { id: Id; name: string }[]; + parent: Maybe; children: IMTeamHierarchyOption[]; }; @@ -22,13 +23,23 @@ type IMTeamHierarchyViewProps = { selectedItemId: Id; onSelectedItemChange: (nodeId: Id, selected: boolean) => void; diseaseOutbreakEventName: string; + onSearchChange: (term: string) => void; + searchTerm: string; }; export const IMTeamHierarchyView: React.FC = React.memo(props => { - const { onSelectedItemChange, items, selectedItemId, diseaseOutbreakEventName } = props; + const { + onSelectedItemChange, + items, + selectedItemId, + diseaseOutbreakEventName, + searchTerm, + onSearchChange, + } = props; return ( + } @@ -40,23 +51,11 @@ export const IMTeamHierarchyView: React.FC = React.mem nodeId={item.id} teamRole={item.teamRole} member={item.member} - selected={selectedItemId === item.id} + selectedItemId={selectedItemId} onSelectedChange={onSelectedItemChange} diseaseOutbreakEventName={diseaseOutbreakEventName} - > - {item.children && - item.children.map(child => ( - - ))} - + subChildren={item.children} + /> ))} diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 5f9f4082..a2a5c4d4 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -30,6 +30,8 @@ export const IMTeamBuilderPage: React.FC = React.memo(() => { selectedHierarchyItemId, openDeleteModalData, disableDeletion, + searchTerm, + onSearchChange, onSelectHierarchyItem, goToIncidentManagementTeamRole, onDeleteIncidentManagementTeamMember, @@ -88,6 +90,8 @@ export const IMTeamBuilderPage: React.FC = React.memo(() => { selectedItemId={selectedHierarchyItemId} onSelectedItemChange={onSelectHierarchyItem} diseaseOutbreakEventName={getCurrentEventTracker()?.name || ""} + onSearchChange={onSearchChange} + searchTerm={searchTerm} /> void; disableDeletion: boolean; + onSearchChange: (term: string) => void; + searchTerm: string; }; export function useIMTeamBuilder(id: Id): State { @@ -51,6 +54,7 @@ export function useIMTeamBuilder(id: Id): State { const [openDeleteModalData, setOpenDeleteModalData] = useState( undefined ); + const [searchTerm, setSearchTerm] = useState(""); const getIncidentManagementTeam = useCallback(() => { compositionRoot.incidentManagementTeam.get.execute(id).run( @@ -58,7 +62,7 @@ export function useIMTeamBuilder(id: Id): State { setIncidentManagementTeam(incidentManagementTeam); setIncidentManagementTeamHierarchyItems( mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( - incidentManagementTeam + incidentManagementTeam?.teamHierarchy ) ); }, @@ -198,6 +202,28 @@ export function useIMTeamBuilder(id: Id): State { } }, [incidentManagementTeam?.teamHierarchy]); + const onSearchChange = useCallback( + (term: string) => { + setSearchTerm(term); + + if (incidentManagementTeamHierarchyItems) { + const filteredIncidentManagementTeamHierarchyItems = term + ? filterIncidentManagementTeamHierarchy( + incidentManagementTeamHierarchyItems, + term + ) + : mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( + incidentManagementTeam?.teamHierarchy + ); + + setIncidentManagementTeamHierarchyItems( + filteredIncidentManagementTeamHierarchyItems + ); + } + }, + [incidentManagementTeam?.teamHierarchy, incidentManagementTeamHierarchyItems] + ); + const lastUpdated = getDateAsLocaleDateTimeString(new Date()); //TO DO : Fetch sync time from datastore once implemented return { @@ -212,13 +238,15 @@ export function useIMTeamBuilder(id: Id): State { openDeleteModalData, onOpenDeleteModalData, disableDeletion, + searchTerm, + onSearchChange, }; } function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( - incidentManagementTeam: Maybe + incidentManagementTeamHierarchy: Maybe ): IMTeamHierarchyOption[] { - if (incidentManagementTeam?.teamHierarchy) { + if (incidentManagementTeamHierarchy) { const createHierarchyItem = ( item: TeamMember, teamRole: TeamRole @@ -237,11 +265,11 @@ function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( teamRoles: item.teamRoles, workPosition: item.workPosition, }), - parents: [], + parent: teamRole.reportsToUsername, children: [], }); - const teamMap = incidentManagementTeam?.teamHierarchy.reduce< + const teamMap = incidentManagementTeamHierarchy.reduce< Record >((map, item) => { const hierarchyItems = item.teamRoles?.map(teamRole => @@ -259,34 +287,55 @@ function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( ); }, {}); - return incidentManagementTeam.teamHierarchy.reduce((acc, item) => { - return item.teamRoles - ? item.teamRoles.reduce((innerAcc, teamRole) => { - const hierarchyItem = teamMap[teamRole.id]; - if (!hierarchyItem) return innerAcc; - - const reportsToUsername = teamRole.reportsToUsername; - if (reportsToUsername) { - const parentItem = Object.values(teamMap).find( - teamItem => teamItem.member?.username === reportsToUsername - ); - - if (parentItem) { - parentItem.children = [...(parentItem.children || []), hierarchyItem]; - hierarchyItem.parents = [ - ...hierarchyItem.parents, - { id: parentItem.id, name: parentItem.teamRole }, - ]; - } - } - - return hierarchyItem.parents.length === 0 - ? [...innerAcc, hierarchyItem] - : innerAcc; - }, acc) - : acc; - }, []); + return buildTree(teamMap); } else { return []; } } + +function buildTree(teamMap: Record): IMTeamHierarchyOption[] { + const findChildren = (parentUsername: string): IMTeamHierarchyOption[] => + Object.values(teamMap) + .filter(item => item.parent === parentUsername) + .reduce((acc, item) => { + const children = findChildren(item.member?.username || ""); + return [...acc, { ...item, children: [...item.children, ...children] }]; + }, []); + + return Object.values(teamMap).reduce((acc, item) => { + const isRoot = !item.parent; + if (isRoot) { + const children = findChildren(item.member?.username || ""); + return [...acc, { ...item, children: [...item.children, ...children] }]; + } + + return acc; + }, []); +} + +function filterIncidentManagementTeamHierarchy( + items: IMTeamHierarchyOption[], + searchTerm: string +): IMTeamHierarchyOption[] { + return _c( + items.map(item => { + const filteredChildren = filterIncidentManagementTeamHierarchy( + item.children, + searchTerm + ); + + const isMatch = item.teamRole.toLowerCase().includes(searchTerm.toLowerCase()); + + if (isMatch || filteredChildren.length > 0) { + return { + ...item, + children: filteredChildren, + }; + } + + return null; + }) + ) + .compact() + .toArray(); +} From a1334f8d2d5a27534fab57b3416282e5fcfe55f7 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 10 Oct 2024 14:45:57 +0200 Subject: [PATCH 10/10] Improve UI and remove dependency with role codes hardcoded --- .../IncidentManagementTeamD2Repository.ts | 177 ++++++++---------- src/data/repositories/RoleD2Repository.ts | 87 +++++++-- .../IncidentManagementTeamBuilderConstants.ts | 42 +---- .../IncidentManagementTeamTestRepository.ts | 20 +- .../utils/IncidentManagementTeamMapper.ts | 115 ++++-------- src/domain/entities/ConfigurableForm.ts | 3 +- .../IncidentManagementTeamRepository.ts | 16 +- ...IncidentManagementTeamMemberRoleUseCase.ts | 15 +- .../usecases/GetDiseaseOutbreakByIdUseCase.ts | 12 +- .../GetIncidentManagementTeamByIdUseCase.ts | 8 +- src/domain/usecases/SaveEntityUseCase.ts | 13 +- .../disease-outbreak/SaveDiseaseOutbreak.ts | 49 +++-- .../GetIncidentManagementTeamById.ts | 20 +- .../GetIncidentManagementTeamWithOptions.ts | 14 +- .../im-team-hierarchy/IMTeamHierarchyView.tsx | 46 +++-- ...tManagementTeamMemberToInitialFormState.ts | 27 +-- .../form-page/mapFormStateToEntityData.ts | 9 +- .../useIMTeamBuilder.ts | 12 +- 18 files changed, 346 insertions(+), 339 deletions(-) diff --git a/src/data/repositories/IncidentManagementTeamD2Repository.ts b/src/data/repositories/IncidentManagementTeamD2Repository.ts index bea23b44..c479234c 100644 --- a/src/data/repositories/IncidentManagementTeamD2Repository.ts +++ b/src/data/repositories/IncidentManagementTeamD2Repository.ts @@ -19,54 +19,51 @@ import { } from "./utils/IncidentManagementTeamMapper"; import { TeamMember, TeamRole } from "../../domain/entities/incident-management-team/TeamMember"; import { getProgramStage } from "./utils/MetadataHelper"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; import { assertOrError } from "./utils/AssertOrError"; +import { Role } from "../../domain/entities/incident-management-team/Role"; export class IncidentManagementTeamD2Repository implements IncidentManagementTeamRepository { constructor(private api: D2Api) {} get( diseaseOutbreakId: Id, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData> { - return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( - ({ dataElementRoles, events }) => { - const maybeIncidentManagementTeam: Maybe = - mapD2EventsToIncidentManagementTeam(events, dataElementRoles, teamMembers); - return Future.success(maybeIncidentManagementTeam); - } - ); + return this.getIncidentManagementTeamEvents(diseaseOutbreakId).flatMap(d2Events => { + return Future.success( + mapD2EventsToIncidentManagementTeam(d2Events, roles, teamMembers) + ); + }); } - getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData { - return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( - ({ dataElementRoles, events }) => { - return apiToFuture( - this.api.metadata.get({ - users: { - fields: d2UserFields, - filter: { username: { eq: username } }, - }, - }) + getIncidentManagementTeamMember( + username: Id, + diseaseOutbreakId: Id, + roles: Role[] + ): FutureData { + return this.getIncidentManagementTeamEvents(diseaseOutbreakId).flatMap(d2Events => { + return apiToFuture( + this.api.metadata.get({ + users: { + fields: d2UserFields, + filter: { username: { eq: username } }, + }, + }) + ) + .flatMap(response => + assertOrError(response.users[0], "Incident Management Team Member") ) - .flatMap(response => - assertOrError(response.users[0], "Incident Management Team Member") - ) - .map(d2User => - this.mapUserToIncidentManagementTeamMember( - d2User as D2UserFix, - events, - dataElementRoles - ) - ); - } - ); + .map(d2User => + this.mapUserToIncidentManagementTeamMember(d2User as D2UserFix, d2Events, roles) + ); + }); } private mapUserToIncidentManagementTeamMember( d2User: D2UserFix, events: D2TrackerEvent[], - dataElementRoles: D2DataElement[] + roles: Role[] ): TeamMember { const avatarId = d2User?.avatar?.id; const photoUrlString = avatarId @@ -88,11 +85,7 @@ export class IncidentManagementTeamD2Repository implements IncidentManagementTea workPosition: undefined, // TODO: Get workPosition when defined }); - const teamRoles = getTeamMemberIncidentManagementTeamRoles( - teamMember, - events, - dataElementRoles - ); + const teamRoles = getTeamMemberIncidentManagementTeamRoles(teamMember, events, roles); return new TeamMember({ ...teamMember, @@ -103,80 +96,75 @@ export class IncidentManagementTeamD2Repository implements IncidentManagementTea saveIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData { - return this.saveOrDeleteIncidentManagementTeamMember( + return this.saveOrDeleteIncidentManagementTeamMember({ teamMemberRole, incidentManagementTeamMember, diseaseOutbreakId, - "CREATE_AND_UPDATE" - ); + importStrategy: "CREATE_AND_UPDATE", + roles, + }); } deleteIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData { - return this.saveOrDeleteIncidentManagementTeamMember( + return this.saveOrDeleteIncidentManagementTeamMember({ teamMemberRole, incidentManagementTeamMember, diseaseOutbreakId, - "DELETE" - ); + importStrategy: "DELETE", + roles, + }); } - private getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId: Id): FutureData<{ - dataElementRoles: D2DataElement[]; - events: D2TrackerEvent[]; - }> { - return Future.joinObj( - { - dataElementRoles: apiToFuture( - this.api.models.dataElements.get({ - fields: dataElementFields, - paging: false, - filter: { - id: { - in: Object.values( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS - ), - }, - }, - }) - ), - events: apiToFuture( - this.api.tracker.events.get({ - program: RTSL_ZEBRA_PROGRAM_ID, - orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, - trackedEntity: diseaseOutbreakId, - programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, - fields: { - dataValues: { - dataElement: { id: true, code: true }, - value: true, - }, - trackedEntity: true, - event: true, - }, - }) - ), - }, - { concurrency: 2 } - ).flatMap(({ dataElementRoles, events }) => { - return Future.success({ - dataElementRoles: dataElementRoles.objects, - events: events.instances, + private getIncidentManagementTeamEvents(diseaseOutbreakId: Id): FutureData { + return apiToFuture( + this.api.tracker.events.get({ + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + trackedEntity: diseaseOutbreakId, + programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + fields: { + dataValues: { + dataElement: { id: true, code: true }, + value: true, + }, + trackedEntity: true, + event: true, + }, + }) + ) + .flatMap(response => + assertOrError( + response.instances, + `Incident management team builder program stage not found` + ) + ) + .flatMap(d2Events => { + return Future.success(d2Events); }); - }); } - private saveOrDeleteIncidentManagementTeamMember( - teamMemberRole: TeamRole, - incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id, - importStrategy: "CREATE_AND_UPDATE" | "DELETE" - ): FutureData { + private saveOrDeleteIncidentManagementTeamMember(params: { + teamMemberRole: TeamRole; + incidentManagementTeamMember: TeamMember; + diseaseOutbreakId: Id; + importStrategy: "CREATE_AND_UPDATE" | "DELETE"; + roles: Role[]; + }): FutureData { + const { + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + importStrategy, + roles, + } = params; return getProgramStage( this.api, RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID @@ -209,7 +197,8 @@ export class IncidentManagementTeamD2Repository implements IncidentManagementTea incidentManagementTeamMember, diseaseOutbreakId, enrollmentId, - incidentManagementTeamBuilderDataElements + incidentManagementTeamBuilderDataElements, + roles ); return apiToFuture( diff --git a/src/data/repositories/RoleD2Repository.ts b/src/data/repositories/RoleD2Repository.ts index 90584f6c..42fd39fd 100644 --- a/src/data/repositories/RoleD2Repository.ts +++ b/src/data/repositories/RoleD2Repository.ts @@ -4,49 +4,96 @@ import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; import { Role } from "../../domain/entities/incident-management-team/Role"; import { RoleRepository } from "../../domain/repositories/RoleRepository"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID } from "./consts/DiseaseOutbreakConstants"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES } from "./consts/IncidentManagementTeamBuilderConstants"; export class RoleD2Repository implements RoleRepository { constructor(private api: D2Api) {} getAll(): FutureData { return apiToFuture( - this.api.models.dataElements.get({ - fields: dataElementFields, - paging: false, + this.api.models.programStages.get({ + fields: programStageFields, filter: { - id: { in: Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS) }, + id: { + eq: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + }, }, }) ) - .flatMap(response => assertOrError(response.objects, `Roles not found`)) - .flatMap(d2DataElementRoles => { - if (d2DataElementRoles.length === 0) - return Future.error(new Error(`Roles not found`)); - else - return Future.success( - d2DataElementRoles.map(d2DataElementRole => - this.mapDataElementToRole(d2DataElementRole) + .flatMap(response => + assertOrError( + response.objects, + `Incident management team builder program stage not found` + ) + ) + .flatMap(d2ProgramStages => { + const programStageDataElementsIds = + d2ProgramStages[0]?.programStageDataElements.map( + ({ dataElement }) => dataElement.id + ); + if (!programStageDataElementsIds?.length) { + return Future.error( + new Error( + `Incident management team builder program stage data elements not found` ) ); + } else { + const programStageDataElementsRoleIds = programStageDataElementsIds?.filter( + id => + id !== + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.teamMemberAssigned && + id !== + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.reportsToUsername + ); + + return apiToFuture( + this.api.models.dataElements.get({ + fields: dataElementFields, + paging: false, + filter: { + id: { + in: programStageDataElementsRoleIds, + }, + }, + }) + ) + .flatMap(response => + assertOrError( + response.objects, + `Incident management team builder data elements not found` + ) + ) + .flatMap(d2DataElements => { + return Future.success( + this.mapProgramStageDataElementsToRoles(d2DataElements) + ); + }); + } }); } - private mapDataElementToRole(d2DataElementRole: D2DataElement): Role { - return { - id: d2DataElementRole.id, - code: d2DataElementRole.code, - name: d2DataElementRole.name, - }; + private mapProgramStageDataElementsToRoles(d2DataElements: D2DataElement[]): Role[] { + return d2DataElements.map(dataElement => ({ + id: dataElement.id, + name: dataElement.name, + code: dataElement.code, + })); } } +const programStageFields = { + programStageDataElements: { + dataElement: { id: true }, + }, +} as const; + const dataElementFields = { id: true, code: true, name: true, } as const; -type D2DataElement = MetadataPick<{ +export type D2DataElement = MetadataPick<{ dataElements: { fields: typeof dataElementFields }; }>["dataElements"][number]; diff --git a/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts index c43a079d..77c081d1 100644 --- a/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts +++ b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts @@ -1,47 +1,11 @@ -import { GetValue } from "../../../utils/ts-utils"; +export const INCIDENT_MANAGER_ROLE = "fnZ7EcG5CCV"; -export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS = { - incidentManagerRole: "fnZ7EcG5CCV", - caseManagementRole: "Ci2TwQIVR2x", - ipcUnitLeadRole: "NARFizS9nsk", - labUnitLeadRole: "SsXKTkPrJt9", - operationalSectionLeadRole: "lO197QfYLBc", - surveillanceUnitLeadRole: "EnmRCZYjSV6", - vaccineUnitRole: "RMqPVOnz8ja", -} as const; - -export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS = { +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES = { teamMemberAssigned: "iodfsSspCov", reportsToUsername: "TFIPHJyXN6H", - incidentManagerRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, - caseManagementRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole, - ipcUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole, - labUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole, - operationalSectionLeadRole: - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole, - surveillanceUnitLeadRole: - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole, - vaccineUnitRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole, } as const; -export const incidentManagementTeamBuilderCodes = { +export const incidentManagementTeamBuilderCodesWithoutRoles = { teamMemberAssigned: "RTSL_ZEB_DET_IMB_TMA", reportsToUsername: "RTSL_ZEB_DET_IMB_REPORTS", - incidentManagerRole: "RTSL_ZEB_DET_IMB_INCIDENT_MANAGER", - caseManagementRole: "RTSL_ZEB_DET_IMB_CASE_MANAGMENT", - ipcUnitLeadRole: "RTSL_ZEB_DET_IMB_IPC_LEAD", - labUnitLeadRole: "RTSL_ZEB_DET_IMB_LAB_LEAD", - operationalSectionLeadRole: "RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD", - surveillanceUnitLeadRole: "RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD", - vaccineUnitRole: "RTSL_ZEB_DET_IMB_VACCINE_UNIT", } as const; - -export type IncidentManagementTeamBuilderCodes = GetValue< - typeof incidentManagementTeamBuilderCodes ->; - -export function isStringInIncidentManagementTeamBuilderCodes( - code: string -): code is IncidentManagementTeamBuilderCodes { - return (Object.values(incidentManagementTeamBuilderCodes) as string[]).includes(code); -} diff --git a/src/data/repositories/test/IncidentManagementTeamTestRepository.ts b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts index ed2db170..8782b3ff 100644 --- a/src/data/repositories/test/IncidentManagementTeamTestRepository.ts +++ b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts @@ -1,16 +1,18 @@ import { Future } from "../../../domain/entities/generic/Future"; import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { Role } from "../../../domain/entities/incident-management-team/Role"; import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; import { Id } from "../../../domain/entities/Ref"; import { IncidentManagementTeamRepository } from "../../../domain/repositories/IncidentManagementTeamRepository"; import { Maybe } from "../../../utils/ts-utils"; import { FutureData } from "../../api-futures"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../consts/IncidentManagementTeamBuilderConstants"; export class IncidentManagementTeamTestRepository implements IncidentManagementTeamRepository { get( _diseaseOutbreakId: Id, - _teamMembers: TeamMember[] + _teamMembers: TeamMember[], + _roles: Role[] ): FutureData> { return Future.success(undefined); } @@ -18,7 +20,8 @@ export class IncidentManagementTeamTestRepository implements IncidentManagementT saveIncidentManagementTeamMemberRole( _teamMemberRole: TeamRole, _incidentManagementTeamMember: TeamMember, - _diseaseOutbreakId: Id + _diseaseOutbreakId: Id, + _roles: Role[] ): FutureData { return Future.success(undefined); } @@ -26,12 +29,17 @@ export class IncidentManagementTeamTestRepository implements IncidentManagementT deleteIncidentManagementTeamMemberRole( _teamMemberRole: TeamRole, _incidentManagementTeamMember: TeamMember, - _diseaseOutbreakId: Id + _diseaseOutbreakId: Id, + _roles: Role[] ): FutureData { return Future.success(undefined); } - getIncidentManagementTeamMember(username: Id, _diseaseOutbreakId: Id): FutureData { + getIncidentManagementTeamMember( + username: Id, + _diseaseOutbreakId: Id, + _roles: Role[] + ): FutureData { const teamMember: TeamMember = new TeamMember({ id: username, username: username, @@ -42,7 +50,7 @@ export class IncidentManagementTeamTestRepository implements IncidentManagementT { id: "role", name: "role", - roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + roleId: INCIDENT_MANAGER_ROLE, reportsToUsername: "reportsToUsername", }, ], diff --git a/src/data/repositories/utils/IncidentManagementTeamMapper.ts b/src/data/repositories/utils/IncidentManagementTeamMapper.ts index 3bafec72..8c511a5d 100644 --- a/src/data/repositories/utils/IncidentManagementTeamMapper.ts +++ b/src/data/repositories/utils/IncidentManagementTeamMapper.ts @@ -9,29 +9,24 @@ import { } from "../consts/DiseaseOutbreakConstants"; import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; import { Maybe } from "../../../utils/ts-utils"; -import { D2DataElement } from "../IncidentManagementTeamD2Repository"; import _c from "../../../domain/entities/generic/Collection"; import { Id } from "../../../domain/entities/Ref"; import { SelectedPick } from "@eyeseetea/d2-api/api"; import { D2DataElementSchema } from "@eyeseetea/d2-api/2.36"; -import { - IncidentManagementTeamBuilderCodes, - isStringInIncidentManagementTeamBuilderCodes, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, -} from "../consts/IncidentManagementTeamBuilderConstants"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES } from "../consts/IncidentManagementTeamBuilderConstants"; +import { Role } from "../../../domain/entities/incident-management-team/Role"; export function mapD2EventsToIncidentManagementTeam( - events: D2TrackerEvent[], - dataElementRoles: D2DataElement[], + d2Events: D2TrackerEvent[], + roles: Role[], teamMembers: TeamMember[] ): Maybe { const teamHierarchy: TeamMember[] = teamMembers.reduce( (acc: TeamMember[], teamMember: TeamMember) => { - const memberRoleEvents = events.filter(event => { + const memberRoleEvents = d2Events.filter(event => { const teamMemberAssignedUsername = getValueById( event.dataValues, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.teamMemberAssigned ); return teamMemberAssignedUsername === teamMember.username; }); @@ -42,8 +37,9 @@ export function mapD2EventsToIncidentManagementTeam( const teamRoles = getTeamMemberIncidentManagementTeamRoles( teamMember, memberRoleEvents, - dataElementRoles + roles ); + return teamRoles.length === 0 ? acc : [...acc, new TeamMember({ ...teamMember, teamRoles: teamRoles })]; @@ -60,47 +56,42 @@ export function mapD2EventsToIncidentManagementTeam( export function getTeamMemberIncidentManagementTeamRoles( teamMemberAssigned: TeamMember, events: D2TrackerEvent[], - dataElementRoles: D2DataElement[] + roles: Role[] ): TeamRole[] { return events.reduce((acc: TeamRole[], event: D2TrackerEvent) => { if ( teamMemberAssigned.username === getValueById( event.dataValues, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.teamMemberAssigned ) ) { - const teamRole = getTeamRole(event.event, event.dataValues, dataElementRoles); + const teamRole = getTeamRole(event.event, event.dataValues, roles); + return teamRole ? [...acc, teamRole] : acc; } return acc; }, []); } -function getTeamRole( - eventId: Id, - dataValues: DataValue[], - dataElementRoles: D2DataElement[] -): Maybe { - const roleIds = Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS); - - const selectedRoleId = roleIds.find(roleId => { - const role = getValueById(dataValues, roleId); +function getTeamRole(eventId: Id, dataValues: DataValue[], roles: Role[]): Maybe { + const selectedRoleId = roles.find(({ id }) => { + const role = getValueById(dataValues, id); return role === "true"; - }); + })?.id; - const roleDataElement = dataElementRoles.find(dataElement => dataElement.id === selectedRoleId); + const roleSelected = roles.find(role => role.id === selectedRoleId); const reportsToUsername = getValueById( dataValues, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.reportsToUsername + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.reportsToUsername ); - if (selectedRoleId && roleDataElement) { + if (selectedRoleId && roleSelected) { return { id: eventId, roleId: selectedRoleId, - name: roleDataElement?.name, + name: roleSelected?.name, reportsToUsername: reportsToUsername, }; } @@ -122,19 +113,17 @@ export function mapIncidentManagementTeamMemberToD2Event( incidentManagementTeamMember: TeamMember, teiId: Id, enrollmentId: Id, - programStageDataElementsMetadata: D2ProgramStageDataElementsMetadata[] + programStageDataElementsMetadata: D2ProgramStageDataElementsMetadata[], + roles: Role[] ): D2TrackerEvent { - const dataElementValues: Record = - getValueFromIncidentManagementTeamMember( - incidentManagementTeamMember.username, - teamMemberRole - ); + const dataElementValues = getValueFromIncidentManagementTeamMember( + incidentManagementTeamMember.username, + teamMemberRole, + roles + ); const dataValues: DataValue[] = programStageDataElementsMetadata.map(programStage => { - if (!isStringInIncidentManagementTeamBuilderCodes(programStage.dataElement.code)) { - throw new Error("DataElement code not found in IncidentManagementTeamBuilderCodes"); - } - const typedCode: IncidentManagementTeamBuilderCodes = programStage.dataElement.code; + const typedCode = programStage.dataElement.code; return getPopulatedDataElement(programStage.dataElement.id, dataElementValues[typedCode]); }); @@ -155,48 +144,22 @@ export function mapIncidentManagementTeamMemberToD2Event( export function getValueFromIncidentManagementTeamMember( incidentManagementTeamMemberUsername: string, - teamRoleAssigned: TeamRole -): Record { + teamRoleAssigned: TeamRole, + roles: Role[] +): Record { const checkRoleSelected = (roleId: string): boolean => (teamRoleAssigned?.roleId || "") === roleId; + const rolesObjByCode = roles.reduce((acc, role) => { + return { + ...acc, + [role.code]: checkRoleSelected(role.id) ? "true" : "", + }; + }, {}); + return { RTSL_ZEB_DET_IMB_TMA: incidentManagementTeamMemberUsername, RTSL_ZEB_DET_IMB_REPORTS: teamRoleAssigned?.reportsToUsername ?? "", - RTSL_ZEB_DET_IMB_INCIDENT_MANAGER: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_CASE_MANAGMENT: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_IPC_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_LAB_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_VACCINE_UNIT: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole - ) - ? "true" - : "", + ...rolesObjByCode, }; } diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index beefb3b1..0649bdba 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -11,6 +11,7 @@ import { RiskAssessmentGrading } from "./risk-assessment/RiskAssessmentGrading"; import { RiskAssessmentSummary } from "./risk-assessment/RiskAssessmentSummary"; import { RiskAssessmentQuestionnaire } from "./risk-assessment/RiskAssessmentQuestionnaire"; import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; +import { Role } from "./incident-management-team/Role"; export type DiseaseOutbreakEventOptions = { dataSources: Option[]; @@ -86,7 +87,7 @@ export type RiskAssessmentQuestionnaireFormData = BaseFormData & { }; export type IncidentManagementTeamRoleOptions = { - roles: Option[]; + roles: Role[]; teamMembers: TeamMember[]; incidentManagers: TeamMember[]; }; diff --git a/src/domain/repositories/IncidentManagementTeamRepository.ts b/src/domain/repositories/IncidentManagementTeamRepository.ts index fe710da6..becbc70c 100644 --- a/src/domain/repositories/IncidentManagementTeamRepository.ts +++ b/src/domain/repositories/IncidentManagementTeamRepository.ts @@ -1,23 +1,31 @@ import { FutureData } from "../../data/api-futures"; import { Maybe } from "../../utils/ts-utils"; import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; +import { Role } from "../entities/incident-management-team/Role"; import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; import { Id } from "../entities/Ref"; export interface IncidentManagementTeamRepository { get( diseaseOutbreakId: Id, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData>; saveIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData; deleteIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData; - getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData; + getIncidentManagementTeamMember( + username: Id, + diseaseOutbreakId: Id, + roles: Role[] + ): FutureData; } diff --git a/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts index fe5c9823..b509ab8a 100644 --- a/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts +++ b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts @@ -2,11 +2,13 @@ import { FutureData } from "../../data/api-futures"; import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; import { Id } from "../entities/Ref"; import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; export class DeleteIncidentManagementTeamMemberRoleUseCase { constructor( private options: { incidentManagementTeamRepository: IncidentManagementTeamRepository; + roleRepository: RoleRepository; } ) {} @@ -15,10 +17,13 @@ export class DeleteIncidentManagementTeamMemberRoleUseCase { incidentManagementTeam: TeamMember, diseaseOutbreakId: Id ): FutureData { - return this.options.incidentManagementTeamRepository.deleteIncidentManagementTeamMemberRole( - teamMemberRole, - incidentManagementTeam, - diseaseOutbreakId - ); + return this.options.roleRepository.getAll().flatMap(roles => { + return this.options.incidentManagementTeamRepository.deleteIncidentManagementTeamMemberRole( + teamMemberRole, + incidentManagementTeam, + diseaseOutbreakId, + roles + ); + }); } } diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 5c420427..d108f490 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -7,6 +7,7 @@ import { IncidentManagementTeamRepository } from "../repositories/IncidentManage import { OptionsRepository } from "../repositories/OptionsRepository"; import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; import { getAll } from "./utils/risk-assessment/GetRiskAssessmentById"; @@ -20,6 +21,7 @@ export class GetDiseaseOutbreakByIdUseCase { orgUnitRepository: OrgUnitRepository; riskAssessmentRepository: RiskAssessmentRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; + roleRepository: RoleRepository; } ) {} @@ -56,11 +58,8 @@ export class GetDiseaseOutbreakByIdUseCase { this.options.optionsRepository, this.options.teamMemberRepository ), - incidentManagementTeam: getIncidentManagementTeamById( - id, - this.options.incidentManagementTeamRepository, - this.options.teamMemberRepository - ), + incidentManagementTeam: getIncidentManagementTeamById(id, this.options), + roles: this.options.roleRepository.getAll(), }).flatMap( ({ mainSyndrome, @@ -70,9 +69,10 @@ export class GetDiseaseOutbreakByIdUseCase { areasAffectedDistricts, riskAssessment, incidentManagementTeam, + roles, }) => { return this.options.incidentManagementTeamRepository - .getIncidentManagementTeamMember(incidentManagerName, id) + .getIncidentManagementTeamMember(incidentManagerName, id, roles) .flatMap(incidentManager => { const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent({ diff --git a/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts index 2f994a94..53d6b658 100644 --- a/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts +++ b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts @@ -3,22 +3,20 @@ import { Maybe } from "../../utils/ts-utils"; import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; import { Id } from "../entities/Ref"; import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; export class GetIncidentManagementTeamByIdUseCase { constructor( private options: { + roleRepository: RoleRepository; teamMemberRepository: TeamMemberRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; } ) {} public execute(diseaseOutbreakEventId: Id): FutureData> { - return getIncidentManagementTeamById( - diseaseOutbreakEventId, - this.options.incidentManagementTeamRepository, - this.options.teamMemberRepository - ); + return getIncidentManagementTeamById(diseaseOutbreakEventId, this.options); } } diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 377c27ab..1ab20e3d 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../data/api-futures"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { ConfigurableForm } from "../entities/ConfigurableForm"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; @@ -9,6 +9,7 @@ import { IncidentManagementTeamRepository } from "../repositories/IncidentManage import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { saveDiseaseOutbreak } from "./utils/disease-outbreak/SaveDiseaseOutbreak"; +import { RoleRepository } from "../repositories/RoleRepository"; export class SaveEntityUseCase { constructor( @@ -17,6 +18,7 @@ export class SaveEntityUseCase { riskAssessmentRepository: RiskAssessmentRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; + roleRepository: RoleRepository; } ) {} @@ -30,6 +32,7 @@ export class SaveEntityUseCase { incidentManagementTeamRepository: this.options.incidentManagementTeamRepository, teamMemberRepository: this.options.teamMemberRepository, + roleRepository: this.options.roleRepository, }, formData.entity ); @@ -43,9 +46,7 @@ export class SaveEntityUseCase { case "incident-management-team-member-assignment": { const isIncidentManager = formData.entity.teamRoles?.find( - role => - role.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + role => role.roleId === INCIDENT_MANAGER_ROLE ); const hasIncidentManagerChanged = @@ -73,6 +74,7 @@ export class SaveEntityUseCase { incidentManagementTeamRepository: this.options.incidentManagementTeamRepository, teamMemberRepository: this.options.teamMemberRepository, + roleRepository: this.options.roleRepository, }, updatedDiseaseOutbreakEvent ); @@ -92,7 +94,8 @@ export class SaveEntityUseCase { return this.options.incidentManagementTeamRepository.saveIncidentManagementTeamMemberRole( teamRoleToSave, formData.entity, - formData.eventTrackerDetails.id + formData.eventTrackerDetails.id, + formData.options.roles ); } } diff --git a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts index a82543d5..c43aee08 100644 --- a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts +++ b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts @@ -1,11 +1,13 @@ import { FutureData } from "../../../../data/api-futures"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { DiseaseOutbreakEventBaseAttrs } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../entities/generic/Future"; +import { Role } from "../../../entities/incident-management-team/Role"; import { TeamMember, TeamRole } from "../../../entities/incident-management-team/TeamMember"; import { Id } from "../../../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../../../repositories/RoleRepository"; import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; export function saveDiseaseOutbreak( @@ -13,6 +15,7 @@ export function saveDiseaseOutbreak( diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; + roleRepository: RoleRepository; }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs ): FutureData { @@ -28,26 +31,26 @@ function saveIncidentManagerTeamMemberRole( diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; + roleRepository: RoleRepository; }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs ): FutureData { - return repositories.teamMemberRepository.getAll().flatMap(teamMembers => { + return Future.joinObj({ + roles: repositories.roleRepository.getAll(), + teamMembers: repositories.teamMemberRepository.getAll(), + }).flatMap(({ roles, teamMembers }) => { return repositories.incidentManagementTeamRepository - .get(diseaseOutbreakEventBaseAttrs.id, teamMembers) + .get(diseaseOutbreakEventBaseAttrs.id, teamMembers, roles) .flatMap(incidentManagementTeam => { const incidentManagerTeamMemberFound = incidentManagementTeam?.teamHierarchy?.find( teamMember => teamMember.teamRoles?.some( - teamRole => - teamRole.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRole => teamRole.roleId === INCIDENT_MANAGER_ROLE ) ); const incidentManagerTeamRole = incidentManagerTeamMemberFound?.teamRoles?.find( - teamRole => - teamRole.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRole => teamRole.roleId === INCIDENT_MANAGER_ROLE ); if ( @@ -61,13 +64,15 @@ function saveIncidentManagerTeamMemberRole( diseaseOutbreakEventBaseAttrs, incidentManagerTeamMemberFound, incidentManagerTeamRole, - teamMembers + teamMembers, + roles ); } else { return createNewIncidentManager( repositories, diseaseOutbreakEventBaseAttrs, - teamMembers + teamMembers, + roles ); } }); @@ -83,12 +88,14 @@ function changeIncidentManager( diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, oldIncidentManager: TeamMember, oldIncidentManagerTeamRole: TeamRole, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData { if (oldIncidentManager.username !== diseaseOutbreakEventBaseAttrs.incidentManagerName) { const newIncidentManager = teamMembers.find( teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName ); + if (!newIncidentManager) { return Future.error( new Error( @@ -96,24 +103,28 @@ function changeIncidentManager( ) ); } + const newIncidentManagerTeamRole: TeamRole = { id: "", name: "", - roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + roleId: INCIDENT_MANAGER_ROLE, reportsToUsername: undefined, }; + return repositories.incidentManagementTeamRepository .deleteIncidentManagementTeamMemberRole( oldIncidentManagerTeamRole, oldIncidentManager, - diseaseOutbreakEventBaseAttrs.id + diseaseOutbreakEventBaseAttrs.id, + roles ) .flatMap(() => { return repositories.incidentManagementTeamRepository .saveIncidentManagementTeamMemberRole( newIncidentManagerTeamRole, newIncidentManager, - diseaseOutbreakEventBaseAttrs.id + diseaseOutbreakEventBaseAttrs.id, + roles ) .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); }); @@ -129,7 +140,8 @@ function createNewIncidentManager( teamMemberRepository: TeamMemberRepository; }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData { const newIncidentManager = teamMembers.find( teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName @@ -146,14 +158,15 @@ function createNewIncidentManager( const incidentManagerTeamRole: TeamRole = { id: "", name: "", - roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + roleId: INCIDENT_MANAGER_ROLE, reportsToUsername: undefined, }; return repositories.incidentManagementTeamRepository .saveIncidentManagementTeamMemberRole( incidentManagerTeamRole, newIncidentManager, - diseaseOutbreakEventBaseAttrs.id + diseaseOutbreakEventBaseAttrs.id, + roles ) .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); } diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts index be105f1f..508f6f2f 100644 --- a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts @@ -1,16 +1,28 @@ import { FutureData } from "../../../../data/api-futures"; import { Maybe } from "../../../../utils/ts-utils"; +import { Future } from "../../../entities/generic/Future"; import { IncidentManagementTeam } from "../../../entities/incident-management-team/IncidentManagementTeam"; import { Id } from "../../../entities/Ref"; import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../../../repositories/RoleRepository"; import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; export function getIncidentManagementTeamById( diseaseOutbreakId: Id, - incidentManagementTeamRepository: IncidentManagementTeamRepository, - teamMemberRepository: TeamMemberRepository + repositories: { + roleRepository: RoleRepository; + teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } ): FutureData> { - return teamMemberRepository.getAll().flatMap(teamMembers => { - return incidentManagementTeamRepository.get(diseaseOutbreakId, teamMembers); + return Future.joinObj({ + roles: repositories.roleRepository.getAll(), + teamMembers: repositories.teamMemberRepository.getAll(), + }).flatMap(({ roles, teamMembers }) => { + return repositories.incidentManagementTeamRepository.get( + diseaseOutbreakId, + teamMembers, + roles + ); }); } diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts index 4b39c6d4..19e213bf 100644 --- a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../../../data/api-futures"; -import { incidentManagementTeamBuilderCodes } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { Maybe } from "../../../../utils/ts-utils"; import { SECTION_IDS } from "../../../../webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { IncidentManagementTeamMemberFormData } from "../../../entities/ConfigurableForm"; @@ -24,11 +24,7 @@ export function getIncidentManagementTeamWithOptions( roles: repositories.roleRepository.getAll(), teamMembers: repositories.teamMemberRepository.getForIncidentManagementTeamMembers(), incidentManagers: repositories.teamMemberRepository.getIncidentManagers(), - incidentManagementTeam: getIncidentManagementTeamById( - eventTrackerDetails.id, - repositories.incidentManagementTeamRepository, - repositories.teamMemberRepository - ), + incidentManagementTeam: getIncidentManagementTeamById(eventTrackerDetails.id, repositories), }).flatMap(({ roles, teamMembers, incidentManagers, incidentManagementTeam }) => { const teamMemberSelected = incidentManagementTeam?.teamHierarchy.find(teamMember => teamMember.teamRoles?.some(teamRole => teamRole.id === incidentManagementTeamRoleId) @@ -55,8 +51,10 @@ export function getIncidentManagementTeamWithOptions( rules: [ { type: "disableFieldOptionWithSameFieldValue", - fieldId: incidentManagementTeamBuilderCodes.teamMemberAssigned, - fieldIdsToDisableOption: [incidentManagementTeamBuilderCodes.reportsToUsername], + fieldId: incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned, + fieldIdsToDisableOption: [ + incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername, + ], sectionsWithFieldsToDisableOption: [SECTION_IDS.reportsTo], }, ], diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx index fad574f6..b15fcfc2 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx @@ -39,25 +39,27 @@ export const IMTeamHierarchyView: React.FC = React.mem return ( - - } - defaultExpandIcon={} - > - {items.map(item => ( - - ))} - + + + } + defaultExpandIcon={} + > + {items.map(item => ( + + ))} + + ); }); @@ -80,3 +82,9 @@ const StyledIMTeamHierarchyView = styled(TreeViewMUI)` align-items: baseline; } `; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; diff --git a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts index 96b5643a..5fc25c0b 100644 --- a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts +++ b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts @@ -4,15 +4,15 @@ import { mapTeamMemberToUser, mapToPresentationOptions } from "../mapEntityToFor import { Option as UIOption } from "../../../components/utils/option"; import { User } from "../../../components/user-selector/UserSelector"; import { - incidentManagementTeamBuilderCodes, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, + INCIDENT_MANAGER_ROLE, + incidentManagementTeamBuilderCodesWithoutRoles, } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; export const TEAM_ROLE_FIELD_ID = "team-role-field"; export const SECTION_IDS = { teamRole: "team-role-section", - teamMemberAssigned: `${incidentManagementTeamBuilderCodes.teamMemberAssigned}-section`, - reportsTo: `${incidentManagementTeamBuilderCodes.reportsToUsername}-section`, + teamMemberAssigned: `${incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned}-section`, + reportsTo: `${incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername}-section`, }; export function mapIncidentManagementTeamMemberToInitialFormState( @@ -30,10 +30,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( const roleOptions: UIOption[] = mapToPresentationOptions(roles); const roleOptionsWithoutIncidentManager: UIOption[] = mapToPresentationOptions( - roles.filter( - role => - role.id !== RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole - ) + roles.filter(role => role.id !== INCIDENT_MANAGER_ROLE) ); const teamMemberOptions: User[] = teamMembers.map(tm => mapTeamMemberToUser(tm)); const incidentManagerOptions: User[] = incidentManagers.map(tm => mapTeamMemberToUser(tm)); @@ -65,16 +62,13 @@ export function mapIncidentManagementTeamMemberToInitialFormState( type: "select", multiple: false, options: - teamRoleToAssing?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE ? roleOptions : roleOptionsWithoutIncidentManager, value: teamRoleToAssing?.roleId || "", required: true, showIsRequired: true, - disabled: - teamRoleToAssing?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + disabled: teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE, }, ], }, @@ -85,7 +79,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( required: true, fields: [ { - id: incidentManagementTeamBuilderCodes.teamMemberAssigned, + id: incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned, placeholder: "Select a team member", helperText: "Only available team members are shown", isVisible: true, @@ -93,8 +87,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( type: "select", multiple: false, options: - teamRoleToAssing?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE ? incidentManagerOptions : teamMemberOptions, value: incidentManagementTeamMember?.username || "", @@ -111,7 +104,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( required: false, fields: [ { - id: incidentManagementTeamBuilderCodes.reportsToUsername, + id: incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername, placeholder: "Select a team member", isVisible: true, errors: [], diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 46742ebe..0b04834a 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -37,7 +37,7 @@ import { } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; -import { incidentManagementTeamBuilderCodes } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; export function mapFormStateToEntityData( formState: FormState, @@ -497,12 +497,15 @@ function mapFormStateToIncidentManagementTeamMember( const teamMemberAssigned = teamMembers.find(teamMember => { return ( teamMember.username === - getStringFieldValueById(incidentManagementTeamBuilderCodes.teamMemberAssigned) + getStringFieldValueById( + incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned + ) ); }); const reportsToUserNameSelected = - getStringFieldValueById(incidentManagementTeamBuilderCodes.reportsToUsername) || ""; + getStringFieldValueById(incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername) || + ""; const filteredTeamMemberAssignedRoles = teamMemberAssigned?.teamRoles?.filter( teamRole => teamRole.id !== incidentManagementTeamRoleId diff --git a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts index 0ae8f224..16b84d1f 100644 --- a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts +++ b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts @@ -10,7 +10,7 @@ import { IMTeamHierarchyOption } from "../../components/im-team-hierarchy/IMTeam import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import _c from "../../../domain/entities/generic/Collection"; type GlobalMessage = { @@ -106,9 +106,7 @@ export function useIMTeamBuilder(id: Id): State { role => role.id === selection ); - const isIncidentManagerRoleSelected = - selectedRole?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole; + const isIncidentManagerRoleSelected = selectedRole?.roleId === INCIDENT_MANAGER_ROLE; setSelectedHierarchyItemId(selection); setDisableDeletion(isIncidentManagerRoleSelected); @@ -191,11 +189,7 @@ export function useIMTeamBuilder(id: Id): State { const incidentManagerUser = useMemo(() => { const incidentManagerTeamMember = incidentManagementTeam?.teamHierarchy.find(member => { - return member.teamRoles?.some( - role => - role.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole - ); + return member.teamRoles?.some(role => role.roleId === INCIDENT_MANAGER_ROLE); }); if (incidentManagerTeamMember) { return mapTeamMemberToUser(incidentManagerTeamMember);