From d1007bd9051c3b9613e3953270e81d9286aff1d1 Mon Sep 17 00:00:00 2001 From: brianbrix Date: Thu, 21 Aug 2025 22:42:11 +0300 Subject: [PATCH 01/20] AMP-31026: Output / outcome manager AMP-31027: List/View Existing Outcomes and Outputs AMP-31028:Add New Outcome AMP-31029:Add New Output --- .../components/modals/OutcomeModal.tsx | 112 ++++++ .../components/modals/OutputModal.tsx | 140 +++++++ .../components/table/Table.tsx | 9 + .../config/initialTranslations.json | 18 +- .../pages/OutcomeOutputManagementPage.tsx | 379 ++++++++++++++++++ .../pages/css/ModalZIndexFix.css | 7 + .../reampv2-app/src/routing/routes.js | 9 + .../manager/AmpOutcomeOutputEndpoints.java | 128 ++++++ .../indicator/manager/dto/AmpOutcomeDTO.java | 33 ++ .../indicator/manager/dto/AmpOutputDTO.java | 27 ++ .../service/AmpOutcomeOutputService.java | 155 +++++++ .../ampapi/endpoints/reports/ReportsUtil.java | 6 +- .../module/aim/dbentity/AmpOutcome.java | 24 ++ .../module/aim/dbentity/AmpOutput.java | 24 ++ .../module/aim/dbentity/AmpOutcome.hbm.xml | 19 + .../module/aim/dbentity/AmpOutput.hbm.xml | 19 + .../moduleConfig/aim/module-config.xml | 2 + 17 files changed, 1107 insertions(+), 4 deletions(-) create mode 100644 amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutcomeModal.tsx create mode 100644 amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutputModal.tsx create mode 100644 amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutcomeOutputManagementPage.tsx create mode 100644 amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/css/ModalZIndexFix.css create mode 100644 amp/src/main/java/org/digijava/kernel/ampapi/endpoints/indicator/manager/AmpOutcomeOutputEndpoints.java create mode 100644 amp/src/main/java/org/digijava/kernel/ampapi/endpoints/indicator/manager/dto/AmpOutcomeDTO.java create mode 100644 amp/src/main/java/org/digijava/kernel/ampapi/endpoints/indicator/manager/dto/AmpOutputDTO.java create mode 100644 amp/src/main/java/org/digijava/kernel/ampapi/endpoints/indicator/manager/service/AmpOutcomeOutputService.java create mode 100644 amp/src/main/java/org/digijava/module/aim/dbentity/AmpOutcome.java create mode 100644 amp/src/main/java/org/digijava/module/aim/dbentity/AmpOutput.java create mode 100644 amp/src/main/resources/org/digijava/module/aim/dbentity/AmpOutcome.hbm.xml create mode 100644 amp/src/main/resources/org/digijava/module/aim/dbentity/AmpOutput.hbm.xml diff --git a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutcomeModal.tsx b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutcomeModal.tsx new file mode 100644 index 00000000000..81eeefb3068 --- /dev/null +++ b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutcomeModal.tsx @@ -0,0 +1,112 @@ +import React, { useRef } from 'react'; +import { Modal, Button, Form, Row, Col } from 'react-bootstrap'; +import { Formik, FormikProps, Form as FormikForm, Field } from 'formik'; +import * as Yup from 'yup'; +import styles from './css/IndicatorModal.module.css'; + +interface AddNewOutcomeModalProps { + show: boolean; + setShow: (show: boolean) => void; + onSubmit?: (outcome: { name: string; description?: string }) => void; + initialName?: string; + initialDescription?: string; + translations?: Record; +} + +const OutcomeModal: React.FC = ({ show, setShow, onSubmit, initialName = '', initialDescription = '', translations = {} }) => { + const nodeRef = useRef(null); + const validationSchema = Yup.object().shape({ + name: Yup.string().required(translations['amp.outcomeoutput:errors-name-required'] || 'Name is required'), + description: Yup.string() + }); + + const initialValues = { + name: initialName, + description: initialDescription + }; + + const handleClose = () => setShow(false); + + return ( + + + {translations['amp.outcomeoutput:modal-title-outcome'] || 'Add New Outcome'} + + { + if (onSubmit) onSubmit(values); + setShow(false); + resetForm(); + }} + > + {(props: FormikProps<{ name: string; description?: string }>) => ( +
+ +
+ + + {translations['amp.outcomeoutput:outcome-name'] || 'Outcome Name'} + + + {props.errors.name} + + + + + + {translations['amp.outcomeoutput:outcome-description'] || 'Outcome Description'} + + + {props.errors.description} + + + +
+
+ + + + +
+ )} +
+
+ ); +}; + +export default OutcomeModal; diff --git a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutputModal.tsx b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutputModal.tsx new file mode 100644 index 00000000000..7d83e91f496 --- /dev/null +++ b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/modals/OutputModal.tsx @@ -0,0 +1,140 @@ +import React, { useRef } from 'react'; +import { Modal, Button, Form, Row, Col } from 'react-bootstrap'; +import { Formik, FormikProps } from 'formik'; +import * as Yup from 'yup'; +import Select from 'react-select'; +import styles from './css/IndicatorModal.module.css'; + +interface Outcome { + id: number; + name: string; +} + +interface AddNewOutputModalProps { + show: boolean; + setShow: (show: boolean) => void; + outcomes: Outcome[]; + onSubmit?: (output: { name: string; description?: string; outcomeIds: number[] }) => void; + initialName?: string; + initialDescription?: string; + initialOutcomes?: Outcome[]; + translations?: Record; + loading?: boolean; +} + +const OutputModal: React.FC = ({ show, setShow, outcomes, onSubmit, initialName = '', initialDescription = '', initialOutcomes = [], translations = {}, loading = false }) => { + const nodeRef = useRef(null); + const validationSchema = Yup.object().shape({ + name: Yup.string().required(translations['amp.outcomeoutput:errors-name-required'] || 'Name is required'), + description: Yup.string(), + outcomeIds: Yup.array().min(1, translations['amp.outcomeoutput:errors-linked-outcomes-required'] || 'Select at least one outcome') + }); + + const initialValues = { + name: initialName, + description: initialDescription, + outcomeIds: initialOutcomes.map(o => o.id) + }; + + const outcomeOptions = outcomes.map(o => ({ value: o.id, label: o.name })); + const handleClose = () => setShow(false); + + return ( + + + {translations['amp.outcomeoutput:modal-title-output'] || 'Add New Output'} + + { + if (onSubmit) onSubmit(values); + setShow(false); + resetForm(); + }} + > + {(props: FormikProps<{ name: string; description?: string; outcomeIds: number[] }>) => ( +
+ +
+ + + {translations['amp.outcomeoutput:output-name'] || 'Output Name'} + + + {props.errors.name} + + + + + + {translations['amp.outcomeoutput:output-description'] || 'Output Description'} + + + {props.errors.description} + + + + + + {translations['amp.outcomeoutput:linked-outcomes'] || 'Linked Outcomes'} + { - if (value) props.setFieldValue('ascending', value.value) - }} - defaultValue={{ - value: false, - label: translations["amp.indicatormanager:true"] - }} - /> - - {props.errors.ascending} - - - - - {translations["amp.indicatormanager:table-header-creation-date"]} - - - - - {filterBySector && ( + + +
+ {/* Core Indicator Information */} +
{translations["amp.indicatormanager:core-info"]}
+
- - {translations["amp.indicatormanager:sectors"]} - { - (sectors.length > 0) ? ( - - ) - } + + {translations["amp.indicatormanager:indicator-name"]} + + + {props.errors.name} + + + + {translations["amp.indicatormanager:indicator-code"]} + + + {props.errors.code} + - )} - - - - - {translations["amp.indicatormanager:indicators-category"]} - { - categories.length > 0 ? ( + + + {translations["amp.indicatormanager:indicator-description"]} + + + {props.errors.description} + + + + + + {translations["amp.indicatormanager:relevance-for-climate-change"]} + + + + + + Type + ({ value: outcome.id, label: outcome.name }))} + onChange={(selectedValue) => { + setSelectedOutcomeId(selectedValue?.value ?? null); + props.setFieldValue('outcomeId', selectedValue?.value); + }} + onBlur={props.handleBlur} + className={`basic-multi-select ${(props.errors.outcomeId && props.touched.outcomeId) && styles.text_is_invalid}`} + classNamePrefix="select" + value={allOutcomes.find(outcome => outcome.id === selectedOutcomeId) ? { value: selectedOutcomeId, label: allOutcomes.find(outcome => outcome.id === selectedOutcomeId)?.name } : null} + /> + + + {translations["amp.indicatormanager:output"]} { + if (selectedValue) { + handleProgramSchemeChange(selectedValue.value, props); + } + }} + isClearable + getOptionValue={(option) => option.value} + onBlur={props.handleBlur} + className={`basic-multi-select ${styles.input_field}`} + classNamePrefix="select" + /> + + {programFieldVisible && ( + + Program { + const selectedValues = values.map((value: any) => parseInt(value.value)) + props.setFieldValue('sectors', selectedValues); + }} + onBlur={props.handleBlur} + className={`basic-multi-select ${(props.errors.sectors && props.touched.sectors) && styles.text_is_invalid}`} + classNamePrefix="select" + /> + + +
+ {/* Data Definition and Sourcing */} +
{translations["amp.indicatormanager:data-definition-sourcing-info"] || "Data Definition and Sourcing"}
+
+ + + {translations["amp.indicatormanager:data"]} + + + + {translations["amp.indicatormanager:data-source"]} + + + + + + Disaggregation + { + props.setFieldValue('unitOfMeasure', selectedValue?.value); + }} + onBlur={props.handleBlur} + className={`basic-multi-select ${(props.errors.unitOfMeasure && props.touched.unitOfMeasure) && styles.text_is_invalid}`} + classNamePrefix="select" + value={unitOfMeasureOptions.find(opt => opt.value === props.values.unitOfMeasure) || null} + /> + + + + + Calculation Method + + + +
+ {/* Responsibility and Frequency */} +
Responsibility and Frequency
+
+ + + Responsible Organization(s) + { + props.setFieldValue('frequency', selectedValue?.value); + }} + onBlur={props.handleBlur} + className={`basic-multi-select ${(props.errors.frequency && props.touched.frequency) && styles.text_is_invalid}`} + classNamePrefix="select" + value={frequencyOptions.find(opt => opt.value === props.values.frequency) || null} + /> + + +
+ {/* Value Tracking */} +
Value Tracking
+
+ + +

