diff --git a/i18n/en.pot b/i18n/en.pot index 0a68ef12a..37696a306 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-04-20T04:35:46.003Z\n" -"PO-Revision-Date: 2026-04-20T04:35:46.003Z\n" +"POT-Creation-Date: 2026-04-23T07:13:29.327Z\n" +"PO-Revision-Date: 2026-04-23T07:13:29.328Z\n" msgid "" "THIS NEW RELEASE INCLUDES SHARING SETTINGS PER INSTANCES. FOR THIS VERSION " diff --git a/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/AnalyticsPanel.tsx b/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/AnalyticsPanel.tsx new file mode 100644 index 000000000..649c8cd47 --- /dev/null +++ b/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/AnalyticsPanel.tsx @@ -0,0 +1,134 @@ +import { TextField } from "@material-ui/core"; +import React, { ChangeEvent, useMemo } from "react"; +import styled from "styled-components"; +import i18n from "../../../../../utils/i18n"; +import { + AnalyticsOptions, + AnalyticsPanelKind, + defaultAnalyticsOptions, + RunAnalyticsSettings, +} from "../../../../webapp/msf-aggregate-data/pages/MSFEntities"; +import Dropdown from "../../../core/components/dropdown/Dropdown"; +import { Toggle } from "../../../core/components/toggle/Toggle"; + +export interface AnalyticsPanelProps { + title: string; + kind: AnalyticsPanelKind; + runSetting: RunAnalyticsSettings; + onRunSettingChange(value: RunAnalyticsSettings): void; + options: AnalyticsOptions | undefined; + onOptionsChange(options: AnalyticsOptions): void; +} + +type SkipFlag = keyof Omit; + +type FlagDef = { key: SkipFlag; label: string }; + +const individualFlags = (): FlagDef[] => [ + { key: "skipResourceTables", label: i18n.t("Skip generation of resource tables") }, + { key: "skipEvents", label: i18n.t("Skip generation of event data") }, + { key: "skipEnrollment", label: i18n.t("Skip generation of enrollment data") }, + { key: "skipOrgUnitOwnership", label: i18n.t("Skip generation of organisation unit ownership data") }, + { key: "skipTrackedEntities", label: i18n.t("Skip generation of tracked entity data") }, +]; + +const aggregateFlags = (): FlagDef[] => [ + { key: "skipResourceTables", label: i18n.t("Skip generation of resource tables") }, + { key: "skipAggregate", label: i18n.t("Skip generation of aggregate data and completeness data") }, + { key: "skipOutliers", label: i18n.t("Skip generation of outlier data") }, +]; + +export const AnalyticsPanel: React.FC = ({ + title, + kind, + runSetting, + onRunSettingChange, + options, + onOptionsChange, +}) => { + const runSettingItems = useMemo( + () => [ + { id: "true" as const, name: i18n.t("True") }, + { id: "false" as const, name: i18n.t("False") }, + { id: "by-sync-rule-settings" as const, name: i18n.t("Use sync rule settings") }, + ], + [] + ); + + const flags = useMemo(() => (kind === "individual" ? individualFlags() : aggregateFlags()), [kind]); + + const effectiveOptions = options ?? defaultAnalyticsOptions; + const showOptions = runSetting === "true"; + + const setLastYears = (event: ChangeEvent) => { + const lastYears = parseInt(event.target.value); + onOptionsChange({ ...effectiveOptions, lastYears }); + }; + + const setFlag = (key: SkipFlag) => (value: boolean) => { + onOptionsChange({ ...effectiveOptions, [key]: value }); + }; + + return ( + + {title} + + + label={i18n.t("Run Analytics")} + items={runSettingItems} + onValueChange={onRunSettingChange} + value={runSetting} + hideEmpty + /> + + {showOptions && ( + + + {flags.map(({ key, label }) => ( + + ))} + + + + + )} + + ); +}; + +const Panel = styled.div` + flex: 1; + min-width: 320px; + padding: 8px 16px 16px; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; +`; + +const PanelTitle = styled.h4` + margin-top: 0; +`; + +const Options = styled.div` + margin-top: 16px; +`; + +const Flags = styled.div` + display: flex; + flex-direction: column; + margin-bottom: 16px; +`; + +const YearsField = styled(TextField)` + width: 300px; +`; diff --git a/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx b/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx index 63012b1a1..6a91eff4c 100644 --- a/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx +++ b/src/presentation/react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog.tsx @@ -1,12 +1,17 @@ -import { Divider, makeStyles, TextField, Theme } from "@material-ui/core"; +import { Divider } from "@material-ui/core"; import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; import { Dictionary } from "lodash"; -import React, { ChangeEvent, useMemo, useState } from "react"; +import React, { useState } from "react"; +import styled from "styled-components"; import i18n from "../../../../../utils/i18n"; -import { MSFSettings, RunAnalyticsSettings } from "../../../../webapp/msf-aggregate-data/pages/MSFEntities"; -import Dropdown from "../../../core/components/dropdown/Dropdown"; +import { + AnalyticsOptions, + MSFSettings, + RunAnalyticsSettings, +} from "../../../../webapp/msf-aggregate-data/pages/MSFEntities"; import { Toggle } from "../../../core/components/toggle/Toggle"; import { NamedDate, OrgUnitDateSelector } from "../org-unit-date-selector/OrgUnitDateSelector"; +import { AnalyticsPanel } from "./AnalyticsPanel"; export interface MSFSettingsDialogProps { settings: MSFSettings; @@ -15,27 +20,8 @@ export interface MSFSettingsDialogProps { } export const MSFSettingsDialog: React.FC = ({ onClose, onSave, settings: defaultSettings }) => { - const classes = useStyles(); - const [settings, updateSettings] = useState(defaultSettings); - const analyticsSettingItems = useMemo(() => { - return [ - { - id: "true" as const, - name: i18n.t("True"), - }, - { - id: "false" as const, - name: i18n.t("False"), - }, - { - id: "by-sync-rule-settings" as const, - name: i18n.t("Use sync rule settings"), - }, - ]; - }, []); - const setRunAnalyticsBefore = (runAnalyticsBefore: RunAnalyticsSettings) => { updateSettings(settings => ({ ...settings, runAnalyticsBefore })); }; @@ -44,9 +30,12 @@ export const MSFSettingsDialog: React.FC = ({ onClose, o updateSettings(settings => ({ ...settings, runAnalyticsAfter })); }; - const setAnalyticsYears = (event: ChangeEvent) => { - const analyticsYears = parseInt(event.target.value); - updateSettings(settings => ({ ...settings, analyticsYears })); + const setAnalyticsBefore = (analyticsBefore: AnalyticsOptions) => { + updateSettings(settings => ({ ...settings, analyticsBefore })); + }; + + const setAnalyticsAfter = (analyticsAfter: AnalyticsOptions) => { + updateSettings(settings => ({ ...settings, analyticsAfter })); }; const updateProjectMinimumDates = (projectStartDates: Dictionary) => { @@ -76,36 +65,31 @@ export const MSFSettingsDialog: React.FC = ({ onClose, o cancelText={i18n.t("Cancel")} saveText={i18n.t("Save")} > -
-

