diff --git a/frontend/src/App.scss b/frontend/src/App.scss index 91d6b6c1..3bbe0119 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -409,6 +409,7 @@ } .button-export { + margin-left: 5px; border: 1px solid #01625f; font-weight: 600; font-size: 12px; diff --git a/frontend/src/pages/cases/components/ScenarioModelingForm.js b/frontend/src/pages/cases/components/ScenarioModelingForm.js index a7637b5e..11d1e7ff 100644 --- a/frontend/src/pages/cases/components/ScenarioModelingForm.js +++ b/frontend/src/pages/cases/components/ScenarioModelingForm.js @@ -1,17 +1,31 @@ import React, { useMemo, useState } from "react"; import { Row, Col, Form, Input, Select, InputNumber, TreeSelect } from "antd"; -import { CaseUIState, CaseVisualState } from "../store"; -import { selectProps, InputNumberThousandFormatter } from "../../../lib"; +import { CaseUIState, CaseVisualState, CurrentCaseState } from "../store"; +import { + selectProps, + InputNumberThousandFormatter, + flatten, + getFunctionDefaultValue, +} from "../../../lib"; import { VisualCardWrapper } from "./"; import { SegmentTabsWrapper } from "../layout"; import { thousandFormatter } from "../../../components/chart/options/common"; import { isEmpty } from "lodash"; +import { customFormula } from "../../../lib/formula"; const MAX_VARIABLES = [0, 1, 2, 3, 4]; +const masterCommodityCategories = window.master?.commodity_categories || []; +const commodityNames = masterCommodityCategories.reduce((acc, curr) => { + const commodities = curr.commodities.reduce((a, c) => { + return { ...a, [c.id]: c.name }; + }, {}); + return { ...acc, ...commodities }; +}, {}); + const generateDriverOptions = ({ group, questions }) => { return questions.map((q) => ({ - value: `${group.id}-${q.id}`, //case_commodity_id-question_id + value: `${group.id}-${q.id}`, label: q.text, children: generateDriverOptions({ group, questions: q.childrens }), })); @@ -26,25 +40,20 @@ const Question = ({ index, segment, percentage }) => { const fieldName = `${segment.id}-${index}`; const incomeDriverOptions = useMemo(() => { - const options = incomeDataDrivers.map((driver) => { - return { - value: driver.groupName, - title: driver.groupName, + return incomeDataDrivers.map((driver) => ({ + value: driver.groupName, + title: driver.groupName, + disabled: true, + children: driver.questionGroups.map((qg) => ({ + value: qg.id, + label: qg.commodity_name, disabled: true, - children: driver.questionGroups.map((qg) => { - return { - value: qg.id, - label: qg.commodity_name, - disabled: true, - children: generateDriverOptions({ - group: qg, - questions: qg.questions, - }), - }; + children: generateDriverOptions({ + group: qg, + questions: qg.questions, }), - }; - }); - return options; + })), + })); }, [incomeDataDrivers]); const currentValue = useMemo(() => { @@ -72,14 +81,8 @@ const Question = ({ index, segment, percentage }) => { setSelectedDriver(value)} treeData={incomeDriverOptions} @@ -89,34 +92,30 @@ const Question = ({ index, segment, percentage }) => { - {["absolute", "percentage"].map((qtype) => { - return ( - - setNewValue(val)} - /> - - ); - })} + {["absolute", "percentage"].map((qtype) => ( + + setNewValue(val)} + /> + + ))} {thousandFormatter(currentValue, 2)} @@ -132,8 +131,356 @@ const Question = ({ index, segment, percentage }) => { const ScenariIncomeoDriverAndChart = ({ segment, currentScenarioData }) => { const [scenarioDriversForm] = Form.useForm(); + const { incomeDataDrivers, questionGroups, totalIncomeQuestions } = + CaseVisualState.useState((s) => s); + const currentCase = CurrentCaseState.useState((s) => s); + + // Update child question feasible answer to 0 if the parent question is updated + const flattenIncomeDataDriversQuestions = useMemo(() => { + // combine with commodity value + return incomeDataDrivers + .flatMap((driver) => (!driver ? [] : flatten(driver.questionGroups))) + .flatMap((group) => { + const childQuestions = !group ? [] : flatten(group.questions); + return childQuestions.map((cq) => ({ + case_commodity: group.id, + ...group, + ...cq, + })); + }); + }, [incomeDataDrivers]); + + const calculateChildrenValues = (question, fieldKey, values) => { + const childrenQuestions = flattenIncomeDataDriversQuestions.filter( + (q) => q.parent === question?.parent + ); + const allChildrensIds = childrenQuestions.map((q) => `${fieldKey}-${q.id}`); + return allChildrensIds.reduce((acc, id) => { + const value = values?.[id]; + if (value) { + acc.push({ id, value }); + } + return acc; + }, []); + }; + + const onScenarioModelingIncomeDriverFormValuesChange = ( + changedValue, + allNewValues + ) => { + // CALCULATION :: calculate new total income based on driver change + // assume new value is the current value + const valueKey = Object.keys(changedValue)[0]; + const [valueField, segmentId, index] = valueKey.split("-"); + + const driverDropdownKey = Object.keys(allNewValues).find( + (key) => + key === `driver-${segmentId}-${index}` && + (allNewValues?.[key] || allNewValues?.[key] === 0) + ); + // const [field, segmentId, index] = driverDropdownKey.split("-"); + const driverDropdownValue = allNewValues[driverDropdownKey]; + const [caseCommodityId, questionId] = driverDropdownValue + ? driverDropdownValue.split("-") + : []; + + let newFeasibleValue = 0; + const newValue = changedValue[valueKey]; + const currentSegmentAnswer = + segment.answers?.[`current-${driverDropdownValue}`]; + + if ( + valueField === "percentage" && + typeof currentSegmentAnswer !== "undefined" + ) { + const valueTmp = currentSegmentAnswer * (newValue / 100); + newFeasibleValue = newValue ? currentSegmentAnswer + valueTmp : 0; + } + + if ( + valueField === "absolute" && + typeof currentSegmentAnswer !== "undefined" + ) { + newFeasibleValue = newValue; + } + + const findQuestion = flattenIncomeDataDriversQuestions.find( + (q) => + q.case_commodity === parseInt(caseCommodityId) && + q.id === parseInt(questionId) + ); + + const flattenChildrens = !findQuestion + ? [] + : flatten(findQuestion.childrens); + + const updatedChildAnswer = flattenChildrens + .map((q) => { + return { + key: `current-${caseCommodityId}-${q.id}`, + value: 0, + }; + }) + .reduce((a, b) => { + a[b.key] = b.value; + return a; + }, {}); + + // update segment answers for change driver + let updatedScenarioSegmentAnswer = {}; + const updatedSegment = { + ...segment, + answers: { + ...segment.answers, + ...updatedChildAnswer, + [`current-${driverDropdownValue}`]: newFeasibleValue, + }, + }; + + // recalculate the income drivers calculation + if (findQuestion?.parent && findQuestion?.question_type !== "aggregator") { + const recalculate = ({ key }) => { + const [fieldName, caseCommodityId, questionId] = key.split("-"); + const fieldKey = `${fieldName}-${caseCommodityId}`; + + // const commodity = currentCase.case_commodities.find( + // (cc) => cc.id === parseInt(caseCommodityId) + // ); + + const question = flattenIncomeDataDriversQuestions.find( + (q) => q.id === parseInt(questionId) + ); + const parentQuestion = flattenIncomeDataDriversQuestions.find( + (q) => q.id === question?.parent + ); + + // handleQuestionType( + // question, + // commodity, + // fieldName, + // updatedSegment.answers, + // fieldKey + // ); + + const allChildrensValues = calculateChildrenValues( + question, + fieldKey, + updatedSegment.answers + ); + const sumAllChildrensValues = parentQuestion?.default_value + ? getFunctionDefaultValue( + parentQuestion, + fieldKey, + allChildrensValues + ) + : allChildrensValues.reduce((acc, { value }) => acc + value, 0); - const onScenarioModelingIncomeDriverFormValuesChange = (_, allNewValues) => { + const parentQuestionField = `${fieldKey}-${question?.parent}`; + if (parentQuestion) { + // use parentValue if child is 0 + const parentValue = !sumAllChildrensValues + ? updatedSegment.answers?.[parentQuestionField] || 0 + : sumAllChildrensValues; + + updatedScenarioSegmentAnswer = { + ...updatedScenarioSegmentAnswer, + [parentQuestionField]: parentValue, + }; + } + + if (parentQuestion?.parent) { + recalculate({ key: parentQuestionField }); + } + }; + Object.keys(updatedSegment.answers).forEach((key) => { + recalculate({ key }); + }); + } + // EOL recalculate the income drivers calculation + + // generate questions + const flattenedQuestionGroups = questionGroups.flatMap((group) => { + const questions = group ? flatten(group.questions) : []; + return questions.map((q) => ({ + ...q, + commodity_id: group.commodity_id, + })); + }); + const totalCommodityQuestions = flattenedQuestionGroups.filter( + (q) => q.question_type === "aggregator" + ); + const costQuestions = flattenedQuestionGroups.filter((q) => + q.text.toLowerCase().includes("cost") + ); + + updatedSegment["answers"] = { + ...updatedSegment.answers, + ...updatedScenarioSegmentAnswer, + }; + const updatedDasboardData = [updatedSegment].map((segment) => { + const answers = isEmpty(segment?.answers) ? {} : segment.answers; + const remappedAnswers = Object.keys(answers).map((key) => { + const [fieldKey, caseCommodityId, questionId] = key.split("-"); + const commodity = currentCase.case_commodities.find( + (cc) => cc.id === parseInt(caseCommodityId) + ); + const commodityFocus = commodity?.commodity_type === "focus"; + const totalCommodityValue = totalCommodityQuestions.find( + (q) => q.id === parseInt(questionId) + ); + const cost = costQuestions.find( + (q) => + q.id === parseInt(questionId) && + q.parent === 1 && + q.commodityId === commodity.commodity + ); + const question = flattenedQuestionGroups.find( + (q) => + q.id === parseInt(questionId) && + q.commodity_id === commodity.commodity + ); + const totalOtherDiversifiedIncome = + question?.question_type === "diversified" && !question.parent; + return { + name: fieldKey, + question: question, + commodityFocus: commodityFocus, + commodityType: commodity?.commodity_type, + caseCommodityId: parseInt(caseCommodityId), + commodityId: commodity?.commodity, + commodityName: commodityNames?.[commodity?.commodity], + questionId: parseInt(questionId), + value: answers?.[key] || 0, // if not found set as 0 to calculated inside array reduce + isTotalFeasibleFocusIncome: + totalCommodityValue && commodityFocus && fieldKey === "feasible" + ? true + : false, + isTotalFeasibleDiversifiedIncome: + totalCommodityValue && !commodityFocus && fieldKey === "feasible" + ? true + : totalOtherDiversifiedIncome && fieldKey === "feasible" + ? true + : false, + isTotalCurrentFocusIncome: + totalCommodityValue && commodityFocus && fieldKey === "current" + ? true + : false, + isTotalCurrentDiversifiedIncome: + totalCommodityValue && !commodityFocus && fieldKey === "current" + ? true + : totalOtherDiversifiedIncome && fieldKey === "current" + ? true + : false, + feasibleCost: + cost && answers[key] && fieldKey === "feasible" ? true : false, + currentCost: + cost && answers[key] && fieldKey === "current" ? true : false, + costName: cost ? cost.text : "", + }; + }); + + const totalCurrentIncomeAnswer = totalIncomeQuestions + .map((qs) => segment?.answers?.[`current-${qs}`] || 0) + .filter((a) => a) + .reduce((acc, a) => acc + a, 0); + const totalFeasibleIncomeAnswer = totalIncomeQuestions + .map((qs) => segment?.answers?.[`feasible-${qs}`] || 0) + .filter((a) => a) + .reduce((acc, a) => acc + a, 0); + + const totalCostFeasible = remappedAnswers + .filter((a) => a.feasibleCost) + .reduce((acc, curr) => acc + curr.value, 0); + const totalCostCurrent = remappedAnswers + .filter((a) => a.currentCost) + .reduce((acc, curr) => acc + curr.value, 0); + const totalFeasibleFocusIncome = remappedAnswers + .filter((a) => a.isTotalFeasibleFocusIncome) + .reduce((acc, curr) => acc + curr.value, 0); + const totalFeasibleDiversifiedIncome = remappedAnswers + .filter((a) => a.isTotalFeasibleDiversifiedIncome) + .reduce((acc, curr) => acc + curr.value, 0); + const totalCurrentFocusIncome = remappedAnswers + .filter((a) => a.isTotalCurrentFocusIncome) + .reduce((acc, curr) => acc + curr.value, 0); + const totalCurrentDiversifiedIncome = remappedAnswers + .filter((a) => a.isTotalCurrentDiversifiedIncome) + .reduce((acc, curr) => acc + curr.value, 0); + + const focusCommodityAnswers = remappedAnswers + .filter((a) => a.commodityType === "focus") + .map((a) => ({ + id: `${a.name}-${a.questionId}`, + value: a.value, + })); + + const currentRevenueFocusCommodity = getFunctionDefaultValue( + { default_value: customFormula.revenue_focus_commodity }, + "current", + focusCommodityAnswers + ); + const feasibleRevenueFocusCommodity = getFunctionDefaultValue( + { default_value: customFormula.revenue_focus_commodity }, + "feasible", + focusCommodityAnswers + ); + const currentFocusCommodityCoP = getFunctionDefaultValue( + { default_value: customFormula.focus_commodity_cost_of_production }, + "current", + focusCommodityAnswers + ); + const feasibleFocusCommodityCoP = getFunctionDefaultValue( + { default_value: customFormula.focus_commodity_cost_of_production }, + "feasible", + focusCommodityAnswers + ); + + return { + ...segment, + total_current_income: totalCurrentIncomeAnswer, + total_feasible_income: totalFeasibleIncomeAnswer, + total_feasible_cost: -totalCostFeasible, + total_current_cost: -totalCostCurrent, + total_feasible_focus_income: totalFeasibleFocusIncome, + total_feasible_diversified_income: totalFeasibleDiversifiedIncome, + total_current_focus_income: totalCurrentFocusIncome, + total_current_diversified_income: totalCurrentDiversifiedIncome, + total_current_revenue_focus_commodity: currentRevenueFocusCommodity, + total_feasible_revenue_focus_commodity: feasibleRevenueFocusCommodity, + total_current_focus_commodity_cost_of_production: + currentFocusCommodityCoP, + total_feasible_focus_commodity_cost_of_production: + feasibleFocusCommodityCoP, + answers: remappedAnswers, + }; + })[0]; + + const currentScenarioValue = currentScenarioData.scenarioValues.find( + (scenario) => scenario.segmentId === segment.id + ); + + let updatedScenarioValue = {}; + if (currentScenarioValue) { + updatedScenarioValue = { + ...updatedScenarioValue, + ...currentScenarioValue, + allNewValues: { + ...currentScenarioValue.allNewValues, + ...allNewValues, + }, + updatedDasboardData, + }; + } else { + updatedScenarioValue = { + ...updatedScenarioValue, + segmentId: segment.id, + name: segment.name, + allNewValues, + updatedDasboardData, + }; + } + + // update state value CaseVisualState.update((s) => ({ ...s, scenarioModeling: { @@ -150,9 +497,7 @@ const ScenariIncomeoDriverAndChart = ({ segment, currentScenarioData }) => { (item) => item.segmentId !== segment.id ), { - segmentId: segment.id, - name: segment.name, - allNewValues: allNewValues, + ...updatedScenarioValue, }, ], }; @@ -195,11 +540,19 @@ const ScenariIncomeoDriverAndChart = ({ segment, currentScenarioData }) => { Make sure that you select variables you can influence/are within your control. +
+ {thousandFormatter( + currentScenarioData.scenarioValues.find( + (s) => s.segmentId === segment.id + )?.updatedDasboardData?.total_current_income || 0, + 2 + )} +
{ Change - {MAX_VARIABLES.map((index) => { - return ( - - ); - })} + {MAX_VARIABLES.map((index) => ( + + ))} @@ -270,7 +620,6 @@ const ScenarioModelingForm = ({ currentScenarioData }) => { return ( - {/* Scenario Details Form */}
{ {...selectProps} disabled={!enableEditCase} options={[ - { - label: "Percentage", - value: true, - }, - { - label: "Absolute", - value: false, - }, + { label: "Percentage", value: true }, + { label: "Absolute", value: false }, ]} /> @@ -318,9 +661,7 @@ const ScenarioModelingForm = ({ currentScenarioData }) => {
- {/* EOL Scenario Details Form */} - {/* Scenario Income Drivers & Chart */} { /> - {/* EOL Scenario Income Drivers & Chart */}
); }; diff --git a/frontend/src/pages/cases/components/SegmentSelector.js b/frontend/src/pages/cases/components/SegmentSelector.js index b5f39e41..a0db7d63 100644 --- a/frontend/src/pages/cases/components/SegmentSelector.js +++ b/frontend/src/pages/cases/components/SegmentSelector.js @@ -1,6 +1,7 @@ import React, { useEffect } from "react"; import { Radio } from "antd"; import { CaseVisualState } from "../store"; +import { orderBy } from "lodash"; const SegmentSelector = ({ selectedSegment, setSelectedSegment }) => { const dashboardData = CaseVisualState.useState((s) => s.dashboardData); @@ -18,7 +19,7 @@ const SegmentSelector = ({ selectedSegment, setSelectedSegment }) => { return ( - {dashboardData.map((d) => ( + {orderBy(dashboardData, ["id"]).map((d) => ( {d.name} diff --git a/frontend/src/pages/cases/components/VisualCardWrapper.js b/frontend/src/pages/cases/components/VisualCardWrapper.js index 6c68f8a7..caa1d45b 100644 --- a/frontend/src/pages/cases/components/VisualCardWrapper.js +++ b/frontend/src/pages/cases/components/VisualCardWrapper.js @@ -1,30 +1,117 @@ -import React from "react"; +import React, { useState } from "react"; import { Row, Col, Card, Space, Button, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; +import { toPng } from "html-to-image"; + +const htmlToImageConvert = (exportElementRef, exportFilename, setExporting) => { + if (!exportElementRef) { + console.error("Please provide you element ref using react useRef"); + setTimeout(() => { + setExporting(false); + }, 100); + return; + } + // add custom padding + exportElementRef.current.style.padding = "10px"; + // + toPng(exportElementRef.current, { + filter: (node) => { + const exclusionClasses = [ + "save-as-image-btn", + "show-label-btn", + "info-tooltip", + ]; + return !exclusionClasses.some((classname) => + node.classList?.contains(classname) + ); + }, + cacheBust: false, + backgroundColor: "#fff", + style: { + padding: 32, + width: "100%", + }, + }) + .then((dataUrl) => { + const link = document.createElement("a"); + link.download = `${exportFilename}.png`; + link.href = dataUrl; + link.click(); + }) + .catch((err) => { + console.error("Error while downloading content", err); + }) + .finally(() => { + // remove custom padding + exportElementRef.current.style.padding = "0px"; + // + setTimeout(() => { + setExporting(false); + }, 100); + }); +}; const VisualCardWrapper = ({ children, title, bordered = false, tooltipText = null, + showLabel, + setShowLabel, + exportElementRef, + exportFilename = "Undefined", + extraButtons = [], }) => { + const [exportimg, setExporting] = useState(false); + + const handleOnClickSaveAsImage = () => { + setExporting(true); + htmlToImageConvert(exportElementRef, exportFilename, setExporting); + }; + return ( - + +
{title}
- - - + {tooltipText ? ( + + + + ) : ( + "" + )}
- - + + {setShowLabel ? ( + + ) : ( + "" + )} + {exportElementRef ? ( + + ) : ( + "" + )} + {extraButtons?.length ? extraButtons.map((button) => button) : ""}
} diff --git a/frontend/src/pages/cases/layout/CaseWrapper.js b/frontend/src/pages/cases/layout/CaseWrapper.js index 1ee3c593..ba354fe2 100644 --- a/frontend/src/pages/cases/layout/CaseWrapper.js +++ b/frontend/src/pages/cases/layout/CaseWrapper.js @@ -15,10 +15,8 @@ const { Sider, Content } = Layout; const sidebarItems = [ { - title: - "Set an income target: use a living income benchmark or define the target yoruself", - description: - "Set an income target: define the target yourself or rely on a living income.", + title: "Set an income target", + description: "Use a living income benchmark or define the target yourself.", }, { title: "Enter your income data", @@ -82,14 +80,14 @@ const CaseWrapper = ({ children, step, caseId, currentCase }) => { - + - + { const currentCase = CurrentCaseState.useState((s) => s); @@ -9,7 +10,7 @@ const SegmentTabsWrapper = ({ children, setbackfunction, setnextfunction }) => { const childrenCount = React.Children.count(children); const segmentTabItems = useMemo(() => { - return currentCase.segments.map((segment) => ({ + return orderBy(currentCase.segments, ["id"]).map((segment) => ({ label: segment.name, key: segment.id, children: @@ -45,7 +46,7 @@ const SegmentTabsWrapper = ({ children, setbackfunction, setnextfunction }) => { return ( - + { React.Children.map(children, (child, index) => child.key === "right" ? ( React.isValidElement(child) ? ( - + {child} ) : null diff --git a/frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js b/frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js index 8a202ea8..c69feeaf 100644 --- a/frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js +++ b/frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js @@ -414,7 +414,9 @@ const AssessImpactMitigationStrategies = ({ {contextHolder} -
Explanatory text
+
+ Assess the impact of mitigation strategies +
This page enables you to explore various scenarios by adjusting your income drivers in different ways across your segments. This allows @@ -588,16 +590,10 @@ const AssessImpactMitigationStrategies = ({
1.
-
Explore graphs
-
- - - -
2.
-
Changing target
+
Changing the target
- If you like to change the target, Please choose whether you would + If you like to change the target, please choose whether you would like to express the changes in current values using percentages or absolute values.
@@ -625,7 +621,7 @@ const AssessImpactMitigationStrategies = ({ -
3.
+
2.
Adjust current values below
diff --git a/frontend/src/pages/cases/steps/UnderstandIncomeGap.js b/frontend/src/pages/cases/steps/UnderstandIncomeGap.js index 55b55834..0adf6f32 100644 --- a/frontend/src/pages/cases/steps/UnderstandIncomeGap.js +++ b/frontend/src/pages/cases/steps/UnderstandIncomeGap.js @@ -7,6 +7,7 @@ import { CompareIncomeGap, // ChartIncomeDriverAcrossSegments, ChartExploreIncomeDriverBreakdown, + ChartIncomeLevelsForDifferentCommodities, } from "../visualizations"; /** @@ -37,12 +38,13 @@ const UnderstandIncomeGap = ({ setbackfunction, setnextfunction }) => { -
Explanatory text
+
What is the current income situation?
- This page enables you to explore various scenarios by adjusting your - income drivers in different ways across your segments. This allows - you to understand the potential paths towards improving farmer - household income + This page provides an overview of the current income situation of + farmers across various segments, offering insights into the living + income gap. It also enables a detailed exploration of the + composition of income drivers, helping you better understand their + contributions.
@@ -58,9 +60,19 @@ const UnderstandIncomeGap = ({ setbackfunction, setnextfunction }) => { + {/* EOL Chart */} + + + Explore your income drivers + + + {/* Chart */} + + + {/* EOL Chart */}
); diff --git a/frontend/src/pages/cases/visualizations/ChartBiggestImpactOnIncome.js b/frontend/src/pages/cases/visualizations/ChartBiggestImpactOnIncome.js index 8b086ed6..2f3966db 100644 --- a/frontend/src/pages/cases/visualizations/ChartBiggestImpactOnIncome.js +++ b/frontend/src/pages/cases/visualizations/ChartBiggestImpactOnIncome.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useRef } from "react"; import { Card, Col, Row, Space } from "antd"; import { VisualCardWrapper } from "../components"; import { orderBy } from "lodash"; @@ -15,11 +15,11 @@ import { CaseVisualState } from "../store"; const legendColors = ["#F9BC05", "#82b2b2", "#03625f"]; -const ChartBiggestImpactOnIncome = ({ - showLabel = true /* TODO:: manage this later*/, -}) => { +const ChartBiggestImpactOnIncome = () => { const dashboardData = CaseVisualState.useState((s) => s.dashboardData); const [selectedSegment, setSelectedSegment] = useState(null); + const [showLabel, setShowLabel] = useState(null); + const chartRef = useRef(null); const chartData = useMemo(() => { if (!dashboardData.length || !selectedSegment) { @@ -185,12 +185,11 @@ const ChartBiggestImpactOnIncome = ({ title = "Change in income driver value (%)"; } if (x === "income") { - title = - "Percentage change in income with driver at feasible level,\nwhile all other drivers stay the same (%)"; + title = "Income change (%): change only this driver"; } if (x === "additional") { title = - "Percentage change in income when this driver moves\nfrom current to feasible level while all other drivers\nare at feasible level (%)"; + "Income change (%): change this driver, other drivers are at feasible levels"; } const data = transformedData.map((d) => ({ name: d.name, @@ -296,7 +295,15 @@ const ChartBiggestImpactOnIncome = ({ - + { const [label, setLabel] = useState(null); const [chartTitle, setChartTitle] = useState(null); - // const elLineChart = useRef(null); + + const elLineChart = useRef(null); const binningData = useMemo(() => { if (!segment?.id) { @@ -367,6 +368,8 @@ const ChartBinningDriversSensitivityAnalysis = ({ title={chartTitle} tooltipText={lineChartTooltipText} bordered + exportElementRef={elLineChart} + exportFilename={chartTitle} > { + const currentCase = CurrentCaseState.useState((s) => s); + const totalIncomeQuestions = CaseVisualState.useState( + (s) => s.totalIncomeQuestions + ); + const [showLabel, setShowLabel] = useState(false); + const elChartHHIncome = useRef(null); + + const chartData = useMemo(() => { + if (!currentCase.segments.length) { + return []; + } + const res = currentCase.segments.map((item) => { + const answers = item.answers || {}; + const current = totalIncomeQuestions + .map((qs) => answers?.[`current-${qs}`] || 0) + .filter((a) => a) + .reduce((acc, a) => acc + a, 0); + const feasible = totalIncomeQuestions + .map((qs) => answers?.[`feasible-${qs}`] || 0) + .filter((a) => a) + .reduce((acc, a) => acc + a, 0); + return { + name: item.name, + data: [ + { + name: "Current Income", + value: Math.round(current), + color: "#03625f", + }, + { + name: "Feasible Income", + value: Math.round(feasible), + color: "#82b2b2", + }, + ], + }; + }); + return res; + }, [totalIncomeQuestions, currentCase.segments]); + + return ( + + + + ); +}; + +export default ChartCalculatedHouseholdIncome; diff --git a/frontend/src/pages/cases/visualizations/ChartExploreIncomeDriverBreakdown.js b/frontend/src/pages/cases/visualizations/ChartExploreIncomeDriverBreakdown.js index 562bcd01..79f90e97 100644 --- a/frontend/src/pages/cases/visualizations/ChartExploreIncomeDriverBreakdown.js +++ b/frontend/src/pages/cases/visualizations/ChartExploreIncomeDriverBreakdown.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useRef } from "react"; import { Card, Col, Row, Space } from "antd"; import { VisualCardWrapper } from "../components"; import { getColumnStackBarOptions } from "../../../components/chart/lib"; @@ -55,6 +55,8 @@ const ChartExploreIncomeDriverBreakdown = () => { const [selectedSegment, setSelectedSegment] = useState(null); const [selectedDriver, setSelectedDriver] = useState(null); const [axisTitle, setAxisTitle] = useState(currentCase.currency); + const [showLabel, setShowLabel] = useState(false); + const chartBreakdownDriverRef = useRef(null); const selectedSegmentData = useMemo(() => { if (!selectedSegment || !dashboardData.length) { @@ -294,7 +296,14 @@ const ChartExploreIncomeDriverBreakdown = () => { - + { origin: selectedSegmentData ? [selectedSegmentData] : [], yAxis: { name: axisTitle }, grid: chartGrid(selectedDriver), - // showLabel: showLabel, + showLabel: showLabel, })} /> diff --git a/frontend/src/pages/cases/visualizations/ChartIncomeGap.js b/frontend/src/pages/cases/visualizations/ChartIncomeGap.js index 5c6b090e..78196822 100644 --- a/frontend/src/pages/cases/visualizations/ChartIncomeGap.js +++ b/frontend/src/pages/cases/visualizations/ChartIncomeGap.js @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useMemo, useState, useRef } from "react"; import { Card, Col, Row, Space } from "antd"; import { VisualCardWrapper } from "../components"; import { CaseVisualState, CurrentCaseState } from "../store"; @@ -48,6 +48,9 @@ const ChartIncomeGap = () => { const currentCase = CurrentCaseState.useState((s) => s); const dashboardData = CaseVisualState.useState((s) => s.dashboardData); + const [showLabel, setShowLabel] = useState(false); + const chartIncomeGapRef = useRef(null); + const chartData = useMemo(() => { return seriesTmp.map((tmp) => { const data = dashboardData.map((d) => { @@ -82,7 +85,14 @@ const ChartIncomeGap = () => { - + { series: chartData, origin: dashboardData, yAxis: { name: `Income (${currentCase.currency})` }, - // showLabel: showLabel, + showLabel: showLabel, })} /> diff --git a/frontend/src/pages/cases/visualizations/ChartIncomeLevelsForDifferentCommodities.js b/frontend/src/pages/cases/visualizations/ChartIncomeLevelsForDifferentCommodities.js new file mode 100644 index 00000000..529d1db4 --- /dev/null +++ b/frontend/src/pages/cases/visualizations/ChartIncomeLevelsForDifferentCommodities.js @@ -0,0 +1,263 @@ +import React, { useState, useMemo, useRef } from "react"; +import { Card, Col, Row, Space } from "antd"; +import { VisualCardWrapper } from "../components"; +import { CaseVisualState, CurrentCaseState } from "../store"; +import { SegmentSelector } from "../components"; +import Chart from "../../../components/chart"; +import { capitalize, uniqBy, sum } from "lodash"; + +const colors = [ + "#1b726f", + "#9cc2c1", + "#4eb8ff", + "#b7e2ff", + "#81e4ab", + "#ddf8e9", + "#3d4149", + "#787d87", +]; +// const currentColors = ["#1b726f", "#4eb8ff", "#81e4ab", "#3d4149"]; +// const feasibleColors = ["#9cc2c1", "#b7e2ff", "#ddf8e9", "#787d87"]; + +const ChartIncomeLevelsForDifferentCommodities = () => { + const currentCase = CurrentCaseState.useState((s) => s); + const dashboardData = CaseVisualState.useState((s) => s.dashboardData); + + const [selectedSegment, setSelectedSegment] = useState(null); + const [showLabel, setShowLabel] = useState(false); + const chartRef = useRef(null); + + const selectedSegmentData = useMemo(() => { + if (!selectedSegment || !dashboardData.length) { + return null; + } + return dashboardData.find((d) => d.id === selectedSegment); + }, [dashboardData, selectedSegment]); + + const chartData = useMemo(() => { + if (!selectedSegmentData) { + return []; + } + const parentQuestions = selectedSegmentData.answers.filter( + (a) => !a.question?.parent && a.question?.question_type === "aggregator" + ); + if (!parentQuestions?.length) { + return []; + } + const parendQuestionIds = parentQuestions.map((pq) => pq.question.id); + // list commodities exclude diversified income + const commoditiesTemp = selectedSegmentData.answers + .filter((a) => { + const currentCommodity = currentCase.case_commodities.find( + (c) => c?.id === a.caseCommodityId + ); + if ( + a.commodityId && + a.commodityName && + (parendQuestionIds.includes(a.question?.parent) || + currentCommodity?.breakdown === false) + ) { + return a; + } + return false; + }) + .map((a) => { + const parentQuestion = parentQuestions.find( + (pq) => + pq.commodityId === a.commodityId && + pq.commodityType === a.commodityType + )?.question; + return { + commodityId: a.commodityId, + commodityName: a.commodityName, + commodityFocus: a.commodityFocus, + questions: parentQuestion + ? parentQuestion.childrens.map((q) => ({ + id: q.id, + text: q.text, + })) + : [], + }; + }); + const commodities = uniqBy(commoditiesTemp, "commodityId"); + // populate chart data + const currentCommodityValuesExceptFocus = []; + const feasibleCommodityValuesExceptFocus = []; + const res = commodities.map((cm, cmi) => { + const data = ["current", "feasible"].map((x, xi) => { + // const colors = x === "current" ? currentColors : feasibleColors; + const title = `${capitalize(x)} ${selectedSegmentData.name}`; + // recalculate total value + const incomeQuestion = selectedSegmentData.answers.find( + (a) => + a.name === x && + a.commodityId === cm.commodityId && + !a.question.parent && + a.question.question_type !== "diversified" + ); + const newTotalValue = + incomeQuestion && incomeQuestion?.value + ? Math.round(incomeQuestion.value) + : 0; + // add newTotalValue to temp variable for diversified value calculation + if (x === "current" && !cm.commodityFocus) { + currentCommodityValuesExceptFocus.push(newTotalValue); + } + if (x === "feasible" && !cm.commodityFocus) { + feasibleCommodityValuesExceptFocus.push(newTotalValue); + } + // map drivers value + const stack = cm.questions.map((q, qi) => { + const answer = selectedSegmentData.answers.find( + (a) => + a.commodityId === cm.commodityId && + a.name === x && + a.questionId === q.id + ); + const value = answer && answer.value ? Math.round(answer.value) : 0; + return { + name: q.text, + title: q.text, + value: value, + total: value, + order: qi, + color: colors[qi], + }; + }); + return { + name: title, + title: title, + stack: stack, + value: newTotalValue, + total: newTotalValue, + commodityId: cm.commodityId, + commodityName: cm.commodityName, + color: colors[xi], + }; + }); + return { + name: cm.commodityName, + title: cm.commodityName, + order: cmi, + data: data, + }; + }); + + // DIVERSIFIED CALCULATION - add diversified income value + let diversifiedQUestions = selectedSegmentData.answers + .filter( + (a) => + (!a.commodityId || !a.commodityName) && + a.question.question_type === "diversified" && + !a.question.parent + ) + .flatMap((a) => a.question); + diversifiedQUestions = uniqBy(diversifiedQUestions, "id"); + // populate diversified income value + const diversifiedData = ["current", "feasible"].map((x, xi) => { + // const colors = x === "current" ? currentColors : feasibleColors; + const title = `${capitalize(x)} ${selectedSegmentData.name}`; + let newValue = 0; + if (x === "current") { + newValue = + selectedSegmentData.total_current_diversified_income - + sum(currentCommodityValuesExceptFocus); + } + if (x === "feasible") { + newValue = + selectedSegmentData.total_feasible_diversified_income - + sum(feasibleCommodityValuesExceptFocus); + } + newValue = Math.round(newValue); + const stack = diversifiedQUestions.map((q, qi) => { + const answer = selectedSegmentData.answers.find( + (a) => + (!a.commodityId || !a.commodityName) && + a.name === x && + a.questionId === q.id + ); + const value = answer && answer.value ? Math.round(answer.value) : 0; + return { + name: q.text, + title: q.text, + value: value, + total: value, + order: qi, + color: colors[qi], + }; + }); + return { + name: title, + title: title, + stack: stack, + value: newValue, + total: newValue, + commodityId: null, + commodityName: null, + color: colors[xi], + }; + }); + res.push({ + name: "Diversified Income", + title: "Diversified Income", + order: res.length, + data: diversifiedData, + }); + return res; + }, [selectedSegmentData, currentCase]); + + return ( + + + + +
+ Explore income levels for different commodities +
+
+ This graph shows the current net income levels for your focus + commodity, any secondary or tertiary commodities, next to + diversified income within different segments. Use it to compare + income levels across sources and segments. +
+
+ + + + + + + + + + + + + +
+
+ ); +}; + +export default ChartIncomeLevelsForDifferentCommodities; diff --git a/frontend/src/pages/cases/visualizations/ChartMonetaryImpactOnIncome.js b/frontend/src/pages/cases/visualizations/ChartMonetaryImpactOnIncome.js index 19368a6c..335a93e3 100644 --- a/frontend/src/pages/cases/visualizations/ChartMonetaryImpactOnIncome.js +++ b/frontend/src/pages/cases/visualizations/ChartMonetaryImpactOnIncome.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useRef } from "react"; import { Card, Col, Row, Space } from "antd"; import { VisualCardWrapper } from "../components"; import { @@ -15,11 +15,13 @@ import { getFunctionDefaultValue } from "../../../lib"; import { SegmentSelector } from "../components"; import { CaseVisualState, CurrentCaseState } from "../store"; -const ChartMonetaryImpactOnIncome = ({ showLabel = false }) => { +const ChartMonetaryImpactOnIncome = () => { const dashboardData = CaseVisualState.useState((s) => s.dashboardData); const currentCase = CurrentCaseState.useState((s) => s); const [selectedSegment, setSelectedSegment] = useState(null); + const [showLabel, setShowLabel] = useState(null); + const chartRef = useRef(null); const chartData = useMemo(() => { const data = dashboardData.find((d) => d.id === selectedSegment); @@ -269,8 +271,12 @@ const ChartMonetaryImpactOnIncome = ({ showLabel = false }) => { @@ -288,22 +294,15 @@ const ChartMonetaryImpactOnIncome = ({ showLabel = false }) => {
- What is the monetary impact of each income driver as we move - income drivers from their current to feasible levels? + What is the monetary impact of adjusting income drivers?
- This waterfall chart visually illustrates how adjustments in - income drivers influence the transition from the current income - level to a feasible income level. Each element represents how - income changes resulting from changing an income driver from its - current to its feasible level, keeping the other income drivers at - their current levels. Insights: The graph serves to clarify which - income drivers have the most significant impact on increasing - household income levels. The values do not add up to the feasible - income level because some income drivers are interconnected and in - this graph we only assess the change in income caused by changing - one income driver to its feasible levels, while the others remain - at their current levels. + This waterfall chart shows how income shifts as each driver is + adjusted from current to feasible levels, while others remain + constant. It highlights the drivers with the biggest impact on + boosting household income. Note that the values don’t sum to the + feasible income level, as the chart focuses on the isolated effect + of each driver, without accounting for their interactions.
diff --git a/frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js b/frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js index c8104922..f923aea2 100644 --- a/frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js +++ b/frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js @@ -1,16 +1,13 @@ import React, { useMemo } from "react"; import { Card, Row, Col, Space, Tag } from "antd"; -import { CaseUIState, CurrentCaseState, CaseVisualState } from "../store"; +import { CaseUIState, CurrentCaseState } from "../store"; import { thousandFormatter } from "../../../components/chart/options/common"; -import { VisualCardWrapper } from "../components"; -import Chart from "../../../components/chart"; +import ChartCalculatedHouseholdIncome from "./ChartCalculatedHouseholdIncome"; +import ExploreDataFromOtherStudiesTable from "./ExploreDataFromOtherStudiesTable"; const EnterIncomeDataVisual = () => { const { activeSegmentId } = CaseUIState.useState((s) => s.general); const currentCase = CurrentCaseState.useState((s) => s); - const totalIncomeQuestions = CaseVisualState.useState( - (s) => s.totalIncomeQuestions - ); const currentSegment = useMemo( () => @@ -19,39 +16,6 @@ const EnterIncomeDataVisual = () => { [currentCase.segments, activeSegmentId] ); - const chartData = useMemo(() => { - if (!currentCase.segments.length) { - return []; - } - const res = currentCase.segments.map((item) => { - const answers = item.answers || {}; - const current = totalIncomeQuestions - .map((qs) => answers?.[`current-${qs}`] || 0) - .filter((a) => a) - .reduce((acc, a) => acc + a, 0); - const feasible = totalIncomeQuestions - .map((qs) => answers?.[`feasible-${qs}`] || 0) - .filter((a) => a) - .reduce((acc, a) => acc + a, 0); - return { - name: item.name, - data: [ - { - name: "Current Income", - value: Math.round(current), - color: "#03625f", - }, - { - name: "Feasible Income", - value: Math.round(feasible), - color: "#82b2b2", - }, - ], - }; - }); - return res; - }, [totalIncomeQuestions, currentCase.segments]); - if (!currentSegment) { return Failed to load current segment data; } @@ -72,25 +36,10 @@ const EnterIncomeDataVisual = () => {
- - - + - - Household Income Table - +
); diff --git a/frontend/src/pages/cases/visualizations/ExploreDataFromOtherStudiesTable.js b/frontend/src/pages/cases/visualizations/ExploreDataFromOtherStudiesTable.js new file mode 100644 index 00000000..6bd412b6 --- /dev/null +++ b/frontend/src/pages/cases/visualizations/ExploreDataFromOtherStudiesTable.js @@ -0,0 +1,183 @@ +import React, { useState, useEffect } from "react"; +import { VisualCardWrapper } from "../components"; +import { Button, Col, Row, Select, Table } from "antd"; +import { driverOptions } from "../../explore-studies"; +import { thousandFormatter } from "../../../components/chart/options/common"; +import { api } from "../../../lib"; +import { CurrentCaseState } from "../store"; +import { isEmpty, upperFirst } from "lodash"; + +const ExploreDataFromOtherStudiesTable = () => { + const currentCase = CurrentCaseState.useState((s) => s); + + const [selectedDriver, setSelectedDriver] = useState("area"); + const [loadingRefData, setLoadingRefData] = useState(false); + const [referenceData, setReferenceData] = useState([]); + const [exploreButtonLink, setExploreButtonLink] = useState(null); + + useEffect(() => { + const country = currentCase?.country; + const commodity = currentCase?.case_commodities?.find( + (x) => x.commodity_type === "focus" + )?.commodity; + if (!isEmpty(currentCase) && selectedDriver) { + setLoadingRefData(true); + setExploreButtonLink( + `/explore-studies/${country}/${commodity}/${selectedDriver}` + ); + api + .get( + `reference_data/reference_value?country=${country}&commodity=${commodity}&driver=${selectedDriver}` + ) + .then((res) => { + setReferenceData(res.data); + }) + .catch(() => { + setReferenceData([]); + }) + .finally(() => { + setLoadingRefData(false); + }); + } + }, [currentCase, selectedDriver]); + + return ( + + + , + ]} + > + + +