{translations["amp.indicatormanager:base-values"]}

+
+ {/* Original Value and Date in one row */} - - {translations["amp.indicatormanager:program-scheme"]} - { - programSchemes.length > 0 ? ( - - ) - } + + {translations['amp.indicatormanager:original-value']} + + + + {props.errors.base?.originalValue} + + + + + {translations["amp.indicatormanager:original-value-date"]} + { + if (value) { + props.setFieldValue('base.originalValueDate', value); + } + }} + onClear={() => { + props.setFieldValue('base.originalValueDate', null); + }} + onBlur={props.handleBlur} + disabled={baseOriginalValueDateDisabled} + className={`${styles.input_field} ${(props.errors.base?.originalValueDate && props.touched.base?.originalValueDate) && styles.text_is_invalid}`}/> + + + {props.errors.base?.originalValueDate} + + {/* Revised Value and Date in one row */} + + + {translations["amp.indicatormanager:revised-value"]} + + + + {props.errors.base?.revisedValue} + + - {programFieldVisible && ( - - - {translations["amp.indicatormanager:programs"]} - { - programs.length > 0 ? ( - + + {translations['amp.indicatormanager:revised-value-date']} + { + if (value) { + props.setFieldValue('base.revisedValueDate', value); + } + }} + onClear={() => { + props.setFieldValue('base.revisedValueDate', null); + }} + onBlur={props.handleBlur} + name="base.revisedValueDate" + className={`${styles.input_field} ${(props.errors.base?.revisedValueDate && props.touched.base?.revisedValueDate) && styles.text_is_invalid}`} + /> + + + {props.errors.base?.revisedValueDate} + + + +
+ +

{translations["amp.indicatormanager:target-values"]}

+ {/* Original Value and Date in one row */} + + + {translations["amp.indicatormanager:target-value"]} + + + + {props.errors.target?.originalValue} + + + + {translations["amp.indicatormanager:target-value-date"]} + { + if (value) { + props.setFieldValue('target.originalValueDate', value); + } + }} + onClear={() => { + props.setFieldValue('target.originalValueDate', null); + }} + onBlur={props.handleBlur} + disabled={targetOriginalValueDateDisabled} + className={`${styles.input_field} ${(props.errors.target?.originalValueDate && props.touched.target?.originalValueDate) && styles.text_is_invalid}`} /> + + + {props.errors.target?.originalValueDate} + + + + {/* Revised Value and Date in one row */} + + + {translations["amp.indicatormanager:revised-value"]} + + + + {props.errors.target?.revisedValue} + + + + + {translations["amp.indicatormanager:revised-value-date"]} + { + if (value) { + props.setFieldValue('target.revisedValueDate', value); } - - + }} + onClear={() => { + props.setFieldValue('target.revisedValueDate', null); + }} + onBlur={props.handleBlur} + name="target.revisedValueDate" + className={`${styles.input_field} ${(props.errors.target?.revisedValueDate && props.touched.target?.revisedValueDate) && styles.text_is_invalid}`} + /> - )} - - )} - - - - -