{i18n.t("Analytics")}

- -
- - label={i18n.t("Run Analytics Before")} - items={analyticsSettingItems} - onValueChange={setRunAnalyticsBefore} - value={settings.runAnalyticsBefore} - hideEmpty - /> - - label={i18n.t("Run Analytics After")} - items={analyticsSettingItems} - onValueChange={setRunAnalyticsAfter} - value={settings.runAnalyticsAfter} - hideEmpty +
+ {i18n.t("Analytics")} + + + - -
-
+ + -
-

{i18n.t("Data values settings")}

+
+ {i18n.t("Data values settings")}
= ({ onClose, o value={settings.checkInPreviousPeriods ?? false} />
-
+ - + -
-

{i18n.t("Project minimum dates")}

+
+ {i18n.t("Project minimum dates")}
= ({ onClose, o onChange={updateProjectMinimumDates} />
-
+ ); }; -const useStyles = makeStyles((theme: Theme) => ({ - selector: { - margin: theme.spacing(0, 0, 3, 0), - }, - yearsSelector: { - minWidth: 250, - marginTop: -8, - marginLeft: 15, - }, - info: { - margin: theme.spacing(0, 0, 2, 1), - fontSize: "0.8em", - }, - title: { - marginTop: 0, - }, - section: { - marginBottom: 20, - }, - divider: { - marginBottom: 20, - }, -})); +const Section = styled.div` + margin-bottom: 20px; +`; + +const SectionTitle = styled.h3` + margin-top: 0; +`; + +const Panels = styled.div` + display: flex; + gap: 16px; + flex-wrap: wrap; +`; + +const SpacedDivider = styled(Divider)` + margin-bottom: 20px; +`; diff --git a/src/presentation/webapp/msf-aggregate-data/pages/MSFEntities.tsx b/src/presentation/webapp/msf-aggregate-data/pages/MSFEntities.tsx index 7ce21597e..91e0f2fca 100644 --- a/src/presentation/webapp/msf-aggregate-data/pages/MSFEntities.tsx +++ b/src/presentation/webapp/msf-aggregate-data/pages/MSFEntities.tsx @@ -3,30 +3,73 @@ import { NamedDate } from "../../../react/msf-aggregate-data/components/org-unit export type RunAnalyticsSettings = "true" | "false" | "by-sync-rule-settings"; +export type AnalyticsOptions = { + lastYears: number; + skipAggregate?: boolean; + skipResourceTables?: boolean; + skipEvents?: boolean; + skipEnrollment?: boolean; + skipOrgUnitOwnership?: boolean; + skipTrackedEntities?: boolean; + skipOutliers?: boolean; +}; + +export type AnalyticsPanelKind = "individual" | "aggregate"; + +const analyticsFlagsByKind: Record)[]> = { + individual: ["skipResourceTables", "skipEvents", "skipEnrollment", "skipOrgUnitOwnership", "skipTrackedEntities"], + aggregate: ["skipResourceTables", "skipAggregate", "skipOutliers"], +}; + +export function toAnalyticsRequest(options: AnalyticsOptions, kind: AnalyticsPanelKind): AnalyticsOptions { + const request: AnalyticsOptions = { lastYears: options.lastYears }; + for (const key of analyticsFlagsByKind[kind]) { + if (options[key] !== undefined) request[key] = options[key]; + } + return request; +} + export type MSFSettings = { runAnalyticsBefore: RunAnalyticsSettings; runAnalyticsAfter: RunAnalyticsSettings; - analyticsYears: number; + analyticsBefore?: AnalyticsOptions; + analyticsAfter?: AnalyticsOptions; projectMinimumDates: Record; deleteDataValuesBeforeSync?: boolean; checkInPreviousPeriods?: boolean; lastExecutions: Record; }; -export type PersistedMSFSettings = Omit; - export type AdvancedSettings = { period?: ObjectWithPeriod; }; export const MSFStorageKey = "msf-storage"; +export const defaultAnalyticsOptions: AnalyticsOptions = { + lastYears: 2, +}; + export const defaultMSFSettings: MSFSettings = { runAnalyticsBefore: "by-sync-rule-settings", runAnalyticsAfter: "by-sync-rule-settings", - analyticsYears: 2, projectMinimumDates: {}, deleteDataValuesBeforeSync: false, checkInPreviousPeriods: false, lastExecutions: {}, }; + +export type StoredMSFSettings = Partial & { analyticsYears?: number }; + +export function buildMSFSettings(raw: StoredMSFSettings | undefined | null): MSFSettings { + const { analyticsYears, analyticsBefore, analyticsAfter, ...rest } = raw ?? {}; + const legacyPanel: AnalyticsOptions | undefined = + analyticsYears !== undefined ? { ...defaultAnalyticsOptions, lastYears: analyticsYears } : undefined; + + return { + ...defaultMSFSettings, + ...rest, + analyticsBefore: analyticsBefore ?? legacyPanel, + analyticsAfter: analyticsAfter ?? legacyPanel, + }; +} diff --git a/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx b/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx index 1c54ec16d..ec701c60f 100644 --- a/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx +++ b/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePage.tsx @@ -10,8 +10,15 @@ import PageHeader from "../../../react/core/components/page-header/PageHeader"; import { useAppContext } from "../../../react/core/contexts/AppContext"; import { AdvancedSettingsDialog } from "../../../react/msf-aggregate-data/components/advanced-settings-dialog/AdvancedSettingsDialog"; import { MSFSettingsDialog } from "../../../react/msf-aggregate-data/components/msf-settings-dialog/MSFSettingsDialog"; -import { AdvancedSettings, defaultMSFSettings, MSFSettings, MSFStorageKey, PersistedMSFSettings } from "./MSFEntities"; -import { executeAggregateData, isGlobalInstance } from "./MSFHomePagePresenter"; +import { + AdvancedSettings, + buildMSFSettings, + defaultMSFSettings, + MSFSettings, + MSFStorageKey, + StoredMSFSettings, +} from "./MSFEntities"; +import { executeAggregateData } from "./MSFHomePagePresenter"; export const MSFHomePage: React.FC = () => { const { api, compositionRoot } = useAppContext(); @@ -42,13 +49,8 @@ export const MSFHomePage: React.FC = () => { }, [api]); useEffect(() => { - compositionRoot.customData.get(MSFStorageKey).then(settings => { - setMsfSettings(oldSettings => ({ - ...oldSettings, - ...settings, - runAnalyticsBefore: isGlobalInstance() ? "false" : "by-sync-rule-settings", - runAnalyticsAfter: isGlobalInstance() ? "false" : "by-sync-rule-settings", - })); + compositionRoot.customData.get(MSFStorageKey).then(settings => { + setMsfSettings(buildMSFSettings(settings)); }); }, [compositionRoot]); @@ -83,11 +85,7 @@ export const MSFHomePage: React.FC = () => { const handleSaveMSFSettings = async (msfSettings: MSFSettings) => { setShowMSFSettingsDialog(false); setMsfSettings(msfSettings); - await compositionRoot.customData.save(MSFStorageKey, { - ...msfSettings, - runAnalyticsBefore: undefined, - runAnalyticsAfter: undefined, - }); + await compositionRoot.customData.save(MSFStorageKey, msfSettings); }; const snackbar = useSnackbar(); diff --git a/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePagePresenter.ts b/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePagePresenter.ts index a66ae78c8..6ba6a67d6 100644 --- a/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePagePresenter.ts +++ b/src/presentation/webapp/msf-aggregate-data/pages/MSFHomePagePresenter.ts @@ -16,7 +16,13 @@ import { promiseMap } from "../../../../utils/common"; import { formatDateLong } from "../../../../utils/date"; import { availablePeriods } from "../../../../utils/synchronization"; import { CompositionRoot } from "../../../CompositionRoot"; -import { AdvancedSettings, MSFSettings } from "./MSFEntities"; +import { + AdvancedSettings, + AnalyticsOptions, + defaultAnalyticsOptions, + MSFSettings, + toAnalyticsRequest, +} from "./MSFEntities"; import { NamedRef, Ref } from "../../../../domain/common/entities/Ref"; type LoggerFunction = (event: string, userType?: "user" | "admin") => void; @@ -92,7 +98,11 @@ export async function executeAggregateData( if (runAnalyticsBeforeIsRequired) { const localInstance = await compositionRoot.instances.getLocal(); - await runAnalytics(localInstance, addEventToProgress, msfSettings.analyticsYears); + const analyticsOptions = toAnalyticsRequest( + msfSettings.analyticsBefore ?? defaultAnalyticsOptions, + "individual" + ); + await runAnalytics(localInstance, addEventToProgress, analyticsOptions); } const reports = await promiseMap(rulesWithoutRunAnalylics, syncRule => @@ -113,8 +123,12 @@ export async function executeAggregateData( await promiseMap(targetInstances, async instanceId => { const instance = await compositionRoot.instances.getById(instanceId); + const analyticsOptions = toAnalyticsRequest( + msfSettings.analyticsAfter ?? defaultAnalyticsOptions, + "aggregate" + ); instance.match({ - success: async instance => await runAnalytics(instance, addEventToProgress, msfSettings.analyticsYears), + success: async instance => await runAnalytics(instance, addEventToProgress, analyticsOptions), error: () => { addEventToProgress( i18n.t(`An error has occurred retrieving the instance {{name}}`, { @@ -369,8 +383,8 @@ async function getSyncRules( .value(); } -async function runAnalytics(instance: Instance, addEventToProgress: LoggerFunction, lastYears: number) { - for await (const message of executeAnalytics(instance, { lastYears })) { +async function runAnalytics(instance: Instance, addEventToProgress: LoggerFunction, options: AnalyticsOptions) { + for await (const message of executeAnalytics(instance, options)) { addEventToProgress(message, "admin"); } diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 58be2080e..353824f85 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -10,7 +10,9 @@ export async function* executeAnalytics(instance: Instance, options?: AnalyticsO yield i18n.t("Running analytics for instance {{name}}", instance); const api = getD2APiFromInstance(instance); - const { response } = await api.analytics.run(options).getData(); + const { response } = await api + .post("/resourceTables/analytics", { ...options }) + .getData(); const endpoint = response.relativeNotifierEndpoint.replace("/api", ""); let done = false; @@ -33,10 +35,15 @@ export async function* executeAnalytics(instance: Instance, options?: AnalyticsO type AnalyticsMessage = { message: string; completed: boolean }; type AnalyticsResponse = AnalyticsMessage[] | null; +type RunAnalyticsTaskResponse = { response: { relativeNotifierEndpoint: string } }; + interface AnalyticsOptions { skipResourceTables?: boolean; skipAggregate?: boolean; skipEvents?: boolean; skipEnrollment?: boolean; + skipOrgUnitOwnership?: boolean; + skipTrackedEntities?: boolean; + skipOutliers?: boolean; lastYears?: number; }