diff --git a/i18n/en.pot b/i18n/en.pot index d25c0a8..50ecc1b 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-03-14T01:55:56.426Z\n" -"PO-Revision-Date: 2024-03-14T01:55:56.426Z\n" +"POT-Creation-Date: 2024-03-16T16:23:42.597Z\n" +"PO-Revision-Date: 2024-03-16T16:23:42.597Z\n" msgid "Cannot be blank: {{fieldName}}" msgstr "" @@ -59,6 +59,11 @@ msgstr "" msgid "Save Config Analysis" msgstr "" +msgid "" +"No user with Capture rights and Organisation Unit associated to the issue " +"was found" +msgstr "" + msgid "Updating Issue..." msgstr "" @@ -74,6 +79,30 @@ msgstr "" msgid "Double click to edit" msgstr "" +msgid "Yes" +msgstr "" + +msgid "No" +msgstr "" + +msgid "{{countryNames}} and {{moreCountriesCount}} more" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Countries" +msgstr "" + +msgid "Periods" +msgstr "" + +msgid "Action" +msgstr "" + +msgid "Follow Up" +msgstr "" + msgid "Issue" msgstr "" @@ -95,12 +124,6 @@ msgstr "" msgid "Azure URL" msgstr "" -msgid "Follow Up" -msgstr "" - -msgid "Action" -msgstr "" - msgid "Contact Emails" msgstr "" @@ -146,7 +169,7 @@ msgstr "" msgid "Data Quality Analysis" msgstr "" -msgid "Outliers detection analysis based on DHIS2 min-max standard functionality" +msgid "Configuration" msgstr "" msgid "Algorithm" @@ -155,42 +178,30 @@ msgstr "" msgid "Threshold" msgstr "" -msgid "Run" -msgstr "" - -msgid "Run to get results" -msgstr "" - -msgid "No Issues found" -msgstr "" - msgid "Running analysis..." msgstr "" -msgid "Missing disaggregates in selected catcombos" -msgstr "" - msgid "CatCombos" msgstr "" -msgid "Medical doctors analysis: General Practicioners missing and double counts" -msgstr "" - msgid "Disaggregations" msgstr "" msgid "Double Counts Threshold" msgstr "" -msgid "Missing nursing personnel when midwifery personnel is present" -msgstr "" - msgid "Loading" msgstr "" msgid "Validation Rule Group" msgstr "" +msgid "Run" +msgstr "" + +msgid "Run to get results" +msgstr "" + msgid "Activity Level" msgstr "" @@ -215,34 +226,7 @@ msgstr "" msgid "Enrolled" msgstr "" -msgid "Configuration" -msgstr "" - -msgid "Outliers" -msgstr "" - -msgid "Trends" -msgstr "" - -msgid "Disaggregates" -msgstr "" - -msgid "General Practitioners" -msgstr "" - -msgid "Nursing" -msgstr "" - -msgid "Nursing/Midwifery" -msgstr "" - -msgid "Midwifery" -msgstr "" - -msgid "Density" -msgstr "" - -msgid "Other" +msgid "No Issues found" msgstr "" msgid "Are you sure you want to {{action}} the selected rows?" diff --git a/i18n/es.po b/i18n/es.po index 5f96b35..a0779ca 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-03-14T01:55:56.426Z\n" +"POT-Creation-Date: 2024-03-16T16:23:42.597Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -59,6 +59,11 @@ msgstr "" msgid "Save Config Analysis" msgstr "" +msgid "" +"No user with Capture rights and Organisation Unit associated to the issue " +"was found" +msgstr "" + msgid "Updating Issue..." msgstr "" @@ -74,6 +79,30 @@ msgstr "" msgid "Double click to edit" msgstr "" +msgid "Yes" +msgstr "" + +msgid "No" +msgstr "" + +msgid "{{countryNames}} and {{moreCountriesCount}} more" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Countries" +msgstr "" + +msgid "Periods" +msgstr "" + +msgid "Action" +msgstr "" + +msgid "Follow Up" +msgstr "" + msgid "Issue" msgstr "" @@ -95,12 +124,6 @@ msgstr "" msgid "Azure URL" msgstr "" -msgid "Follow Up" -msgstr "" - -msgid "Action" -msgstr "" - msgid "Contact Emails" msgstr "" @@ -146,8 +169,7 @@ msgstr "" msgid "Data Quality Analysis" msgstr "" -msgid "" -"Outliers detection analysis based on DHIS2 min-max standard functionality" +msgid "Configuration" msgstr "" msgid "Algorithm" @@ -156,43 +178,30 @@ msgstr "" msgid "Threshold" msgstr "" -msgid "Run" -msgstr "" - -msgid "Run to get results" -msgstr "" - -msgid "No Issues found" -msgstr "" - msgid "Running analysis..." msgstr "" -msgid "Missing disaggregates in selected catcombos" -msgstr "" - msgid "CatCombos" msgstr "" -msgid "" -"Medical doctors analysis: General Practicioners missing and double counts" -msgstr "" - msgid "Disaggregations" msgstr "" msgid "Double Counts Threshold" msgstr "" -msgid "Missing nursing personnel when midwifery personnel is present" -msgstr "" - msgid "Loading" msgstr "" msgid "Validation Rule Group" msgstr "" +msgid "Run" +msgstr "" + +msgid "Run to get results" +msgstr "" + msgid "Activity Level" msgstr "" @@ -217,34 +226,7 @@ msgstr "" msgid "Enrolled" msgstr "" -msgid "Configuration" -msgstr "" - -msgid "Outliers" -msgstr "" - -msgid "Trends" -msgstr "" - -msgid "Disaggregates" -msgstr "" - -msgid "General Practitioners" -msgstr "" - -msgid "Nursing" -msgstr "" - -msgid "Nursing/Midwifery" -msgstr "" - -msgid "Midwifery" -msgstr "" - -msgid "Density" -msgstr "" - -msgid "Other" +msgid "No Issues found" msgstr "" msgid "Are you sure you want to {{action}} the selected rows?" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 6c1d0c2..238e61e 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -122,7 +122,13 @@ function getCompositionRoot(repositories: Repositories, metadata: MetadataItem) repositories.settingsRepository ), }, - issues: { save: new SaveIssueUseCase(repositories.qualityAnalysisRepository, metadata) }, + issues: { + save: new SaveIssueUseCase( + repositories.qualityAnalysisRepository, + repositories.usersRepository, + metadata + ), + }, settings: { get: new GetSettingsUseCase(repositories.settingsRepository) }, nursingMidwifery: { getDisaggregations: new GetMidwiferyPersonnelDisaggregationsUseCase( diff --git a/src/data/common/D2CategoryOption.ts b/src/data/common/D2CategoryOption.ts index cd5d3f1..d67f1e6 100644 --- a/src/data/common/D2CategoryOption.ts +++ b/src/data/common/D2CategoryOption.ts @@ -3,6 +3,7 @@ import { FutureData, apiToFuture } from "../api-futures"; import { Id } from "../../domain/entities/Ref"; import _ from "../../domain/entities/generic/Collection"; import { CategoryOption } from "../../domain/entities/CategoryOption"; +import { Maybe } from "$/utils/ts-utils"; export class D2CategoryOption { constructor(private api: D2Api) {} @@ -21,15 +22,21 @@ export class D2CategoryOption { }) .map(d2Response => { return d2Response.data.objects.map(d2CategoryOption => { - return { - id: d2CategoryOption.id, - name: - d2CategoryOption.displayShortName || - d2CategoryOption.displayFormName || - d2CategoryOption.displayName, - }; + return { id: d2CategoryOption.id, name: this.getName(d2CategoryOption) }; }); }) ); } + + private getName(d2Name: D2TranslatioName): string { + const name = d2Name.displayShortName || d2Name.displayFormName || d2Name.displayName; + if (name === "default") return "Total"; + return name || ""; + } } + +type D2TranslatioName = { + displayShortName: Maybe<string>; + displayFormName: Maybe<string>; + displayName: Maybe<string>; +}; diff --git a/src/data/common/D2Country.ts b/src/data/common/D2Country.ts index 7906a48..4480e32 100644 --- a/src/data/common/D2Country.ts +++ b/src/data/common/D2Country.ts @@ -1,37 +1,51 @@ -import { D2Api } from "../../types/d2-api"; +import { D2Api } from "$/types/d2-api"; import { FutureData, apiToFuture } from "../api-futures"; -import { Id } from "../../domain/entities/Ref"; -import _ from "../../domain/entities/generic/Collection"; -import { Country } from "../../domain/entities/Country"; +import { Id } from "$/domain/entities/Ref"; +import _ from "$/domain/entities/generic/Collection"; +import { Country } from "$/domain/entities/Country"; +import { Future } from "$/domain/entities/generic/Future"; export class D2OrgUnit { constructor(private api: D2Api) {} getByIds(ids: Id[]): FutureData<Country[]> { - return apiToFuture( - this.api.models.organisationUnits - .get({ - fields: { - id: true, - displayName: true, - displayFormName: true, - displayShortName: true, - path: true, - }, - filter: { id: { in: ids } }, - }) - .map(d2Response => { - return d2Response.data.objects.map(d2OrgUnit => { - return { - id: d2OrgUnit.id, - name: - d2OrgUnit.displayShortName || - d2OrgUnit.displayFormName || - d2OrgUnit.displayName, - path: d2OrgUnit.path, - }; + const $requests = Future.sequential( + _(ids) + .chunk(50) + .map(countriesIds => { + return apiToFuture( + this.api.models.organisationUnits.get({ + fields: { + id: true, + displayName: true, + displayFormName: true, + displayShortName: true, + path: true, + }, + filter: { id: { in: countriesIds } }, + }) + ).map(d2Response => { + return d2Response.objects.map((d2OrgUnit): Country => { + return { + id: d2OrgUnit.id, + name: + d2OrgUnit.displayShortName || + d2OrgUnit.displayFormName || + d2OrgUnit.displayName, + path: d2OrgUnit.path, + writeAccess: false, + }; + }); }); }) + .value() ); + + return Future.sequential([$requests]).flatMap(countries => { + const first = _(countries).first(); + if (!first) return Future.success([]); + const allCountries = _(first).flatten().value(); + return Future.success(allCountries); + }); } } diff --git a/src/data/common/D2User.ts b/src/data/common/D2User.ts index 9291ab6..baf455c 100644 --- a/src/data/common/D2User.ts +++ b/src/data/common/D2User.ts @@ -1,10 +1,41 @@ -import { User } from "../../domain/entities/User"; -import { D2Api, MetadataPick } from "../../types/d2-api"; +import { UserGroup } from "$/domain/entities/UserGroup"; +import { Future } from "$/domain/entities/generic/Future"; +import { User } from "$/domain/entities/User"; +import { D2Api, MetadataPick } from "$/types/d2-api"; import { apiToFuture, FutureData } from "../api-futures"; +import _ from "$/domain/entities/generic/Collection"; export class D2User { constructor(private api: D2Api) {} + getByIds(ids: string[]): FutureData<User[]> { + const $requests = Future.sequential( + _(ids) + .chunk(50) + .map(usersIds => { + return apiToFuture( + this.api.models.users.get({ + fields: userFields, + filter: { id: { in: usersIds } }, + paging: false, + }) + ).map(d2Response => { + return d2Response.objects.map((d2User): User => { + return this.buildUser(d2User); + }); + }); + }) + .value() + ); + + return Future.sequential([$requests]).flatMap(users => { + const first = _(users).first(); + if (!first) return Future.success([]); + const allUsers = _(first).flatten().value(); + return Future.success(allUsers); + }); + } + getCurrent(): FutureData<User> { return apiToFuture(this.api.currentUser.get({ fields: userFields })).map(d2User => { return this.buildUser(d2User); @@ -24,10 +55,24 @@ export class D2User { } private buildUser(d2User: D2UserEntity) { + const readAccessCountries = d2User.teiSearchOrganisationUnits.map(d2OrgUnit => { + return { ...d2OrgUnit, writeAccess: false }; + }); + const writeAccessCountries = d2User.organisationUnits.map(d2OrgUnit => { + return { ...d2OrgUnit, writeAccess: true }; + }); return new User({ id: d2User.id, name: d2User.displayName, - userGroups: d2User.userGroups, + userGroups: d2User.userGroups.map(d2UserGroup => { + return UserGroup.build({ + id: d2UserGroup.id, + name: d2UserGroup.name, + usersIds: d2UserGroup.users.map(d2User => d2User.id), + }).get(); + }), + countries: [...readAccessCountries, ...writeAccessCountries], + email: d2User.email, ...d2User.userCredentials, }); } @@ -36,8 +81,15 @@ export class D2User { const userFields = { id: true, displayName: true, - userGroups: { id: true, name: true }, - userCredentials: { username: true, userRoles: { id: true, name: true, authorities: true } }, + email: true, + userGroups: { id: true, name: true, users: true }, + userCredentials: { + lastLogin: true, + username: true, + userRoles: { id: true, name: true, authorities: true }, + }, + teiSearchOrganisationUnits: { id: true, name: true, path: true }, + organisationUnits: { id: true, name: true, path: true }, } as const; type D2UserEntity = MetadataPick<{ users: { fields: typeof userFields } }>["users"][number]; diff --git a/src/data/repositories/AnalysisSectionD2Repository.ts b/src/data/repositories/AnalysisSectionD2Repository.ts index a7bd46e..e692f83 100644 --- a/src/data/repositories/AnalysisSectionD2Repository.ts +++ b/src/data/repositories/AnalysisSectionD2Repository.ts @@ -11,6 +11,7 @@ export class AnalysisSectionD2Repository implements AnalysisSectionRepository { const sections = this.metadata.programs.qualityIssues.programStages.map(programStage => { return QualityAnalysisSection.create({ id: programStage.id, + description: programStage.description, name: programStage.name, issues: [], status: "", diff --git a/src/data/repositories/IssueD2Repository.ts b/src/data/repositories/IssueD2Repository.ts index 64fdef5..e38a393 100644 --- a/src/data/repositories/IssueD2Repository.ts +++ b/src/data/repositories/IssueD2Repository.ts @@ -39,6 +39,7 @@ export class IssueD2Repository implements IssueRepository { get(options: GetIssuesOptions): FutureData<RowsPaginated<QualityAnalysisIssue>> { const { filters, pagination } = options; + const filtersParams = this.buildFilters(options.filters); return apiToFuture( this.api.tracker.events.get({ programStage: filters.sectionId ? filters.sectionId : undefined, @@ -49,10 +50,9 @@ export class IssueD2Repository implements IssueRepository { pageSize: pagination.pageSize, // TODO: order and filter does not work together // ERROR: Query failed because of a syntax error (SqlState: 42703)", - order: options.filters.name - ? undefined - : this.buildOrder(options.sorting) || undefined, - filter: this.buildFilters(options.filters), + // disabling order if any filter is present + order: filtersParams ? undefined : this.buildOrder(options.sorting) || undefined, + filter: filtersParams, event: filters.id ? filters.id : undefined, }) ).flatMap(d2Response => { @@ -160,7 +160,7 @@ export class IssueD2Repository implements IssueRepository { }, { id: this.metadata.dataElements.followUp.id, - value: issue.followUp ? "true" : "", + value: issue.followUp ? "true" : "false", }, { id: this.metadata.dataElements.contactEmails.id, @@ -325,10 +325,56 @@ export class IssueD2Repository implements IssueRepository { ? `${this.metadata.dataElements.issueNumber.id}:LIKE:${filter.name}` : undefined; - const allFilters = _([numberFilter]).compact().value(); + const periodsFilter = this.buildFilterMultipleValue( + filter.periods, + this.metadata.dataElements.period.id + ); + + const statusFilter = this.buildFilterMultipleValue( + filter.status, + this.metadata.dataElements.status.id + ); + + const actionsFilter = this.buildFilterMultipleValue( + filter.actions, + this.metadata.dataElements.action.id + ); + + const countriesFilter = this.buildFilterMultipleValue( + filter.countries, + this.metadata.dataElements.country.id + ); + + const followUpFilter = this.buildFollowUpFilter(filter.followUp); + + const allFilters = _([ + numberFilter, + periodsFilter, + statusFilter, + actionsFilter, + countriesFilter, + followUpFilter, + ]) + .compact() + .value(); return allFilters.length > 0 ? allFilters.join(",") : undefined; } + + private buildFilterMultipleValue(value: Maybe<string[]>, dataElementId: Id): Maybe<string> { + const valueSeparatedByComma = value ? value.join(";") : undefined; + return valueSeparatedByComma ? `${dataElementId}:IN:${valueSeparatedByComma}` : undefined; + } + + private buildFollowUpFilter(followUpValue: Maybe<string>): Maybe<string> { + if (followUpValue === "1") { + return `${this.metadata.dataElements.followUp.id}:eq:true`; + } else if (followUpValue === "0") { + return `${this.metadata.dataElements.followUp.id}:eq:false`; + } else { + return undefined; + } + } } type DataElementKey = keyof MetadataItem["dataElements"]; diff --git a/src/data/repositories/MetadataD2Repository.ts b/src/data/repositories/MetadataD2Repository.ts index 4d2e818..07d78f4 100644 --- a/src/data/repositories/MetadataD2Repository.ts +++ b/src/data/repositories/MetadataD2Repository.ts @@ -39,6 +39,10 @@ const metadataCodes = { correlative: "NHWA_DQI_Issue_Correlative_Number", }, dataSets: { module1: "NHWA-M1-2023", module2: "NHWA-M2-2023" }, + userGroups: { + dataCaptureModule1: "NHWA _DATA Capture Module 1", + dataCaptureModule2And4: "NHWA _DATA Capture Module 2-4", + }, }; const metadataFields = { @@ -75,6 +79,10 @@ const metadataFields = { }, filter: { code: { in: rec(metadataCodes.programs).values() } }, }, + userGroups: { + fields: { id: true, name: true, code: true, users: true }, + filter: { name: { in: rec(metadataCodes.userGroups).values() } }, + }, }; export class MetadataD2Repository implements MetadataRepository { diff --git a/src/data/repositories/QualityAnalysisD2Repository.ts b/src/data/repositories/QualityAnalysisD2Repository.ts index 174f890..c75fa27 100644 --- a/src/data/repositories/QualityAnalysisD2Repository.ts +++ b/src/data/repositories/QualityAnalysisD2Repository.ts @@ -134,18 +134,28 @@ export class QualityAnalysisD2Repository implements QualityAnalysisRepository { }); } - remove(id: Id[]): FutureData<void> { + remove(id: Id): FutureData<void> { return apiToFuture( - this.api.tracker.post( + this.api.tracker.postAsync( { importStrategy: "DELETE" }, - { trackedEntities: id.map(id => ({ trackedEntity: id })) } + { trackedEntities: [{ trackedEntity: id }] } ) - ).flatMap(d2Response => { - if (d2Response.status === "ERROR") { - return Future.error(new Error(d2Response.message)); - } else { - return Future.success(undefined); - } + ).flatMap(d2JobResponse => { + return apiToFuture( + // this rule is being applied outside the context of testing-library + // more info here: https://github.com/testing-library/eslint-plugin-testing-library/blob/main/docs/rules/await-async-utils.md + // eslint-disable-next-line testing-library/await-async-utils + this.api.system.waitFor("TRACKER_IMPORT_JOB", d2JobResponse.response.id) + ).flatMap(d2Response => { + if (d2Response?.status === "ERROR") { + return Future.error(new Error(d2Response.message)); + } else { + const dataStore = this.api.dataStore(DATA_QUALITY_NAMESPACE); + return apiToFuture(dataStore.delete(id)).flatMap(() => { + return Future.success(undefined); + }); + } + }); }); } @@ -384,7 +394,7 @@ export class QualityAnalysisD2Repository implements QualityAnalysisRepository { }, { dataElement: this.metadata.dataElements.followUp.id, - value: issue.followUp ? "true" : "", + value: issue.followUp ? "true" : "false", }, { dataElement: this.metadata.dataElements.issueNumber.id, @@ -540,6 +550,7 @@ export class QualityAnalysisD2Repository implements QualityAnalysisRepository { return new QualityAnalysisSection({ id: programStage.id, name: programStage.name, + description: programStage.description, issues: qaIssues.filter(issue => issue.type === programStage.id), status: sectionData?.status || "", }); diff --git a/src/data/repositories/UserD2Repository.ts b/src/data/repositories/UserD2Repository.ts index 339fa4e..51ab15b 100644 --- a/src/data/repositories/UserD2Repository.ts +++ b/src/data/repositories/UserD2Repository.ts @@ -1,8 +1,8 @@ -import { User } from "../../domain/entities/User"; -import { UserRepository } from "../../domain/repositories/UserRepository"; -import { D2Api } from "../../types/d2-api"; -import { FutureData } from "../api-futures"; -import { D2User } from "../common/D2User"; +import { User } from "$/domain/entities/User"; +import { UserRepository } from "$/domain/repositories/UserRepository"; +import { D2Api } from "$/types/d2-api"; +import { FutureData } from "$/data/api-futures"; +import { D2User } from "$/data/common/D2User"; export class UserD2Repository implements UserRepository { private d2User: D2User; @@ -10,6 +10,10 @@ export class UserD2Repository implements UserRepository { this.d2User = new D2User(this.api); } + getByIds(ids: string[]): FutureData<User[]> { + return this.d2User.getByIds(ids); + } + public getCurrent(): FutureData<User> { return this.d2User.getCurrent(); } diff --git a/src/data/repositories/UserTestRepository.ts b/src/data/repositories/UserTestRepository.ts index 51805c6..4fa0a89 100644 --- a/src/data/repositories/UserTestRepository.ts +++ b/src/data/repositories/UserTestRepository.ts @@ -1,10 +1,13 @@ -import { User } from "../../domain/entities/User"; -import { createAdminUser } from "../../domain/entities/__tests__/userFixtures"; -import { Future } from "../../domain/entities/generic/Future"; -import { UserRepository } from "../../domain/repositories/UserRepository"; -import { FutureData } from "../api-futures"; +import { User } from "$/domain/entities/User"; +import { createAdminUser } from "$/domain/entities/__tests__/userFixtures"; +import { Future } from "$/domain/entities/generic/Future"; +import { UserRepository } from "$/domain/repositories/UserRepository"; +import { FutureData } from "$/data/api-futures"; export class UserTestRepository implements UserRepository { + getByIds(): FutureData<User[]> { + throw new Error("Method not implemented."); + } getByUsernames(_: string[]): FutureData<User[]> { throw new Error("Method not implemented."); } diff --git a/src/domain/entities/Country.ts b/src/domain/entities/Country.ts index b0e5bb7..97a5955 100644 --- a/src/domain/entities/Country.ts +++ b/src/domain/entities/Country.ts @@ -1,3 +1,3 @@ import { NamedRef } from "./Ref"; -export type Country = NamedRef & { path: string }; +export type Country = NamedRef & { path: string; writeAccess: boolean }; diff --git a/src/domain/entities/MetadataItem.ts b/src/domain/entities/MetadataItem.ts index eaea019..e6e9c35 100644 --- a/src/domain/entities/MetadataItem.ts +++ b/src/domain/entities/MetadataItem.ts @@ -1,4 +1,4 @@ -import { Id, NamedCodeRef, NamedRef } from "./Ref"; +import { Id, NamedCodeRef, NamedRef, Ref } from "./Ref"; export interface OptionSet extends NamedCodeRef { options: Array<{ id: Id; name: string; code: string }>; @@ -40,4 +40,8 @@ export interface MetadataItem { correlative: NamedCodeRef; }; programs: { qualityIssues: NamedRef & { programStages: ProgramStage[] } }; + userGroups: { + dataCaptureModule1: NamedCodeRef & { users: Ref[] }; + dataCaptureModule2And4: NamedCodeRef & { users: Ref[] }; + }; } diff --git a/src/domain/entities/QualityAnalysisSection.ts b/src/domain/entities/QualityAnalysisSection.ts index 01bf629..a3e4c5d 100644 --- a/src/domain/entities/QualityAnalysisSection.ts +++ b/src/domain/entities/QualityAnalysisSection.ts @@ -5,12 +5,19 @@ import { Id } from "./Ref"; export interface QualityAnalysisSectionAttrs { id: Id; name: string; + description: string; issues: QualityAnalysisIssue[]; status: string; } +const SECTION_PENDING_STATE = "pending"; + export class QualityAnalysisSection extends Struct<QualityAnalysisSectionAttrs>() { static getInitialStatus(): string { - return "pending"; + return SECTION_PENDING_STATE; + } + + static isPending(section: QualityAnalysisSection): boolean { + return section.status === SECTION_PENDING_STATE; } } diff --git a/src/domain/entities/User.ts b/src/domain/entities/User.ts index 9323f57..9cc469d 100644 --- a/src/domain/entities/User.ts +++ b/src/domain/entities/User.ts @@ -1,12 +1,18 @@ +import { Maybe } from "$/utils/ts-utils"; +import { Country } from "./Country"; import { Struct } from "./generic/Struct"; -import { NamedRef } from "./Ref"; +import { DateISOString, NamedRef } from "./Ref"; +import { UserGroup } from "./UserGroup"; export interface UserAttrs { id: string; + email: string; name: string; username: string; + lastLogin: Maybe<DateISOString>; userRoles: UserRole[]; - userGroups: NamedRef[]; + userGroups: UserGroup[]; + countries: Country[]; } export interface UserRole extends NamedRef { diff --git a/src/domain/entities/UserGroup.ts b/src/domain/entities/UserGroup.ts new file mode 100644 index 0000000..af99546 --- /dev/null +++ b/src/domain/entities/UserGroup.ts @@ -0,0 +1,36 @@ +import { Id } from "./Ref"; +import { Either } from "./generic/Either"; +import { ValidationError } from "./generic/Errors"; +import { Struct } from "./generic/Struct"; +import { validateRequired } from "./generic/validations"; + +type UserGroupAttrs = { + id: Id; + name: string; + usersIds: Id[]; +}; + +export class UserGroup extends Struct<UserGroupAttrs>() { + static build(attrs: UserGroupAttrs): Either<ValidationError<UserGroup>[], UserGroup> { + const userGroup = new UserGroup(attrs); + + const errors: ValidationError<UserGroup>[] = [ + { + property: "name" as const, + errors: validateRequired(userGroup.name), + value: userGroup.name, + }, + { + property: "id" as const, + errors: validateRequired(userGroup.id), + value: userGroup.id, + }, + ].filter(validation => validation.errors.length > 0); + + if (errors.length === 0) { + return Either.success(userGroup); + } else { + return Either.error(errors); + } + } +} diff --git a/src/domain/entities/__tests__/User.spec.ts b/src/domain/entities/__tests__/User.spec.ts index 49bfcb4..d781ff9 100644 --- a/src/domain/entities/__tests__/User.spec.ts +++ b/src/domain/entities/__tests__/User.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { createAdminUser, createNonAdminUser, createUserWithGroups } from "./userFixtures"; +import { UserGroup } from "../UserGroup"; describe("User", () => { it("should be admin if has a role with authority ALL", () => { @@ -15,7 +16,9 @@ describe("User", () => { it("should return belong to user group equal to false when the id exist", () => { const userGroupId = "BwyMfDBLih9"; - const user = createUserWithGroups([{ id: userGroupId, name: "Group 1" }]); + const user = createUserWithGroups([ + UserGroup.build({ id: userGroupId, name: "Group 1", usersIds: [] }).get(), + ]); expect(user.belongToUserGroup(userGroupId)).toBe(true); }); @@ -23,7 +26,9 @@ describe("User", () => { const existedUserGroupId = "BwyMfDBLih9"; const nonExistedUserGroupId = "f31IM13BgwJ"; - const user = createUserWithGroups([{ id: existedUserGroupId, name: "Group 1" }]); + const user = createUserWithGroups([ + UserGroup.build({ id: existedUserGroupId, name: "Group 1", usersIds: [] }).get(), + ]); expect(user.belongToUserGroup(nonExistedUserGroupId)).toBe(false); }); diff --git a/src/domain/entities/__tests__/userFixtures.ts b/src/domain/entities/__tests__/userFixtures.ts index 28c6290..aae60dc 100644 --- a/src/domain/entities/__tests__/userFixtures.ts +++ b/src/domain/entities/__tests__/userFixtures.ts @@ -1,5 +1,5 @@ import { User, UserRole } from "../User"; -import { NamedRef } from "../Ref"; +import { UserGroup } from "../UserGroup"; export function createAdminUser(): User { const adminRoles = [{ id: "Hg7n0MwzUQn", name: "Super user", authorities: ["ALL"] }]; @@ -11,21 +11,27 @@ export function createNonAdminUser(): User { return createUser(nonAdminRoles, []); } -export function createUserWithGroups(userGroups: NamedRef[] = []): User { +export function createUserWithGroups(userGroups: UserGroup[] = []): User { return new User({ id: "YjJdEO6d38H", name: "John Traore", username: "user", userRoles: [], userGroups, + countries: [], + email: "john@company.com", + lastLogin: "", }); } -function createUser(userRoles: UserRole[], userGroups: NamedRef[] = []): User { +function createUser(userRoles: UserRole[], userGroups: UserGroup[] = []): User { return new User({ id: "kQiwoyMYHBS", name: "John Traore", username: "user", userRoles, userGroups, + countries: [], + email: "john@company.com", + lastLogin: "", }); } diff --git a/src/domain/repositories/IssueRepository.ts b/src/domain/repositories/IssueRepository.ts index 77d6a48..3d9ea54 100644 --- a/src/domain/repositories/IssueRepository.ts +++ b/src/domain/repositories/IssueRepository.ts @@ -1,5 +1,5 @@ import { RowsPaginated } from "$/domain/entities/Pagination"; -import { Id } from "$/domain/entities/Ref"; +import { Id, Period } from "$/domain/entities/Ref"; import { Maybe } from "$/utils/ts-utils"; import { FutureData } from "../../data/api-futures"; import { QualityAnalysisIssue } from "../entities/QualityAnalysisIssue"; @@ -14,12 +14,14 @@ export type GetIssuesOptions = { pagination: Pick<Pagination, "page" | "pageSize">; sorting: { field: string; order: "asc" | "desc" }; filters: { - endDate: Maybe<string>; + actions: Maybe<string[]>; + countries: string[]; name: Maybe<string>; - startDate: Maybe<string>; - status: Maybe<string>; + periods: Period[]; + status: Maybe<string[]>; analysisIds: Maybe<Id[]>; sectionId: Maybe<Id>; id: Maybe<Id>; + followUp: Maybe<string>; }; }; diff --git a/src/domain/repositories/QualityAnalysisRepository.ts b/src/domain/repositories/QualityAnalysisRepository.ts index 4258bd7..ae856f0 100644 --- a/src/domain/repositories/QualityAnalysisRepository.ts +++ b/src/domain/repositories/QualityAnalysisRepository.ts @@ -7,7 +7,7 @@ export interface QualityAnalysisRepository { get(options: QualityAnalysisOptions): FutureData<QualityAnalysisPaginated>; getById(id: Id): FutureData<QualityAnalysis>; save(qualityAnalysis: QualityAnalysis[]): FutureData<void>; - remove(id: Id[]): FutureData<void>; + remove(id: Id): FutureData<void>; } export type Pagination = { diff --git a/src/domain/repositories/UserRepository.ts b/src/domain/repositories/UserRepository.ts index 8f64b5e..2b365b8 100644 --- a/src/domain/repositories/UserRepository.ts +++ b/src/domain/repositories/UserRepository.ts @@ -3,5 +3,6 @@ import { User } from "../entities/User"; export interface UserRepository { getCurrent(): FutureData<User>; + getByIds(ids: string[]): FutureData<User[]>; getByUsernames(usernames: string[]): FutureData<User[]>; } diff --git a/src/domain/usecases/GetMissingDisaggregatesUseCase.ts b/src/domain/usecases/GetMissingDisaggregatesUseCase.ts index ca8216d..d309ed1 100644 --- a/src/domain/usecases/GetMissingDisaggregatesUseCase.ts +++ b/src/domain/usecases/GetMissingDisaggregatesUseCase.ts @@ -20,7 +20,6 @@ import { getCurrentSection } from "./common/utils"; import { SettingsRepository } from "../repositories/SettingsRepository"; import { SectionDisaggregation, SectionSetting, Settings } from "$/domain/entities/Settings"; import { MissingComboValue } from "$/domain/entities/MissingComboValue"; -import { disaggregateKey } from "$/webapp/pages/analysis/steps"; const separator = " - "; export class GetMissingDisaggregatesUseCase { @@ -63,7 +62,7 @@ export class GetMissingDisaggregatesUseCase { ); return this.issueUseCase - .getTotalIssuesBySection(analysis, disaggregateKey) + .getTotalIssuesBySection(analysis, options.sectionId) .flatMap(totalIssues => { const missingDisaggregateValues = missingValues.flatMap(missingValue => missingValue.type === "dataElements" ? missingValue.values : [] @@ -76,13 +75,15 @@ export class GetMissingDisaggregatesUseCase { const issues = this.createIssuesFromMissingAggregate( missingDisaggregateValues as MissingDisaggregates[], analysis, - totalIssues + totalIssues, + options ); const issues2 = this.createIssuesFromMissingCombos( missingComboValues as MissingComboValue[], analysis, - totalIssues + issues.length + totalIssues + issues.length, + options ); const allIssues = [...issues, ...issues2]; @@ -90,7 +91,7 @@ export class GetMissingDisaggregatesUseCase { return this.issueUseCase.save(allIssues, analysis.id).flatMap(() => { const analysisUpdate = this.analysisUseCase.updateAnalysis( analysis, - disaggregateKey, + options.sectionId, allIssues.length ); return this.analysisRepository.save([analysisUpdate]).flatMap(() => { @@ -104,9 +105,10 @@ export class GetMissingDisaggregatesUseCase { private createIssuesFromMissingAggregate( missingValues: MissingDisaggregates[], analysis: QualityAnalysis, - totalIssues: number + totalIssues: number, + options: GetMissingDisaggregatesOptions ): QualityAnalysisIssue[] { - const section = getCurrentSection(analysis, disaggregateKey); + const section = getCurrentSection(analysis, options.sectionId); const onlyMissing = missingValues.filter(missingValue => missingValue.hasMissingValues); let acumulativeIssueNumber = totalIssues; @@ -152,9 +154,10 @@ export class GetMissingDisaggregatesUseCase { private createIssuesFromMissingCombos( missingValues: MissingComboValue[], analysis: QualityAnalysis, - totalIssues: number + totalIssues: number, + options: GetMissingDisaggregatesOptions ): QualityAnalysisIssue[] { - const section = getCurrentSection(analysis, disaggregateKey); + const section = getCurrentSection(analysis, options.sectionId); const onlyMissing = missingValues.filter(missingValue => missingValue.hasMissingValues); @@ -203,7 +206,7 @@ export class GetMissingDisaggregatesUseCase { ): MissingValues[] { const keys = _(dataValues).keys().value(); - const sectionSetting = this.getSectionSetting(settings); + const sectionSetting = this.getSectionSetting(settings, options); const selectedDisaggregations = sectionSetting.disaggregations.filter(disaggregation => { return options.disaggregationsIds.includes(disaggregation.id); }); @@ -355,7 +358,7 @@ export class GetMissingDisaggregatesUseCase { settings: Settings ): FutureData<{ dataValues: Record<string, DataValue[]>; dataElements: DataElement[] }> { const { module } = analysis; - const sectionSetting = this.getSectionSetting(settings); + const sectionSetting = this.getSectionSetting(settings, options); const selectedDisaggregations = sectionSetting.disaggregations.filter(disaggregation => { return options.disaggregationsIds.includes(disaggregation.id); }); @@ -415,21 +418,6 @@ export class GetMissingDisaggregatesUseCase { }); } - private validateDisaggregation( - selectedDisaggregations: SectionDisaggregation[], - dataElement: DataElement - ): Maybe<SectionDisaggregation> { - if (!dataElement.disaggregation) return undefined; - - return selectedDisaggregations.find(d => { - if (d.type === "combos") { - return d.disaggregationId === dataElement.disaggregation?.id; - } else { - return d.id === dataElement.disaggregation?.id; - } - }); - } - private getDataValues(analysis: QualityAnalysis) { return this.dataValueUseCase.get( analysis.countriesAnalysis, @@ -438,9 +426,12 @@ export class GetMissingDisaggregatesUseCase { ); } - private getSectionSetting(settings: Settings): SectionSetting { - const sectionSetting = settings.sections.find(section => section.id === disaggregateKey); - if (!sectionSetting) throw Error(`Cannot found section in settings: ${disaggregateKey}`); + private getSectionSetting( + settings: Settings, + options: GetMissingDisaggregatesOptions + ): SectionSetting { + const sectionSetting = settings.sections.find(section => section.id === options.sectionId); + if (!sectionSetting) throw Error(`Cannot found section in settings: ${options.sectionId}`); return sectionSetting; } } @@ -448,6 +439,7 @@ export class GetMissingDisaggregatesUseCase { type GetMissingDisaggregatesOptions = { analysisId: Id; disaggregationsIds: Id[]; + sectionId: Id; }; type MissingValues = { diff --git a/src/domain/usecases/RemoveQualityUseCase.ts b/src/domain/usecases/RemoveQualityUseCase.ts index 7fdedb6..bf60ec3 100644 --- a/src/domain/usecases/RemoveQualityUseCase.ts +++ b/src/domain/usecases/RemoveQualityUseCase.ts @@ -1,11 +1,19 @@ import { FutureData } from "$/data/api-futures"; import { Id } from "$/domain/entities/Ref"; import { QualityAnalysisRepository } from "$/domain/repositories/QualityAnalysisRepository"; +import { Future } from "$/domain/entities/generic/Future"; export class RemoveQualityUseCase { constructor(private qualityAnalysisRepository: QualityAnalysisRepository) {} execute(qualityAnalysisIds: Id[]): FutureData<void> { - return this.qualityAnalysisRepository.remove(qualityAnalysisIds); + const concurrencyRequest = 5; + const $requests = Future.parallel( + qualityAnalysisIds.map(issue => this.qualityAnalysisRepository.remove(issue)), + { concurrency: concurrencyRequest } + ); + return $requests.flatMap(() => { + return Future.success(undefined); + }); } } diff --git a/src/domain/usecases/RunOutlierUseCase.ts b/src/domain/usecases/RunOutlierUseCase.ts index 2babd77..2594a8e 100644 --- a/src/domain/usecases/RunOutlierUseCase.ts +++ b/src/domain/usecases/RunOutlierUseCase.ts @@ -3,17 +3,15 @@ import { IssueStatus } from "$/domain/entities/IssueStatus"; import { Outlier } from "$/domain/entities/Outlier"; import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; import { QualityAnalysisIssue } from "$/domain/entities/QualityAnalysisIssue"; -import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; import { Id } from "$/domain/entities/Ref"; import { Future } from "$/domain/entities/generic/Future"; import { getUid } from "$/utils/uid"; -import { outlierKey } from "$/webapp/pages/analysis/steps"; -import { IssueRepository } from "../repositories/IssueRepository"; -import { OutlierRepository } from "../repositories/OutlierRepository"; -import { QualityAnalysisRepository } from "../repositories/QualityAnalysisRepository"; +import { IssueRepository } from "$/domain/repositories/IssueRepository"; +import { OutlierRepository } from "$/domain/repositories/OutlierRepository"; +import { QualityAnalysisRepository } from "$/domain/repositories/QualityAnalysisRepository"; import _ from "$/domain/entities/generic/Collection"; -import { ModuleRepository } from "../repositories/ModuleRepository"; -import { DataElement } from "../entities/DataElement"; +import { ModuleRepository } from "$/domain/repositories/ModuleRepository"; +import { DataElement } from "$/domain/entities/DataElement"; import { UCIssue } from "./common/UCIssue"; import { UCAnalysis } from "./common/UCAnalysis"; @@ -41,14 +39,14 @@ export class RunOutlierUseCase { dataElements.map(dataElement => dataElement.id) ).flatMap(outliers => { return this.issueUseCase - .getTotalIssuesBySection(analysis, outlierKey) + .getTotalIssuesBySection(analysis, options.sectionId) .flatMap(totalIssues => { return this.saveIssues(outliers, analysis, totalIssues, options); }) .flatMap(() => { const analysisToUpdate = this.analysisUseCase.updateAnalysis( analysis, - outlierKey, + options.sectionId, outliers.length ); return this.analysisRepository @@ -100,21 +98,20 @@ export class RunOutlierUseCase { totalIssues: number, options: RunOutlierUseCaseOptions ): FutureData<void> { - const section = this.getCurrentSection(analysis); if (outliers.length === 0) return Future.success(undefined); const issuesToSave = outliers.map((outlier, index) => { const currentNumber = totalIssues + 1 + index; const correlative = currentNumber < 10 ? `0${currentNumber}` : currentNumber; const issueNumber = `${analysis.sequential.value}-S01-I${correlative}`; return new QualityAnalysisIssue({ - id: getUid(`issue-event_${outlierKey}_${new Date().getTime()}`), + id: getUid(`issue-event_${options.sectionId}_${new Date().getTime()}`), number: issueNumber, azureUrl: "", period: outlier.period, - country: { id: outlier.countryId, name: "", path: "" }, + country: { id: outlier.countryId, name: "", path: "", writeAccess: false }, dataElement: { id: outlier.dataElementId, name: "" }, categoryOption: { id: outlier.categoryOptionId, name: "" }, - description: "", + description: this.getDescriptionIssue(outlier, options), followUp: false, status: IssueStatus.create({ id: "", @@ -122,8 +119,8 @@ export class RunOutlierUseCase { name: "", }), action: undefined, - actionDescription: this.getActionDescription(outlier, options), - type: section.id, + actionDescription: "", + type: options.sectionId, comments: "", contactEmails: "", correlative: String(currentNumber), @@ -132,13 +129,7 @@ export class RunOutlierUseCase { return this.issueUseCase.save(issuesToSave, analysis.id); } - private getCurrentSection(analysis: QualityAnalysis): QualityAnalysisSection { - const section = analysis.sections.find(section => section.name === outlierKey); - if (!section) throw Error(`Cannot found section: ${outlierKey}`); - return section; - } - - private getActionDescription(outlier: Outlier, options: RunOutlierUseCaseOptions): string { + private getDescriptionIssue(outlier: Outlier, options: RunOutlierUseCaseOptions): string { return outlier.zScore ? `An outlier was detected using ${ options.algorithm @@ -153,4 +144,5 @@ type RunOutlierUseCaseOptions = { qualityAnalysisId: Id; algorithm: string; threshold: string; + sectionId: Id; }; diff --git a/src/domain/usecases/RunPractitionersValidationUseCase.ts b/src/domain/usecases/RunPractitionersValidationUseCase.ts index f227267..440c466 100644 --- a/src/domain/usecases/RunPractitionersValidationUseCase.ts +++ b/src/domain/usecases/RunPractitionersValidationUseCase.ts @@ -12,7 +12,6 @@ import { DataValue } from "$/domain/entities/DataValue"; import { DataValueRepository } from "$/domain/repositories/DataValueRepository"; import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; import { QualityAnalysisIssue } from "$/domain/entities/QualityAnalysisIssue"; -import { practitionersKey } from "$/webapp/pages/analysis/steps"; import { IssueRepository } from "$/domain/repositories/IssueRepository"; import { UCIssue } from "./common/UCIssue"; import { UCAnalysis } from "./common/UCAnalysis"; @@ -54,7 +53,7 @@ export class RunPractitionersValidationUseCase { const practitionerDataElements = this.groupDataElements(dataElements); return this.issueUseCase - .getTotalIssuesBySection(analysis, practitionersKey) + .getTotalIssuesBySection(analysis, options.sectionId) .flatMap(totalIssues => { return this.getDataValues(analysis).flatMap(dataValues => { const practitionerDataValues = this.getPractitionerDataValues( @@ -97,7 +96,7 @@ export class RunPractitionersValidationUseCase { const analysisToUpdate = this.analysysUseCase.updateAnalysis( analysis, - practitionersKey, + options.sectionId, issues.length ); @@ -172,7 +171,8 @@ export class RunPractitionersValidationUseCase { `Values for ${dataElement.dataElementParent.name} subcategories are missing`, dataValue, totalIssues + (index + 1), - analysis + analysis, + options ); }); @@ -196,7 +196,8 @@ export class RunPractitionersValidationUseCase { description, dataValue, totalIssues + missingIssues.length + (index + 1), - analysis + analysis, + options ); }); @@ -209,9 +210,10 @@ export class RunPractitionersValidationUseCase { description: string, dataValue: DataValue, currentNumber: number, - analysis: QualityAnalysis + analysis: QualityAnalysis, + options: PractitionersValidationOptions ) { - const section = getCurrentSection(analysis, practitionersKey); + const section = getCurrentSection(analysis, options.sectionId); const { dataElementId, period, countryId, categoryOptionComboId } = dataValue; const prefix = `${analysis.sequential.value}-S04`; const issueNumber = this.issueUseCase.generateIssueNumber(currentNumber, prefix); @@ -400,8 +402,9 @@ export class RunPractitionersValidationUseCase { type PractitionersValidationOptions = { analysisId: Id; - threshold: number; dissagregationsIds: Id[]; + sectionId: Id; + threshold: number; }; type DataElementsLevel = { diff --git a/src/domain/usecases/SaveIssueUseCase.ts b/src/domain/usecases/SaveIssueUseCase.ts index 2b05db7..084e535 100644 --- a/src/domain/usecases/SaveIssueUseCase.ts +++ b/src/domain/usecases/SaveIssueUseCase.ts @@ -1,37 +1,120 @@ import { FutureData } from "$/data/api-futures"; -import { IssueAction } from "../entities/IssueAction"; -import { IssueStatus } from "../entities/IssueStatus"; -import { MetadataItem } from "../entities/MetadataItem"; -import { QualityAnalysis } from "../entities/QualityAnalysis"; -import { IssuePropertyName, QualityAnalysisIssue } from "../entities/QualityAnalysisIssue"; -import { QualityAnalysisSection } from "../entities/QualityAnalysisSection"; -import { Id } from "../entities/Ref"; -import { QualityAnalysisRepository } from "../repositories/QualityAnalysisRepository"; +import { User } from "$/domain/entities/User"; +import { IssueAction } from "$/domain/entities/IssueAction"; +import { IssueStatus } from "$/domain/entities/IssueStatus"; +import { MetadataItem } from "$/domain/entities/MetadataItem"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { IssuePropertyName, QualityAnalysisIssue } from "$/domain/entities/QualityAnalysisIssue"; +import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; +import { Id } from "$/domain/entities/Ref"; +import { Future } from "$/domain/entities/generic/Future"; +import { QualityAnalysisRepository } from "$/domain/repositories/QualityAnalysisRepository"; +import { UserRepository } from "$/domain/repositories/UserRepository"; +import _ from "$/domain/entities/generic/Collection"; +import { Maybe } from "$/utils/ts-utils"; export class SaveIssueUseCase { constructor( private analysisRepository: QualityAnalysisRepository, + private userRepository: UserRepository, private metadata: MetadataItem ) {} - execute(options: SaveIssueOptions): FutureData<void> { + execute(options: SaveIssueOptions): FutureData<SaveIssueResponse> { return this.getAnalysis(options).flatMap(analysis => { - const analysisUpdate = QualityAnalysis.build({ - ...analysis, - sections: analysis.sections.map(section => { - if (section.id !== options.issue.type) return section; - return QualityAnalysisSection.create({ - ...section, - issues: [this.buildIssueWithNewValue(options)], - }); - }), - }).get(); - - return this.analysisRepository.save([analysisUpdate]); + return this.generateContactEmails(analysis, options).flatMap(contactEmails => { + const issueToUpdate = this.buildIssueWithNewValue(options, contactEmails); + const analysisUpdate = QualityAnalysis.build({ + ...analysis, + sections: analysis.sections.map(section => { + if (section.id !== options.issue.type) return section; + return QualityAnalysisSection.create({ + ...section, + issues: [issueToUpdate], + }); + }), + }).get(); + + return this.analysisRepository.save([analysisUpdate]).flatMap(() => { + return Future.success({ contactEmailsChanged: Boolean(contactEmails) }); + }); + }); + }); + } + + private generateContactEmails( + analysis: QualityAnalysis, + options: SaveIssueOptions + ): FutureData<Maybe<ContactEmailsUsers>> { + if (options.propertyToUpdate === "followUp" && options.valueToUpdate === true) { + const usersIds = this.getUsersIdsFromGroup(analysis); + if (usersIds.length === 0) return Future.success(undefined); + + return this.getUsersByIds(usersIds, options.issue).flatMap(users => { + const contactEmails = this.getContactEmailsUsers(users); + return Future.success(contactEmails); + }); + } + return Future.success(undefined); + } + + private getContactEmailsUsers(users: User[]): Maybe<ContactEmailsUsers> { + const firstUser = _(users).first(); + if (!firstUser) return undefined; + const ccUsers = users.slice(1); + return { to: firstUser, cc: ccUsers }; + } + + private getUsersByIds(userIds: Id[], issue: QualityAnalysisIssue): FutureData<User[]> { + return this.userRepository.getByIds(userIds).flatMap(users => { + const loggedInUsersWithEmail = this.getLoggedInUsersWithEmail(users); + const usersInCountry = this.getUsersInIssueCountry(loggedInUsersWithEmail, issue); + return Future.success(usersInCountry); }); } - private buildIssueWithNewValue(options: SaveIssueOptions): QualityAnalysisIssue { + private getUsersInIssueCountry(users: User[], issue: QualityAnalysisIssue): User[] { + return _(users) + .map(user => { + const userIsInIssueCountry = user.countries.some( + country => country.writeAccess && country.id === issue.country?.id + ); + return userIsInIssueCountry ? user : undefined; + }) + .compact() + .value(); + } + + private getLoggedInUsersWithEmail(users: User[]): User[] { + return _(users) + .map(user => { + if (!user.email || !user.lastLogin) return undefined; + return user; + }) + .compact() + .sortBy(user => user.lastLogin, { + compareFn: (first, second) => { + if (!first || !second) return 0; + return first < second ? 1 : first > second ? -1 : 0; + }, + }) + .value(); + } + + private getUsersIdsFromGroup(analysis: QualityAnalysis): Id[] { + if (analysis.module.name.toLocaleLowerCase().includes("module 1")) { + return this.metadata.userGroups.dataCaptureModule1.users.map(user => user.id); + } else if (analysis.module.name.toLocaleLowerCase().includes("module 2")) { + return this.metadata.userGroups.dataCaptureModule2And4.users.map(user => user.id); + } else { + return []; + } + } + + private buildIssueWithNewValue( + options: SaveIssueOptions, + contactEmails: Maybe<ContactEmailsUsers> + ): QualityAnalysisIssue { switch (options.propertyToUpdate) { case "azureUrl": { return this.setNewValue(options); @@ -83,9 +166,13 @@ export class SaveIssueUseCase { }); } case "followUp": { + const value = options.valueToUpdate as boolean; return QualityAnalysisIssue.create({ ...options.issue, - followUp: options.valueToUpdate as boolean, + contactEmails: value + ? this.getContactEmailsString(contactEmails) + : options.issue.contactEmails, + followUp: value, }); } default: @@ -93,6 +180,13 @@ export class SaveIssueUseCase { } } + private getContactEmailsString(contactEmails: Maybe<ContactEmailsUsers>): string { + if (!contactEmails) return ""; + const to = `TO: ${contactEmails.to.email}`; + const cc = contactEmails.cc.map(user => user.email).join(";"); + return cc ? `${to} || CC:${cc}` : to; + } + private setNewValue(options: SaveIssueOptions): QualityAnalysisIssue { return QualityAnalysisIssue.create({ ...options.issue, @@ -111,3 +205,9 @@ type SaveIssueOptions = { propertyToUpdate: IssuePropertyName; valueToUpdate: string | boolean; }; + +type ContactEmailsUsers = { to: User; cc: User[] }; + +type SaveIssueResponse = { + contactEmailsChanged: boolean; +}; diff --git a/src/domain/usecases/ValidateMidwiferyAndPersonnelUseCase.ts b/src/domain/usecases/ValidateMidwiferyAndPersonnelUseCase.ts index 44f2bd8..abf030a 100644 --- a/src/domain/usecases/ValidateMidwiferyAndPersonnelUseCase.ts +++ b/src/domain/usecases/ValidateMidwiferyAndPersonnelUseCase.ts @@ -1,3 +1,5 @@ +import _ from "lodash"; + import { FutureData } from "$/data/api-futures"; import { DataElement } from "$/domain/entities/DataElement"; import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; @@ -6,16 +8,16 @@ import { Future } from "$/domain/entities/generic/Future"; import { DataValueRepository } from "$/domain/repositories/DataValueRepository"; import { ModuleRepository } from "$/domain/repositories/ModuleRepository"; import { QualityAnalysisRepository } from "$/domain/repositories/QualityAnalysisRepository"; -import _ from "lodash"; -import { UCAnalysis } from "./common/UCAnalysis"; -import { UCDataValue } from "./common/UCDataValue"; import { DataValue } from "$/domain/entities/DataValue"; import { MidwiferyNursing } from "$/domain/entities/MidwiferyPersonnel"; -import { SettingsRepository } from "../repositories/SettingsRepository"; +import { SettingsRepository } from "$/domain/repositories/SettingsRepository"; import { SectionDisaggregation } from "$/domain/entities/Settings"; import { UCIssue } from "./common/UCIssue"; import { QualityAnalysisIssue } from "$/domain/entities/QualityAnalysisIssue"; -import { IssueRepository } from "../repositories/IssueRepository"; +import { IssueRepository } from "$/domain/repositories/IssueRepository"; + +import { UCAnalysis } from "./common/UCAnalysis"; +import { UCDataValue } from "./common/UCDataValue"; import { getCurrentSection } from "./common/utils"; export class ValidateMidwiferyAndPersonnelUseCase { diff --git a/src/domain/usecases/common/UCAnalysis.ts b/src/domain/usecases/common/UCAnalysis.ts index 840a7ab..479acda 100644 --- a/src/domain/usecases/common/UCAnalysis.ts +++ b/src/domain/usecases/common/UCAnalysis.ts @@ -11,16 +11,12 @@ export class UCAnalysis { return this.analysisRepository.getById(id); } - updateAnalysis( - analysis: QualityAnalysis, - sectionName: string, - totalIssues: number - ): QualityAnalysis { + updateAnalysis(analysis: QualityAnalysis, sectionId: Id, totalIssues: number): QualityAnalysis { return QualityAnalysis.build({ ...analysis, lastModification: new Date().toISOString(), sections: analysis.sections.map(section => { - if (section.name !== sectionName) return section; + if (section.id !== sectionId) return section; return QualityAnalysisSection.create({ ...section, status: totalIssues === 0 ? "success" : "success_with_issues", diff --git a/src/domain/usecases/common/UCIssue.ts b/src/domain/usecases/common/UCIssue.ts index 8f07140..166a56b 100644 --- a/src/domain/usecases/common/UCIssue.ts +++ b/src/domain/usecases/common/UCIssue.ts @@ -7,7 +7,6 @@ import { IssueStatus } from "$/domain/entities/IssueStatus"; import _ from "$/domain/entities/generic/Collection"; import { FutureData } from "$/data/api-futures"; import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; -import { getCurrentSection } from "./utils"; export class UCIssue { constructor(private issueRepository: IssueRepository) {} @@ -42,7 +41,7 @@ export class UCIssue { number: issueNumber, azureUrl: "", period: period, - country: { id: countryId, name: "", path: "" }, + country: { id: countryId, name: "", path: "", writeAccess: false }, dataElement: { id: dataElementId, name: "" }, categoryOption: { id: categoryOptionComboId, name: "" }, description: description, @@ -67,18 +66,19 @@ export class UCIssue { return issueNumber; } - getTotalIssuesBySection(analysis: QualityAnalysis, sectionName: string): FutureData<number> { - const section = getCurrentSection(analysis, sectionName); + getTotalIssuesBySection(analysis: QualityAnalysis, sectionId: string): FutureData<number> { return this.issueRepository .get({ filters: { - endDate: undefined, + actions: undefined, + countries: [], analysisIds: [analysis.id], name: undefined, - sectionId: section?.id, - startDate: undefined, + sectionId: sectionId, + periods: [], status: undefined, id: undefined, + followUp: undefined, }, pagination: { page: 1, pageSize: 10 }, sorting: { field: "number", order: "asc" }, diff --git a/src/domain/usecases/common/utils.ts b/src/domain/usecases/common/utils.ts index 79cdee7..12ced4d 100644 --- a/src/domain/usecases/common/utils.ts +++ b/src/domain/usecases/common/utils.ts @@ -30,10 +30,10 @@ export function getQualityAnalysis( export function getCurrentSection( analysis: QualityAnalysis, - sectionName: string + sectionId: Id ): QualityAnalysisSection { - const section = analysis.sections.find(section => section.name === sectionName); - if (!section) throw Error(`Cannot found section: ${sectionName}`); + const section = analysis.sections.find(section => section.id === sectionId); + if (!section) throw Error(`Cannot found section: ${sectionId}`); return section; } @@ -46,13 +46,15 @@ export function getIssues( return issueRepository .get({ filters: { - endDate: undefined, + actions: undefined, + countries: [], + periods: [], analysisIds: [analysis.id], name: undefined, sectionId: section?.id, - startDate: undefined, status: undefined, id: undefined, + followUp: undefined, }, pagination: { page: 1, pageSize: 10 }, sorting: { field: "number", order: "asc" }, diff --git a/src/webapp/components/configuration-form/ConfigurationForm.tsx b/src/webapp/components/configuration-form/ConfigurationForm.tsx index 6d36944..6e9f6e4 100644 --- a/src/webapp/components/configuration-form/ConfigurationForm.tsx +++ b/src/webapp/components/configuration-form/ConfigurationForm.tsx @@ -13,7 +13,7 @@ import { Country } from "$/domain/entities/Country"; import styled from "styled-components"; import { getDefaultModules } from "$/data/common/D2Module"; -function getIdFromCountriesPaths(paths: string[]): string[] { +export function getIdFromCountriesPaths(paths: string[]): string[] { return _(paths) .map(path => { return _(path.split("/")).last() || undefined; @@ -49,7 +49,7 @@ export function useCountries(props: UseCountriesProps) { export const ConfigurationForm: React.FC<ConfigurationFormProps> = React.memo(props => { const { initialCountries, initialData, onSave } = props; - const { api, metadata } = useAppContext(); + const { api, currentUser, metadata } = useAppContext(); const { countries } = useCountries({ ids: initialData.countriesAnalysis }); const [formData, setFormData] = React.useState<QualityAnalysis>(() => { return initialData; @@ -159,7 +159,7 @@ export const ConfigurationForm: React.FC<ConfigurationFormProps> = React.memo(pr selected={selectedOrgUnits} levels={[1, 2, 3]} selectableLevels={[1, 2, 3]} - rootIds={initialCountries} + rootIds={currentUser.countries.map(country => country.id)} withElevation={false} /> </OrgUnitContainer> diff --git a/src/webapp/components/country-selector/CountrySelector.tsx b/src/webapp/components/country-selector/CountrySelector.tsx new file mode 100644 index 0000000..d933fee --- /dev/null +++ b/src/webapp/components/country-selector/CountrySelector.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { OrgUnitsSelector } from "@eyeseetea/d2-ui-components"; + +import { D2Api } from "$/types/d2-api"; +import { Id } from "$/domain/entities/Ref"; +import _ from "$/domain/entities/generic/Collection"; + +export const CountrySelector: React.FC<CountrySelectorProps> = props => { + const { api, onChange, rootIds, selectedCountriesIds: selectedOrgUnits } = props; + + const onOrgUnitsChange = (ids: Id[]) => { + onChange(ids); + }; + + return ( + <OrgUnitsSelector + api={api} + onChange={onOrgUnitsChange} + selected={selectedOrgUnits} + levels={[1, 2, 3]} + selectableLevels={[1, 2, 3]} + rootIds={rootIds} + withElevation={false} + /> + ); +}; + +type CountrySelectorProps = { + api: D2Api; + onChange: (ids: Id[]) => void; + rootIds: Id[]; + selectedCountriesIds: Id[]; +}; diff --git a/src/webapp/components/issues/EditIssueValue.tsx b/src/webapp/components/issues/EditIssueValue.tsx index ad837cd..3c2671a 100644 --- a/src/webapp/components/issues/EditIssueValue.tsx +++ b/src/webapp/components/issues/EditIssueValue.tsx @@ -5,7 +5,7 @@ import { IssuePropertyName, QualityAnalysisIssue } from "$/domain/entities/Quali import { CheckboxInline } from "./CheckboxInline"; import { SelectorInline } from "./SelectorInline"; import { useAppContext } from "$/webapp/contexts/app-context"; -import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { SnackbarState, useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; import { Id } from "$/domain/entities/Ref"; import i18n from "$/utils/i18n"; import { useParams } from "react-router-dom"; @@ -14,10 +14,26 @@ type UpdateIssuePropertyProps = { analysisId: Id; issue: QualityAnalysisIssue; field: IssuePropertyName; + setRefresh?: React.Dispatch<React.SetStateAction<number>>; }; +function getContactEmailNotification( + field: IssuePropertyName, + value: boolean, + emailChanged: boolean, + snackbar: SnackbarState +) { + if (field === "followUp" && value === true && !emailChanged) { + snackbar.warning( + i18n.t( + "No user with Capture rights and Organisation Unit associated to the issue was found" + ) + ); + } +} + export function useUpdateIssueProperty(props: UpdateIssuePropertyProps) { - const { analysisId, field, issue } = props; + const { analysisId, field, issue, setRefresh } = props; const { compositionRoot } = useAppContext(); const loading = useLoading(); const snackbar = useSnackbar(); @@ -33,9 +49,16 @@ export function useUpdateIssueProperty(props: UpdateIssuePropertyProps) { valueToUpdate: value, }) .run( - () => { + result => { loading.hide(); snackbar.success(i18n.t("Issue Updated")); + if (setRefresh) setRefresh(refresh => refresh + 1); + getContactEmailNotification( + field, + value as boolean, + Boolean(result.contactEmailsChanged), + snackbar + ); }, err => { snackbar.error(err.message); @@ -43,7 +66,7 @@ export function useUpdateIssueProperty(props: UpdateIssuePropertyProps) { } ); }, - [compositionRoot.issues.save, loading, snackbar, issue, analysisId, field] + [compositionRoot.issues.save, loading, snackbar, issue, analysisId, field, setRefresh] ); return { updateIssue }; @@ -52,11 +75,12 @@ export function useUpdateIssueProperty(props: UpdateIssuePropertyProps) { export const EditIssueValue: React.FC<EditIssueValueProps> = React.memo(props => { const { id } = useParams<{ id: string }>(); const { metadata } = useAppContext(); - const { issue, field } = props; + const { issue, field, setRefresh } = props; const { updateIssue } = useUpdateIssueProperty({ analysisId: id, field: field, issue: issue, + setRefresh, }); const onSave = (value: string | boolean) => { @@ -67,7 +91,13 @@ export const EditIssueValue: React.FC<EditIssueValueProps> = React.memo(props => case "comments": return <InputInline value={issue.comments} onSave={onSave} />; case "contactEmails": - return <InputInline value={issue.contactEmails} onSave={onSave} />; + return ( + <InputInline + key={issue.contactEmails} + value={issue.contactEmails} + onSave={onSave} + /> + ); case "description": return <InputInline value={issue.description} onSave={onSave} />; case "actionDescription": @@ -101,4 +131,8 @@ export const EditIssueValue: React.FC<EditIssueValueProps> = React.memo(props => } }); -type EditIssueValueProps = { issue: QualityAnalysisIssue; field: IssuePropertyName }; +type EditIssueValueProps = { + issue: QualityAnalysisIssue; + field: IssuePropertyName; + setRefresh?: React.Dispatch<React.SetStateAction<number>>; +}; diff --git a/src/webapp/components/issues/IssueFilters.tsx b/src/webapp/components/issues/IssueFilters.tsx new file mode 100644 index 0000000..2b3399d --- /dev/null +++ b/src/webapp/components/issues/IssueFilters.tsx @@ -0,0 +1,178 @@ +import React from "react"; + +import { Id, Period } from "$/domain/entities/Ref"; +import { GetIssuesOptions } from "$/domain/repositories/IssueRepository"; +import { SelectMultiCheckboxes } from "../selectmulti-checkboxes/SelectMultiCheckboxes"; +import { periods } from "../analysis-filter/AnalysisFilter"; +import { Maybe } from "$/utils/ts-utils"; +import i18n from "$/utils/i18n"; +import { useAppContext } from "$/webapp/contexts/app-context"; +import { Dropdown, useSnackbar } from "@eyeseetea/d2-ui-components"; +import styled from "styled-components"; +import { Button, Dialog, DialogActions, TextField } from "@material-ui/core"; +import { CountrySelector } from "../country-selector/CountrySelector"; +import { getIdFromCountriesPaths } from "../configuration-form/ConfigurationForm"; +import { Country } from "$/domain/entities/Country"; + +const followUpItems = [ + { + value: "1", + text: i18n.t("Yes"), + }, + { + value: "0", + text: i18n.t("No"), + }, +]; + +function extractCountriesNames(countries: Country[], totalCountriesSelected: number) { + const countryNames = countries + .map(country => country.name) + .slice(0, 2) + .join(", "); + + const moreCountriesCount = totalCountriesSelected - 2; + + const countryNamesString = + moreCountriesCount > 0 + ? i18n.t("{{countryNames}} and {{moreCountriesCount}} more", { + countryNames, + moreCountriesCount, + }) + : countryNames; + + return countryNamesString; +} + +export const IssueFilters: React.FC<IssueFiltersProps> = props => { + const { api, compositionRoot, currentUser, metadata } = useAppContext(); + const snackbar = useSnackbar(); + const { initialFilters, onChange } = props; + const [openCountry, setOpenCountry] = React.useState(false); + const [selectedCountriesIds, setSelectedCountriesIds] = React.useState<Id[]>([]); + const [countries, setCountries] = React.useState<Country[]>([]); + + const onFilterChange = React.useCallback< + (value: Maybe<string> | string[], filterAttribute: string) => void + >( + (value, filterAttribute) => { + onChange(prev => ({ ...prev, [filterAttribute]: value })); + }, + [onChange] + ); + + const actions = metadata.optionSets.nhwaAction.options.map(option => { + return { value: option.code, text: option.name }; + }); + + const status = metadata.optionSets.nhwaStatus.options.map(option => { + return { value: option.code, text: option.name }; + }); + + const onClickCountry = () => { + setOpenCountry(true); + }; + + const onChangeFilterCountry = (action: "save" | "close") => { + if (action === "save") { + const countriesIds = getIdFromCountriesPaths(selectedCountriesIds); + compositionRoot.countries.getByIds.execute(countriesIds.slice(0, 2)).run( + countries => { + onFilterChange(countriesIds, "countries"); + setCountries(countries); + setOpenCountry(false); + }, + error => { + snackbar.error(error.message); + } + ); + } else { + setOpenCountry(false); + } + }; + + const onCountrySelected = (paths: Id[]) => { + setSelectedCountriesIds(paths); + }; + + const countryNamesString = extractCountriesNames(countries, selectedCountriesIds.length); + + return ( + <FilterContainer> + <Dialog fullWidth maxWidth="lg" open={openCountry}> + <CountrySelector + api={api} + onChange={onCountrySelected} + rootIds={currentUser.countries.map(country => country.id)} + selectedCountriesIds={selectedCountriesIds} + /> + <DialogActions> + <Button onClick={() => onChangeFilterCountry("close")} color="primary"> + {i18n.t("Close")} + </Button> + <Button onClick={() => onChangeFilterCountry("save")} color="primary"> + {i18n.t("Save")} + </Button> + </DialogActions> + </Dialog> + <div> + <TextField + name="countries" + label={i18n.t("Countries")} + onClick={onClickCountry} + title={countryNamesString} + value={countryNamesString} + /> + </div> + + <SelectMultiCheckboxes + label={i18n.t("Periods")} + onChange={values => onFilterChange(values, "periods")} + options={periods} + value={initialFilters.periods} + /> + + <SelectMultiCheckboxes + label={i18n.t("Status")} + onChange={values => onFilterChange(values, "status")} + options={status} + value={initialFilters.status || []} + /> + + <SelectMultiCheckboxes + label={i18n.t("Action")} + onChange={values => onFilterChange(values, "actions")} + options={actions} + value={initialFilters.actions || []} + /> + + <Dropdown + className="config-form-selector" + label={i18n.t("Follow Up")} + items={followUpItems} + onChange={value => onFilterChange(value, "followUp")} + value={initialFilters.followUp} + /> + </FilterContainer> + ); +}; + +type IssueFiltersProps = { + initialFilters: IssueFilterState; + onChange: React.Dispatch<React.SetStateAction<GetIssuesOptions["filters"]>>; +}; + +export type IssueFilterState = { + countries: Id[]; + actions: Maybe<Id[]>; + followUp: Maybe<string>; + periods: Period[]; + status: Maybe<Id[]>; +}; + +const FilterContainer = styled.div` + padding: 0 1rem; + align-items: center; + display: flex; + gap: 1rem; +`; diff --git a/src/webapp/components/issues/IssueTable.tsx b/src/webapp/components/issues/IssueTable.tsx index fd64a1d..b556c3c 100644 --- a/src/webapp/components/issues/IssueTable.tsx +++ b/src/webapp/components/issues/IssueTable.tsx @@ -9,6 +9,7 @@ import { Id } from "$/domain/entities/Ref"; import { EditIssueValue } from "./EditIssueValue"; export function useTableConfig() { + const [refresh, setRefresh] = React.useState(0); const tableConfig = React.useMemo<TableConfig<QualityAnalysisIssue>>(() => { return { actions: [], @@ -21,19 +22,11 @@ export function useTableConfig() { name: "categoryOption", text: i18n.t("Category"), sortable: false, - getValue: value => { - return value.categoryOption?.name === "default" - ? "Total" - : value.categoryOption?.name; - }, }, { name: "description", text: i18n.t("Description"), sortable: false, - getValue: value => { - return <EditIssueValue key={value.id} field="description" issue={value} />; - }, }, { name: "status", @@ -47,6 +40,7 @@ export function useTableConfig() { name: "azureUrl", text: i18n.t("Azure URL"), sortable: false, + hidden: true, getValue: value => { return <EditIssueValue key={value.id} field="azureUrl" issue={value} />; }, @@ -56,7 +50,12 @@ export function useTableConfig() { text: i18n.t("Follow Up"), sortable: false, getValue: value => ( - <EditIssueValue key={value.id} field="followUp" issue={value} /> + <EditIssueValue + key={value.id} + field="followUp" + issue={value} + setRefresh={setRefresh} + /> ), }, { @@ -71,7 +70,6 @@ export function useTableConfig() { name: "contactEmails", text: i18n.t("Contact Emails"), sortable: false, - hidden: true, getValue: value => { return ( <EditIssueValue key={value.id} field="contactEmails" issue={value} /> @@ -109,14 +107,15 @@ export function useTableConfig() { }; }, []); - return { tableConfig }; + return { tableConfig, refresh }; } export function useGetRows( filters: GetIssuesOptions["filters"], reloadKey: number, analysisId: Id, - sectionId: Id + sectionId: Id, + refreshIssue: number ) { const { compositionRoot } = useAppContext(); const [loading, setLoading] = React.useState(false); @@ -124,7 +123,7 @@ export function useGetRows( const getRows = React.useCallback<GetRows<QualityAnalysisIssue>>( (search, pagination, sorting) => { return new Promise((resolve, reject) => { - if (reloadKey < 0) + if (reloadKey < 0 || refreshIssue < 0) return resolve({ pager: { page: 1, pageCount: 1, pageSize: 10, total: 0 }, objects: [], @@ -135,13 +134,15 @@ export function useGetRows( pagination: { page: pagination.page, pageSize: pagination.pageSize }, sorting: { field: sorting.field, order: sorting.order }, filters: { + actions: filters.actions, + countries: filters.countries, name: search, - endDate: filters.endDate, - startDate: filters.startDate, + periods: filters.periods, status: filters.status, analysisIds: [analysisId], sectionId: sectionId, id: undefined, + followUp: filters.followUp, }, }) .run( @@ -158,12 +159,15 @@ export function useGetRows( }, [ compositionRoot.outlier.get, - filters.endDate, - filters.startDate, + filters.actions, + filters.followUp, + filters.periods, filters.status, + filters.countries, sectionId, analysisId, reloadKey, + refreshIssue, ] ); diff --git a/src/webapp/pages/analysis/AnalysisPage.tsx b/src/webapp/pages/analysis/AnalysisPage.tsx index 10c3c2f..a6e174d 100644 --- a/src/webapp/pages/analysis/AnalysisPage.tsx +++ b/src/webapp/pages/analysis/AnalysisPage.tsx @@ -1,25 +1,78 @@ import React from "react"; -import { Wizard } from "@eyeseetea/d2-ui-components"; +import { Wizard, WizardStep } from "@eyeseetea/d2-ui-components"; import { PageHeader } from "$/webapp/components/page-header/PageHeader"; -import { outlierKey, steps } from "./steps"; +import { getComponentFromSectionName } from "./steps"; import styled from "styled-components"; -import { useHistory } from "react-router-dom"; +import { useHistory, useParams } from "react-router-dom"; import { PageContainer } from "$/webapp/components/page-container/PageContainer"; +import { useAnalysisById } from "$/webapp/hooks/useAnalysis"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { ConfigurationStep } from "./steps/ConfigurationStep"; +import i18n from "$/utils/i18n"; +import _ from "$/domain/entities/generic/Collection"; +import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; +import { Maybe } from "$/utils/ts-utils"; -type PageProps = { name: string }; +function buildStepsFromSections( + analysis: QualityAnalysis, + updateAnalysis: UpdateAnalysisState +): WizardStep[] { + const sectionSteps = _(analysis.sections) + .map(section => { + const StepComponent = getComponentFromSectionName(section.name); + if (!StepComponent) return undefined; + + return { + id: section.id, + key: section.name.toLowerCase(), + label: section.name, + component: () => ( + <StepComponent + analysis={analysis} + section={section} + title={section.description || section.name} + updateAnalysis={updateAnalysis} + /> + ), + completed: !QualityAnalysisSection.isPending(section), + }; + }) + .compact() + .value(); + + return [ + { + key: "configuration", + label: i18n.t("Configuration"), + component: ConfigurationStep, + completed: true, + }, + ...sectionSteps, + ]; +} export const AnalysisPage: React.FC<PageProps> = React.memo(props => { const { name } = props; + const id = useParams<{ id: string }>(); const history = useHistory(); const onBack = () => { history.push("/"); }; + const { analysis, setAnalysis } = useAnalysisById(id); + + const analysisSteps = React.useMemo(() => { + if (!analysis) return []; + return buildStepsFromSections(analysis, setAnalysis); + }, [analysis, setAnalysis]); + + if (!analysis) return null; + return ( <PageContainer> <PageHeader title={name} onBackClick={onBack} /> - <Stepper initialStepKey={outlierKey} steps={steps} /> + <Stepper initialStepKey="outliers" steps={analysisSteps} /> </PageContainer> ); }); @@ -29,3 +82,14 @@ const Stepper = styled(Wizard)` overflow-x: scroll; } `; + +type PageProps = { name: string }; + +export type PageStepProps = { + analysis: QualityAnalysis; + section: QualityAnalysisSection; + updateAnalysis: UpdateAnalysisState; + title: string; +}; + +export type UpdateAnalysisState = React.Dispatch<React.SetStateAction<Maybe<QualityAnalysis>>>; diff --git a/src/webapp/pages/analysis/steps.ts b/src/webapp/pages/analysis/steps.ts index f0530b0..f58214f 100644 --- a/src/webapp/pages/analysis/steps.ts +++ b/src/webapp/pages/analysis/steps.ts @@ -1,83 +1,31 @@ -import { ConfigurationStep } from "./steps/ConfigurationStep"; -import { DensityStep } from "./steps/DensityStep"; import { GeneralPractitionersStep } from "./steps/4-general-practitioners/GeneralPractitionersStep"; import { DisaggregatesStep } from "./steps/3-disaggregates/DisaggregatesStep"; -import { MidwiferyStep } from "./steps/MidwiferyStep"; import { NursingMidwiferyStep } from "./steps/7-nursingMidwifery/NursingMidwiferyStep"; -import { NursingStep } from "./steps/NursingStep"; import { OutliersStep } from "./steps/1-outliers/OutliersStep"; -import { ValidationStep } from "./steps/9-validation/ValidationStep"; -import { TrendsStep } from "./steps/TrendsStep"; -import i18n from "$/utils/i18n"; -export const outlierKey = - "1. Outliers detection analysis based on DHIS2 min-max standard functionality"; - -export const disaggregateKey = "3. Missing disaggregates in selected catcombos "; - -export const practitionersKey = - "4. Medical doctors analysis: General Practicioners missing and double counts"; - -export const missingNursing = "7. Missing nursing personnel when midwifery personnel is present"; - -export const steps = [ +const sectionsComponents = [ { - key: "configuration", - label: i18n.t("Configuration"), - component: ConfigurationStep, - completed: true, - }, - { - key: outlierKey, - label: i18n.t("Outliers"), + name: "Outliers", component: OutliersStep, - completed: true, - }, - { - key: "trends", - label: i18n.t("Trends"), - component: TrendsStep, - completed: false, }, { - key: "disaggregates", - label: i18n.t("Disaggregates"), + name: "Disaggregates", component: DisaggregatesStep, - completed: false, }, { - key: practitionersKey, - label: i18n.t("General Practitioners"), + name: "General Practitioners", component: GeneralPractitionersStep, - completed: false, }, { - key: "nursing", - label: i18n.t("Nursing"), - component: NursingStep, - completed: false, - }, - { - key: missingNursing, - label: i18n.t("Nursing/Midwifery"), + name: "Nursing/Midwifery", component: NursingMidwiferyStep, - completed: false, - }, - { - key: "midwifery", - label: i18n.t("Midwifery"), - component: MidwiferyStep, - completed: false, - }, - { - key: "density", - label: i18n.t("Density"), - component: DensityStep, - completed: false, - }, - { - key: "other", - label: i18n.t("Other"), - component: ValidationStep, }, ]; + +export function getComponentFromSectionName(name: string) { + const sectionComponent = sectionsComponents.find( + component => component.name.toLowerCase() === name.toLowerCase() + ); + if (!sectionComponent) return undefined; + return sectionComponent.component; +} diff --git a/src/webapp/pages/analysis/steps/1-outliers/OutliersStep.tsx b/src/webapp/pages/analysis/steps/1-outliers/OutliersStep.tsx index 8c33c10..c7aa55a 100644 --- a/src/webapp/pages/analysis/steps/1-outliers/OutliersStep.tsx +++ b/src/webapp/pages/analysis/steps/1-outliers/OutliersStep.tsx @@ -1,42 +1,34 @@ import React from "react"; +import { Dropdown } from "@eyeseetea/d2-ui-components"; + import i18n from "$/utils/i18n"; -import styled from "styled-components"; -import { Typography, Button } from "@material-ui/core"; -import { Dropdown, ObjectsTable, useObjectsTable } from "@eyeseetea/d2-ui-components"; -import { EmptyState } from "$/webapp/components/empty-state/EmptyState"; -import { useParams } from "react-router-dom"; -import { useGetRows, useTableConfig } from "$/webapp/components/issues/IssueTable"; -import { GetIssuesOptions } from "$/domain/repositories/IssueRepository"; -import { initialFilters } from "$/webapp/utils/issues"; -import { useAnalysisById } from "$/webapp/hooks/useAnalysis"; import { algorithmList, thresholdList, useAnalysisOutlier } from "./useOutliers"; import { Maybe } from "$/utils/ts-utils"; -import { outlierKey } from "../../steps"; +import { StepAnalysis } from "../StepAnalysis"; +import { PageStepProps } from "../../AnalysisPage"; const defaultOutlierParams = { algorithm: "Z_SCORE", threshold: "3" }; -export const OutliersStep: React.FC<PageProps> = React.memo(() => { - const params = useParams<{ id: string }>(); +export const OutliersStep: React.FC<PageStepProps> = React.memo(props => { + const { title, analysis, section, updateAnalysis } = props; + const [reload, refreshReload] = React.useState(0); const [qualityFilters, setQualityFilters] = React.useState(defaultOutlierParams); - const [filters, _] = React.useState<GetIssuesOptions["filters"]>(initialFilters); - - const { tableConfig } = useTableConfig(); - const { analysis, setAnalysis } = useAnalysisById({ id: params.id }); - const section = analysis?.sections.find(section => section.name === outlierKey); - - const { getRows, loading } = useGetRows(filters, reload, params.id, section?.id || ""); - const config = useObjectsTable(tableConfig, getRows); const { runAnalysisOutlier } = useAnalysisOutlier({ onSucess: qualityAnalysis => { refreshReload(reload + 1); - setAnalysis(qualityAnalysis); + updateAnalysis(qualityAnalysis); }, }); const runAnalysis = () => { - runAnalysisOutlier(qualityFilters.algorithm, params.id, qualityFilters.threshold); + runAnalysisOutlier( + qualityFilters.algorithm, + analysis.id, + section.id, + qualityFilters.threshold + ); }; const onFilterChange = React.useCallback< @@ -45,82 +37,30 @@ export const OutliersStep: React.FC<PageProps> = React.memo(() => { setQualityFilters(prev => ({ ...prev, [filterAttribute]: value })); }, []); - const isPending = section?.status === "pending"; + if (!analysis) return null; return ( - <Container> - <> - <AnalysisHeader> - <StyledTypography variant="h2"> - {i18n.t( - "Outliers detection analysis based on DHIS2 min-max standard functionality" - )} - </StyledTypography> - {isPending && ( - <FiltersContainer> - <Dropdown - hideEmpty - items={algorithmList} - onChange={value => onFilterChange(value, "algorithm")} - value={qualityFilters.algorithm} - label={i18n.t("Algorithm")} - /> - <Dropdown - hideEmpty - items={thresholdList} - onChange={value => onFilterChange(value, "threshold")} - value={qualityFilters.threshold} - label={i18n.t("Threshold")} - /> - <Button - variant="contained" - color="primary" - size="small" - onClick={() => runAnalysis()} - > - {i18n.t("Run")} - </Button> - </FiltersContainer> - )} - </AnalysisHeader> - {isPending && ( - <EmptyState message={i18n.t("Run to get results")} variant="neutral" /> - )} - {section?.status === "success" && ( - <EmptyState message={i18n.t("No Issues found")} variant="success" /> - )} - </> - {section?.status === "success_with_issues" && ( - <ObjectsTable loading={loading} {...config} /> - )} - </Container> + <StepAnalysis + id={analysis.id} + onRun={runAnalysis} + reload={reload} + section={section} + title={title} + > + <Dropdown + hideEmpty + items={algorithmList} + onChange={value => onFilterChange(value, "algorithm")} + value={qualityFilters.algorithm} + label={i18n.t("Algorithm")} + /> + <Dropdown + hideEmpty + items={thresholdList} + onChange={value => onFilterChange(value, "threshold")} + value={qualityFilters.threshold} + label={i18n.t("Threshold")} + /> + </StepAnalysis> ); }); - -const Container = styled.section``; - -const AnalysisHeader = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - height: 5rem; - gap: 1rem; - margin-block-end: 1.75rem; - flex-wrap: wrap; -`; - -const StyledTypography = styled(Typography)` - font-size: 1.2rem; - font-weight: 500; - max-width: 22rem; -`; - -const FiltersContainer = styled.div` - display: flex; - align-items: center; - gap: 1rem; -`; - -interface PageProps { - name: string; -} diff --git a/src/webapp/pages/analysis/steps/1-outliers/useOutliers.ts b/src/webapp/pages/analysis/steps/1-outliers/useOutliers.ts index e9e0148..77301c4 100644 --- a/src/webapp/pages/analysis/steps/1-outliers/useOutliers.ts +++ b/src/webapp/pages/analysis/steps/1-outliers/useOutliers.ts @@ -4,6 +4,7 @@ import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; import i18n from "$/utils/i18n"; import { useAppContext } from "$/webapp/contexts/app-context"; import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { Id } from "$/domain/entities/Ref"; export const thresholdList = [ { value: "1", text: "1.0" }, @@ -30,10 +31,15 @@ export function useAnalysisOutlier(props: UseRunAnalysisProps) { const loading = useLoading(); const runAnalysisOutlier = React.useCallback( - (algorithm: string, id: string, threshold: string) => { + (algorithm: string, analysisId: Id, sectionId: Id, threshold: string) => { loading.show(true, i18n.t("Running analysis...")); compositionRoot.outlier.run - .execute({ algorithm: algorithm, qualityAnalysisId: id, threshold: threshold }) + .execute({ + sectionId: sectionId, + algorithm: algorithm, + qualityAnalysisId: analysisId, + threshold: threshold, + }) .run( qualityAnalysis => { loading.hide(); diff --git a/src/webapp/pages/analysis/steps/3-disaggregates/DisaggregatesStep.tsx b/src/webapp/pages/analysis/steps/3-disaggregates/DisaggregatesStep.tsx index dbc0e49..0f07ab5 100644 --- a/src/webapp/pages/analysis/steps/3-disaggregates/DisaggregatesStep.tsx +++ b/src/webapp/pages/analysis/steps/3-disaggregates/DisaggregatesStep.tsx @@ -4,18 +4,16 @@ import styled from "styled-components"; import { useDisaggregatesStep } from "./useDisaggregatesStep"; import { SelectMultiCheckboxes } from "$/webapp/components/selectmulti-checkboxes/SelectMultiCheckboxes"; import { StepAnalysis } from "../StepAnalysis"; -import { useParams } from "react-router-dom"; -import { disaggregateKey } from "../../steps"; +import { PageStepProps } from "../../AnalysisPage"; -export const DisaggregatesStep: React.FC<PageProps> = React.memo(() => { - const { id } = useParams<{ id: string }>(); - - const { analysis, disaggregations, handleChange, reload, runAnalysis, selectedDisagregations } = +export const DisaggregatesStep: React.FC<PageStepProps> = React.memo(props => { + const { analysis, section, title, updateAnalysis } = props; + const { disaggregations, handleChange, reload, runAnalysis, selectedDisagregations } = useDisaggregatesStep({ - analysisId: id, + analysis: analysis, + updateAnalysis, + sectionId: section.id, }); - const section = analysis?.sections.find(section => section.name === disaggregateKey); - if (!analysis?.id || !section) return null; const onClick = () => { runAnalysis(); @@ -27,7 +25,7 @@ export const DisaggregatesStep: React.FC<PageProps> = React.memo(() => { onRun={onClick} reload={reload} section={section} - title={i18n.t("Missing disaggregates in selected catcombos")} + title={title} > <FiltersContainer> <SelectMultiCheckboxes @@ -46,5 +44,3 @@ const FiltersContainer = styled.div` align-items: center; gap: 1rem; `; - -type PageProps = { name: string }; diff --git a/src/webapp/pages/analysis/steps/3-disaggregates/useDisaggregatesStep.tsx b/src/webapp/pages/analysis/steps/3-disaggregates/useDisaggregatesStep.tsx index 866d295..40e5d70 100644 --- a/src/webapp/pages/analysis/steps/3-disaggregates/useDisaggregatesStep.tsx +++ b/src/webapp/pages/analysis/steps/3-disaggregates/useDisaggregatesStep.tsx @@ -3,40 +3,32 @@ import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; import i18n from "$/utils/i18n"; import { useAppContext } from "$/webapp/contexts/app-context"; -import { useAnalysisById } from "$/webapp/hooks/useAnalysis"; import { Id } from "$/domain/entities/Ref"; import { Option } from "$/webapp/components/selectmulti-checkboxes/SelectMultiCheckboxes"; -import { disaggregateKey } from "../../steps"; import _ from "$/domain/entities/generic/Collection"; +import { UpdateAnalysisState } from "../../AnalysisPage"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; export function useDisaggregatesStep(props: UseDisaggregatesStepProps) { - const { analysisId } = props; + const { analysis, sectionId, updateAnalysis } = props; const { compositionRoot } = useAppContext(); const snackbar = useSnackbar(); const loading = useLoading(); const [reload, refreshReload] = React.useState(0); - const { analysis, setAnalysis } = useAnalysisById({ id: analysisId }); + const [disaggregations, setDisaggregations] = React.useState<Option[]>([]); const [selectedDisagregations, setSelectedDisagregations] = React.useState<string[]>([]); React.useEffect(() => { - if (!analysis) return; - - const section = analysis.sections.find(section => section.name === disaggregateKey); - - compositionRoot.disaggregates.getCategoriesCombos.execute(section?.name || "").run( + compositionRoot.disaggregates.getCategoriesCombos.execute(sectionId).run( settingSection => { - const initialDisaggregations = - _(settingSection.disaggregations) - .map(disaggregation => { - return { - value: disaggregation.id, - text: disaggregation.name, - }; - }) - .sortBy(item => item.text) - .value() || []; + const initialDisaggregations = _(settingSection.disaggregations) + .map(disaggregation => { + return { value: disaggregation.id, text: disaggregation.name }; + }) + .sortBy(item => item.text) + .value(); setDisaggregations(initialDisaggregations); setSelectedDisagregations(initialDisaggregations.map(item => item.value)); }, @@ -44,7 +36,7 @@ export function useDisaggregatesStep(props: UseDisaggregatesStepProps) { snackbar.error(err.message); } ); - }, [analysis, compositionRoot.disaggregates.getCategoriesCombos, snackbar]); + }, [analysis, compositionRoot.disaggregates.getCategoriesCombos, snackbar, sectionId]); const handleChange = (values: string[]) => { setSelectedDisagregations(values); @@ -54,11 +46,15 @@ export function useDisaggregatesStep(props: UseDisaggregatesStepProps) { if (!analysis) return false; loading.show(true, i18n.t("Running analysis...")); compositionRoot.missingDisaggregates.get - .execute({ analysisId: analysis.id, disaggregationsIds: selectedDisagregations }) + .execute({ + analysisId: analysis.id, + disaggregationsIds: selectedDisagregations, + sectionId: sectionId, + }) .run( result => { refreshReload(reload + 1); - setAnalysis(result); + updateAnalysis(result); loading.hide(); }, err => { @@ -78,4 +74,8 @@ export function useDisaggregatesStep(props: UseDisaggregatesStepProps) { }; } -type UseDisaggregatesStepProps = { analysisId: Id }; +type UseDisaggregatesStepProps = { + analysis: QualityAnalysis; + sectionId: Id; + updateAnalysis: UpdateAnalysisState; +}; diff --git a/src/webapp/pages/analysis/steps/4-general-practitioners/GeneralPractitionersStep.tsx b/src/webapp/pages/analysis/steps/4-general-practitioners/GeneralPractitionersStep.tsx index f88282f..9adc74f 100644 --- a/src/webapp/pages/analysis/steps/4-general-practitioners/GeneralPractitionersStep.tsx +++ b/src/webapp/pages/analysis/steps/4-general-practitioners/GeneralPractitionersStep.tsx @@ -1,24 +1,18 @@ import React from "react"; -import { useParams } from "react-router-dom"; import { Dropdown } from "@eyeseetea/d2-ui-components"; import i18n from "$/utils/i18n"; import { StepAnalysis } from "$/webapp/pages/analysis/steps/StepAnalysis"; -import { practitionersKey } from "$/webapp/pages/analysis/steps"; import styled from "styled-components"; import { useGeneralPractitionersStep } from "./useGeneralPractitionersStep"; import { SelectMultiCheckboxes } from "$/webapp/components/selectmulti-checkboxes/SelectMultiCheckboxes"; +import { PageStepProps } from "../../AnalysisPage"; -interface PageProps { - name: string; -} - -export const GeneralPractitionersStep: React.FC<PageProps> = React.memo(() => { - const { id } = useParams<{ id: string }>(); +export const GeneralPractitionersStep: React.FC<PageStepProps> = React.memo(props => { + const { analysis, section, title, updateAnalysis } = props; const { - analysis, disaggregations, doubleCountsList, reload, @@ -27,15 +21,16 @@ export const GeneralPractitionersStep: React.FC<PageProps> = React.memo(() => { threshold, handleChange, valueChange, - } = useGeneralPractitionersStep({ analysisId: id }); - const section = analysis?.sections.find(section => section.name === practitionersKey); + } = useGeneralPractitionersStep({ + analysis: analysis, + section: section, + updateAnalysis: updateAnalysis, + }); const onClick = () => { runAnalysis(); }; - if (!analysis?.id || !section) return null; - return ( <div> <StepAnalysis @@ -43,10 +38,7 @@ export const GeneralPractitionersStep: React.FC<PageProps> = React.memo(() => { onRun={onClick} reload={reload} section={section} - title={i18n.t( - "Medical doctors analysis: General Practicioners missing and double counts", - { nsSeparator: true } - )} + title={title} > <SelectMultiCheckboxes options={disaggregations} diff --git a/src/webapp/pages/analysis/steps/4-general-practitioners/useGeneralPractitionersStep.tsx b/src/webapp/pages/analysis/steps/4-general-practitioners/useGeneralPractitionersStep.tsx index 5120630..01e7f2a 100644 --- a/src/webapp/pages/analysis/steps/4-general-practitioners/useGeneralPractitionersStep.tsx +++ b/src/webapp/pages/analysis/steps/4-general-practitioners/useGeneralPractitionersStep.tsx @@ -1,9 +1,11 @@ +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; import { Id } from "$/domain/entities/Ref"; import i18n from "$/utils/i18n"; import { useAppContext } from "$/webapp/contexts/app-context"; -import { useAnalysisById } from "$/webapp/hooks/useAnalysis"; import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; import React from "react"; +import { UpdateAnalysisState } from "../../AnalysisPage"; const doubleCountsList = [ { @@ -29,13 +31,12 @@ const doubleCountsList = [ ]; export function useGeneralPractitionersStep(props: UseGeneralPractitionersStepProps) { - const { analysisId } = props; + const { analysis, section, updateAnalysis } = props; const { compositionRoot } = useAppContext(); const snackbar = useSnackbar(); const loading = useLoading(); const [reload, refreshReload] = React.useState(0); - const { analysis, setAnalysis } = useAnalysisById({ id: analysisId }); const [disaggregations, setDisaggregations] = React.useState<{ value: Id; text: string }[]>([]); const [selectedDisaggregations, setSelectedDissagregations] = React.useState<string[]>([]); const [threshold, setThreshold] = React.useState<string>("1"); @@ -71,18 +72,18 @@ export function useGeneralPractitionersStep(props: UseGeneralPractitionersStepPr }, [analysis?.module.id, compositionRoot.modules.getDisaggregations, snackbar]); const runAnalysis = React.useCallback(() => { - if (!analysis) return false; loading.show(true, i18n.t("Running analysis...")); compositionRoot.practitioners.run .execute({ analysisId: analysis.id, threshold: Number(threshold), dissagregationsIds: selectedDisaggregations, + sectionId: section.id, }) .run( analysis => { refreshReload(reload + 1); - setAnalysis(analysis); + updateAnalysis(analysis); loading.hide(); }, err => { @@ -92,10 +93,11 @@ export function useGeneralPractitionersStep(props: UseGeneralPractitionersStepPr ); }, [ analysis, + section.id, compositionRoot.practitioners.run, loading, reload, - setAnalysis, + updateAnalysis, snackbar, threshold, selectedDisaggregations, @@ -109,12 +111,14 @@ export function useGeneralPractitionersStep(props: UseGeneralPractitionersStepPr runAnalysis, reload, selectedDisaggregations, - setAnalysis, + updateAnalysis, valueChange, threshold, }; } type UseGeneralPractitionersStepProps = { - analysisId: Id; + analysis: QualityAnalysis; + section: QualityAnalysisSection; + updateAnalysis: UpdateAnalysisState; }; diff --git a/src/webapp/pages/analysis/steps/7-nursingMidwifery/NursingMidwiferyStep.tsx b/src/webapp/pages/analysis/steps/7-nursingMidwifery/NursingMidwiferyStep.tsx index c4aa35a..a5f2b83 100644 --- a/src/webapp/pages/analysis/steps/7-nursingMidwifery/NursingMidwiferyStep.tsx +++ b/src/webapp/pages/analysis/steps/7-nursingMidwifery/NursingMidwiferyStep.tsx @@ -1,56 +1,30 @@ import React from "react"; import i18n from "$/utils/i18n"; -import styled from "styled-components"; + import { useNursingMidwiferyStep } from "./useNursingMidwiferyStep"; import { SelectMultiCheckboxes } from "$/webapp/components/selectmulti-checkboxes/SelectMultiCheckboxes"; -import { useParams } from "react-router-dom"; import { StepAnalysis } from "../StepAnalysis"; -import { missingNursing } from "../../steps"; - -interface PageProps { - name: string; -} - -export const NursingMidwiferyStep: React.FC<PageProps> = React.memo(() => { - const { id } = useParams<{ id: string }>(); - const { - analysis, - disaggregations, - selectedDisaggregations, - handleChange, - reload, - runAnalysis, - } = useNursingMidwiferyStep({ analysisId: id }); - const section = analysis?.sections.find(section => section.name === missingNursing); +import { PageStepProps } from "../../AnalysisPage"; - if (!analysis || !section) return null; +export const NursingMidwiferyStep: React.FC<PageStepProps> = React.memo(props => { + const { analysis, section, title, updateAnalysis } = props; + const { disaggregations, selectedDisaggregations, handleChange, reload, runAnalysis } = + useNursingMidwiferyStep({ analysis, section, updateAnalysis }); return ( - <Container> - <StepAnalysis - id={analysis.id} - section={section} - reload={reload} - title={i18n.t("Missing nursing personnel when midwifery personnel is present")} - onRun={runAnalysis} - > - <FiltersContainer> - <SelectMultiCheckboxes - options={disaggregations} - onChange={handleChange} - value={selectedDisaggregations} - label={i18n.t("CatCombos")} - /> - </FiltersContainer> - </StepAnalysis> - </Container> + <StepAnalysis + id={analysis.id} + section={section} + reload={reload} + title={title} + onRun={runAnalysis} + > + <SelectMultiCheckboxes + options={disaggregations} + onChange={handleChange} + value={selectedDisaggregations} + label={i18n.t("CatCombos")} + /> + </StepAnalysis> ); }); - -const Container = styled.section``; - -const FiltersContainer = styled.div` - display: flex; - align-items: center; - gap: 1rem; -`; diff --git a/src/webapp/pages/analysis/steps/7-nursingMidwifery/useNursingMidwiferyStep.tsx b/src/webapp/pages/analysis/steps/7-nursingMidwifery/useNursingMidwiferyStep.tsx index b18c28a..36f035c 100644 --- a/src/webapp/pages/analysis/steps/7-nursingMidwifery/useNursingMidwiferyStep.tsx +++ b/src/webapp/pages/analysis/steps/7-nursingMidwifery/useNursingMidwiferyStep.tsx @@ -2,29 +2,25 @@ import React from "react"; import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; import i18n from "$/utils/i18n"; -import { useAnalysisById } from "$/webapp/hooks/useAnalysis"; import { Id } from "$/domain/entities/Ref"; import { useAppContext } from "$/webapp/contexts/app-context"; -import { missingNursing } from "../../steps"; +import { QualityAnalysis } from "$/domain/entities/QualityAnalysis"; +import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; +import { UpdateAnalysisState } from "../../AnalysisPage"; export function useNursingMidwiferyStep(props: UseNursingMidwiferyStepProps) { - const { analysisId } = props; + const { analysis, section, updateAnalysis } = props; const { compositionRoot } = useAppContext(); const snackbar = useSnackbar(); const loading = useLoading(); const [reload, refreshReload] = React.useState(0); - const { analysis, setAnalysis } = useAnalysisById({ id: analysisId }); const [disaggregations, setDisaggregations] = React.useState<{ value: Id; text: string }[]>([]); const [selectedDisaggregations, setSelectedDissagregations] = React.useState<string[]>([]); React.useEffect(() => { - if (!analysis) return; - const section = analysis?.sections.find(section => section.name === missingNursing); - if (!section) return; - loading.show(true, i18n.t("Loading")); - compositionRoot.nursingMidwifery.getDisaggregations.execute(section.name).run( + compositionRoot.nursingMidwifery.getDisaggregations.execute(section.id).run( result => { const selectedDisaggregations = result.map(item => ({ value: item.id, @@ -39,27 +35,24 @@ export function useNursingMidwiferyStep(props: UseNursingMidwiferyStepProps) { snackbar.error(error.message); } ); - }, [analysis, compositionRoot.nursingMidwifery.getDisaggregations, loading, snackbar]); + }, [section.id, compositionRoot.nursingMidwifery.getDisaggregations, loading, snackbar]); const handleChange = (values: string[]) => { setSelectedDissagregations(values); }; const runAnalysis = React.useCallback(() => { - if (!analysis) return false; - const section = analysis?.sections.find(section => section.name === missingNursing); - if (!section) return false; loading.show(true, i18n.t("Running analysis...")); compositionRoot.nursingMidwifery.validate .execute({ analysisId: analysis.id, disaggregationsIds: selectedDisaggregations, - sectionId: section.name, + sectionId: section.id, }) .run( analysis => { refreshReload(reload + 1); - setAnalysis(analysis); + updateAnalysis(analysis); loading.hide(); }, err => { @@ -72,9 +65,10 @@ export function useNursingMidwiferyStep(props: UseNursingMidwiferyStepProps) { compositionRoot.nursingMidwifery.validate, loading, reload, - setAnalysis, + updateAnalysis, snackbar, selectedDisaggregations, + section.id, ]); return { @@ -87,4 +81,8 @@ export function useNursingMidwiferyStep(props: UseNursingMidwiferyStepProps) { }; } -type UseNursingMidwiferyStepProps = { analysisId: Id }; +type UseNursingMidwiferyStepProps = { + analysis: QualityAnalysis; + section: QualityAnalysisSection; + updateAnalysis: UpdateAnalysisState; +}; diff --git a/src/webapp/pages/analysis/steps/StepAnalysis.tsx b/src/webapp/pages/analysis/steps/StepAnalysis.tsx index 15e4126..85e5039 100644 --- a/src/webapp/pages/analysis/steps/StepAnalysis.tsx +++ b/src/webapp/pages/analysis/steps/StepAnalysis.tsx @@ -7,21 +7,25 @@ import i18n from "$/utils/i18n"; import { Id } from "$/domain/entities/Ref"; import { EmptyState } from "$/webapp/components/empty-state/EmptyState"; import { useGetRows, useTableConfig } from "$/webapp/components/issues/IssueTable"; -import { GetIssuesOptions } from "$/domain/repositories/IssueRepository"; -import { initialFilters } from "$/webapp/utils/issues"; import { QualityAnalysisSection } from "$/domain/entities/QualityAnalysisSection"; +import { IssueFilters } from "$/webapp/components/issues/IssueFilters"; +import { initialFilters } from "$/webapp/utils/issues"; export const StepAnalysis: React.FC<StepContainerProps> = React.memo(props => { const { children, id, onRun, reload, section, title } = props; - const [filters, _] = React.useState<GetIssuesOptions["filters"]>(initialFilters); + const [filters, setFilters] = React.useState(initialFilters); - const { tableConfig } = useTableConfig(); - const { getRows, loading } = useGetRows(filters, reload, id, section.id || ""); + const { tableConfig, refresh } = useTableConfig(); + const { getRows, loading } = useGetRows(filters, reload, id, section.id, refresh); const config = useObjectsTable(tableConfig, getRows); const isPending = section.status === "pending"; + const filterComponents = React.useMemo(() => { + return <IssueFilters initialFilters={filters} onChange={setFilters} />; + }, [filters]); + return ( <Container> <> @@ -49,7 +53,7 @@ export const StepAnalysis: React.FC<StepContainerProps> = React.memo(props => { )} </> {section.status === "success_with_issues" && ( - <ObjectsTable loading={loading} {...config} /> + <ObjectsTable loading={loading} {...config} filterComponents={filterComponents} /> )} </Container> ); diff --git a/src/webapp/pages/app/App.css b/src/webapp/pages/app/App.css index cfdb46c..20a7151 100644 --- a/src/webapp/pages/app/App.css +++ b/src/webapp/pages/app/App.css @@ -16,6 +16,12 @@ li { min-height: 40px !important; } +.config-form-selector > label { + color: #494949; + font-size: 1em; + top: 0 !important; +} + .config-form-selector.disabled > div { cursor: none; opacity: 0.7; diff --git a/src/webapp/utils/issues.ts b/src/webapp/utils/issues.ts index b36db67..03a8c3d 100644 --- a/src/webapp/utils/issues.ts +++ b/src/webapp/utils/issues.ts @@ -1,11 +1,13 @@ import { GetIssuesOptions } from "$/domain/repositories/IssueRepository"; export const initialFilters: GetIssuesOptions["filters"] = { - endDate: undefined, + actions: [], + countries: [], + periods: [], name: undefined, - startDate: undefined, - status: undefined, + status: [], analysisIds: undefined, sectionId: "", id: undefined, + followUp: undefined, };