{translations["amp.indicatormanager:base-values"]}

-
-
- - - - {translations['amp.indicatormanager:original-value']} - - - - {props.errors.base?.originalValue} - - - - - {translations["amp.indicatormanager:original-value-date"]} - { - if (value) { - props.setFieldValue('base.originalValueDate', value); - } - }} - onClear={() => { - props.setFieldValue('base.originalValueDate', null); - }} - onBlur={props.handleBlur} - disabled={baseOriginalValueDateDisabled} - className={`${styles.input_field} ${(props.errors.base?.originalValueDate && props.touched.base?.originalValueDate) && styles.text_is_invalid}`}/> - - - {props.errors.base?.originalValueDate} - - - - - - - {translations["amp.indicatormanager:revised-value"]} - - - - {props.errors.base?.revisedValue} - - - - - {translations['amp.indicatormanager:revised-value-date']} - { - if (value) { - props.setFieldValue('base.revisedValueDate', value); - } - }} - onClear={() => { - props.setFieldValue('base.revisedValueDate', null); - }} - onBlur={props.handleBlur} - name="base.revisedValueDate" - className={`${styles.input_field} ${(props.errors.base?.revisedValueDate && props.touched.base?.revisedValueDate) && styles.text_is_invalid}`} - /> - - - {props.errors.base?.revisedValueDate} - - - -
- - -

{translations["amp.indicatormanager:target-values"]}

