diff --git a/i18n/en.pot b/i18n/en.pot index 42705daf..eef4b4f9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,56 @@ 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-07-04T13:31:07.802Z\n" -"PO-Revision-Date: 2024-07-04T13:31:07.802Z\n" +"POT-Creation-Date: 2024-07-01T02:04:44.570Z\n" +"PO-Revision-Date: 2024-07-01T02:04:44.570Z\n" + +msgid "Low" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "High" +msgstr "" + +msgid "Less than 0.1%" +msgstr "" + +msgid "Between 0.1% to 0.25%" +msgstr "" + +msgid "Above 0.25%" +msgstr "" + +msgid "Within a district" +msgstr "" + +msgid "Within a province with more than one district affected" +msgstr "" + +msgid "More than one province affected with high threat of spread locally and internationally" +msgstr "" + +msgid "Available within the district with support from provincial and national level" +msgstr "" + +msgid "Available within the province with minimal support from national level" +msgstr "" + +msgid "Available at national with support required from international" +msgstr "" + +msgid "Grade 1" +msgstr "" + +msgid "Grade 2" +msgstr "" + +msgid "Grade 3" +msgstr "" + +msgid "Invalid grade" +msgstr "" msgid "Add new option" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 4e848d1c..2dc83d3b 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,13 +1,64 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-07-04T13:31:07.802Z\n" +"POT-Creation-Date: 2024-07-01T02:04:44.570Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" +msgid "Low" +msgstr "" + +msgid "Medium" +msgstr "" + +msgid "High" +msgstr "" + +msgid "Less than 0.1%" +msgstr "" + +msgid "Between 0.1% to 0.25%" +msgstr "" + +msgid "Above 0.25%" +msgstr "" + +msgid "Within a district" +msgstr "" + +msgid "Within a province with more than one district affected" +msgstr "" + +msgid "" +"More than one province affected with high threat of spread locally and " +"internationally" +msgstr "" + +msgid "" +"Available within the district with support from provincial and national level" +msgstr "" + +msgid "Available within the province with minimal support from national level" +msgstr "" + +msgid "Available at national with support required from international" +msgstr "" + +msgid "Grade 1" +msgstr "" + +msgid "Grade 2" +msgstr "" + +msgid "Grade 3" +msgstr "" + +msgid "Invalid grade" +msgstr "" + msgid "Add new option" msgstr "" @@ -52,3 +103,18 @@ msgstr "" msgid "Resources" msgstr "" + +#~ msgid "Add" +#~ msgstr "AƱadir" + +#~ msgid "List" +#~ msgstr "Listar" + +#~ msgid "Back" +#~ msgstr "Volver" + +#~ msgid "Help" +#~ msgstr "Ayuda" + +#~ msgid "Hello {{name}}" +#~ msgstr "Hola {{name}}" diff --git a/index.html b/index.html index 492d83db..1e5a8d80 100644 --- a/index.html +++ b/index.html @@ -4,15 +4,22 @@ - + - + - Vite + React + TS + Zebra diff --git a/package.json b/package.json index a649d7aa..f3c68aa8 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "dhis2-app-skeleton", - "description": "DHIS2 Skeleton App", + "name": "zebra", + "description": "Zambia Emergency Bridge for Response Application", "version": "0.0.1", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", "repository": { "type": "git", - "url": "git+https://github.com/eyeseetea/dhis2-app-skeleton.git" + "url": "git+https://github.com/eyeseetea/zebra-dev.git" }, "dependencies": { "@dhis2/app-runtime": "2.8.0", @@ -112,8 +112,8 @@ "script-example": "npx ts-node src/scripts/example.ts" }, "manifest.webapp": { - "name": "DHIS2 Skeleton App", - "description": "DHIS2 Skeleton App", + "name": "zebra", + "description": "Zambia Emergency Bridge for Response Application", "icons": { "48": "icon.png" }, diff --git a/src/domain/entities/DiseaseOutbreakEvent.ts b/src/domain/entities/DiseaseOutbreakEvent.ts new file mode 100644 index 00000000..ad19ab9f --- /dev/null +++ b/src/domain/entities/DiseaseOutbreakEvent.ts @@ -0,0 +1,60 @@ +import { Struct } from "./generic/Struct"; +import { IncidentActionPlan } from "./incident-action-plan/IncidentActionPlan"; +import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; +import { TeamMember } from "./incident-management-team/TeamMember"; +import { OrgUnit } from "./OrgUnit"; +import { CodedNamedRef, NamedRef } from "./Ref"; +import { RiskAssessment } from "./risk-assessment/RiskAssessment"; +import { Maybe } from "../../utils/ts-utils"; + +type HazardType = + | "Biological:Human" + | "Biological:Animal" + | "Chemical" + | "Environmental" + | "Unknown"; + +type IncidentStatusType = "Watch" | "Alert" | "Respond" | "Closed" | "Discarded"; + +type DateWithNarrative = { + date: Date; + narrative: string; +}; + +type Syndrome = CodedNamedRef; +type Disease = CodedNamedRef; +type NotificationSource = CodedNamedRef; + +type DiseaseOutbreakEventAttrs = NamedRef & { + created: Date; + lastUpdated: Date; + createdBy: TeamMember; + hazardType: HazardType; + mainSyndrome: Syndrome; + suspectedDisease: Disease; + notificationSource: NotificationSource; + areasAffected: { + provinces: OrgUnit[]; + districts: OrgUnit[]; + }; + incidentStatus: IncidentStatusType; + emerged: DateWithNarrative; + detected: DateWithNarrative; + notified: DateWithNarrative; + responseNarrative: string; + incidentManager: TeamMember; + notes: Maybe; + riskAssessments: RiskAssessment[]; + IncidentActionPlan: IncidentActionPlan; + IncidentManagementTeam: IncidentManagementTeam; +}; +/** + * Note: DiseaseOutbreakEvent represents Event in the Figma. + * Not using event as it is a keyword and can also be confused with dhis event + **/ + +export class DiseaseOutbreakEvent extends Struct() { + static validateEventName() { + //TO DO : Ensure event name is unique on event creation. + } +} diff --git a/src/domain/entities/OrgUnit.ts b/src/domain/entities/OrgUnit.ts new file mode 100644 index 00000000..554c0721 --- /dev/null +++ b/src/domain/entities/OrgUnit.ts @@ -0,0 +1,7 @@ +import { CodedNamedRef } from "./Ref"; + +type OrgUnitLevelType = "Province" | "District"; + +export type OrgUnit = CodedNamedRef & { + level: OrgUnitLevelType; +}; diff --git a/src/domain/entities/Properties.ts b/src/domain/entities/Properties.ts new file mode 100644 index 00000000..4fe8844a --- /dev/null +++ b/src/domain/entities/Properties.ts @@ -0,0 +1,29 @@ +import { CodedNamedRef } from "./Ref"; + +type PropertTypes = "string" | "date" | "number" | "boolean"; + +type BaseProperty = CodedNamedRef & { + type: PropertTypes; +}; + +type StringProperty = BaseProperty & { + type: "string"; + value: string; +}; + +type DateProperty = BaseProperty & { + type: "date"; + value: Date; +}; + +type NumberProperty = BaseProperty & { + type: "number"; + value: number; +}; + +type BooleanProperty = BaseProperty & { + type: "boolean"; + value: boolean; +}; + +export type Property = StringProperty | DateProperty | NumberProperty | BooleanProperty; diff --git a/src/domain/entities/Ref.ts b/src/domain/entities/Ref.ts index 8b95ca2f..922a5d18 100644 --- a/src/domain/entities/Ref.ts +++ b/src/domain/entities/Ref.ts @@ -7,3 +7,7 @@ export interface Ref { export interface NamedRef extends Ref { name: string; } + +export interface CodedNamedRef extends NamedRef { + code: string; +} diff --git a/src/domain/entities/generic/Either.ts b/src/domain/entities/generic/Either.ts index 33234090..adff2c62 100644 --- a/src/domain/entities/generic/Either.ts +++ b/src/domain/entities/generic/Either.ts @@ -17,6 +17,11 @@ export class Either { constructor(public value: EitherValue) {} + getOrThrow(): Data { + if (this.value.data) return this.value.data; + else throw this.value.error; + } + match(matchObj: MatchObject): Res { switch (this.value.type) { case "success": diff --git a/src/domain/entities/generic/Struct.ts b/src/domain/entities/generic/Struct.ts index cf4d9816..5a01041e 100644 --- a/src/domain/entities/generic/Struct.ts +++ b/src/domain/entities/generic/Struct.ts @@ -15,7 +15,7 @@ export function Struct() { abstract class Base { - constructor(_attributes: Attrs) { + protected constructor(_attributes: Attrs) { Object.assign(this, _attributes); } @@ -28,15 +28,10 @@ export function Struct() { const ParentClass = this.constructor as new (values: Attrs) => typeof this; return new ParentClass({ ...this._getAttributes(), ...partialAttrs }); } - - static create(this: new (attrs: Attrs) => U, attrs: Attrs): U { - return new this(attrs); - } } return Base as { new (values: Attrs): Attrs & Base; - create: (typeof Base)["create"]; }; } diff --git a/src/domain/entities/incident-action-plan/ActionPlan.ts b/src/domain/entities/incident-action-plan/ActionPlan.ts new file mode 100644 index 00000000..09336e77 --- /dev/null +++ b/src/domain/entities/incident-action-plan/ActionPlan.ts @@ -0,0 +1,8 @@ +import { Property } from "../Properties"; +import { Struct } from "../generic/Struct"; + +interface ActionPlanAttrs { + properties: Property[]; +} + +export class ActionPlan extends Struct() {} diff --git a/src/domain/entities/incident-action-plan/IncidentActionPlan.ts b/src/domain/entities/incident-action-plan/IncidentActionPlan.ts new file mode 100644 index 00000000..222a8df2 --- /dev/null +++ b/src/domain/entities/incident-action-plan/IncidentActionPlan.ts @@ -0,0 +1,12 @@ +import { ActionPlan } from "./ActionPlan"; +import { Ref } from "../Ref"; +import { Struct } from "../generic/Struct"; +import { ResponseAction } from "./ResponseAction"; + +interface IncidentActionPlanAttrs extends Ref { + lastUpdated: Date; + actionPlan: ActionPlan; + responseActions: ResponseAction[]; +} + +export class IncidentActionPlan extends Struct() {} diff --git a/src/domain/entities/incident-action-plan/ResponseAction.ts b/src/domain/entities/incident-action-plan/ResponseAction.ts new file mode 100644 index 00000000..1d2c268c --- /dev/null +++ b/src/domain/entities/incident-action-plan/ResponseAction.ts @@ -0,0 +1,22 @@ +import { CodedNamedRef } from "../Ref"; +import { Struct } from "../generic/Struct"; +import { TeamMember } from "../incident-management-team/TeamMember"; + +type ResponseActionStatusType = "NotDone" | "Pending" | "InProgress" | "Complete"; +type ResponseActionVerificationType = "Verified" | "Unverified"; +type MainTask = CodedNamedRef; +type SubPillar = CodedNamedRef; +type TimeLine = CodedNamedRef; + +interface ResponseActionAttrs { + mainTask: MainTask; + subActivities: string; + subPillar: SubPillar; + responsibleOfficer: TeamMember; + dueDate: Date; + timeLine: TimeLine; + status: ResponseActionStatusType; + verification: ResponseActionVerificationType; +} + +export class ResponseAction extends Struct() {} diff --git a/src/domain/entities/incident-management-team/IncidentManagementTeam.ts b/src/domain/entities/incident-management-team/IncidentManagementTeam.ts new file mode 100644 index 00000000..cb9298bf --- /dev/null +++ b/src/domain/entities/incident-management-team/IncidentManagementTeam.ts @@ -0,0 +1,8 @@ +import { Struct } from "../generic/Struct"; +import { TeamMember } from "./TeamMember"; + +interface IncidentManagementTeamAttrs { + teamHierarchy: TeamMember[]; +} + +export class IncidentManagementTeam extends Struct() {} diff --git a/src/domain/entities/incident-management-team/TeamMember.ts b/src/domain/entities/incident-management-team/TeamMember.ts new file mode 100644 index 00000000..4e77678e --- /dev/null +++ b/src/domain/entities/incident-management-team/TeamMember.ts @@ -0,0 +1,25 @@ +import { NamedRef } from "../Ref"; +import { Struct } from "../generic/Struct"; + +type PhoneNumber = string; +type Email = string; +type IncidentManagerStatus = "Available" | "Unavailable"; + +export type TeamRole = NamedRef & { + level: number; +}; + +interface TeamMemberAttrs extends NamedRef { + phone: PhoneNumber; + email: Email; + status: IncidentManagerStatus; + photo: URL; + role: TeamRole; +} + +export class TeamMember extends Struct() { + static validatePhAndEmail() { + //TO DO : any validations for phone number? + //TO DO : any validations for email? + } +} diff --git a/src/domain/entities/risk-assessment/RiskAssessment.ts b/src/domain/entities/risk-assessment/RiskAssessment.ts new file mode 100644 index 00000000..1a3fd827 --- /dev/null +++ b/src/domain/entities/risk-assessment/RiskAssessment.ts @@ -0,0 +1,12 @@ +import { Struct } from "../generic/Struct"; +import { RiskAssessmentGrading } from "./RiskAssessmentGrading"; +import { RiskAssessmentQuestionnaire } from "./RiskAssessmentQuestionnaire"; +import { RiskAssessmentSummary } from "./RiskAssessmentSummary"; + +interface RiskAssessmentAttrs { + grading: RiskAssessmentGrading[]; + summary: RiskAssessmentSummary; + questionnaire: RiskAssessmentQuestionnaire[]; +} + +export class RiskAssessment extends Struct() {} diff --git a/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts b/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts new file mode 100644 index 00000000..3b053d60 --- /dev/null +++ b/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts @@ -0,0 +1,153 @@ +import i18n from "@eyeseetea/feedback-component/locales"; +import { Ref } from "../Ref"; +import { Struct } from "../generic/Struct"; +import { Either } from "../generic/Either"; + +type WeightedOptionTypes = "Low" | "Medium" | "High"; +type PopulationWeightOptionsTypes = "LessPercentage" | "MediumPercentage" | "HighPercentage"; +type GeographicalSpreadOptionsTypes = + | "WithinDistrict" + | "MoretThanOneDistrict" + | "MoreThanOneProvince"; +type CapacityOptionsTypes = + | "ProvincialNationalLevel" + | "ProvincialLevel" + | "NationalInternationalLevel"; + +export type LowWeightedOption = { + type: "Low"; + weight: 1; +}; +export type MediumWeightedOption = { + type: "Medium"; + weight: 2; +}; +export type HighWeightedOption = { + type: "High"; + weight: 3; +}; + +export type LowPopulationAtRisk = { + type: "LessPercentage"; + weight: 1; +}; +export type MediumPopulationAtRisk = { + type: "MediumPercentage"; + weight: 2; +}; +export type HighPopulationAtRisk = { + type: "HighPercentage"; + weight: 3; +}; + +export type LowGeographicalSpread = { + type: "WithinDistrict"; + weight: 1; +}; +export type MediumGeographicalSpread = { + type: "MoretThanOneDistrict"; + weight: 2; +}; +export type HighGeographicalSpread = { + type: "MoreThanOneProvince"; + weight: 3; +}; + +export type LowCapacity = { + type: "ProvincialNationalLevel"; + weight: 1; +}; +export type MediumCapacity = { + type: "ProvincialLevel"; + weight: 2; +}; +export type HighCapacity = { + type: "NationalInternationalLevel"; + weight: 3; +}; + +export type Grade = "Grade1" | "Grade2" | "Grade3"; + +type AllOptionTypes = + | WeightedOptionTypes + | PopulationWeightOptionsTypes + | GeographicalSpreadOptionsTypes + | CapacityOptionsTypes + | Grade; + +const translations: Record = { + Low: i18n.t("Low"), + Medium: i18n.t("Medium"), + High: i18n.t("High"), + LessPercentage: i18n.t("Less than 0.1%"), + MediumPercentage: i18n.t("Between 0.1% to 0.25%"), + HighPercentage: i18n.t("Above 0.25%"), + WithinDistrict: i18n.t("Within a district"), + MoretThanOneDistrict: i18n.t("Within a province with more than one district affected"), + MoreThanOneProvince: i18n.t( + "More than one province affected with high threat of spread locally and internationally" + ), + ProvincialNationalLevel: i18n.t( + "Available within the district with support from provincial and national level" + ), + ProvincialLevel: i18n.t( + "Available within the province with minimal support from national level" + ), + NationalInternationalLevel: i18n.t( + "Available at national with support required from international" + ), + Grade1: i18n.t("Grade 1"), + Grade2: i18n.t("Grade 2"), + Grade3: i18n.t("Grade 3"), +}; + +interface RiskAssessmentGradingAttrs extends Ref { + lastUpdated: Date; + populationAtRisk: LowPopulationAtRisk | MediumPopulationAtRisk | HighPopulationAtRisk; + attackRate: LowWeightedOption | MediumWeightedOption | HighWeightedOption; + geographicalSpread: LowGeographicalSpread | MediumGeographicalSpread | HighGeographicalSpread; + complexity: LowWeightedOption | MediumWeightedOption | HighWeightedOption; + capacity: LowCapacity | MediumCapacity | HighCapacity; + reputationalRisk: LowWeightedOption | MediumWeightedOption | HighWeightedOption; + severity: LowWeightedOption | MediumWeightedOption | HighWeightedOption; +} + +export class RiskAssessmentGrading extends Struct() { + private constructor(attrs: RiskAssessmentGradingAttrs) { + super(attrs); + } + + public static create(attrs: RiskAssessmentGradingAttrs): RiskAssessmentGrading { + return new RiskAssessmentGrading(attrs); + } + + public static getTranslatedLabel(key: AllOptionTypes): string { + return translations[key]; + } + + getGrade(): Either { + return this.calculateGrade(); + } + + calculateGrade(): Either { + const totalWeight = + this.populationAtRisk.weight + + this.attackRate.weight + + this.geographicalSpread.weight + + this.complexity.weight + + this.capacity.weight + + this.reputationalRisk.weight + + this.severity.weight; + + if (totalWeight > 21) return Either.error(new Error(i18n.t("Invalid grade"))); + + const grade: Grade = + totalWeight <= 7 + ? "Grade1" + : totalWeight > 7 && totalWeight <= 14 + ? "Grade2" + : "Grade3"; + + return Either.success(grade); + } +} diff --git a/src/domain/entities/risk-assessment/RiskAssessmentQuestionnaire.ts b/src/domain/entities/risk-assessment/RiskAssessmentQuestionnaire.ts new file mode 100644 index 00000000..4a77cab4 --- /dev/null +++ b/src/domain/entities/risk-assessment/RiskAssessmentQuestionnaire.ts @@ -0,0 +1,8 @@ +import { Property } from "../Properties"; +import { Struct } from "../generic/Struct"; + +interface RiskAssessmentQuestionnaireAttrs { + questions: Property[]; +} + +export class RiskAssessmentQuestionnaire extends Struct() {} diff --git a/src/domain/entities/risk-assessment/RiskAssessmentSummary.ts b/src/domain/entities/risk-assessment/RiskAssessmentSummary.ts new file mode 100644 index 00000000..21b26ce2 --- /dev/null +++ b/src/domain/entities/risk-assessment/RiskAssessmentSummary.ts @@ -0,0 +1,8 @@ +import { Property } from "../Properties"; +import { Struct } from "../generic/Struct"; + +interface RiskAssessmentSummaryAttrs { + properties: Property[]; +} + +export class RiskAssessmentSummary extends Struct() {} diff --git a/src/domain/entities/risk-assessment/__tests__/RiskAssessmentGrading.spec.ts b/src/domain/entities/risk-assessment/__tests__/RiskAssessmentGrading.spec.ts new file mode 100644 index 00000000..1f1b9e3c --- /dev/null +++ b/src/domain/entities/risk-assessment/__tests__/RiskAssessmentGrading.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { RiskAssessmentGrading } from "../RiskAssessmentGrading"; + +const lowPopulationAtRisk = { type: "LessPercentage" as const, weight: 1 as const }; +const mediumPopulationAtRisk = { type: "MediumPercentage" as const, weight: 2 as const }; +const highPopulationAtRisk = { type: "HighPercentage" as const, weight: 3 as const }; +const lowWeightedOption = { type: "Low" as const, weight: 1 as const }; +const mediumWeightedOption = { type: "Medium" as const, weight: 2 as const }; +const highWeightedOption = { type: "High" as const, weight: 3 as const }; +const lowGeographicalSpread = { type: "WithinDistrict" as const, weight: 1 as const }; +const mediumGeographicalSpread = { type: "MoretThanOneDistrict" as const, weight: 2 as const }; +const highGeographicalSpread = { type: "MoreThanOneProvince" as const, weight: 3 as const }; +const lowCapacity = { type: "ProvincialNationalLevel" as const, weight: 1 as const }; +const mediumCapacity = { type: "ProvincialLevel" as const, weight: 2 as const }; +const highCapacity = { type: "NationalInternationalLevel" as const, weight: 3 as const }; + +describe("RiskAssessmentGrading", () => { + it("should be Grade1 if total weight is less than or equal to 7", () => { + const riskAssessmentGrading = RiskAssessmentGrading.create({ + id: "1", + lastUpdated: new Date(), + populationAtRisk: lowPopulationAtRisk, + attackRate: lowWeightedOption, + geographicalSpread: lowGeographicalSpread, + complexity: lowWeightedOption, + capacity: lowCapacity, + reputationalRisk: lowWeightedOption, + severity: lowWeightedOption, + }); + const grade = riskAssessmentGrading.getGrade().getOrThrow(); + if (grade) expect(RiskAssessmentGrading.getTranslatedLabel(grade)).toBe("Grade 1"); + }); + + it("should be Grade2 if total weight is greater than 7 and less than equal to 14", () => { + const riskAssessmentGrading = RiskAssessmentGrading.create({ + id: "2", + lastUpdated: new Date(), + populationAtRisk: mediumPopulationAtRisk, + attackRate: mediumWeightedOption, + geographicalSpread: mediumGeographicalSpread, + complexity: mediumWeightedOption, + capacity: mediumCapacity, + reputationalRisk: mediumWeightedOption, + severity: mediumWeightedOption, + }); + const grade = riskAssessmentGrading.getGrade().getOrThrow(); + if (grade) expect(RiskAssessmentGrading.getTranslatedLabel(grade)).toBe("Grade 2"); + }); + + it("should be Grade3 if score is greater than 14", () => { + const riskAssessmentGrading = RiskAssessmentGrading.create({ + id: "3", + lastUpdated: new Date(), + populationAtRisk: highPopulationAtRisk, + attackRate: highWeightedOption, + geographicalSpread: highGeographicalSpread, + complexity: highWeightedOption, + capacity: highCapacity, + reputationalRisk: highWeightedOption, + severity: highWeightedOption, + }); + + const grade = riskAssessmentGrading.getGrade().getOrThrow(); + if (grade) expect(RiskAssessmentGrading.getTranslatedLabel(grade)).toBe("Grade 3"); + }); +});