diff --git a/i18n/en.pot b/i18n/en.pot index 4d53e015..392348f3 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-16T14:36:22.158Z\n" -"PO-Revision-Date: 2024-10-16T14:36:22.158Z\n" +"POT-Creation-Date: 2024-11-03T18:35:32.151Z\n" +"PO-Revision-Date: 2024-11-03T18:35:32.151Z\n" msgid "Low" msgstr "" @@ -87,9 +87,15 @@ msgstr "" msgid "Edit Action Plan" msgstr "" +msgid "Event completed" +msgstr "" + msgid "Edit Details" msgstr "" +msgid "Complete Event" +msgstr "" + msgid "Notes" msgstr "" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index dd06ecb2..c4d38f18 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -67,6 +67,7 @@ import { GetConfigurationsUseCase } from "./domain/usecases/GetConfigurationsUse import { ConfigurationsRepository } from "./domain/repositories/ConfigurationsRepository"; import { ConfigurationsD2Repository } from "./data/repositories/ConfigurationsD2Repository"; import { ConfigurationsTestRepository } from "./data/repositories/test/ConfigurationsTestRepository"; +import { CompleteEventTrackerUseCase } from "./domain/usecases/CompleteEventTrackerUseCase"; export type CompositionRoot = ReturnType; @@ -106,6 +107,7 @@ function getCompositionRoot(repositories: Repositories) { repositories.configurationsRepository, repositories.teamMemberRepository ), + complete: new CompleteEventTrackerUseCase(repositories), }, incidentActionPlan: { get: new GetIncidentActionByIdUseCase(repositories), diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index 768b0278..a9688a2b 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -2,7 +2,7 @@ import { D2Api } from "../../types/d2-api"; import { DiseaseOutbreakEventRepository } from "../../domain/repositories/DiseaseOutbreakEventRepository"; import { apiToFuture, FutureData } from "../api-futures"; import { DiseaseOutbreakEventBaseAttrs } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { Id, ConfigLabel } from "../../domain/entities/Ref"; +import { Id } from "../../domain/entities/Ref"; import { mapDiseaseOutbreakEventToTrackedEntityAttributes, mapTrackedEntityAttributesToDiseaseOutbreak, @@ -13,6 +13,7 @@ import { getProgramTEAsMetadata } from "./utils/MetadataHelper"; import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; +import { D2TrackerEnrollment } from "@eyeseetea/d2-api/api/trackerEnrollments"; export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRepository { constructor(private api: D2Api) {} @@ -84,8 +85,44 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep ); } - getConfigStrings(): FutureData { - throw new Error("Method not implemented."); + complete(id: Id): FutureData { + return apiToFuture( + this.api.tracker.enrollments.get({ + fields: { + enrollment: true, + enrolledAt: true, + occurredAt: true, + }, + trackedEntity: id, + enrolledBefore: new Date().toISOString(), + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + }) + ).flatMap(enrollmentResponse => { + const currentEnrollment = enrollmentResponse.instances[0]; + const currentEnrollmentId = currentEnrollment?.enrollment; + if (!currentEnrollment || !currentEnrollmentId) { + return Future.error(new Error(`Enrollment not found for Event Tracker`)); + } + + const enrollment: D2TrackerEnrollment = { + ...currentEnrollment, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + program: RTSL_ZEBRA_PROGRAM_ID, + trackedEntity: id, + status: "COMPLETED", + }; + + return apiToFuture( + this.api.tracker.post({ importStrategy: "UPDATE" }, { enrollments: [enrollment] }) + ).flatMap(response => { + if (response.status !== "OK") { + return Future.error( + new Error(`Error completing disease outbreak event : ${response.message}`) + ); + } else return Future.success(undefined); + }); + }); } //TO DO : Implement delete/archive after requirement confirmation diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index f92cf21d..b7dc9d78 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -10,6 +10,9 @@ import { DiseaseOutbreakEventRepository } from "../../../domain/repositories/Dis import { FutureData } from "../../api-futures"; export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventRepository { + complete(_id: Id): FutureData { + return Future.success(undefined); + } get(id: Id): FutureData { return Future.success({ id: id, diff --git a/src/domain/repositories/DiseaseOutbreakEventRepository.ts b/src/domain/repositories/DiseaseOutbreakEventRepository.ts index 42d3d280..63a5ff33 100644 --- a/src/domain/repositories/DiseaseOutbreakEventRepository.ts +++ b/src/domain/repositories/DiseaseOutbreakEventRepository.ts @@ -1,10 +1,10 @@ import { FutureData } from "../../data/api-futures"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { ConfigLabel, Id } from "../entities/Ref"; +import { Id } from "../entities/Ref"; export interface DiseaseOutbreakEventRepository { get(id: Id): FutureData; getAll(): FutureData; save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData; - getConfigStrings(): FutureData; + complete(id: Id): FutureData; } diff --git a/src/domain/usecases/CompleteEventTrackerUseCase.ts b/src/domain/usecases/CompleteEventTrackerUseCase.ts new file mode 100644 index 00000000..ec08661b --- /dev/null +++ b/src/domain/usecases/CompleteEventTrackerUseCase.ts @@ -0,0 +1,15 @@ +import { FutureData } from "../../data/api-futures"; +import { Id } from "../entities/Ref"; +import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; + +export class CompleteEventTrackerUseCase { + constructor( + private options: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + } + ) {} + + public execute(id: Id): FutureData { + return this.options.diseaseOutbreakEventRepository.complete(id); + } +} diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index ad631c0b..76f4bcbc 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -10,8 +10,6 @@ import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; -import { getIncidentAction } from "./utils/incident-action/GetIncidentActionById"; -import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; import { getAll } from "./utils/risk-assessment/GetRiskAssessmentById"; export class GetDiseaseOutbreakByIdUseCase { @@ -62,18 +60,8 @@ export class GetDiseaseOutbreakByIdUseCase { this.options.riskAssessmentRepository, configurations ), - incidentAction: getIncidentAction( - id, - this.options.incidentActionRepository, - configurations - ), - incidentManagementTeam: getIncidentManagementTeamById( - id, - this.options, - configurations - ), roles: this.options.roleRepository.getAll(), - }).flatMap(({ riskAssessment, incidentAction, incidentManagementTeam, roles }) => { + }).flatMap(({ riskAssessment, roles }) => { return this.options.incidentManagementTeamRepository .getIncidentManagementTeamMember(incidentManagerName, id, roles) .flatMap(incidentManager => { @@ -86,8 +74,8 @@ export class GetDiseaseOutbreakByIdUseCase { notificationSource: notificationSource, incidentManager: incidentManager, riskAssessment: riskAssessment, - incidentActionPlan: incidentAction, - incidentManagementTeam: incidentManagementTeam, + incidentActionPlan: undefined, //IAP is fetched on menu click. It is not needed here. + incidentManagementTeam: undefined, //IMT is fetched on menu click. It is not needed here. }); return Future.success(diseaseOutbreakEvent); }); diff --git a/src/webapp/components/form/form-summary/FormSummary.tsx b/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx similarity index 80% rename from src/webapp/components/form/form-summary/FormSummary.tsx rename to src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx index 7578f752..2b8f9a45 100644 --- a/src/webapp/components/form/form-summary/FormSummary.tsx +++ b/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx @@ -6,14 +6,16 @@ import { Box, Button, Typography } from "@material-ui/core"; import { UserCard } from "../../user-selector/UserCard"; import { RouteName, useRoutes } from "../../../hooks/useRoutes"; import { EditOutlined } from "@material-ui/icons"; +import { CheckOutlined } from "@material-ui/icons"; import { Loader } from "../../loader/Loader"; import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { FormSummaryData } from "../../../pages/event-tracker/useDiseaseOutbreakEvent"; import { Maybe } from "../../../../utils/ts-utils"; import { FormType } from "../../../pages/form-page/FormPage"; import { Id } from "../../../../domain/entities/Ref"; +import { useAppContext } from "../../../contexts/app-context"; -export type FormSummaryProps = { +export type EventTrackerFormSummaryProps = { id: Id; formType: FormType; formSummary: Maybe; @@ -22,7 +24,8 @@ export type FormSummaryProps = { const ROW_COUNT = 3; -export const FormSummary: React.FC = React.memo(props => { +export const EventTrackerFormSummary: React.FC = React.memo(props => { + const { compositionRoot } = useAppContext(); const { id, formType, formSummary, summaryError } = props; const { goTo } = useRoutes(); const snackbar = useSnackbar(); @@ -38,6 +41,18 @@ export const FormSummary: React.FC = React.memo(props => { goTo(RouteName.EDIT_FORM, { formType: formType, id: id }); }, [formType, goTo, id]); + const onCompleteClick = useCallback(() => { + compositionRoot.diseaseOutbreakEvent.complete.execute(id).run( + () => { + snackbar.success(i18n.t("Event completed")); + }, + err => { + snackbar.error(i18n.t(`Failed to complete event: ${err.message}`)); + console.error(err); + } + ); + }, [compositionRoot, id, snackbar]); + const editButton = ( ); + const completeButton = ( + + ); + const getSummaryColumn = useCallback((index: number, label: string, value: string) => { return ( @@ -66,6 +92,7 @@ export const FormSummary: React.FC = React.memo(props => { title={formSummary.subTitle} hasSeparator={true} headerButton={editButton} + secondaryHeaderButton={completeButton} titleVariant="secondary" > diff --git a/src/webapp/components/section/Section.tsx b/src/webapp/components/section/Section.tsx index 2a3cdbe7..ff3a0495 100644 --- a/src/webapp/components/section/Section.tsx +++ b/src/webapp/components/section/Section.tsx @@ -9,6 +9,7 @@ type SectionProps = { lastUpdated?: string; children: React.ReactNode; headerButton?: React.ReactNode; + secondaryHeaderButton?: React.ReactNode; hasSeparator?: boolean; titleVariant?: "primary" | "secondary"; }; @@ -18,6 +19,7 @@ export const Section: React.FC = React.memo( title = "", lastUpdated = "", headerButton, + secondaryHeaderButton, hasSeparator = false, children, titleVariant = "primary", @@ -40,7 +42,10 @@ export const Section: React.FC = React.memo( ) : null} - {headerButton ?
{headerButton}
: null} + + {headerButton ?
{headerButton}
: null} + {secondaryHeaderButton ?
{secondaryHeaderButton}
: null} +
{children} @@ -48,7 +53,10 @@ export const Section: React.FC = React.memo( ); } ); - +const ButtonContainer = styled.div` + display: flex; + gap: 5px; +`; const SectionContainer = styled.section<{ $hasSeparator?: boolean }>` width: 100%; margin-block-end: ${props => (props.$hasSeparator ? "0" : "24px")}; diff --git a/src/webapp/components/table/BasicTable.tsx b/src/webapp/components/table/BasicTable.tsx index 46ca19bf..35fa3bd0 100644 --- a/src/webapp/components/table/BasicTable.tsx +++ b/src/webapp/components/table/BasicTable.tsx @@ -1,10 +1,18 @@ -import React from "react"; -import { Table, TableBody, TableCell, TableHead, TableRow } from "@material-ui/core"; +import React, { useCallback, useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TableSortLabel, +} from "@material-ui/core"; import styled from "styled-components"; import { Maybe } from "../../../utils/ts-utils"; import i18n from "../../../utils/i18n"; import { Option } from "../utils/option"; import { Cell } from "./Cell"; +import _c from "../../../domain/entities/generic/Collection"; const noop = () => {}; @@ -34,18 +42,41 @@ interface BasicTableProps { rows: TableRowType[]; onChange?: (cell: Maybe, rowIndex: number, column: TableColumn["value"]) => void; showRowIndex?: boolean; + onOrderBy?: (direction: "asc" | "desc") => void; } export const BasicTable: React.FC = React.memo( - ({ columns, rows, onChange = noop, showRowIndex = false }) => { + ({ columns, rows, onChange = noop, showRowIndex = false, onOrderBy }) => { + const [order, setOrder] = useState<"asc" | "desc">(); + + const orderBy = useCallback(() => { + const updatedOrder = order === "asc" ? "desc" : "asc"; + setOrder(prevOrder => (prevOrder === "asc" ? "desc" : "asc")); + onOrderBy && onOrderBy(updatedOrder); + }, [onOrderBy, order]); + return ( {showRowIndex && } - {columns.map(({ value, label }) => ( - {i18n.t(label)} - ))} + {columns.map(({ value, label }) => + label === "Assessment Date" ? ( + + + {label} + + + ) : ( + + {i18n.t(label)} + + ) + )} diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index b8693e3f..7e2691f7 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -5,7 +5,7 @@ import { useParams } from "react-router-dom"; import { AddCircleOutline, EditOutlined } from "@material-ui/icons"; import i18n from "../../../utils/i18n"; import { Layout } from "../../components/layout/Layout"; -import { FormSummary } from "../../components/form/form-summary/FormSummary"; +import { EventTrackerFormSummary } from "../../components/form/form-summary/EventTrackerFormSummary"; import { Chart } from "../../components/chart/Chart"; import { Section } from "../../components/section/Section"; import { BasicTable, TableColumn } from "../../components/table/BasicTable"; @@ -42,8 +42,13 @@ export const EventTrackerPage: React.FC = React.memo(() => { id: string; }>(); const { goTo } = useRoutes(); - const { formSummary, summaryError, riskAssessmentRows, eventTrackerDetails } = - useDiseaseOutbreakEvent(id); + const { + formSummary, + summaryError, + riskAssessmentRows, + eventTrackerDetails, + orderByRiskAssessmentDate, + } = useDiseaseOutbreakEvent(id); const { changeCurrentEventTracker, getCurrentEventTracker } = useCurrentEventTracker(); const currentEventTracker = getCurrentEventTracker(); const { lastAnalyticsRuntime } = useLastAnalyticsRuntime(); @@ -69,7 +74,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { return ( - { titleVariant="secondary" > {riskAssessmentRows.length > 0 ? ( - + ) : ( {i18n.t("Risks associated with this event have not yet been assessed.")} diff --git a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts index d0670bcc..0433f5e2 100644 --- a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts +++ b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Id } from "../../../domain/entities/Ref"; import { Maybe } from "../../../utils/ts-utils"; import { useAppContext } from "../../contexts/app-context"; @@ -141,6 +141,40 @@ export function useDiseaseOutbreakEvent(id: Id) { return []; } }; + const orderByRiskAssessmentDate = useCallback( + (direction: "asc" | "desc") => { + setRiskAssessmentRows(prevRows => { + if (direction === "asc") { + const sortedRows = prevRows.sort((a, b) => { + if (!a.riskAssessmentDate) return -1; + if (!b.riskAssessmentDate) return 1; - return { formSummary, summaryError, riskAssessmentRows, eventTrackerDetails }; + const dateA = new Date(a.riskAssessmentDate).toISOString(); + const dateB = new Date(b.riskAssessmentDate).toISOString(); + return dateA < dateB ? -1 : dateA > dateB ? 1 : 0; + }); + return sortedRows; + } else { + const sortedRows = prevRows.sort((a, b) => { + if (!a.riskAssessmentDate) return -1; + if (!b.riskAssessmentDate) return -1; + + const dateA = new Date(a.riskAssessmentDate).toISOString(); + const dateB = new Date(b.riskAssessmentDate).toISOString(); + return dateA < dateB ? 1 : dateA > dateB ? -1 : 0; + }); + return sortedRows; + } + }); + }, + [setRiskAssessmentRows] + ); + + return { + formSummary, + summaryError, + riskAssessmentRows, + eventTrackerDetails, + orderByRiskAssessmentDate, + }; }