- - - {translations["amp.indicatormanager:target-value"]} - - - - {props.errors.target?.originalValue} - - - - {translations["amp.indicatormanager:target-value-date"]} - { - if (value) { - props.setFieldValue('target.originalValueDate', value); - } - }} - onClear={() => { - props.setFieldValue('target.originalValueDate', null); - }} - onBlur={props.handleBlur} - disabled={targetOriginalValueDateDisabled} - className={`${styles.input_field} ${(props.errors.target?.originalValueDate && props.touched.target?.originalValueDate) && styles.text_is_invalid}`} /> - - - {props.errors.target?.originalValueDate} - - - - - - - {translations["amp.indicatormanager:revised-value"]} - - - - {props.errors.target?.revisedValue} - - - - - {translations["amp.indicatormanager:revised-value-date"]} - { - if (value) { - props.setFieldValue('target.revisedValueDate', value); - } - }} - onClear={() => { - props.setFieldValue('target.revisedValueDate', null); - }} - onBlur={props.handleBlur} - name="target.revisedValueDate" - className={`${styles.input_field} ${(props.errors.target?.revisedValueDate && props.touched.target?.revisedValueDate) && styles.text_is_invalid}`} - /> - - - {props.errors.target?.revisedValueDate} - - - -
-
- - - - - - - + + {props.errors.target?.revisedValueDate} + + + + + {/* Other Considerations */} +
Other Considerations
+
+ + + {translations["amp.indicatormanager:ascending"]} + { - props.setFieldValue("ascending", value?.value); - }} - defaultValue={{ value: props.values.ascending, label: props.values.ascending ? translations["amp.indicatormanager:true"] : translations["amp.indicatormanager:false"] }} - /> - - - - {translations["amp.indicatormanager:table-header-creation-date"]} - - - - - {filterBySector && ( - - - {translations["amp.indicatormanager:sectors"]} - { - (sectors.length > 0 && defaultSectors !== undefined)? ( - - ) - } - - - )} - - - - - {translations["amp.indicatormanager:indicators-category"]} - { - categories.length > 0 ? ( - - ) - } - - - - {filterByProgram && ( - <> - - - {translations["amp.indicatormanager:program-scheme"]} - { - programSchemes.length > 0 ? ( - - ) - } - - - - {programFieldVisible && ( - - - {translations["amp.indicatormanager:programs"]} - { - (programs.length > 0) ? ( - - } - - - - )} - - )} - - - + {/* Core Indicator Information */} +
{translations["amp.indicatormanager:core-info"]}
+
- -

{translations["amp.indicatormanager:base-values"]}

-
-
- - - - {translations['amp.indicatormanager:original-value']} + + {translations["amp.indicatormanager:indicator-name"]} - + name="name" + className={`${styles.input_field} ${(props.errors.name && props.touched.name) && styles.text_is_invalid}`} + isInvalid={!!props.errors.name} + required + aria-required type="text" + placeholder={translations["amp.indicatormanager:enter-indicator-name"]} + /> - {props.errors.base?.originalValue} + {props.errors.name} - - - {translations["amp.indicatormanager:original-value-date"]} - { - if (value) { - props.setFieldValue("base.originalValueDate", value); - } - }} - onClear={() => { - props.setFieldValue("base.originalValueDate", null); - }} + + {translations["amp.indicatormanager:indicator-code"]} + - - {props.errors.base?.originalValueDate} + {props.errors.code} - - - {translations["amp.indicatormanager:revised-value"]} + + {translations["amp.indicatormanager:indicator-description"]} - + name="description" + as="textarea" + rows={2} + className={`${styles.input_field} ${(props.errors.description && props.touched.description) && styles.text_is_invalid}`} + placeholder={translations["amp.indicatormanager:enter-indicator-description"]} + /> - {props.errors.base?.revisedValue} + {props.errors.description} - - - {translations['amp.indicatormanager:revised-value-date']} - { - if (value) { - props.setFieldValue("base.revisedValueDate", value); + + + + {translations["amp.indicatormanager:relevance-for-climate-change"]} + + + + + + Type + ({ value: outcome.id, label: outcome.name }))} + onChange={(selectedValue) => { + setSelectedOutcomeId(selectedValue?.value ?? null); + props.setFieldValue('outcomeId', selectedValue?.value); + }} + onBlur={props.handleBlur} + className={`basic-multi-select ${(props.errors.outcomeId && props.touched.outcomeId) && styles.text_is_invalid}`} + classNamePrefix="select" + value={allOutcomes.find(outcome => outcome.id === selectedOutcomeId) ? { value: selectedOutcomeId, label: allOutcomes.find(outcome => outcome.id === selectedOutcomeId)?.name } : null} + /> + + + {translations["amp.indicatormanager:output"]} + { + if (selectedValue) { + setDefaultProgramScheme(selectedValue); + handleProgramSchemeChange(selectedValue.value, props); } }} - onClear={() => { - props.setFieldValue("base.revisedValueDate", null); + isClearable + getOptionValue={(option) => option.value} + onBlur={props.handleBlur} + className={`basic-multi-select ${styles.input_field}`} + classNamePrefix="select" + value={defaultProgramScheme} + /> + + {programFieldVisible && ( + + Program + { + const selectedValues = values.map((value: any) => parseInt(value.value)) + setDefaultSectors(values as any); + props.setFieldValue('sectors', selectedValues); }} onBlur={props.handleBlur} - name="base.revisedValueDate" - className={`${styles.input_field} ${(props.errors.base?.revisedValueDate && props.touched.base?.revisedValueDate) && styles.text_is_invalid}`} - id="baseRevisedValueDate" - inputRef={baseRevisedValueDateRef} + className={`basic-multi-select ${(props.errors.sectors && props.touched.sectors) && styles.text_is_invalid}`} + classNamePrefix="select" + value={defaultSectors} /> - - - {props.errors.base?.revisedValueDate} - - - - -

{translations["amp.indicatormanager:target-values"]}

+
+ {/* Data Definition and Sourcing */} +
{translations["amp.indicatormanager:data-definition-sourcing-info"] || "Data Definition and Sourcing"}
+
- - {translations["amp.indicatormanager:target-value"]} + + {translations["amp.indicatormanager:data"]} - - - {props.errors.target?.originalValue} - + name="data" + type="text" + className={styles.input_field} + placeholder={translations["amp.indicatormanager:enter-data"]} + /> - - {translations["amp.indicatormanager:target-value-date"]} - { - if (value) { - props.setFieldValue("target.originalValueDate", value); - } + + {translations["amp.indicatormanager:data-source"]} + + + + + + Disaggregation + { + props.setFieldValue('unitOfMeasure', selectedValue?.value); }} - disabled={targetOriginalValueDateDisabled} onBlur={props.handleBlur} - name="target.originalValueDate" - className={`${styles.input_field} ${(props.errors.target?.originalValueDate && props.touched.target?.originalValueDate) && styles.text_is_invalid}`} - id="targetOriginalValueDate" - inputRef={targetOriginalValueDateRef} + className={`basic-multi-select ${(props.errors.unitOfMeasure && props.touched.unitOfMeasure) && styles.text_is_invalid}`} + classNamePrefix="select" + value={unitOfMeasureOptions.find(opt => opt.value === props.values.unitOfMeasure) || null} /> - - - {translations["amp.indicatormanager:revised-value"]} + + Calculation Method - - - {props.errors.target?.revisedValue} - + name="calculationMethod" + type="text" + className={styles.input_field} + placeholder="Describe how the indicator's value is calculated" + /> - - - {translations["amp.indicatormanager:revised-value-date"]} - { - if (value) { - props.setFieldValue("target.revisedValueDate", value); - } + +
+ {/* Responsibility and Frequency */} +
Responsibility and Frequency
+
+ + + Responsible Organization(s) + { + props.setFieldValue('frequency', selectedValue?.value); }} onBlur={props.handleBlur} - name="target.revisedValueDate" - className={`${styles.input_field} ${(props.errors.target?.revisedValueDate && props.touched.target?.revisedValueDate) && styles.text_is_invalid}`} - id="targetRevisedValueDate" - inputRef={targetRevisedValueDateRef} + className={`basic-multi-select ${(props.errors.frequency && props.touched.frequency) && styles.text_is_invalid}`} + classNamePrefix="select" + value={frequencyOptions.find(opt => opt.value === props.values.frequency) || null} + /> + + +
+ {/* Value Tracking */} +
Value Tracking
+
+ + +

{translations["amp.indicatormanager:base-values"]}

+
+ {/* Original Value and Date in one row */} + + + {translations['amp.indicatormanager:original-value']} + + + + {props.errors.base?.originalValue} + + + + + {translations["amp.indicatormanager:original-value-date"]} + { + if (value) { + props.setFieldValue("base.originalValueDate", value); + } + }} + onClear={() => { + props.setFieldValue("base.originalValueDate", null); + }} + onBlur={props.handleBlur} + name="base.originalValueDate" + disabled={baseOriginalValueDateDisabled} + className={`${styles.input_field} ${(props.errors.base?.originalValueDate && props.touched.base?.originalValueDate) && styles.text_is_invalid}`} + id="baseOriginalValueDate" + inputRef={baseOriginalValueDateRef} + /> + + + {props.errors.base?.originalValueDate} + + + + {/* Revised Value and Date in one row */} + + + {translations["amp.indicatormanager:revised-value"]} + + + + {props.errors.base?.revisedValue} + + + + + {translations['amp.indicatormanager:revised-value-date']} + { + if (value) { + props.setFieldValue("base.revisedValueDate", value); + } + }} + onClear={() => { + props.setFieldValue("base.revisedValueDate", null); + }} + onBlur={props.handleBlur} + name="base.revisedValueDate" + className={`${styles.input_field} ${(props.errors.base?.revisedValueDate && props.touched.base?.revisedValueDate) && styles.text_is_invalid}`} + id="baseRevisedValueDate" + inputRef={baseRevisedValueDateRef} + /> + + + {props.errors.base?.revisedValueDate} + + + +
+ +

{translations["amp.indicatormanager:target-values"]}

+ {/* Original Value and Date in one row */} + + + {translations["amp.indicatormanager:target-value"]} + + + + {props.errors.target?.originalValue} + + + + {translations["amp.indicatormanager:target-value-date"]} + { + if (value) { + props.setFieldValue("target.originalValueDate", value); + } + }} + onClear={() => { + props.setFieldValue("target.originalValueDate", null); + }} + disabled={targetOriginalValueDateDisabled} + onBlur={props.handleBlur} + name="target.originalValueDate" + className={`${styles.input_field} ${(props.errors.target?.originalValueDate && props.touched.target?.originalValueDate) && styles.text_is_invalid}`} + id="targetOriginalValueDate" + inputRef={targetOriginalValueDateRef} + /> + + + {/* Revised Value and Date in one row */} + + + {translations["amp.indicatormanager:revised-value"]} + + + + {props.errors.target?.revisedValue} + + + + + {translations["amp.indicatormanager:revised-value-date"]} + { + if (value) { + props.setFieldValue("target.revisedValueDate", value); + } + }} + onClear={() => { + props.setFieldValue("target.revisedValueDate", null); + }} + onBlur={props.handleBlur} + name="target.revisedValueDate" + className={`${styles.input_field} ${(props.errors.target?.revisedValueDate && props.touched.target?.revisedValueDate) && styles.text_is_invalid}`} + id="targetRevisedValueDate" + inputRef={targetRevisedValueDateRef} /> + + {props.errors.target?.revisedValueDate} + + + +
+
+ {/* Other Considerations */} +
Other Considerations
+
+ + + {translations["amp.indicatormanager:ascending"]} + { - setSelectedSector(item.value) - }} - components={{ - IndicatorSeparator: () => null, - }} - /> - ) : ( - <> - { - !sectorsReducer.loading && ( + + {filterBySector && ( + +
+ {translations['amp.indicatormanager:sectors']} + {sectorsReducer.sectors.length > 0 ? ( + null, - }} + components={{ IndicatorSeparator: () => null }} className={styles.filter_select} + classNamePrefix="react-select" /> - ) - - } - - ) - } -
- )} - - { - filterByProgram && ( -
- {translations['amp.indicatormanager:programs']} - { - programOptions.length > 0 ? ( - null, - }} - className={styles.filter_select} - /> - ) - - } - - )} + )} +
+ + )} + + {filterByProgram && ( + +
+ {translations['amp.indicatormanager:programs']} + {programOptions.length > 0 ? ( + null }} + className={styles.filter_select} + classNamePrefix="react-select" + /> + )} +
+ + )} + + +
+ {translations['amp.indicatormanager:outcome']} + setSelectedOutput(opt?.value || 0)} + className={styles.filter_select} + components={{ IndicatorSeparator: () => null }} + menuPlacement="auto" + classNamePrefix="react-select" + /> +
+ + + +
+ {translations['amp.indicatormanager:indicator-type']} + props.values.outcomeIds.includes(opt.value))} - onChange={selected => props.setFieldValue('outcomeIds', selected.map((opt: any) => opt.value))} - placeholder={translations['amp.outcomeoutput:linked-outcomes'] || 'Linked Outcomes'} - isDisabled={loading} + + {translations['amp.outcomeoutput:linked-outcome'] || 'Linked Outcome'} + - {props.errors.outcomeIds && props.touched.outcomeIds && ( -
{props.errors.outcomeIds}
- )}
diff --git a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/table/Table.tsx b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/table/Table.tsx index 296f0c18e64..e8ca2846223 100644 --- a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/table/Table.tsx +++ b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/components/table/Table.tsx @@ -301,7 +301,7 @@ const SkeletonTable: React.FC = (props) => { {translations['amp.dashboard:add-new']} + = (props) => {
+ {/* Center the search bar horizontally */} + + +
+ {translations['amp.indicatormanager:search']} +
+ + +
+
+ +
+
{translations['amp.indicatormanager:filters'] || 'Filters'}
- +
+ {/* Other filters below */} {filterBySector && (
@@ -425,20 +453,6 @@ const SkeletonTable: React.FC = (props) => { />
- - -
- {translations['amp.indicatormanager:search']} -
- - -
-
-
diff --git a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/config/initialTranslations.json b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/config/initialTranslations.json index 181b514a220..396f17c44c5 100644 --- a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/config/initialTranslations.json +++ b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/config/initialTranslations.json @@ -192,5 +192,19 @@ "amp.outcomeoutput:cannot-delete-output": "Cannot delete output.", "amp.outcomeoutput:unexpected-error": "An unexpected error occurred.", "amp.outcomeoutput:output-management": "Output Management", - "amp.outcomeoutput:back": "Back" + "amp.outcomeoutput:back": "Back", + "amp.disaggregationmanager:title": "Disaggregation Manager", + "amp.disaggregationmanager:category": "Disaggregation Category", + "amp.disaggregationmanager:options": "Options", + "amp.disaggregationmanager:actions": "Actions", + "amp.disaggregationmanager:edit": "Edit", + "amp.disaggregationmanager:delete": "Delete", + "amp.disaggregationmanager:no-options": "No options", + "amp.disaggregationmanager:add-option": "Add Option", + "amp.disaggregationmanager:edit-option-title": "Edit Option", + "amp.disaggregationmanager:option-value": "Option Value", + "amp.disaggregationmanager:option-value-placeholder": "Enter option value", + "amp.disaggregationmanager:cancel": "Cancel", + "amp.disaggregationmanager:save": "Save", + "amp.dashboard:disaggregation-management": "Disaggregation Management" } diff --git a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/DisaggregationManagerPage.tsx b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/DisaggregationManagerPage.tsx new file mode 100644 index 00000000000..f8704051d53 --- /dev/null +++ b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/DisaggregationManagerPage.tsx @@ -0,0 +1,205 @@ +import React, {useEffect, useRef, useState} from 'react'; +import { Button, Table, Modal, Form } from 'react-bootstrap'; +import { useSelector, useDispatch } from 'react-redux'; +import {getAmpCategories} from "../reducers/fetchAmpCategoryReducer"; +import initialTranslations from "../config/initialTranslations.json"; +import axios from 'axios'; +import {useNavigate} from "react-router-dom"; +import styles from "../components/modals/css/IndicatorModal.module.css"; + +interface CategoryValue { + id: number; + value: string; + children?: CategoryValue[]; +} +const translations = initialTranslations; + + +const DisaggregationManagerPage: React.FC = () => { + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(null); + const [showModal, setShowModal] = useState(false); + const [modalMode, setModalMode] = useState<'add' | 'edit'>('add'); + const [editingChild, setEditingChild] = useState(null); + const [optionsMap, setOptionsMap] = useState<{ [key: number]: any[] }>({}); + const dispatch = useDispatch(); + + const categoriesReducer = useSelector((state: any) => state.fetchAmpCategoryReducer); + + useEffect(() => { + dispatch(getAmpCategories()); + }, [dispatch]); + + useEffect(() => { + if (categoriesReducer && categoriesReducer.categories) { + const disaggregationCategories = categoriesReducer.categories.filter((cat: any) => cat.ampCategoryClass.keyName === 'indicator_disaggregation'); + setCategories(disaggregationCategories); + } + }, [categoriesReducer]); + const navigate = useNavigate() + + useEffect(() => { + async function fetchOptions() { + const map: { [key: number]: any[] } = {}; + for (const category of categories) { + try { + const res = await axios.get(`/rest/indicator_disaggregation/options/${category.id}`); + map[category.id] = res.data; + } catch { + map[category.id] = []; + } + } + setOptionsMap(map); + } + if (categories.length > 0) fetchOptions(); + }, [categories]); + + const handleAddChild = (category: CategoryValue) => { + setSelectedCategory(category); + setModalMode('add'); + setShowModal(true); + }; + + const handleEditChild = (category: CategoryValue, child: CategoryValue) => { + setSelectedCategory(category); + setEditingChild(child); + setModalMode('edit'); + setShowModal(true); + }; + + const refreshCategories = () => { + dispatch(getAmpCategories()); + }; + + const handleDeleteChild = async (category: CategoryValue, child: CategoryValue) => { + await fetch(`/rest/indicator_disaggregation/options/${child.id}`, { method: 'DELETE' }); + refreshCategories(); + }; + + const handleClose = () => setShowModal(false); + const nodeRef = useRef(null); + return ( +
+
+

{translations['amp.disaggregationmanager:title'] || 'Disaggregation Manager'}

+ +
+ + + + + + + + + + {categories.map(category => ( + + + + + + ))} + +
{translations['amp.disaggregationmanager:category'] || 'Disaggregation Category'}{translations['amp.disaggregationmanager:options'] || 'Options'}{translations['amp.disaggregationmanager:actions'] || 'Actions'}
{category.value} +
    + {optionsMap[category.id] && optionsMap[category.id].length > 0 ? optionsMap[category.id].map(child => ( +
  • + {child.value} + + +
  • + )) : {translations['amp.disaggregationmanager:no-options'] || 'No options'}} +
+
+ +
+ + + {modalMode === 'add' ? (translations['amp.disaggregationmanager:add-option'] || 'Add Option' + ':' + selectedCategory?.value) : (translations['amp.disaggregationmanager:edit-option-title'] || 'Edit Option' + ':' + selectedCategory?.value)} + +
{ + e.preventDefault(); + const form = e.currentTarget; + const formData = new FormData(form); + const childValue = formData.get('childValue') as string; + + if (!selectedCategory) return; + + if (modalMode === 'add') { + await fetch(`/rest/indicator_disaggregation/options/${selectedCategory.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: childValue }) + }); + } else if (modalMode === 'edit' && editingChild) { + await fetch(`/rest/indicator_disaggregation/options/${editingChild.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: childValue }) + }); + } + setShowModal(false); + setEditingChild(null); + refreshCategories(); + }} + > + + + + {translations['amp.disaggregationmanager:option-value'] || 'Option Value'} + + + + + + + + +
+
+
+ ); +}; + +export default DisaggregationManagerPage; diff --git a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutcomeOutputManagementPage.tsx b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutcomeOutputManagementPage.tsx index 981eb43c88b..de4375b215e 100644 --- a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutcomeOutputManagementPage.tsx +++ b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutcomeOutputManagementPage.tsx @@ -4,6 +4,7 @@ import BootstrapTable, { PaginationOptions } from '@musicstory/react-bootstrap-t import '@musicstory/react-bootstrap-table2-filter/dist/react-bootstrap-table2-filter.min.css'; import styles from '../components/table/Table.module.css'; import OutcomeModal from '../components/modals/OutcomeModal'; +import OutputModal from '../components/modals/OutputModal'; import action_style from '../components/table/IndicatorTable.module.css'; import ToolkitProvider, { Search, CSVExport, ToolkitContextType } from '@murasoftware/react-bootstrap-table2-toolkit'; import paginationFactory from '@musicstory/react-bootstrap-table2-paginator'; @@ -32,13 +33,19 @@ const translations = initialTranslations; const OutcomeOutputManagementPage: React.FC = () => { const [showAddNewOutcomeModal, setShowAddNewOutcomeModal] = useState(false); const [showEditOutcomeModal, setShowEditOutcomeModal] = useState(false); + const [showOutputModal, setShowOutputModal] = useState(false); const [editingOutcome, setEditingOutcome] = useState(null); + const [selectedOutcome, setSelectedOutcome] = useState(null); + const [editingOutput, setEditingOutput] = useState(null); const outcomes = useSelector((state: any) => state.fetchOutcomesReducer).outcomes; const dispatch = useDispatch(); const navigate = useNavigate(); - + // Fetch outcomes on initial mount + useEffect(() => { + dispatch(getOutcomes()); + }, [dispatch]); const columns = [ { @@ -170,11 +177,168 @@ const OutcomeOutputManagementPage: React.FC = () => { } }; + const handleAddOutput = (outcomeId: number) => { + const outcome = outcomes.find((o: Outcome) => o.id === outcomeId); + setSelectedOutcome(outcome || null); + setEditingOutput(null); + setShowOutputModal(true); + }; + + const handleEditOutput = (output: Output, outcome: Outcome) => { + setSelectedOutcome(outcome); + setEditingOutput(output); + setShowOutputModal(true); + }; + + const handleSaveOutput = async (outputData: { name: string; description?: string }) => { + if (editingOutput && selectedOutcome) { + // Edit existing output + try { + const res = await fetch(`/rest/amp-outcome-output/output/${editingOutput.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...outputData, + outcomeId: selectedOutcome.id + }) + }); + if (res.ok) { + dispatch(getOutcomes()); + } else { + alert('Failed to update output'); + } + } catch (e) { + console.error('Error updating output', e); + alert('Error updating output'); + } + } else if (selectedOutcome) { + // Add new output + try { + const res = await fetch('/rest/amp-outcome-output/output', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...outputData, + outcomeId: selectedOutcome.id + }) + }); + if (res.ok) { + dispatch(getOutcomes()); + } else { + alert('Failed to add output'); + } + } catch (e) { + console.error('Error adding output', e); + alert('Error adding output'); + } + } + setShowOutputModal(false); + setEditingOutput(null); + setSelectedOutcome(null); + }; + + const handleDeleteOutput = async (output: Output) => { + const confirm = await Swal.fire({ + icon: 'warning', + title: translations['amp.outcomeoutput:delete-output'], + html: `
${translations['amp.outcomeoutput:delete-output-confirm']} ${output.name}?
${translations['amp.outcomeoutput:delete-output-warning']}
`, + showCancelButton: true, + confirmButtonText: translations['amp.outcomeoutput:delete'], + cancelButtonText: translations['amp.outcomeoutput:cancel'], + }); + if (confirm.isConfirmed) { + try { + const res = await fetch(`/rest/amp-outcome-output/output/delete/${output.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' } + }); + if (res.ok) { + dispatch(getOutcomes()) + } else { + const error = await res.json(); + let errorMsg = translations['amp.outcomeoutput:error-deleting-output']; + if (error && error.error) { + const firstKey = Object.keys(error.error)[0]; + if (firstKey && error.error[firstKey] && error.error[firstKey][0]) { + errorMsg = error.error[firstKey][0]; + } + } + if (errorMsg.includes('orphan')) { + const forceConfirm = await Swal.fire({ + icon: 'warning', + title: translations['amp.outcomeoutput:indicators-linked'], + html: `${errorMsg}

${translations['amp.outcomeoutput:proceed-orphan-indicators']}`, + showCancelButton: true, + confirmButtonText: translations['amp.outcomeoutput:yes-delete-anyway'], + cancelButtonText: translations['amp.outcomeoutput:cancel'], + }); + if (forceConfirm.isConfirmed) { + try { + const forceRes = await fetch(`/rest/amp-outcome-output/output/delete/${output.id}?forceDelete=true`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' } + }); + if (forceRes.ok) { + dispatch(getOutcomes()) + } else { + const forceError = await forceRes.json(); + let forceErrorMsg = translations['amp.outcomeoutput:error-deleting-output']; + if (forceError && forceError.error) { + const firstKey = Object.keys(forceError.error)[0]; + if (firstKey && forceError.error[firstKey] && forceError.error[firstKey][0]) { + forceErrorMsg = forceError.error[firstKey][0]; + } + } + await Swal.fire({ + icon: 'error', + title: translations['amp.outcomeoutput:cannot-delete-output'], + html: forceErrorMsg + }); + } + } catch (e) { + await Swal.fire({ + icon: 'error', + title: translations['amp.outcomeoutput:error-deleting-output'], + text: translations['amp.outcomeoutput:unexpected-error'] + }); + } + } + } else { + await Swal.fire({ + icon: 'error', + title: translations['amp.outcomeoutput:cannot-delete-output'], + html: errorMsg + }); + } + } + } catch (e) { + await Swal.fire({ + icon: 'error', + title: translations['amp.outcomeoutput:error-deleting-output'], + text: translations['amp.outcomeoutput:unexpected-error'] + }); + } + } + }; + const expandRow = { renderer: (row: Outcome) => (
{row.name} +
{row.description && (
@@ -182,20 +346,42 @@ const OutcomeOutputManagementPage: React.FC = () => {
)} Outputs: -
    - {row.outputs && row.outputs.length > 0 ? row.outputs.map((output: Output) => ( -
  • -
    - {output.name} -
    - {output.description && ( -
    - {output.description} -
    - )} -
  • - )) :
  • No outputs
  • } -
+ + + + + + + + + + {row.outputs && row.outputs.length > 0 ? row.outputs.map((output: Output) => ( + + + + + + )) : } + +
NameDescriptionActions
{output.name}{output.description || ''} + + +
No outputs
), showExpandColumn: true, @@ -234,14 +420,21 @@ const OutcomeOutputManagementPage: React.FC = () => { initialDescription={editingOutcome?.description || ''} translations={translations} /> +

Outcome Management

- -
{
- diff --git a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutputManagementPage.tsx b/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutputManagementPage.tsx deleted file mode 100644 index ca33d7eeb2e..00000000000 --- a/amp/TEMPLATE/reampv2/packages/reampv2-app/src/modules/admin/indicator_manager/pages/OutputManagementPage.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Col, Row, Button } from 'react-bootstrap'; -import BootstrapTable, {PaginationOptions} from '@musicstory/react-bootstrap-table-next'; -import paginationFactory from '@musicstory/react-bootstrap-table2-paginator'; -import ToolkitProvider, { Search, CSVExport, ToolkitContextType } from '@murasoftware/react-bootstrap-table2-toolkit'; -import '@musicstory/react-bootstrap-table2-filter/dist/react-bootstrap-table2-filter.min.css'; -import styles from '../components/table/Table.module.css'; -import action_style from '../components/table/IndicatorTable.module.css'; -import OutputModal from '../components/modals/OutputModal'; -import Swal from 'sweetalert2'; -import { useNavigate } from "react-router-dom"; -import initialTranslations from '../config/initialTranslations.json'; -import {useDispatch, useSelector} from "react-redux"; -import {getOutputs} from "../reducers/fetchOutputsReducer"; - -interface Outcome { - // Define the properties of Outcome based on your API response - id: number; - name: string; -} - -interface Output { - id: number; - name: string; - description?: string; - outcomes?: Outcome[]; -} - -const OutputManagementPage: React.FC = () => { - const navigate = useNavigate(); - const [showAddNewOutputModal, setShowAddNewOutputModal] = useState(false); - const [showEditOutputModal, setShowEditOutputModal] = useState(false); - const [editingOutput, setEditingOutput] = useState(null); - const [loadingEditOutput, setLoadingEditOutput] = useState(false); - const outputs = useSelector((state: any) => state.fetchOutputsReducer).outputs; - const outcomes = useSelector((state: any) => state.fetchOutcomesReducer).outcomes; - const dispatch = useDispatch(); - - const translations = initialTranslations; - - const { SearchBar } = Search; - const { ExportCSVButton } = CSVExport; - - const outputColumns = [ - { - dataField: 'name', - text: translations['amp.outcomeoutput:output-name'], - }, - { - dataField: 'actions', - text: translations['amp.outcomeoutput:actions'], - formatter: (_: any, row: Output) => ( - <> -
-