From ba4a405cd8ac8030134515b5f240b412920444fc Mon Sep 17 00:00:00 2001 From: Wayan Galih Pratama <49269977+wayangalihpratama@users.noreply.github.com> Date: Mon, 20 Jan 2025 20:01:55 +0800 Subject: [PATCH] New User Journey (#388) * Feature/user journey step 1 * [#383] Move current case page into old-cases * [#383] Init new case page * [#383] Load cases data in new case page * [#383] New cases page * [#383] Implement new case filter * [#383] Implement new case settings modal visual * [#383] Handle case settings behaviour with new state management * [#383] Handle save new case settings without segments value * [#383] Support add segments when create case settings * [#383] Support segments payload on update case endpoint * [#383] Handle new case setting with segments correctly * [#383] Breakdown case settings into components * [#383] Initial new case detail page with sidebar * [#383] Handle load case detail in edit case page * [#383] Set step sidebar as fixed sidebar * [#383] Render case title and case settings form in CaseWrapper * [#383] Refactor the export function, static, & lib to correct place * [#383] Load current case settings value correctly * [#385] Refine step path and load related page * [#385] Handle close case setting modal after saved success * [#385] Init set income target page layout * [#385] Refine segment tabs as a wrapper to be reused in another page * [#385] Load region options in set an income target page * [#385] Load income target value and source when select region * [#385] Add segment id into form name for SetIncomeTarget * [#385] Fix yarn lint * [#385] Finalize set income target state update * [#385] Load initial value for SetIncomeTarget * [#385] Add Back/Next button on parent and send the props into children * [#385] Handle change hh adult/child value * [#385] Handle save SetIncomeTarget * [#383] Handle number of farmers field on segment * Feature/389 user journey breakdown step pages into different url (#390) * [#389] Handle EnterIncomeData page button function * [#389] Handle case button on UnderstandIncomeGap * [#389] Handle case button on AssessImpactMitigationStrategies * [#389] Handle case button on ClosingGap * [#389] Handle EnterIncomeData right element (visuals) position in SegmentTabsWrapper * [#389] Get commodity questions * [#389] Add Total Income section * [#389] Fetch and regroup driver questions to follow new design layout * [#389] Move renderPercentageTag fn into lib file * [#389] Initial render driver questions * [#389] Render unit name and current/feasible value field * [#389] Handle disable input/lock when questions breakdown * [#389] Handle onValuesChange of income drivers * [#389] Handle total value in section, parent, and general total income * [#389] Handle income percentage value * [#389] Init handle save EnterIncomeData * [#389] Handle save EnterIncomeData value properly * Feature/391 user journey segment page lib per page (#392) * [#391] Move enableEditCase state into general CaseUIState * [#391] Initial layout for EnterIncomeData visual * [#391] Add income target card on EnterIncomeData page * [#391] Create VisualCardWrapper component * [#391] Render household income bar chart * [#391] Init UnderstandIncomeGap page * [#391] Create visuals component * [#391] Fetch questions on wrapper * [#391] Create dashboardData state * [#391] Render ChartIncomeGap * [#391] Create CompareIncomeGap visualization * [#391] Handle new user journey when prev case doesn't have any segments yet * [#391] Debugging with test full case data for current complete new user journey * [#391] Fix EnterIncomeData page onLoad & onValuesChange * [#391] Refine EnterIncomeData calculation * [#391] Handle enableEditCase state * [#391] Fix EnterIncomeData value & dashboardData calculation * [#391] Add answers & benchmark into segment put endpoint obj * [#391] Create ChartExploreIncomeDriversBreakdown * [#393] Initial AssessImpactMitigationStrategies page * [#391] Create biggest impact on income & monetary contribution chart * [#393] Initial binning driver form * [#393] Set sensitivity analysis value into state --- ...8a23_alter_segment_table_add_number_of_.py | 29 + backend/db/crud_case.py | 33 + backend/db/crud_segment.py | 14 +- backend/models/case.py | 8 +- backend/models/segment.py | 31 +- backend/routes/segment.py | 9 +- backend/tests/test_040_segment.py | 33 + .../test_051_segment_answer_continued.py | 6 + backend/tests/test_1001_case_with_segments.py | 282 ++++++ backend/tests/test_1010_user_deletion.py | 3 +- frontend/src/App.js | 12 +- frontend/src/App.scss | 73 +- frontend/src/components/chart/lib/index.js | 142 +++ frontend/src/components/chart/options/Bar.js | 4 + .../src/components/chart/options/ColumnBar.js | 4 + .../src/components/layout/ContentLayout.js | 53 +- frontend/src/lib/formula.js | 31 + frontend/src/lib/index.js | 189 ++++ frontend/src/pages/admin/users/Users.js | 4 +- frontend/src/pages/cases/Case.js | 922 +++++++++--------- frontend/src/pages/cases/Cases.js | 440 +++++---- frontend/src/pages/cases/cases.scss | 618 +----------- .../pages/cases/components/AreaUnitFields.js | 2 +- .../cases/components/BinningDriverForm.js | 239 +++++ .../src/pages/cases/components/CaseFilter.js | 140 +++ .../src/pages/cases/components/CaseForm.js | 449 +++++++++ .../pages/cases/components/CaseSettings.js | 305 ++++++ .../cases/components/EnterIncomeDataForm.js | 557 +++++++++++ .../cases/components/IncomeDriversDropdown.js | 54 + .../src/pages/cases/components/SegmentForm.js | 86 ++ .../pages/cases/components/SegmentSelector.js | 30 + .../cases/components/VisualCardWrapper.js | 32 + frontend/src/pages/cases/components/index.js | 285 +----- frontend/src/pages/cases/index.js | 2 +- .../src/pages/cases/layout/CaseWrapper.js | 160 +++ .../src/pages/cases/layout/case-wrapper.scss | 104 ++ frontend/src/pages/cases/layout/index.js | 1 + .../steps/AssessImpactMitigationStrategies.js | 255 +++++ frontend/src/pages/cases/steps/ClosingGap.js | 32 + .../src/pages/cases/steps/EnterIncomeData.js | 284 ++++++ .../src/pages/cases/steps/SetIncomeTarget.js | 576 +++++++++++ .../pages/cases/steps/UnderstandIncomeGap.js | 69 ++ frontend/src/pages/cases/steps/index.js | 5 + frontend/src/pages/cases/steps/steps.scss | 366 +++++++ frontend/src/pages/cases/store/case_ui.js | 32 + frontend/src/pages/cases/store/case_visual.js | 32 + .../src/pages/cases/store/current_case.js | 70 ++ frontend/src/pages/cases/store/index.js | 21 + frontend/src/pages/cases/store/prev_case.js | 16 + .../ChartBiggestImpactOnIncome.js | 335 +++++++ .../ChartExploreIncomeDriverBreakdown.js | 346 +++++++ .../ChartIncomeDriverAcrossSegments.js | 30 + .../cases/visualizations/ChartIncomeGap.js | 53 +- .../ChartMonetaryImpactOnIncome.js | 315 ++++++ .../cases/visualizations/CompareIncomeGap.js | 90 ++ .../visualizations/EnterIncomeDataVisual.js | 99 ++ .../src/pages/cases/visualizations/index.js | 156 +-- .../pages/landing/components/GetStarted.js | 2 +- frontend/src/pages/old-cases/Case.js | 512 ++++++++++ frontend/src/pages/old-cases/Cases.js | 423 ++++++++ frontend/src/pages/old-cases/cases.scss | 610 ++++++++++++ .../old-cases/components/AreaUnitFields.js | 67 ++ .../components/CaseProfile.js | 4 +- .../components/DashboardIncomeOverview.js | 0 .../components/DashboardScenarioModeling.js | 2 +- .../DashboardSensitivityAnalysis.js | 0 .../components/DataFields.js | 2 +- .../old-cases/components/DebounceSelect.js | 41 + .../components/IncomeDriverDashboard.js | 2 +- .../components/IncomeDriverDataEntry.js | 2 +- .../components/IncomeDriverForm.js | 2 +- .../components/IncomeDriverTarget.js | 2 +- .../components/Questions.js | 2 +- .../components/Scenario.js | 2 +- .../components/SideMenu.js | 0 .../src/pages/old-cases/components/index.js | 278 ++++++ frontend/src/pages/old-cases/index.js | 2 + .../visualizations/ChartBigImpact.js | 2 +- .../visualizations/ChartBinningHeatmap.js | 0 .../visualizations/ChartCurrentFeasible.js | 0 .../ChartExploreBreakdownDrivers.js | 2 +- .../visualizations/ChartIncomeGap.js | 90 ++ .../ChartIncomeLevelPerCommodities.js | 0 .../ChartMonetaryContribution.js | 2 +- .../visualizations/ChartScenarioModeling.js | 0 .../ChartSensitivityAnalysisLine.js | 0 .../visualizations/DriverDropdown.js | 0 .../visualizations/SegmentSelector.js | 0 .../pages/old-cases/visualizations/index.js | 154 +++ .../pages/welcome/__tests__/Welcome.test.js | 2 +- frontend/src/store/static.js | 89 ++ 91 files changed, 9122 insertions(+), 1742 deletions(-) create mode 100644 backend/alembic/versions/2025_01_14_0721-a5f7676a8a23_alter_segment_table_add_number_of_.py create mode 100644 backend/tests/test_1001_case_with_segments.py create mode 100644 frontend/src/components/chart/lib/index.js create mode 100644 frontend/src/lib/formula.js create mode 100644 frontend/src/pages/cases/components/BinningDriverForm.js create mode 100644 frontend/src/pages/cases/components/CaseFilter.js create mode 100644 frontend/src/pages/cases/components/CaseForm.js create mode 100644 frontend/src/pages/cases/components/CaseSettings.js create mode 100644 frontend/src/pages/cases/components/EnterIncomeDataForm.js create mode 100644 frontend/src/pages/cases/components/IncomeDriversDropdown.js create mode 100644 frontend/src/pages/cases/components/SegmentForm.js create mode 100644 frontend/src/pages/cases/components/SegmentSelector.js create mode 100644 frontend/src/pages/cases/components/VisualCardWrapper.js create mode 100644 frontend/src/pages/cases/layout/CaseWrapper.js create mode 100644 frontend/src/pages/cases/layout/case-wrapper.scss create mode 100644 frontend/src/pages/cases/layout/index.js create mode 100644 frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js create mode 100644 frontend/src/pages/cases/steps/ClosingGap.js create mode 100644 frontend/src/pages/cases/steps/EnterIncomeData.js create mode 100644 frontend/src/pages/cases/steps/SetIncomeTarget.js create mode 100644 frontend/src/pages/cases/steps/UnderstandIncomeGap.js create mode 100644 frontend/src/pages/cases/steps/index.js create mode 100644 frontend/src/pages/cases/steps/steps.scss create mode 100644 frontend/src/pages/cases/store/case_ui.js create mode 100644 frontend/src/pages/cases/store/case_visual.js create mode 100644 frontend/src/pages/cases/store/current_case.js create mode 100644 frontend/src/pages/cases/store/index.js create mode 100644 frontend/src/pages/cases/store/prev_case.js create mode 100644 frontend/src/pages/cases/visualizations/ChartBiggestImpactOnIncome.js create mode 100644 frontend/src/pages/cases/visualizations/ChartExploreIncomeDriverBreakdown.js create mode 100644 frontend/src/pages/cases/visualizations/ChartIncomeDriverAcrossSegments.js create mode 100644 frontend/src/pages/cases/visualizations/ChartMonetaryImpactOnIncome.js create mode 100644 frontend/src/pages/cases/visualizations/CompareIncomeGap.js create mode 100644 frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js create mode 100644 frontend/src/pages/old-cases/Case.js create mode 100644 frontend/src/pages/old-cases/Cases.js create mode 100644 frontend/src/pages/old-cases/cases.scss create mode 100644 frontend/src/pages/old-cases/components/AreaUnitFields.js rename frontend/src/pages/{cases => old-cases}/components/CaseProfile.js (99%) rename frontend/src/pages/{cases => old-cases}/components/DashboardIncomeOverview.js (100%) rename frontend/src/pages/{cases => old-cases}/components/DashboardScenarioModeling.js (99%) rename frontend/src/pages/{cases => old-cases}/components/DashboardSensitivityAnalysis.js (100%) rename frontend/src/pages/{cases => old-cases}/components/DataFields.js (99%) create mode 100644 frontend/src/pages/old-cases/components/DebounceSelect.js rename frontend/src/pages/{cases => old-cases}/components/IncomeDriverDashboard.js (99%) rename frontend/src/pages/{cases => old-cases}/components/IncomeDriverDataEntry.js (99%) rename frontend/src/pages/{cases => old-cases}/components/IncomeDriverForm.js (99%) rename frontend/src/pages/{cases => old-cases}/components/IncomeDriverTarget.js (99%) rename frontend/src/pages/{cases => old-cases}/components/Questions.js (99%) rename frontend/src/pages/{cases => old-cases}/components/Scenario.js (99%) rename frontend/src/pages/{cases => old-cases}/components/SideMenu.js (100%) create mode 100644 frontend/src/pages/old-cases/components/index.js create mode 100644 frontend/src/pages/old-cases/index.js rename frontend/src/pages/{cases => old-cases}/visualizations/ChartBigImpact.js (99%) rename frontend/src/pages/{cases => old-cases}/visualizations/ChartBinningHeatmap.js (100%) rename frontend/src/pages/{cases => old-cases}/visualizations/ChartCurrentFeasible.js (100%) rename frontend/src/pages/{cases => old-cases}/visualizations/ChartExploreBreakdownDrivers.js (99%) create mode 100644 frontend/src/pages/old-cases/visualizations/ChartIncomeGap.js rename frontend/src/pages/{cases => old-cases}/visualizations/ChartIncomeLevelPerCommodities.js (100%) rename frontend/src/pages/{cases => old-cases}/visualizations/ChartMonetaryContribution.js (99%) rename frontend/src/pages/{cases => old-cases}/visualizations/ChartScenarioModeling.js (100%) rename frontend/src/pages/{cases => old-cases}/visualizations/ChartSensitivityAnalysisLine.js (100%) rename frontend/src/pages/{cases => old-cases}/visualizations/DriverDropdown.js (100%) rename frontend/src/pages/{cases => old-cases}/visualizations/SegmentSelector.js (100%) create mode 100644 frontend/src/pages/old-cases/visualizations/index.js diff --git a/backend/alembic/versions/2025_01_14_0721-a5f7676a8a23_alter_segment_table_add_number_of_.py b/backend/alembic/versions/2025_01_14_0721-a5f7676a8a23_alter_segment_table_add_number_of_.py new file mode 100644 index 00000000..0246ce95 --- /dev/null +++ b/backend/alembic/versions/2025_01_14_0721-a5f7676a8a23_alter_segment_table_add_number_of_.py @@ -0,0 +1,29 @@ +"""alter segment table add number_of_farmers column + +Revision ID: a5f7676a8a23 +Revises: 4bb17002ebca +Create Date: 2025-01-14 07:21:08.343305 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "a5f7676a8a23" +down_revision: Union[str, None] = "4bb17002ebca" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "segment", sa.Column("number_of_farmers", sa.Integer(), nullable=True) + ) + + +def downgrade() -> None: + op.drop_column("segment", "number_of_farmers") diff --git a/backend/db/crud_case.py b/backend/db/crud_case.py index 7b6a4c84..f7f37feb 100644 --- a/backend/db/crud_case.py +++ b/backend/db/crud_case.py @@ -79,6 +79,13 @@ def add_case(session: Session, payload: CaseBase, user: User) -> CaseDict: for tag_id in payload.tags: tag = CaseTag(tag=tag_id) case.case_tags.append(tag) + # store segments + if payload.segments: + for segment in payload.segments: + new_segment = Segment( + name=segment.name, number_of_farmers=segment.number_of_farmers + ) + case.case_segments.append(new_segment) session.add(case) session.commit() session.flush() @@ -245,6 +252,32 @@ def update_case(session: Session, id: int, payload: CaseBase) -> CaseDict: volume_measurement_unit=val.volume_measurement_unit, ) case.case_commodities.append(case_commodity) + # handle update segments + if payload.segments: + for segment in payload.segments: + prev_segment = ( + session.query(Segment) + .filter( + and_( + Segment.case == case.id, + Segment.id == segment.id, + ) + ) + .first() + ) + if prev_segment: + # update prev segment + prev_segment.name = segment.name + prev_segment.number_of_farmers = segment.number_of_farmers + session.commit() + session.flush() + session.refresh(prev_segment) + else: + new_segment = Segment( + name=segment.name, + number_of_farmers=segment.number_of_farmers, + ) + case.case_segments.append(new_segment) session.commit() session.flush() session.refresh(case) diff --git a/backend/db/crud_segment.py b/backend/db/crud_segment.py index 58739750..cfaba9dd 100644 --- a/backend/db/crud_segment.py +++ b/backend/db/crud_segment.py @@ -3,8 +3,11 @@ from fastapi import HTTPException, status from models.segment import ( - Segment, SegmentBase, SegmentDict, SegmentUpdateBase, - SegmentWithAnswersDict + Segment, + SegmentBase, + SegmentDict, + SegmentUpdateBase, + SegmentWithAnswersDict, ) from models.segment_answer import SegmentAnswer @@ -44,7 +47,7 @@ def get_segment_by_id(session: Session, id: int) -> SegmentDict: if not segment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Segment {id} not found" + detail=f"Segment {id} not found", ) return segment @@ -93,8 +96,7 @@ def delete_segment(session: Session, id: int): segment = get_segment_by_id(session=session, id=id) # delete segment answers segment_answers = ( - session.query(SegmentAnswer) - .filter(SegmentAnswer.segment == id).all() + session.query(SegmentAnswer).filter(SegmentAnswer.segment == id).all() ) for sa in segment_answers: session.delete(sa) @@ -110,6 +112,6 @@ def get_segments_by_case_id( if not segments: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Segments with case {case_id} not found" + detail=f"Segments with case {case_id} not found", ) return segments diff --git a/backend/models/case.py b/backend/models/case.py index 652ee8d1..c95cd8e4 100644 --- a/backend/models/case.py +++ b/backend/models/case.py @@ -21,7 +21,12 @@ SimplifiedCaseCommodityDict, CaseCommodityType, ) -from models.segment import Segment, SegmentDict, SegmentWithAnswersDict +from models.segment import ( + Segment, + SegmentDict, + SegmentWithAnswersDict, + CaseSettingSegmentPayload, +) from models.case_tag import CaseTag @@ -336,6 +341,7 @@ class CaseBase(BaseModel): other_commodities: Optional[List[OtherCommoditysBase]] = None tags: Optional[List[int]] = None company: Optional[int] = None + segments: Optional[List[CaseSettingSegmentPayload]] = None class PaginatedCaseResponse(BaseModel): diff --git a/backend/models/segment.py b/backend/models/segment.py index 2f2e34ff..0e8fe6c0 100644 --- a/backend/models/segment.py +++ b/backend/models/segment.py @@ -16,6 +16,7 @@ class SegmentDict(TypedDict): target: Optional[float] adult: Optional[float] child: Optional[float] + number_of_farmers: Optional[int] class SimplifiedSegmentDict(TypedDict): @@ -25,6 +26,7 @@ class SimplifiedSegmentDict(TypedDict): target: Optional[float] adult: Optional[float] child: Optional[float] + number_of_farmers: Optional[int] class SegmentWithAnswersDict(TypedDict): @@ -35,42 +37,45 @@ class SegmentWithAnswersDict(TypedDict): target: Optional[float] adult: Optional[float] child: Optional[float] + number_of_farmers: Optional[int] answers: Optional[dict] benchmark: Optional[LivingIncomeBenchmarkDict] class Segment(Base): - __tablename__ = 'segment' + __tablename__ = "segment" id = Column(Integer, primary_key=True, nullable=False) - case = Column(Integer, ForeignKey('case.id')) - region = Column(Integer, ForeignKey('region.id'), nullable=True) + case = Column(Integer, ForeignKey("case.id")) + region = Column(Integer, ForeignKey("region.id"), nullable=True) name = Column(String, nullable=False) target = Column(Float, nullable=True) adult = Column(Float, nullable=True) child = Column(Float, nullable=True) + number_of_farmers = Column(Integer, nullable=True) case_detail = relationship( - 'Case', + "Case", cascade="all, delete", passive_deletes=True, - back_populates='case_segments' + back_populates="case_segments", ) segment_answers = relationship( - 'SegmentAnswer', + "SegmentAnswer", cascade="all, delete", passive_deletes=True, - backref='segment_detail' + backref="segment_detail", ) def __init__( self, name: str, - case: int, + case: Optional[int] = None, region: Optional[int] = None, target: Optional[float] = None, adult: Optional[float] = None, child: Optional[float] = None, + number_of_farmers: Optional[int] = None, id: Optional[int] = None, ): self.id = id @@ -80,6 +85,7 @@ def __init__( self.target = target self.adult = adult self.child = child + self.number_of_farmers = number_of_farmers def __repr__(self) -> int: return f"" @@ -94,6 +100,7 @@ def serialize(self) -> SegmentDict: "target": self.target, "adult": self.adult, "child": self.child, + "number_of_farmers": self.number_of_farmers, } @property @@ -105,6 +112,7 @@ def simplify(self) -> SimplifiedSegmentDict: "target": self.target, "adult": self.adult, "child": self.child, + "number_of_farmers": self.number_of_farmers, } @property @@ -126,6 +134,7 @@ def serialize_with_answers(self) -> SegmentWithAnswersDict: "child": self.child, "answers": answers, "benchmark": None, + "number_of_farmers": self.number_of_farmers, } @@ -148,3 +157,9 @@ class SegmentUpdateBase(BaseModel): adult: Optional[float] = None child: Optional[float] = None answers: Optional[List[SegmentAnswerBase]] = [] + + +class CaseSettingSegmentPayload(BaseModel): + name: str + number_of_farmers: Optional[int] = None + id: Optional[int] = None diff --git a/backend/routes/segment.py b/backend/routes/segment.py index 827f2579..9744246a 100644 --- a/backend/routes/segment.py +++ b/backend/routes/segment.py @@ -12,7 +12,6 @@ from db.connection import get_session from models.segment import ( SegmentBase, - SegmentDict, SegmentUpdateBase, SegmentWithAnswersDict, ) @@ -24,7 +23,7 @@ @segment_route.post( "/segment", - response_model=List[SegmentDict], + response_model=List[SegmentWithAnswersDict], summary="create segment", name="segment:create", tags=["Segment"], @@ -45,12 +44,12 @@ def create_segment( session=session, case_id=case_id, user_id=user.id ) segments = crud_segment.add_segment(session=session, payloads=payload) - return [s.serialize for s in segments] + return [s.serialize_with_answers for s in segments] @segment_route.put( "/segment", - response_model=List[SegmentDict], + response_model=List[SegmentWithAnswersDict], summary="update segment", name="segment:update", tags=["Segment"], @@ -71,7 +70,7 @@ def update_segment( crud_case.case_updated_by( session=session, case_id=case_id, user_id=user.id ) - return [s.serialize for s in segments] + return [s.serialize_with_answers for s in segments] @segment_route.delete( diff --git a/backend/tests/test_040_segment.py b/backend/tests/test_040_segment.py index 6877b7b2..508683ef 100644 --- a/backend/tests/test_040_segment.py +++ b/backend/tests/test_040_segment.py @@ -50,6 +50,9 @@ async def test_create_segment( "target": 1000.0, "adult": 2.0, "child": 3.0, + "number_of_farmers": None, + "answers": {}, + "benchmark": None, } ] # with admin user cred @@ -102,6 +105,9 @@ async def test_create_segment( "target": 2000.0, "adult": 3.0, "child": 2.0, + "number_of_farmers": None, + "answers": {}, + "benchmark": None, }, { "id": res[1]["id"], @@ -111,6 +117,14 @@ async def test_create_segment( "target": 3000.0, "adult": 4.0, "child": 2.0, + "number_of_farmers": None, + "answers": { + "current-1-1": 10000.0, + "current-1-2": None, + "feasible-1-1": None, + "feasible-1-2": None, + }, + "benchmark": None, }, ] @@ -152,6 +166,9 @@ async def test_update_segment( "target": 2000.0, "adult": 4.0, "child": 2.0, + "number_of_farmers": None, + "answers": {}, + "benchmark": None, } ] # with admin user cred @@ -222,6 +239,9 @@ async def test_update_segment( "target": 2000.0, "adult": 5.0, "child": 0.0, + "number_of_farmers": None, + "answers": {}, + "benchmark": None, }, { "id": 2, @@ -231,6 +251,9 @@ async def test_update_segment( "target": 2000.0, "adult": 6.0, "child": 0.0, + "number_of_farmers": None, + "answers": {}, + "benchmark": None, }, { "id": 3, @@ -240,5 +263,15 @@ async def test_update_segment( "target": 3000.0, "adult": 4.0, "child": 2.0, + "number_of_farmers": None, + "answers": { + "current-1-1": 10000.0, + "current-1-2": None, + "current-1-3": None, + "feasible-1-1": None, + "feasible-1-2": None, + "feasible-1-3": 500.0, + }, + "benchmark": None, }, ] diff --git a/backend/tests/test_051_segment_answer_continued.py b/backend/tests/test_051_segment_answer_continued.py index 86c5cba8..5740597e 100644 --- a/backend/tests/test_051_segment_answer_continued.py +++ b/backend/tests/test_051_segment_answer_continued.py @@ -53,6 +53,7 @@ async def test_get_segments_by_case_id( "target": 2000.0, "adult": 6.0, "child": 0.0, + "number_of_farmers": None, "answers": {}, "benchmark": None, }, @@ -64,6 +65,7 @@ async def test_get_segments_by_case_id( "target": 3000.0, "adult": 4.0, "child": 2.0, + "number_of_farmers": None, "answers": { "current-1-1": 10000.0, "current-1-2": None, @@ -82,6 +84,7 @@ async def test_get_segments_by_case_id( "target": 2000.0, "adult": 5.0, "child": 0.0, + "number_of_farmers": None, "answers": { "current-1-1": 100.0, "feasible-1-1": 100.0, @@ -149,6 +152,7 @@ async def test_get_case_by_id_with_segments( "target": 2000.0, "adult": 6.0, "child": 0.0, + "number_of_farmers": None, "answers": {}, "benchmark": None, }, @@ -160,6 +164,7 @@ async def test_get_case_by_id_with_segments( "target": 3000.0, "adult": 4.0, "child": 2.0, + "number_of_farmers": None, "answers": { "current-1-1": 10000.0, "current-1-2": None, @@ -178,6 +183,7 @@ async def test_get_case_by_id_with_segments( "target": 2000.0, "adult": 5.0, "child": 0.0, + "number_of_farmers": None, "answers": { "current-1-1": 100.0, "current-1-2": 200.0, diff --git a/backend/tests/test_1001_case_with_segments.py b/backend/tests/test_1001_case_with_segments.py new file mode 100644 index 00000000..afd28cf7 --- /dev/null +++ b/backend/tests/test_1001_case_with_segments.py @@ -0,0 +1,282 @@ +import sys +import pytest + +from fastapi import FastAPI +from httpx import AsyncClient +from sqlalchemy.orm import Session +from tests.test_000_main import Acc + +from models.case import LivingIncomeStudyEnum +from models.case_commodity import CaseCommodityType + +sys.path.append("..") + +non_admin_account = Acc(email="editor@akvo.org", token=None) +admin_account = Acc(email="super_admin@akvo.org", token=None) + + +class TestCaseWithSegmentRoute: + @pytest.mark.asyncio + async def test_create_case_with_segment( + self, app: FastAPI, session: Session, client: AsyncClient + ) -> None: + payload = { + "name": "Bali Rice and Corn with Segment", + "description": "This is a description", + "date": "2024-10-03", + "year": 2024, + "country": 2, + "focus_commodity": 2, + "currency": "USD", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + "cost_of_production_unit": "Per-area", + "reporting_period": "Per-season", + "segmentation": False, + "living_income_study": LivingIncomeStudyEnum.better_income.value, + "multiple_commodities": False, + "other_commodities": [ + { + "commodity": 3, + "breakdown": True, + "commodity_type": CaseCommodityType.secondary.value, + "volume_measurement_unit": "liters", + "area_size_unit": "hectare", + } + ], + "tags": [1], + "company": 1, + "segments": [ + { + "name": "Segment 1 Name", + "number_of_farmers": 10, + }, + { + "name": "Segment 2 Name", + "number_of_farmers": 8, + }, + ], + } + # with admin user cred + res = await client.post( + app.url_path_for("case:create"), + headers={"Authorization": f"Bearer {admin_account.token}"}, + json=payload, + ) + assert res.status_code == 200 + res = res.json() + assert res == { + "id": 12, + "name": "Bali Rice and Corn with Segment", + "description": "This is a description", + "date": "2024-10-03", + "year": 2024, + "country": 2, + "focus_commodity": 2, + "currency": "USD", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + "cost_of_production_unit": "Per-area", + "reporting_period": "Per-season", + "segmentation": False, + "living_income_study": "better_income", + "multiple_commodities": False, + "logo": None, + "created_by": 1, + "segments": [ + { + "id": 4, + "case": 12, + "name": "Segment 1 Name", + "region": None, + "target": None, + "adult": None, + "child": None, + "number_of_farmers": 10, + }, + { + "id": 5, + "case": 12, + "name": "Segment 2 Name", + "region": None, + "target": None, + "adult": None, + "child": None, + "number_of_farmers": 8, + }, + ], + "case_commodities": [ + { + "id": 13, + "commodity": 2, + "breakdown": True, + "commodity_type": "focus", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + }, + { + "id": 14, + "commodity": 3, + "breakdown": True, + "commodity_type": "secondary", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + }, + { + "id": 15, + "commodity": None, + "breakdown": True, + "commodity_type": "diversified", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + }, + ], + "private": False, + "tags": [1], + "company": 1, + } + + @pytest.mark.asyncio + async def test_update_case_with_segment( + self, app: FastAPI, session: Session, client: AsyncClient + ) -> None: + payload = { + "name": "Bali Rice and Corn with Segment", + "description": "This is a description", + "date": "2024-10-03", + "year": 2024, + "country": 2, + "focus_commodity": 2, + "currency": "USD", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + "cost_of_production_unit": "Per-area", + "reporting_period": "Per-season", + "segmentation": False, + "living_income_study": LivingIncomeStudyEnum.better_income.value, + "multiple_commodities": False, + "other_commodities": [ + { + "commodity": 3, + "breakdown": True, + "commodity_type": CaseCommodityType.secondary.value, + "volume_measurement_unit": "liters", + "area_size_unit": "hectare", + } + ], + "tags": [1], + "company": 1, + "segments": [ + { + "id": 4, + "name": "Segment 1 Name Updated", + "number_of_farmers": 11, + }, + { + "id": 5, + "name": "Segment 2 Name", + "number_of_farmers": 8, + }, + { + "name": "Segment 3 Name", + "number_of_farmers": 9, + }, + ], + } + # with admin user cred + res = await client.put( + app.url_path_for("case:update", case_id=12), + params={"updated": True}, + headers={"Authorization": f"Bearer {admin_account.token}"}, + json=payload, + ) + assert res.status_code == 200 + res = res.json() + assert res == { + "id": 12, + "name": "Bali Rice and Corn with Segment", + "description": "This is a description", + "date": "2024-10-03", + "year": 2024, + "country": 2, + "focus_commodity": 2, + "currency": "USD", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + "cost_of_production_unit": "Per-area", + "reporting_period": "Per-season", + "segmentation": False, + "living_income_study": "better_income", + "multiple_commodities": False, + "created_by": "super_admin@akvo.org", + "created_at": res["created_at"], + "updated_by": "John Doe", + "updated_at": res["updated_at"], + "segments": [ + { + "id": 5, + "case": 12, + "name": "Segment 2 Name", + "region": None, + "target": None, + "adult": None, + "child": None, + "number_of_farmers": 8, + "answers": {}, + "benchmark": None, + }, + { + "id": 4, + "case": 12, + "name": "Segment 1 Name Updated", + "region": None, + "target": None, + "adult": None, + "child": None, + "number_of_farmers": 11, + "answers": {}, + "benchmark": None, + }, + { + "id": 6, + "case": 12, + "name": "Segment 3 Name", + "region": None, + "target": None, + "adult": None, + "child": None, + "number_of_farmers": 9, + "answers": {}, + "benchmark": None, + }, + ], + "case_commodities": [ + { + "id": 13, + "commodity": 2, + "breakdown": True, + "commodity_type": "focus", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + }, + { + "id": 14, + "commodity": 3, + "breakdown": True, + "commodity_type": "secondary", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + }, + { + "id": 15, + "commodity": None, + "breakdown": True, + "commodity_type": "diversified", + "area_size_unit": "hectare", + "volume_measurement_unit": "liters", + }, + ], + "private": False, + "tags": [1], + "company": 1, + } diff --git a/backend/tests/test_1010_user_deletion.py b/backend/tests/test_1010_user_deletion.py index 7781cbe2..aadff523 100644 --- a/backend/tests/test_1010_user_deletion.py +++ b/backend/tests/test_1010_user_deletion.py @@ -50,7 +50,8 @@ async def test_deleting_a_user_who_has_cases( "id": 1, "email": "super_admin@akvo.org", "cases": [ - {"label": "Bali Coffee Production (Private)", "value": 2} + {"label": "Bali Coffee Production (Private)", "value": 2}, + {"label": "Bali Rice and Corn with Segment", "value": 12}, ], } } diff --git a/frontend/src/App.js b/frontend/src/App.js index ca8163df..9535d77a 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -8,6 +8,7 @@ import { PageLayout } from "./components/layout"; import { Home } from "./pages/home"; import { Landing } from "./pages/landing"; import { Login, ResetPassword } from "./pages/login"; +import { Cases as OldCases, Case as OldCase } from "./pages/old-cases"; import { Cases, Case } from "./pages/cases"; import { NotFound } from "./pages/not-found"; import { Welcome } from "./pages/welcome"; @@ -135,8 +136,15 @@ const App = () => { /> } /> } /> - } /> - } /> + } /> + {/* + TODO :: Delete later + Old Case Page + */} + } /> + } /> + } /> + {/* EOL Old Case */} ) : ( "" diff --git a/frontend/src/App.scss b/frontend/src/App.scss index 591c96f8..48c74429 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -41,12 +41,27 @@ font-weight: 700; } + .button-green-transparent { + border: 2px solid $primary-color; + border-radius: 20px; + color: $primary-color; + font-weight: 700; + background: transparent; + } + .button-green-fill { background: $primary-color; border-radius: 20px; color: #ffffff !important; font-weight: 700; } + + .button-ghost { + background: transparent; + border-radius: 20px; + border-color: #000; + font-weight: 700; + } // EOL global css setting header { @@ -55,7 +70,8 @@ padding: 0.5rem $default-padding-size; display: flex; align-items: center; - box-shadow: 0px 4px 20px 0px rgba(0, 0, 0, 0.08); + box-shadow: 0px 0px 3px 2px rgba(0, 0, 0, 0.08); + z-index: 999 !important; .title { margin-left: $default-padding-size; @@ -183,12 +199,17 @@ color: #8c8c8c; } - .title { + .title-wrapper { padding: 0 $default-padding-size; + padding-top: 14px; + } + + .title { font-family: "TabletGothicBold"; font-size: 1.5rem; font-weight: 600; padding-top: 4px; + color: $primary-color; } .subTitle { @@ -368,3 +389,51 @@ font-family: "TabletGothic" !important; font-size: 12px; } + +.visual-card-wrapper { + padding: 0; + border-radius: 20px; + + .ant-card-head { + padding: 12px 24px; + background: #dfdfdf; + border-radius: 20px 20px 0px 0px; + + .title { + font-family: "RocGrotesk"; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 157.15%; + } + + .button-export { + border: 1px solid #01625f; + font-weight: 600; + font-size: 12px; + background: transparent; + color: #01625f; + border-radius: 40px; + + &:hover { + border: 2px solid #01625f; + } + } + } + + .ant-card-body { + padding: 18px 24px; + border-radius: 0px 0px 20px 20px; + background: #fff; + } + + &.bordered { + .ant-card-head { + border: 1px solid #dfdfdf; + } + .ant-card-body { + border: 1px solid #dfdfdf; + border-top: none; + } + } +} diff --git a/frontend/src/components/chart/lib/index.js b/frontend/src/components/chart/lib/index.js new file mode 100644 index 00000000..4e522451 --- /dev/null +++ b/frontend/src/components/chart/lib/index.js @@ -0,0 +1,142 @@ +import { isEmpty } from "lodash"; +import { + Legend, + TextStyle, + AxisLabelFormatter, + LabelStyle, + incomeTargetChartOption, + Color, + backgroundColor, + Easing, + NoData, + formatNumberToString, + thousandFormatter, +} from "../options/common"; + +export const getColumnStackBarOptions = ({ + xAxis = { name: "", axisLabel: {} }, + yAxis = { name: "", min: 0, max: 0 }, + origin = [], + series = [], + showLabel = false, + grid = {}, +}) => { + if (isEmpty(series) || !series) { + return NoData; + } + + const legends = series.map((x) => ({ + name: x.name, + icon: x?.symbol || "circle", + })); + const xAxisData = origin.map((x) => x.name); + + const options = { + legend: { + ...Legend, + data: legends, + top: 15, + left: "right", + orient: "vertical", + }, + tooltip: { + trigger: "axis", + axisPointer: { + type: "shadow", + }, + formatter: function (params) { + let res = "
"; + res += "" + params[0].axisValueLabel + ""; + res += "
    "; + params.forEach((param) => { + res += "
  • "; + res += ""; + res += param.marker; + res += param.seriesName; + res += ""; + res += + "" + + thousandFormatter(param.value) + + ""; + res += "
  • "; + }); + res += "
"; + res += "
"; + return res; + }, + backgroundColor: "#ffffff", + ...TextStyle, + }, + grid: { + top: grid?.top ? grid.top : 25, + left: grid?.left ? grid.left : 50, + right: grid?.right ? grid.right : 190, + bottom: grid?.bottom ? grid.bottom : 25, + show: true, + containLabel: true, + label: { + color: "#222", + ...TextStyle, + }, + }, + xAxis: { + ...xAxis, + nameTextStyle: { ...TextStyle }, + nameLocation: "middle", + nameGap: 50, + boundaryGap: true, + type: "category", + data: xAxisData, + axisLabel: { + width: 100, + interval: 0, + overflow: "break", + ...TextStyle, + color: "#4b4b4e", + formatter: AxisLabelFormatter?.formatter, + ...xAxis.axisLabel, + }, + axisTick: { + alignWithLabel: true, + }, + }, + yAxis: { + ...yAxis, + type: "value", + nameTextStyle: { ...TextStyle }, + nameLocation: "middle", + nameGap: 75, + axisLabel: { + formatter: function (value) { + return formatNumberToString(value); + }, + ...TextStyle, + color: "#9292ab", + }, + }, + series: series.map((s) => { + s = { + ...s, + barMaxWidth: 50, + emphasis: { + focus: "series", + }, + }; + if (s.type === "line") { + return { ...incomeTargetChartOption, ...s }; + } + return { + ...s, + label: { + ...LabelStyle.label, + show: showLabel, + position: "inside", + }, + }; + }), + ...Color, + ...backgroundColor, + ...Easing, + }; + return options; +}; diff --git a/frontend/src/components/chart/options/Bar.js b/frontend/src/components/chart/options/Bar.js index c5028957..819d4b05 100644 --- a/frontend/src/components/chart/options/Bar.js +++ b/frontend/src/components/chart/options/Bar.js @@ -9,6 +9,7 @@ import { axisTitle, NoData, thousandFormatter, + formatNumberToString, } from "./common"; import { sortBy, isEmpty, sumBy } from "lodash"; @@ -68,6 +69,9 @@ const Bar = ({ axisLabel: { ...TextStyle, color: "#9292ab", + formatter: function (value) { + return formatNumberToString(value); + }, }, }, [horizontal ? "yAxis" : "xAxis"]: { diff --git a/frontend/src/components/chart/options/ColumnBar.js b/frontend/src/components/chart/options/ColumnBar.js index 28565e32..ef2dec53 100644 --- a/frontend/src/components/chart/options/ColumnBar.js +++ b/frontend/src/components/chart/options/ColumnBar.js @@ -10,6 +10,7 @@ import { Legend, thousandFormatter, LabelStyle, + formatNumberToString, } from "./common"; import { sortBy, isEmpty, groupBy, orderBy } from "lodash"; @@ -148,6 +149,9 @@ const ColumnBar = ({ axisLabel: { ...TextStyle, color: "#9292ab", + formatter: function (value) { + return formatNumberToString(value); + }, }, }, [horizontal ? "yAxis" : "xAxis"]: { diff --git a/frontend/src/components/layout/ContentLayout.js b/frontend/src/components/layout/ContentLayout.js index 7dff4097..f04f169e 100644 --- a/frontend/src/components/layout/ContentLayout.js +++ b/frontend/src/components/layout/ContentLayout.js @@ -1,5 +1,5 @@ import React, { useState, useMemo } from "react"; -import { Breadcrumb, Card, Tabs, Affix } from "antd"; +import { Breadcrumb, Card, Tabs, Affix, Row, Col } from "antd"; import { HomeOutlined, RightOutlined } from "@ant-design/icons"; import { adminRole } from "../../store/static"; import { UserState } from "../../store"; @@ -33,6 +33,7 @@ const ContentLayout = ({ title = null, subTitle = null, breadcrumbRightContent = null, + titleRighContent = null, }) => { const navigate = useNavigate(); const hasBreadcrumb = breadcrumbItems.length; @@ -87,24 +88,38 @@ const ContentLayout = ({ ) : ( "" )} - {title ? ( -
- {title} -
- ) : ( - "" - )} - {subTitle ? ( -
- {subTitle} -
- ) : ( - "" - )} + + + {title ? ( +
+ {title} +
+ ) : ( + "" + )} + {subTitle ? ( +
+ {subTitle} +
+ ) : ( + "" + )} + + {titleRighContent && ( + + + + {titleRighContent} + + + + )} +
{adminRole.includes(userRole) && currentPath.includes("/admin/") && showTabItemsForPath.includes(window.location.pathname) ? ( diff --git a/frontend/src/lib/formula.js b/frontend/src/lib/formula.js new file mode 100644 index 00000000..459d2f88 --- /dev/null +++ b/frontend/src/lib/formula.js @@ -0,0 +1,31 @@ +export const customFormula = { + revenue_focus_commodity: "#2 * #3 * #4", + focus_commodity_cost_of_production: + "( ( #5 * #2 ) + ( #26 * #3 * #2 ) ) * -1", +}; + +/** + * NOTE + * Focus Income formula ( ( #2 * #3 * #4 ) + ( #40 * #41 * #42 ) ) - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + * Target = 9001, + * Diversified = 9002 + * i = ( a * p * v ) - ( cop * a ) + di + */ +export const yAxisFormula = { + "#2": "( #9002 - #9001 ) / ( ( #5 + ( #26 * #3 ) + #43 ) - ( ( #4 * #3 ) + ( #42 * #41 ) ) )", // area + "#3": "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #4 * #2 ) + ( #42 * #40 ) )", // volume + "#4": "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #3 * #2 ) + ( #41 * #40 ) )", // price + "#5": "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + #9002 ) / ( #2 + #40 )", // CoP for crop + "#26": + "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + #9002 ) / ( ( #3 * #2 ) + ( #41 * #40 ) )", // CoP for aqua + "#9002": + "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) )", // diversified income for all question type + "#40": + "( #9002 - #9001 ) / ( ( #5 + ( #26 * #3 ) + #43 ) - ( ( #4 * #3 ) + ( #42 * #41 ) ) )", // animals (area) + "#41": + "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #4 * #2 ) + ( #42 * #40 ) )", // volume for animals + "#42": + "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #3 * #2 ) + ( #41 * #40 ) )", // price for animals + "#43": + "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + #9002 ) / ( #2 + #40 )", // CoP for animals +}; diff --git a/frontend/src/lib/index.js b/frontend/src/lib/index.js index 09dbb828..1cd14563 100644 --- a/frontend/src/lib/index.js +++ b/frontend/src/lib/index.js @@ -1,3 +1,12 @@ +import uniq from "lodash/uniq"; +import { + commodities, + disableLandUnitFieldForCommodityTypes, + disableIncomeDriversFieldForCommodityTypes, +} from "../store/static"; +import { Tag } from "antd"; +import { ArrowUpOutlined, ArrowDownOutlined } from "@ant-design/icons"; + export const flatten = (data, parent = null) => { let flatData = []; for (const item of data) { @@ -12,4 +21,184 @@ export const flatten = (data, parent = null) => { return flatData; }; +export const selectProps = { + showSearch: true, + allowClear: true, + optionFilterProp: "label", + style: { + width: "100%", + }, +}; + +export const regexQuestionId = /#(\d+)/; + +export const determineDecimalRound = (value) => (value % 1 === 0 ? 0 : 2); + +export const getFunctionDefaultValue = (question, prefix, values = []) => { + const function_name = question?.default_value?.split(" "); + if (!function_name) { + return 0; + } + const getFunction = function_name.reduce((acc, fn) => { + const questionValue = fn.match(regexQuestionId); + if (questionValue) { + const valueName = `${prefix}-${questionValue[1]}`; + const value = values.find((v) => v.id === valueName)?.value; + if (!value) { + acc.push(0); + return acc; + } + acc.push(value.toString()); + } else { + acc.push(fn); + } + return acc; + }, []); + const finalFunction = getFunction.join(""); + return eval(finalFunction); +}; + +export const generateSegmentPayloads = ( + values, + currentCaseId, + commodityList +) => { + // generate segment payloads + const segmentPayloads = values.map((fv) => { + let res = { + case: currentCaseId, + region: fv.region, + name: fv.label, + target: fv?.target || null, + adult: fv?.adult || null, + child: fv?.child || null, + }; + if (fv?.currentSegmentId) { + res = { + ...res, + id: fv.currentSegmentId, + }; + } + // generate segment answer payloads + let segmentAnswerPayloads = []; + const questionIDs = uniq( + Object.keys(fv.answers).map((key) => { + const splitted = key.split("-"); + return parseInt(splitted[2]); + }) + ); + commodityList.forEach((cl) => { + const case_commodity = cl.case_commodity; + questionIDs.forEach((qid) => { + const fieldKey = `${case_commodity}-${qid}`; + const currentValue = fv.answers[`current-${fieldKey}`]; + const feasibleValue = fv.answers[`feasible-${fieldKey}`]; + const answerTmp = { + case_commodity: case_commodity, + question: qid, + current_value: currentValue, + feasible_value: feasibleValue, + }; + segmentAnswerPayloads.push(answerTmp); + }); + }); + segmentAnswerPayloads = segmentAnswerPayloads.filter( + (x) => x.current_value || x.feasible_value + ); + if (segmentAnswerPayloads.length) { + res = { + ...res, + answers: segmentAnswerPayloads, + }; + } + return res; + }); + return segmentPayloads; +}; + +export const InputNumberThousandFormatter = { + formatter: (value, _, round = false) => { + if (round) { + value = Math.round(parseFloat(value)); + } + + // Convert value to a string and split into integer and decimal parts + const [integerPart, decimalPart] = `${value}`.split("."); + + // Format the integer part with commas + const formattedIntegerPart = integerPart.replace( + /\B(?=(\d{3})+(?!\d))/g, + "," + ); + + // Combine the formatted integer part with the decimal part, if it exists + return typeof decimalPart !== "undefined" + ? `${formattedIntegerPart}.${decimalPart}` + : formattedIntegerPart; + }, + parser: (value) => value.replace(/\$\s?|(,*)/g, ""), +}; + +export const removeUndefinedObjectValue = (obj) => { + return Object.entries(obj).reduce((acc, [key, value]) => { + if (typeof value !== "undefined") { + acc[key] = value; + } + return acc; + }, {}); +}; + +export const getFieldDisableStatusForCommodity = (commodity) => { + const findCommodityCategory = commodities + .find((c) => c.id === commodity) + ?.category?.toLowerCase(); + const disableLandUnitField = disableLandUnitFieldForCommodityTypes.includes( + findCommodityCategory + ) + ? true + : false; + const disableDataOnIncomeDriverField = + disableIncomeDriversFieldForCommodityTypes.includes(findCommodityCategory) + ? true + : false; + return { disableLandUnitField, disableDataOnIncomeDriverField }; +}; + +export const calculateIncomePercentage = ({ current, feasible }) => { + if (current && feasible) { + const percent = (feasible / current - 1) * 100; + return { + type: percent === 0 ? "default" : percent > 0 ? "increase" : "decrease", + value: percent, + }; + } + return { + type: "default", + value: 0, + }; +}; + +export const renderPercentageTag = (type = "default", value = 0) => { + value = value % 1 !== 0 || value === 0 ? value.toFixed(2) : value; + value = `${value}%`; + + switch (type) { + case "increase": + return ( + }> + {value} + + ); + case "decrease": + return ( + }> + {value} + + ); + + default: + return {value}; + } +}; + export { default as api } from "./api"; diff --git a/frontend/src/pages/admin/users/Users.js b/frontend/src/pages/admin/users/Users.js index 9ec0f6b4..cc5330d0 100644 --- a/frontend/src/pages/admin/users/Users.js +++ b/frontend/src/pages/admin/users/Users.js @@ -14,7 +14,7 @@ import { List, message, } from "antd"; -import { selectProps } from "../../cases/components"; +import { selectProps } from "../../old-cases/components"; import "./user.scss"; const perPage = 10; @@ -131,7 +131,7 @@ const Users = () => { ), okText: "Go to cases", onOk: () => { - const URL = `/cases?owner=${user.email}`; + const URL = `/old-cases?owner=${user.email}`; window.open(URL, "_blank"); }, cancelText: "Cancel", diff --git a/frontend/src/pages/cases/Case.js b/frontend/src/pages/cases/Case.js index 9349d7da..a9a02173 100644 --- a/frontend/src/pages/cases/Case.js +++ b/frontend/src/pages/cases/Case.js @@ -1,31 +1,30 @@ import React, { useState, useEffect, useMemo } from "react"; +import { Spin, Tabs, Row, Col } from "antd"; +import { CaseWrapper } from "./layout"; import { useParams, useNavigate } from "react-router-dom"; -import { ContentLayout } from "../../components/layout"; +import { api, flatten, getFunctionDefaultValue } from "../../lib"; import { - SideMenu, - CaseProfile, - IncomeDriverDataEntry, - IncomeDriverDashboard, - getFunctionDefaultValue, - customFormula, -} from "./components"; -import { Row, Col, Spin, Card, Alert } from "antd"; -import "./cases.scss"; -import { api, flatten } from "../../lib"; -import { CaseTitleIcon } from "../../lib/icon"; -import dayjs from "dayjs"; -import isEmpty from "lodash/isEmpty"; -import orderBy from "lodash/orderBy"; + CurrentCaseState, + stepPath, + CaseUIState, + PrevCaseState, + CaseVisualState, +} from "./store"; import { UserState } from "../../store"; +import { + SetIncomeTarget, + EnterIncomeData, + UnderstandIncomeGap, + AssessImpactMitigationStrategies, + ClosingGap, +} from "./steps"; +import { EnterIncomeDataVisual } from "./visualizations"; +import "./steps/steps.scss"; +import { isEmpty, orderBy } from "lodash"; +import { customFormula } from "../../lib/formula"; import { adminRole } from "../../store/static"; -const pageDependencies = { - "Income Driver Data Entry": ["Case Profile"], - "Income Driver Dashboard": ["Case Profile", "Income Driver Data Entry"], -}; - const commodityOrder = ["focus", "secondary", "tertiary", "diversified"]; - const masterCommodityCategories = window.master?.commodity_categories || []; const commodityNames = masterCommodityCategories.reduce((acc, curr) => { const commodities = curr.commodities.reduce((a, c) => { @@ -34,478 +33,489 @@ const commodityNames = masterCommodityCategories.reduce((acc, curr) => { return { ...acc, ...commodities }; }, {}); -const options = { - year: "numeric", - month: "long", - day: "numeric", -}; - -const Case = () => { - const { caseId } = useParams(); - const navigate = useNavigate(); - const [caseTitle, setCaseTitle] = useState("New Case"); - const [caseDescription, setCaseDescription] = useState(null); - const [page, setPage] = useState("Case Profile"); - const [formData, setFormData] = useState({}); - const [finished, setFinished] = useState([]); - const [commodityList, setCommodityList] = useState([]); - const [caseData, setCaseData] = useState([]); - const [questionGroups, setQuestionGroups] = useState([]); - const [currentCaseId, setCurrentCaseId] = useState(null); - const [loading, setLoading] = useState(false); - const [initialOtherCommodityTypes, setInitialCommodityTypes] = useState([]); - const [currentCase, setCurrentCase] = useState({}); - const showCaseTitle = false; // don't show title for now - - const { - role: userRole, - internal_user: userInternal, - case_access: userCaseAccess, - email: userEmail, - } = UserState.useState((s) => s); +const Loading = () => ( +
+ +
+); - const enableEditCase = useMemo(() => { - const caseIdParam = caseId ? caseId : currentCaseId; - if (adminRole.includes(userRole)) { - return true; - } - // allow internal user to create new case - if (userInternal && !caseIdParam) { - return true; - } - // check user access - const userPermission = userCaseAccess.find( - (a) => a.case === parseInt(caseIdParam) - )?.permission; - // allow internal user case owner to edit case - if (userInternal && currentCase?.created_by === userEmail) { - return true; - } - if ((userInternal && !userPermission) || userPermission === "view") { - return false; - } - if (userPermission === "edit") { - return true; +const addLevelIntoQuestions = ({ questions, level = 0 }) => { + return questions.map((q) => { + if (q.childrens.length) { + q["childrens"] = addLevelIntoQuestions({ + questions: q.childrens, + level: level + 1, + }); } - return false; - }, [ - caseId, - currentCaseId, - userRole, - userEmail, - userCaseAccess, - userInternal, - currentCase?.created_by, - ]); - - useEffect(() => { - if (caseId && caseData.length) { - setFinished(["Case Profile", "Income Driver Data Entry"]); + if (!q.parent) { + return { + ...q, + level: 0, + }; } - }, [caseData, caseId]); + return { + ...q, + level: level, + }; + }); +}; - const totalIncomeQuestion = useMemo(() => { - const qs = questionGroups.map((group) => { - if (!group) { - return []; - } - const questions = flatten(group.questions).filter((q) => !q.parent); - const commodity = commodityList.find( - (c) => c.commodity === group.commodity_id +const renderPage = (key, navigate) => { + switch (key) { + case stepPath.step1.label: + return ( + + + ); - return questions.map((q) => `${commodity.case_commodity}-${q.id}`); - }); - return qs.flatMap((q) => q); - }, [questionGroups, commodityList]); - - const costQuestions = useMemo(() => { - const qs = questionGroups.map((group) => { - if (!group) { - return []; - } - const questions = flatten(group.questions).filter((q) => - q.text.toLowerCase().includes("cost") + case stepPath.step2.label: + return ( + + + + ); - return questions.map((q) => ({ - ...q, - commodityId: group.commodity_id, - })); - }); - return qs.flatMap((q) => q); - }, [questionGroups]); + case stepPath.step3.label: + return ; + case stepPath.step4.label: + return ; + case stepPath.step5.label: + return ( + + + + ); + default: + return navigate("/not-found"); + } +}; - const flattenedQuestionGroups = useMemo(() => { - const qg = questionGroups.map((group) => { - const questions = group ? flatten(group.questions) : []; - return questions.map((q) => ({ - ...q, - commodityId: group.commodity_id, - })); - }); - return qg.flatMap((q) => q); - }, [questionGroups]); +const SegmentTabsWrapper = ({ children, setbackfunction, setnextfunction }) => { + const currentCase = CurrentCaseState.useState((s) => s); + const { activeSegmentId } = CaseUIState.useState((s) => s.general); + const childrenCount = React.Children.count(children); - const dashboardData = useMemo(() => { - const mappedData = caseData.map((d) => { - const answers = Object.keys(d.answers).map((k) => { - const [dataType, caseCommodityId, questionId] = k.split("-"); - const commodity = commodityList.find( - (x) => x.case_commodity === parseInt(caseCommodityId) - ); - const commodityId = commodity.commodity; - const commodityFocus = - commodity.commodity_type === "focus" ? true : false; - const totalCommodityQuestion = questionGroups - .map((group) => { - if (!group) { - return []; - } - const questions = flatten(group.questions).filter( - (q) => !q.parent && q.question_type === "aggregator" - ); - return questions; - }) - .flatMap((q) => q); + const segmentTabItems = useMemo(() => { + return currentCase.segments.map((segment) => ({ + label: segment.name, + key: segment.id, + children: + childrenCount === 1 + ? React.Children.map(children, (child) => + React.isValidElement(child) + ? React.cloneElement(child, { + segment, + setbackfunction, + setnextfunction, + }) + : null + ) + : React.Children.map(children, (child) => + child.key === "left" + ? React.isValidElement(child) + ? React.cloneElement(child, { + segment, + setbackfunction, + setnextfunction, + }) + : null + : null + ), + })); + }, [ + currentCase.segments, + children, + setbackfunction, + setnextfunction, + childrenCount, + ]); - const totalCommodityValue = totalCommodityQuestion.find( - (q) => q.id === parseInt(questionId) - ); - const cost = costQuestions.find( - (q) => - q.id === parseInt(questionId) && - q.parent === 1 && - q.commodityId === commodityId - ); - const question = flattenedQuestionGroups.find( - (q) => q.id === parseInt(questionId) && q.commodityId === commodityId - ); - const totalOtherDiversifiedIncome = - question?.question_type === "diversified" && !question.parent; - return { - name: dataType, - question: question, - commodityFocus: commodityFocus, - commodityType: commodity.commodity_type, - caseCommodityId: parseInt(caseCommodityId), - commodityId: parseInt(commodityId), - commodityName: commodityNames[commodityId], - questionId: parseInt(questionId), - value: d.answers?.[k] || 0, // if not found set as 0 to calculated inside array reduce - isTotalFeasibleFocusIncome: - totalCommodityValue && commodityFocus && dataType === "feasible" - ? true - : false, - isTotalFeasibleDiversifiedIncome: - totalCommodityValue && !commodityFocus && dataType === "feasible" - ? true - : totalOtherDiversifiedIncome && dataType === "feasible" - ? true - : false, - isTotalCurrentFocusIncome: - totalCommodityValue && commodityFocus && dataType === "current" - ? true - : false, - isTotalCurrentDiversifiedIncome: - totalCommodityValue && !commodityFocus && dataType === "current" - ? true - : totalOtherDiversifiedIncome && dataType === "current" - ? true - : false, - feasibleCost: - cost && d.answers[k] && dataType === "feasible" ? true : false, - currentCost: - cost && d.answers[k] && dataType === "current" ? true : false, - costName: cost ? cost.text : "", - }; - }); - const totalCostFeasible = answers - .filter((a) => a.feasibleCost) - .reduce((acc, curr) => acc + curr.value, 0); - const totalCostCurrent = answers - .filter((a) => a.currentCost) - .reduce((acc, curr) => acc + curr.value, 0); - const totalFeasibleFocusIncome = answers - .filter((a) => a.isTotalFeasibleFocusIncome) - .reduce((acc, curr) => acc + curr.value, 0); - const totalFeasibleDiversifiedIncome = answers - .filter((a) => a.isTotalFeasibleDiversifiedIncome) - .reduce((acc, curr) => acc + curr.value, 0); - const totalCurrentFocusIncome = answers - .filter((a) => a.isTotalCurrentFocusIncome) - .reduce((acc, curr) => acc + curr.value, 0); - const totalCurrentDiversifiedIncome = answers - .filter((a) => a.isTotalCurrentDiversifiedIncome) - .reduce((acc, curr) => acc + curr.value, 0); + return ( + + + { + CaseUIState.update((s) => ({ + ...s, + general: { + ...s.general, + activeSegmentId: val, + }, + })); + }} + /> + + {childrenCount > 1 && + React.Children.map(children, (child, index) => + child.key === "right" ? ( + React.isValidElement(child) ? ( + + {child} + + ) : null + ) : null + )} + + ); +}; - const focusCommodityAnswers = answers - .filter((a) => a.commodityType === "focus") - .map((a) => ({ - id: `${a.name}-${a.questionId}`, - value: a.value, - })); +const Case = () => { + const navigate = useNavigate(); + const { caseId, step } = useParams(); - 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 - ); + const [loading, setLoading] = useState(false); + const currentCase = CurrentCaseState.useState((s) => s); + const { questionGroups, totalIncomeQuestions } = CaseVisualState.useState( + (s) => s + ); + const userState = UserState.useState((s) => s); - return { - ...d, - 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: answers, + const updateStepIncomeTargetState = (key, value) => { + CaseUIState.update((s) => { + s.stepSetIncomeTarget = { + ...s.stepSetIncomeTarget, + [key]: value, }; }); - return orderBy(mappedData, ["id", "key"]); + }; + + // check for enableEditCase + useEffect(() => { + const checkEnableEditCase = () => { + if (adminRole.includes(userState?.role)) { + return true; + } + // allow internal user to create new case + if (userState?.internal_user && !caseId) { + return true; + } + // check user access + const userPermission = userState.case_access.find( + (a) => a.case === parseInt(caseId) + )?.permission; + // allow internal user case owner to edit case + if ( + userState?.internal_user && + currentCase?.created_by === userState?.email + ) { + return true; + } + if ( + (userState?.internal_user && !userPermission) || + userPermission === "view" + ) { + return false; + } + if (userPermission === "edit") { + return true; + } + return false; + }; + CaseUIState.update((s) => ({ + ...s, + general: { + ...s.general, + enableEditCase: checkEnableEditCase, + }, + })); }, [ - caseData, - commodityList, - costQuestions, - questionGroups, - flattenedQuestionGroups, + caseId, + userState?.internal_user, + userState?.email, + userState?.case_access, + userState?.role, + currentCase?.created_by, ]); + // Fetch case details useEffect(() => { - if (caseId && isEmpty(formData) && !loading) { - setCurrentCaseId(caseId); + if (caseId && currentCase.id !== parseInt(caseId)) { setLoading(true); + // prevent fetch the data when it's already defined api .get(`case/${caseId}`) .then((res) => { const { data } = res; - setCurrentCase(data); - setCaseTitle(data.name); - setCaseDescription(data.description); - // set other commodities type - setInitialCommodityTypes( - data.case_commodities.map((x) => x.commodity_type) - ); - // set commodity list and order by id to match - // focus, secondary, tertiary, diversified order - const commodities = commodityOrder - .map((co) => { - const temp = data.case_commodities.find( - (d) => d.commodity_type === co - ); - if (!temp) { - return false; - } - return { - ...temp, - currency: data.currency, - case_commodity: temp.id, - }; - }) - .filter((x) => x); - setCommodityList(commodities); - // focus commodity - const focusCommodityValue = { - name: data.name, - description: data.description, - private: data?.private || false, - tags: data?.tags || [], - country: data.country, - focus_commodity: data.focus_commodity, - year: dayjs(String(data.year)), - currency: data.currency, - area_size_unit: data.area_size_unit, - volume_measurement_unit: data.volume_measurement_unit, - reporting_period: data.reporting_period, - company: data.company, - }; - // secondary - let secondaryCommodityValue = {}; - const secondaryCommodityTmp = data.case_commodities.find( - (val) => val.commodity_type === "secondary" - ); - if (secondaryCommodityTmp) { - Object.keys(secondaryCommodityTmp).forEach((key) => { - let val = secondaryCommodityTmp[key]; - if (key === "breakdown") { - val = val ? 1 : 0; - } - secondaryCommodityValue = { - ...secondaryCommodityValue, - [`1-${key}`]: val, - }; - }); - } - // tertiary - let tertiaryCommodityValue = {}; - const tertiaryCommodityTmp = data.case_commodities.find( - (val) => val.commodity_type === "tertiary" - ); - if (tertiaryCommodityTmp) { - Object.keys(tertiaryCommodityTmp).forEach((key) => { - let val = tertiaryCommodityTmp[key]; - if (key === "breakdown") { - val = val ? 1 : 0; - } - tertiaryCommodityValue = { - ...tertiaryCommodityValue, - [`2-${key}`]: val, - }; - }); - } - // set initial value - setFormData({ - ...focusCommodityValue, - ...secondaryCommodityValue, - ...tertiaryCommodityValue, - }); + CurrentCaseState.update((s) => ({ ...s, ...data })); + PrevCaseState.update((s) => ({ ...s, ...data })); + // set default active segmentId + CaseUIState.update((s) => ({ + ...s, + general: { + ...s.general, + activeSegmentId: data.segments?.[0]?.id || null, + }, + })); }) .catch((e) => { - console.error("Error fetching case profile data", e); + console.error("Error fetching case data", e); navigate("/not-found"); }) .finally(() => { setTimeout(() => { setLoading(false); - setFinished(["Case Profile"]); }, 100); }); } - }, [caseId, formData, loading, navigate]); + }, [caseId, currentCase.id, navigate]); - const setActive = (selected) => { - if (finished.includes(selected)) { - setPage(selected); - } else { - const dependencies = pageDependencies[selected]; - if (dependencies) { - if (dependencies.every((dependency) => finished.includes(dependency))) { - setPage(selected); - } - } + // Fetch questions for income data entry + useEffect(() => { + if (currentCase?.id && currentCase?.case_commodities?.length) { + const reorderedCaseCommodities = commodityOrder + .map((co) => { + const findCommodity = currentCase.case_commodities.find( + (cc) => cc.commodity_type === co + ); + return findCommodity; + }) + .filter((x) => x); + + api.get(`/questions/${currentCase.id}`).then((res) => { + const { data } = res; + const incomeDataDriversTmp = []; + const diversifiedGroupTmp = []; + // regroup the questions to follow new design format + const questionGroupsTmp = reorderedCaseCommodities.map((cc) => { + const tmp = data.find((d) => d.commodity_id === cc.commodity); + tmp["currency"] = currentCase.currency; + tmp["questions"] = addLevelIntoQuestions({ + questions: tmp.questions, + }); + if (cc.commodity_type === "focus") { + incomeDataDriversTmp.push({ + groupName: "Primary Commodity", + questionGroups: [{ ...cc, ...tmp }], + }); + } else { + diversifiedGroupTmp.push({ ...cc, ...tmp }); + } + return { ...cc, ...tmp }; + }); + // add diversified group + incomeDataDriversTmp.push({ + groupName: "Diversified Income", + questionGroups: diversifiedGroupTmp, + }); + // eol + + // get totalIncomeQuestion + const qs = questionGroupsTmp.flatMap((group) => { + if (!group) { + return []; + } + const questions = flatten(group.questions).filter((q) => !q.parent); + // group id is case commodity id + return questions.map((q) => `${group.id}-${q.id}`); + }); + CaseVisualState.update((s) => ({ + ...s, + questionGroups: questionGroupsTmp, + totalIncomeQuestions: qs, + incomeDataDrivers: incomeDataDriversTmp, + })); + // eol + }); } - }; + }, [currentCase.id, currentCase.case_commodities, currentCase.currency]); + + // fetch region data + useEffect(() => { + if (currentCase?.country) { + updateStepIncomeTargetState("regionOptionLoading", true); + api + .get(`region/options?country_id=${currentCase.country}`) + .then((res) => { + updateStepIncomeTargetState("regionOptionStatus", 200); + updateStepIncomeTargetState("regionOptions", res.data); + }) + .catch((e) => { + const { status } = e.response; + updateStepIncomeTargetState("regionOptionStatus", status); + updateStepIncomeTargetState("regionOptions", []); + }) + .finally(() => { + updateStepIncomeTargetState("regionOptionLoading", false); + }); + } + }, [currentCase?.country]); + + // generate dashboard data + useEffect(() => { + if (!isEmpty(currentCase?.segments) && !isEmpty(questionGroups)) { + // 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") + ); + // eol generate questions + const mappedData = currentCase.segments.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, + }; + }); + CaseVisualState.update((s) => ({ + ...s, + dashboardData: orderBy(mappedData, ["id"]), + })); + } + }, [ + currentCase.segments, + currentCase.case_commodities, + questionGroups, + totalIncomeQuestions, + ]); return ( - - {loading ? ( -
- -
- ) : ( - - - {/* Banner for Viewer */} - {!enableEditCase && ( - - - - )} - {/* EOL Banner for Viewer */} - {showCaseTitle && ( - - -

{caseTitle}

- {caseDescription ?

{caseDescription}

: null} -
- -
-
- - )} - - {page === "Case Profile" && ( - - )} - {page === "Income Driver Data Entry" && ( - - )} - {page === "Income Driver Dashboard" && ( - - )} - -
- )} -
+ + {loading ? : renderPage(step, navigate)} + ); }; diff --git a/frontend/src/pages/cases/Cases.js b/frontend/src/pages/cases/Cases.js index 526244b7..26cfd37e 100644 --- a/frontend/src/pages/cases/Cases.js +++ b/frontend/src/pages/cases/Cases.js @@ -1,7 +1,22 @@ -import React, { useEffect, useState, useMemo } from "react"; -import { ContentLayout, TableContent } from "../../components/layout"; -import { Link, useSearchParams } from "react-router-dom"; +import React, { useState, useEffect, useMemo } from "react"; +import "./cases.scss"; +import { ContentLayout } from "../../components/layout"; +import { commodityOptions } from "../../store/static"; +import { DebounceSelect, CaseFilter, CaseSettings } from "./components"; import { + Row, + Col, + Button, + Table, + Input, + Space, + Popconfirm, + message, + Dropdown, +} from "antd"; +import { + PlusOutlined, + FilterOutlined, EditOutlined, UserSwitchOutlined, SaveOutlined, @@ -9,22 +24,15 @@ import { EyeOutlined, DeleteOutlined, } from "@ant-design/icons"; -import { api } from "../../lib"; +import { Link, useSearchParams } from "react-router-dom"; import { UIState, UserState } from "../../store"; -import { - Select, - Space, - Button, - Row, - Col, - Popconfirm, - message, - Input, - InputNumber, -} from "antd"; -import { selectProps, DebounceSelect } from "./components"; +import { api } from "../../lib"; import { isEmpty } from "lodash"; import { adminRole } from "../../store/static"; +import { stepPath } from "./store"; +import { resetCurrentCaseState } from "./store/current_case"; + +const { Search } = Input; const perPage = 10; const defData = { @@ -33,10 +41,21 @@ const defData = { total: 0, total_page: 1, }; -const filterProps = { - ...selectProps, - style: { width: window.innerHeight * 0.175 }, -}; + +const caseSelectorItems = [ + { + key: "all-cases", + label: "All cases", + type: "default", + onClick: () => console.info("1"), + }, + { + key: "my-cases", + label: "My cases", + type: "text", + onClick: () => console.info("2"), + }, +]; const Cases = () => { const [searchParams] = useSearchParams(); @@ -46,11 +65,15 @@ const Cases = () => { const [currentPage, setCurrentPage] = useState(1); const [search, setSearch] = useState(null); const [data, setData] = useState(defData); - const [country, setCountry] = useState(null); - const [commodity, setCommodity] = useState(null); - const [tags, setTags] = useState([]); - const [email, setEmail] = useState(caseOwner || null); - const [year, setYear] = useState(null); + const [filters, setFilters] = useState({ + country: null, + commodity: null, + tags: [], + email: caseOwner || null, + year: null, + }); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [caseSettingModalVisible, setCaseSettingModalVisible] = useState(false); const tagOptions = UIState.useState((s) => s.tagOptions); const { @@ -67,105 +90,20 @@ const Cases = () => { const [messageApi, contextHolder] = message.useMessage(); - const isCaseCreator = useMemo(() => { - if (adminRole.includes(userRole)) { - return true; - } - if (userInternal) { - return true; - } - return false; - }, [userRole, userInternal]); - - useEffect(() => { - if (userID || refresh) { - setLoading(true); - let url = `case?page=${currentPage}&limit=${perPage}`; - if (search) { - url = `${url}&search=${search}`; - } - if (country) { - url = `${url}&country=${country}`; - } - if (commodity) { - url = `${url}&focus_commodity=${commodity}`; - } - if (!isEmpty(tags)) { - const tagQuery = tags.join("&tags="); - url = `${url}&tags=${tagQuery}`; - } - if (email) { - url = `${url}&email=${email}`; - } - if (year) { - url = `${url}&year=${year}`; - } - api - .get(url) - .then((res) => { - setData(res.data); - }) - .catch((e) => { - console.error(e.response); - const { status } = e.response; - if (status === 404) { - setData(defData); - } - }) - .finally(() => { - setLoading(false); - setRefresh(false); - }); - } - }, [ - currentPage, - search, - userID, - commodity, - country, - tags, - refresh, - year, - email, - ]); - - const fetchUsers = (searchValue) => { - return api - .get(`user/search_dropdown?search=${searchValue}`) - .then((res) => res.data); - }; - - const handleOnUpdateCaseOwner = (caseRecord) => { - api - .put(`update_case_owner/${caseRecord.id}?user_id=${selectedUser.value}`) - .then(() => { - setRefresh(true); - setShowChangeOwnerForm(null); - }) - .catch((e) => { - console.error(e); - }); - }; - - const onConfirmDelete = (record) => { - api - .delete(`case/${record.id}`) - .then(() => { - setRefresh(true); - messageApi.open({ - type: "success", - content: "Case deleted successfully.", - }); - }) - .catch(() => { - messageApi.open({ - type: "error", - content: "Failed! Something went wrong.", - }); - }); + const searchProps = { + placeholder: "Find Case", + style: { width: 575 }, + onSearch: (value) => setSearch(value), }; const columns = [ + { + title: "Case Name", + dataIndex: "name", + key: "case", + defaultSortOrder: "descend", + sorter: (a, b) => a.name.localeCompare(b.name), + }, { title: "Country", dataIndex: "country", @@ -174,18 +112,11 @@ const Cases = () => { defaultSortOrder: "descend", sorter: (a, b) => a.country.localeCompare(b.country), }, - { - title: "Case Name", - dataIndex: "name", - key: "case", - defaultSortOrder: "descend", - sorter: (a, b) => a.name.localeCompare(b.name), - }, { title: "Primary Commodity", key: "primary_commodity", render: (record) => { - const findPrimaryCommodity = commodityOptios.find( + const findPrimaryCommodity = commodityOptions.find( (co) => co.value === record.focus_commodity ); if (!findPrimaryCommodity?.label) { @@ -211,9 +142,9 @@ const Cases = () => { }, }, { - title: "Year", - dataIndex: "year", - key: "year", + title: "Date", + dataIndex: "created_at", + key: "created_at", defaultSortOrder: "descend", sorter: (a, b) => a.year - b.year, }, @@ -274,11 +205,12 @@ const Cases = () => { }, }, { + title: "Actions", key: "action", width: "5%", align: "center", render: (text, record) => { - const caseDetailURL = `/cases/${record.id}`; + const caseDetailURL = `/case/${record.id}/${stepPath.step1.label}`; const EditButton = ( @@ -329,55 +261,103 @@ const Cases = () => { }, ]; - const onSearch = (value) => setSearch(value); + useEffect(() => { + // reset currentCase state + resetCurrentCaseState(); + }, []); - const countryOptions = window.master.countries; - const commodityOptios = window.master.commodity_categories - .flatMap((c) => c.commodities) - .map((c) => ({ label: c.name, value: c.id })); + useEffect(() => { + if (userID || refresh) { + const { country, commodity, tags, year, email } = filters; + setLoading(true); + let url = `case?page=${currentPage}&limit=${perPage}`; + if (search) { + url = `${url}&search=${search}`; + } + if (country) { + url = `${url}&country=${country}`; + } + if (commodity) { + url = `${url}&focus_commodity=${commodity}`; + } + if (!isEmpty(tags)) { + const tagQuery = tags.join("&tags="); + url = `${url}&tags=${tagQuery}`; + } + if (email) { + url = `${url}&email=${email}`; + } + if (year) { + url = `${url}&year=${year}`; + } + api + .get(url) + .then((res) => { + setData(res.data); + }) + .catch((e) => { + console.error(e.response); + const { status } = e.response; + if (status === 404) { + setData(defData); + } + }) + .finally(() => { + setLoading(false); + setRefresh(false); + }); + } + }, [currentPage, userID, refresh, search, filters]); - const otherFilters = ( - - - setEmail(e.target.value)} - value={email} - /> - - - ); + const isCaseCreator = useMemo(() => { + if (adminRole.includes(userRole)) { + return true; + } + if (userInternal) { + return true; + } + return false; + }, [userRole, userInternal]); + + const fetchUsers = (searchValue) => { + return api + .get(`user/search_dropdown?search=${searchValue}`) + .then((res) => res.data); + }; + + const handleOnUpdateCaseOwner = (caseRecord) => { + api + .put(`update_case_owner/${caseRecord.id}?user_id=${selectedUser.value}`) + .then(() => { + setRefresh(true); + setShowChangeOwnerForm(null); + }) + .catch((e) => { + console.error(e); + }); + }; + + const onConfirmDelete = (record) => { + api + .delete(`case/${record.id}`) + .then(() => { + setRefresh(true); + messageApi.open({ + type: "success", + content: "Case deleted successfully.", + }); + }) + .catch(() => { + messageApi.open({ + type: "error", + content: "Failed! Something went wrong.", + }); + }); + }; + + const handleApplyFilters = ({ country, commodity, tags, year }) => { + setFilters((prev) => ({ ...prev, country, commodity, tags, year })); + }; return ( { ]} title="Cases" wrapperId="case" + titleRighContent={ + + + ( + setDropdownOpen(false)} + /> + )} + open={dropdownOpen} + > + + + {isCaseCreator && ( + + )} + + } > {contextHolder} - setCurrentPage(page), - }} - otherFilters={otherFilters} - showTotalPagination={true} + + {caseSelectorItems.map((cs) => ( + + + + ))} + + + + setCurrentPage(page), + showSizeChanger: false, + showTotal: (total) => ( +
+ Total Case: {total} +
+ ), + }} + /> + + + + setCaseSettingModalVisible(false)} /> ); diff --git a/frontend/src/pages/cases/cases.scss b/frontend/src/pages/cases/cases.scss index 6101318f..b3cf9bad 100644 --- a/frontend/src/pages/cases/cases.scss +++ b/frontend/src/pages/cases/cases.scss @@ -1,610 +1,72 @@ @import "../../variables.scss"; -#case { - h2 { - font-family: "RocGrotesk" !important; - } - - .case-content { - margin-top: 2rem; - - .case-title-wrapper { - border-radius: 20px; - background: $primary-color; - padding-bottom: 24px; - color: #fff; - min-height: 110px; - - h2, - p { - line-height: 0.75rem; - width: 90%; - } - - h2 { - font-size: 1.5rem; - } - - .case-title-icon { - position: absolute; - bottom: 0; - right: 0; - border-radius: 0 0 20px 0; - } - } - - .case-detail-card-wrapper { - border-radius: 20px; - .ant-card-head { - padding: 20px 24px 12px 24px; - } - - .case-detail-child-card-wrapper { - border: 1px solid #e9e9e9; - - .ant-card-head { - padding: 10px 20px; - background: $primary-color; - color: #fff; - min-height: $card-head-min-height; +.case-filter-container { + width: 375px !important; - .ant-card-head-title { - font-size: 0.9rem; - } - - .ant-switch { - background: $muted-color; - } - - .ant-switch-checked { - background: $yellow-color; - } - } - } - } - - .ant-card-head-title { - font-family: "TabletGothic"; - font-size: 1.2rem; - } - - .chart-container { - padding: 10px 20px 10px 20px; - - .ant-card-head { - padding: 0; - } - - .ant-card-body { - padding: 0px; - } - } - - .income-driver-dashboard { - .income-driver-content { - .card-alert-box { - margin-bottom: 0 !important; - padding: 20px 24px; - } - } + .case-filter-header { + .case-filter-title { + font-size: 14px; + font-weight: 600; } - - .income-driver-dashboard { - margin-bottom: 1rem; - .information-box { - border-radius: 4px; - background: #cce0df; - h3 { - text-align: center; - background: transparent !important; - } - .information-box { - background: #00625f; - color: #fff; - } - } - } - - .income-driver-dashboard, - .segment-group { - .segment-child-wrapper { - border: 1px solid #e9e9e9; - border-radius: 20px; - - .ant-card-head { - background: $primary-color; - color: #fff; - padding: 10px 20px 0 20px; - min-height: $card-head-min-height; - } - } - .ant-card-head { - padding: 0px; - padding-left: 16px; - - h3 { - font-size: 24px; - margin: -1px; - padding: 10px 24px; - small { - position: absolute; - right: 24px; - top: 16px; - cursor: pointer; - } - } - // handle scenario modeling page - .scenario-header-wrapper { - padding: 10px 24px; - h3 { - padding: 0; - } - } - p { - font-size: 14px; - font-weight: normal; - } - // handle scenario modeling page - margin-bottom: 0px; - .ant-card-extra { - padding: 10px 24px; - } - .card-extra-wrapper { - margin-top: 15px; - } - } - .ant-card-body { - padding: 0px; - .total-diversified-income { - margin-left: -4px; - margin-right: -4px; - row-gap: 8px; - background: white; - padding-left: 8px; - // padding-right: 8px; - } - .ant-card-grid { - padding: 10px 20px; - box-shadow: none; - h2 { - &.section-title { - // background: #e6f7ff; - font-size: 0.9rem; - margin: -10px -24px 0 -24px; - padding: 10px 24px; - position: relative; - width: calc(100% + 48px); - div { - position: absolute; - right: 24px; - top: calc(50% - 9px); - } - } - } - h3 { - background: #ffffff; - font-size: 14px; - padding: 10px 24px; - font-weight: bold; - margin: 0px; - &.diversified-income-title { - padding: 22px 0 22px 40px !important; - } - } - h4 { - small { - &.unit { - position: absolute; - right: 0; - top: 22px; - } - } - } - .percentage-wrapper { - font-size: 0.8rem; - .ceret-up { - color: green; - } - .ceret-down { - color: red; - } - } - } - .ant-input-number-disabled { - color: #666; - } - } - } - - .card-subtitle { - padding: 10px 0; - } - .ant-card-body { - padding-bottom: 0px; - } - .timeline-container { - padding: 2rem 5.5rem; - margin-left: -$default-padding-size; - position: fixed; - // top: 171px; - top: 129px; - width: 100%; - z-index: 100; - background-color: $bg-color-grey; - } - } - h3 { - margin: 0px -24px 10px; - padding: 0px 24px 10px; - font-weight: normal; - } - h4 { - margin: 10px 0px; - } - .ant-timeline-item { - cursor: pointer; } - .ant-tabs { - border-radius: 20px; - background: #fff; - padding: 10px 0 20px 0; - } - - .ant-tabs-nav-wrap { - border-radius: 20px; - background: #fff; - padding: 10px 1.5rem 0px; - .ant-tabs-tab-btn { - font-family: "TabletGothicBold"; - font-size: 1rem; - // background: #fff; - text-shadow: none; + .case-filter-body { + margin-top: 16px; + label { + font-weight: 500; + font-size: 12px; } } - .ant-tabs-nav .ant-tabs-tab-active { - border-top: 1px solid #cfcfcf; - border-left: 1px solid #cfcfcf; - border-right: 1px solid #cfcfcf; - } - - .pending-dot { - .ant-steps-item-icon { - background: $yellow-color; - } - } - .anticon { - &.finished-dot { - // position: absolute; - font-size: 20px; - width: 20px; - height: 20px; - left: 1px; - bottom: -4px; - color: #26605f; - } - } - .current-feasible-field { - margin-bottom: 0; - padding-bottom: 0; - } - .data-fields-title { - background: $primary-color; - } - .binning-input { - width: 100% !important; - } - - #income-driver-dashboard { - .ant-tabs { - background: none; - } - .ant-tabs-nav-wrap { - background: none !important; - padding: 0 !important; - border-radius: 0 !important; - } - .ant-tabs-tab-btn { - background: none !important; - } - .ant-tabs-nav .ant-tabs-tab-active { - border-top: 1px solid transparent; - border-left: 1px solid transparent; - border-right: 1px solid transparent; - } - } - - #sensitivity-analysis { - .ant-form-item { - margin-bottom: 0px; - } - - .info-icon { + .case-filter-footer { + margin-top: 16px; + .button-filter { + background-color: $primary-color; color: #fff; - } - - .settings-wrapper { - h2 { - color: #00403e; - font-family: "RocGrotesk"; - font-size: 28px; - font-style: normal; - font-weight: 700; - line-height: 48px; - letter-spacing: -0.8px; - } - - p { - color: #000; - font-family: "TabletGothic"; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 28px; /* 175% */ - } - - .settings-info-wrapper { - .number { - color: #00625f; - text-align: center; - font-family: "RocGrotesk"; - font-size: 16px; - font-style: normal; - font-weight: 700; - line-height: 28px; - letter-spacing: -0.28px; - - padding: 4px 8px; - background: $yellow-color; - border-radius: 100px 70px 100px 70px; - - &.small { - background: #fef4ed; - padding: 2px 6px; - font-size: 14px; - font-weight: normal; - } - } - - .title { - color: #00625f; - font-family: "RocGrotesk"; - font-size: 16px; - font-style: normal; - font-weight: 700; - line-height: 28px; - letter-spacing: -0.336px; - &.small { - font-size: 14px; - font-weight: normal; - line-height: 24px; - } - } - - .description { - color: #000; - font-family: "TabletGothic"; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 24px; - padding-left: 36px; - &.small { - padding-left: 28px; - } - } - - .ant-input-number-group-addon { - background: #fafafa !important; - } - } - } - - .income-driver-table { - table { - border-radius: 20px; + font-weight: 500; + border: none; - th, - td { - font-family: "TabletGothic"; - // background: $secondary-color; - border: none; - padding: 8px 20px; - } - th { - border-bottom: solid $primary-color 1px; - &::before { - display: none; - } - } - tfoot.ant-table-summary { - // background: $secondary-color; - td { - // background: $secondary-color; - border-top: solid $primary-color 1px; - font-family: "TabletGothic"; - - &:first-child { - border-bottom-left-radius: 20px; - } - &:last-child { - border-bottom-right-radius: 20px; - } - } - } + :hover { + color: #fff; } } - - .binning-chart-info-wrapper { - color: #000; - padding-top: 20px; - - .segment { - font-size: 16px; - font-weight: 700; - } - - .label { - font-size: 18px; - font-weight: 700; - } - } - } - - .ant-steps-item-title { - cursor: pointer; - font-size: 16px; - } - // .ant-steps-item-title:after { - // background-color: #053fff !important; - // border: 1px solid #053fff !important; - // } - - #scenario-modeling { - font-family: "TabletGothic"; - - .scenario-tabs-container { - .ant-tabs-nav .ant-tabs-tab { - border: 1px solid #cfcfcf; - } - .ant-tabs-nav .ant-tabs-tab-active { - border-bottom: none; - } - } - - // .scenario-segment-tabs-container { - // .ant-tabs-nav .ant-tabs-tab { - // border-top: none; - // border-left: none; - // border-right: none; - // } - // } - - .scenario-field-item { - margin-bottom: 0px; - } - - .scenario-information-wrapper { - .label { - margin-bottom: 5px; - } - } - - .scenario-step-wrapper { - padding: 20px 0; - - .number { - color: #00625f; - text-align: center; - font-family: "RocGrotesk"; - font-size: 18px; - font-style: normal; - font-weight: 700; - line-height: 28px; - letter-spacing: -0.28px; - - padding: 4px 8px; - background: $yellow-color; - border-radius: 100px 70px 100px 70px; - } - - .title { - color: #00625f; - font-family: "RocGrotesk"; - font-size: 20px; - font-style: normal; - font-weight: 700; - line-height: 28px; - letter-spacing: -0.336px; - } - } - } - - #income-overview-chart { - .income-overview-chart-wrapper { - padding: 0 20px; + .button-reset { + font-weight: 500; } } +} - .chart-title { - margin: 0px 0px -10px 0px; - padding: 0px 24px 10px 0; +.case-settings-modal-container { + .ant-modal-body { + height: 70vh; + overflow-y: auto; + padding: 24px 5px; } - .chart-card-wrapper { - border-radius: 20px; - border: 1px solid #e9e9e9; - + .case-setting-child-card-wrapper { .ant-card-head { - padding: 10px 20px; - background: $primary-color; - color: #fff; - min-height: $card-head-min-height; + background: #f2f2f2; } - .ant-card-head-title { - font-size: 0.9rem !important; - } - - .ant-card-body { - padding: 0 20px !important; - } - - &.with-padding { - .ant-card-body { - padding: 20px !important; - } - } - - &.has-segments-button { - .ant-card-body { - padding: 20px !important; - } + .section-title { + font-weight: 500; + line-height: 1.2; } } - .info-card-wrapper { - border-radius: 20px; - border: 1px solid #e9e9e9; + .segment-card-container { + margin-bottom: 14px; .ant-card-head { - padding: 10px 20px; - background: $primary-color; - color: #fff; - min-height: $card-head-min-height; - } - - .ant-card-head-title { - font-size: 0.9rem !important; - } - - .ant-card-body { - padding: 20px !important; - } - - &.no-padding { - .ant-card-body { - padding: 10px 0 !important; - } - } - - &.head-only { - .ant-card-body { - padding: 0 !important; - } - - .ant-card-head { - border-radius: 20px; - } + background: transparent; } } +} - .benchmark-info-child-wrapper { - font-family: "TabletGothic"; - } +#case { + margin-top: 24px; - h2.income-target-value { - font-family: "TabletGothicBold" !important; + h2 { + font-family: "RocGrotesk" !important; } } diff --git a/frontend/src/pages/cases/components/AreaUnitFields.js b/frontend/src/pages/cases/components/AreaUnitFields.js index 49cd5959..7f72be78 100644 --- a/frontend/src/pages/cases/components/AreaUnitFields.js +++ b/frontend/src/pages/cases/components/AreaUnitFields.js @@ -1,6 +1,6 @@ import React from "react"; import { Form, Select, Row, Col } from "antd"; -import { selectProps } from "./"; +import { selectProps } from "../../../lib"; import { areaUnitOptions, volumeUnitOptions } from "../../../store/static"; const responsiveCol = { diff --git a/frontend/src/pages/cases/components/BinningDriverForm.js b/frontend/src/pages/cases/components/BinningDriverForm.js new file mode 100644 index 00000000..b51503c2 --- /dev/null +++ b/frontend/src/pages/cases/components/BinningDriverForm.js @@ -0,0 +1,239 @@ +import React, { useMemo } from "react"; +import { Row, Col, Form, Select, InputNumber } from "antd"; +import { InputNumberThousandFormatter } from "../../../lib"; +import { selectProps } from "../../../lib"; + +const binningDriverFormStyles = { + inputNumber: { + width: "100%", + }, +}; + +const generateDriverOptions = (drivers, selected, excludes) => { + const options = selected.filter((s) => excludes.includes(s.name)); + return drivers.map((d) => ({ + ...d, + disabled: options.find((o) => o.value === d.value), + })); +}; + +const BinningDriverForm = ({ + segment, + selectedSegment, + hidden, + dataSource = [], + selected = [], +}) => { + const drivers = useMemo(() => { + if (!selectedSegment) { + return []; + } + // filter drivers to include in BinningForm options + const res = dataSource + .filter( + (d) => + !["Total Primary Income", "Total Income", "Income Target"].includes( + d.name + ) + ) + .map((x) => { + return { + value: x.name, + label: x.name, + unitName: x.unitName, + }; + }); + return res; + }, [selectedSegment, dataSource]); + + const options = useMemo(() => { + if (!selected.length) { + return { + "binning-driver-name": drivers, + "x-axis-driver": drivers, + "y-axis-driver": drivers, + }; + } + return { + "binning-driver-name": generateDriverOptions(drivers, selected, [ + "x-axis-driver", + "y-axis-driver", + ]), + "x-axis-driver": generateDriverOptions(drivers, selected, [ + "binning-driver-name", + "y-axis-driver", + ]), + "y-axis-driver": generateDriverOptions(drivers, selected, [ + "binning-driver-name", + "x-axis-driver", + ]), + }; + }, [drivers, selected]); + + return ( + + {/* X AXIS DRIVER FORM ITEM */} + + + + + + + + + + + + + + + + + + {/* EOL Y AXIS DRIVER FORM ITEM */} + + {/* BINNING DRIVER FORM ITEM */} + + + + + + + + + + + + + + + + setEmail(e.target.value)} + value={email} + /> + + + + + + + + + + + + ); +}; + +export default CaseFilter; diff --git a/frontend/src/pages/cases/components/CaseForm.js b/frontend/src/pages/cases/components/CaseForm.js new file mode 100644 index 00000000..1783658c --- /dev/null +++ b/frontend/src/pages/cases/components/CaseForm.js @@ -0,0 +1,449 @@ +import React, { useEffect, useMemo } from "react"; +import { + Card, + Form, + Row, + Col, + Input, + Space, + Tooltip, + DatePicker, + Select, + Divider, + Alert, + Radio, + Switch, +} from "antd"; +import { InfoCircleTwoTone } from "@ant-design/icons"; +import { + countryOptions, + focusCommodityOptions, + commodityOptions, + yesNoOptions, + currencyOptions, +} from "../../../store/static"; +import { selectProps, getFieldDisableStatusForCommodity } from "../../../lib"; +import { AreaUnitFields, SegmentForm } from "."; +import { UIState } from "../../../store"; +import dayjs from "dayjs"; +import { CaseUIState, CurrentCaseState } from "../store"; +import { uniqBy } from "lodash"; + +const responsiveCol = { + xs: { span: 24 }, + sm: { span: 24 }, + md: { span: 24 }, + lg: { span: 12 }, + xl: { span: 12 }, +}; + +const livestockPrompt = ( + <> + In the case of multiple by-products from livestock, please insert + information for each by-product separately. For example, in the case of + cows, consider meat as the secondary commodity income source and milk as the + tertiary income source. Only enter information for these by-products in + detail if there is a commercial production system. If you do not have + detailed information on the production system, we recommend inserting values + on the next page under 'diversified income' -> 'income + from livestock'. + +); + +const SecondaryForm = ({ + index, + indexLabel, + disabled, + disableAreaSizeUnitField, + disableLandUnitField, + disableDataOnIncomeDriverField, +}) => { + const form = Form.useFormInstance(); + const caseUI = CaseUIState.useState((s) => s); + + const updateCaseUI = (key, value) => { + CaseUIState.update((s) => ({ + ...s, + [key]: value, + })); + }; + + const handleOnChangeCommodity = (value) => { + const { disableLandUnitField, disableDataOnIncomeDriverField } = + getFieldDisableStatusForCommodity(value); + const updatedValue = { + ...caseUI[index], + disableLandUnitField, + disableDataOnIncomeDriverField, + }; + // reset breakdown value land unit on disableLandUnitField + if (disableLandUnitField) { + form.setFieldValue(`${index}-area_size_unit`, null); + } + // reset breakdown value when disableDataOnIncomeDriverField + if (disableDataOnIncomeDriverField) { + form.setFieldValue(`${index}-breakdown`, null); + } + updateCaseUI(index, updatedValue); + }; + + return ( + <> + + + + updateCaseUI(index, { + ...caseUI[index], + disableAreaSizeField: e.target.value ? false : true, + }) + } + /> + + + + + + + + + + + + + +
Year of available data
+ + + + + } + rules={[ + { + required: true, + message: "Choose year", + }, + ]} + > + { + return current && dayjs(current).year() > dayjs().year(); + }} + disabled={!enableEditCase} + style={{ width: "100%" }} + /> +
+ + + + + + + + {/* COMMODITIES */} + + + {/* 1 */} + + + Primary commodity + + + + + ); +}; + +export default IncomeDriversDropdown; diff --git a/frontend/src/pages/cases/components/SegmentForm.js b/frontend/src/pages/cases/components/SegmentForm.js new file mode 100644 index 00000000..9febac1b --- /dev/null +++ b/frontend/src/pages/cases/components/SegmentForm.js @@ -0,0 +1,86 @@ +import React from "react"; +import { Form, Card, Button, Row, Col, Input, InputNumber } from "antd"; +import { CloseOutlined, PlusOutlined } from "@ant-design/icons"; +import { CaseUIState } from "../store"; + +const MAX_SEGMENT = 5; + +const SegmentForm = () => { + const { enableEditCase } = CaseUIState.useState((s) => s.general); + + return ( + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + 1 ? ( + + + + + + + + + + + + + ))} + {fields.length < MAX_SEGMENT ? ( + + + + ) : null} + + )} + + ); +}; + +export default SegmentForm; diff --git a/frontend/src/pages/cases/components/SegmentSelector.js b/frontend/src/pages/cases/components/SegmentSelector.js new file mode 100644 index 00000000..b5f39e41 --- /dev/null +++ b/frontend/src/pages/cases/components/SegmentSelector.js @@ -0,0 +1,30 @@ +import React, { useEffect } from "react"; +import { Radio } from "antd"; +import { CaseVisualState } from "../store"; + +const SegmentSelector = ({ selectedSegment, setSelectedSegment }) => { + const dashboardData = CaseVisualState.useState((s) => s.dashboardData); + + useEffect(() => { + if (!selectedSegment && dashboardData?.length > 0) { + setSelectedSegment(dashboardData[0].id); + } + }, [selectedSegment, dashboardData, setSelectedSegment]); + + const handleChangeSegmentSelector = (e) => { + const value = e.target.value; + setSelectedSegment(value); + }; + + return ( + + {dashboardData.map((d) => ( + + {d.name} + + ))} + + ); +}; + +export default SegmentSelector; diff --git a/frontend/src/pages/cases/components/VisualCardWrapper.js b/frontend/src/pages/cases/components/VisualCardWrapper.js new file mode 100644 index 00000000..ada40f67 --- /dev/null +++ b/frontend/src/pages/cases/components/VisualCardWrapper.js @@ -0,0 +1,32 @@ +import React from "react"; +import { Row, Col, Card, Space, Button } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; + +const VisualCardWrapper = ({ children, title, bordered = false }) => { + return ( + + + +
{title}
+
+ +
+
+ + + + + + } + > + {children} + + ); +}; + +export default VisualCardWrapper; diff --git a/frontend/src/pages/cases/components/index.js b/frontend/src/pages/cases/components/index.js index a63c93a3..0d299742 100644 --- a/frontend/src/pages/cases/components/index.js +++ b/frontend/src/pages/cases/components/index.js @@ -1,278 +1,11 @@ -import uniq from "lodash/uniq"; -import { Col, Space } from "antd"; -import { excludeCommodityTypesFromPrimaryCrop } from "../../../store/static"; - -const commodityCategories = window.master?.commodity_categories || []; -export const commodities = commodityCategories - ? commodityCategories.reduce( - (acc, category) => [ - ...acc, - ...category.commodities.map((c) => ({ - ...c, - category: category.name.toLowerCase(), - })), - ], - [] - ) - : []; - -// create focus/primary commodities filtered by excludeCommodityTypesFromPrimaryCrop -export const focusCommodityOptions = commodities - .filter( - (c) => - !excludeCommodityTypesFromPrimaryCrop.includes(c.category.toLowerCase()) - ) - .map((commodity) => ({ - label: commodity.name, - value: commodity.id, - })); - -export const commodityOptions = commodities.map((commodity) => ({ - label: commodity.name, - value: commodity.id, -})); - -export const currencyOptions = window.master?.currencies || []; -export const countryOptions = window.master?.countries || []; - -export const yesNoOptions = [ - { - label: "Yes", - value: 1, - }, - { - label: "No", - value: 0, - }, -]; - -export const tagOptions = [ - { - label: "Smallholder", - value: "smallholder", - }, - { - label: "Large Scale", - value: "large-scale", - }, - { - label: "Plantation", - value: "plantation", - }, - { - label: "Processing", - value: "processing", - }, - { - label: "Trading", - value: "trading", - }, - { - label: "Retail", - value: "retail", - }, - { - label: "Other", - value: "other", - }, -]; - -export const reportingPeriod = [ - { - label: "Per Season", - value: "per-season", - }, - { - label: "Per Year", - value: "per-year", - }, -]; - -export const selectProps = { - showSearch: true, - allowClear: true, - optionFilterProp: "label", - style: { - width: "100%", - }, -}; - -export const indentSize = 37.5; - -export const regexQuestionId = /#(\d+)/; - -export const getFunctionDefaultValue = (question, prefix, values = []) => { - const function_name = question?.default_value?.split(" "); - if (!function_name) { - return 0; - } - const getFunction = function_name.reduce((acc, fn) => { - const questionValue = fn.match(regexQuestionId); - if (questionValue) { - const valueName = `${prefix}-${questionValue[1]}`; - const value = values.find((v) => v.id === valueName)?.value; - if (!value) { - acc.push(0); - return acc; - } - acc.push(value.toString()); - } else { - acc.push(fn); - } - return acc; - }, []); - const finalFunction = getFunction.join(""); - return eval(finalFunction); -}; - -export const generateSegmentPayloads = ( - values, - currentCaseId, - commodityList -) => { - // generate segment payloads - const segmentPayloads = values.map((fv) => { - let res = { - case: currentCaseId, - region: fv.region, - name: fv.label, - target: fv?.target || null, - adult: fv?.adult || null, - child: fv?.child || null, - }; - if (fv?.currentSegmentId) { - res = { - ...res, - id: fv.currentSegmentId, - }; - } - // generate segment answer payloads - let segmentAnswerPayloads = []; - const questionIDs = uniq( - Object.keys(fv.answers).map((key) => { - const splitted = key.split("-"); - return parseInt(splitted[2]); - }) - ); - commodityList.forEach((cl) => { - const case_commodity = cl.case_commodity; - questionIDs.forEach((qid) => { - const fieldKey = `${case_commodity}-${qid}`; - const currentValue = fv.answers[`current-${fieldKey}`]; - const feasibleValue = fv.answers[`feasible-${fieldKey}`]; - const answerTmp = { - case_commodity: case_commodity, - question: qid, - current_value: currentValue, - feasible_value: feasibleValue, - }; - segmentAnswerPayloads.push(answerTmp); - }); - }); - segmentAnswerPayloads = segmentAnswerPayloads.filter( - (x) => x.current_value || x.feasible_value - ); - if (segmentAnswerPayloads.length) { - res = { - ...res, - answers: segmentAnswerPayloads, - }; - } - return res; - }); - return segmentPayloads; -}; - -export const InputNumberThousandFormatter = { - formatter: (value, _, round = false) => { - if (round) { - value = Math.round(parseFloat(value)); - } - const res = - value >= 1000 - ? `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ",") - : value && value % 1 !== 0 - ? parseFloat(value) - : value; - return res; - }, - parser: (value) => value.replace(/\$\s?|(,*)/g, ""), -}; - -export const customFormula = { - revenue_focus_commodity: "#2 * #3 * #4", - focus_commodity_cost_of_production: - "( ( #5 * #2 ) + ( #26 * #3 * #2 ) ) * -1", -}; - -/** - * NOTE - * Focus Income formula ( ( #2 * #3 * #4 ) + ( #40 * #41 * #42 ) ) - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) - * Target = 9001, - * Diversified = 9002 - * i = ( a * p * v ) - ( cop * a ) + di - */ -export const yAxisFormula = { - "#2": "( #9002 - #9001 ) / ( ( #5 + ( #26 * #3 ) + #43 ) - ( ( #4 * #3 ) + ( #42 * #41 ) ) )", // area - "#3": "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #4 * #2 ) + ( #42 * #40 ) )", // volume - "#4": "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #3 * #2 ) + ( #41 * #40 ) )", // price - "#5": "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + #9002 ) / ( #2 + #40 )", // CoP for crop - "#26": - "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + #9002 ) / ( ( #3 * #2 ) + ( #41 * #40 ) )", // CoP for aqua - "#9002": - "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) )", // diversified income for all question type - "#40": - "( #9002 - #9001 ) / ( ( #5 + ( #26 * #3 ) + #43 ) - ( ( #4 * #3 ) + ( #42 * #41 ) ) )", // animals (area) - "#41": - "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #4 * #2 ) + ( #42 * #40 ) )", // volume for animals - "#42": - "( #9001 - ( ( #5 * #2 ) + ( #26 * #3 * #2 ) + ( #43 * #40 ) ) + #9002 ) / ( ( #3 * #2 ) + ( #41 * #40 ) )", // price for animals - "#43": - "( #9001 - ( ( #2 * #4 * #3 ) + ( #40 * #42 * #41 ) ) + #9002 ) / ( #2 + #40 )", // CoP for animals -}; - -export const removeUndefinedObjectValue = (obj) => { - return Object.entries(obj).reduce((acc, [key, value]) => { - if (typeof value !== "undefined") { - acc[key] = value; - } - return acc; - }, {}); -}; - -export const diversifiedIncomeTooltipText = - "The majority of farmer households also earn an income from other sources than the primary commodity. This can be income from other crops, livestock, income earned from off-farm labour or non-farm non labour sources (e.g. remittances, government transfers)."; - -export const Step = ({ - number, - title, - description = null, - titleStyle = {}, -}) => ( - - - {number &&
{number}
} -
- {title} -
-
- {description && ( -
{description}
- )} - -); - export { default as AreaUnitFields } from "./AreaUnitFields"; -export { default as SideMenu } from "./SideMenu"; -export { default as CaseProfile } from "./CaseProfile"; -export { default as DataFields } from "./DataFields"; -export { default as IncomeDriverDataEntry } from "./IncomeDriverDataEntry"; -export { default as IncomeDriverForm } from "./IncomeDriverForm"; -export { default as IncomeDriverTarget } from "./IncomeDriverTarget"; -export { default as IncomeDriverDashboard } from "./IncomeDriverDashboard"; -export { default as DashboardIncomeOverview } from "./DashboardIncomeOverview"; -export { default as DashboardSensitivityAnalysis } from "./DashboardSensitivityAnalysis"; -export { default as DashboardScenarioModeling } from "./DashboardScenarioModeling"; -export { default as Questions } from "./Questions"; -export { default as Scenario } from "./Scenario"; export { default as DebounceSelect } from "./DebounceSelect"; +export { default as CaseFilter } from "./CaseFilter"; +export { default as CaseSettings } from "./CaseSettings"; +export { default as SegmentForm } from "./SegmentForm"; +export { default as CaseForm } from "./CaseForm"; +export { default as EnterIncomeDataForm } from "./EnterIncomeDataForm"; +export { default as VisualCardWrapper } from "./VisualCardWrapper"; +export { default as SegmentSelector } from "./SegmentSelector"; +export { default as IncomeDriversDropdown } from "./IncomeDriversDropdown"; +export { default as BinningDriverForm } from "./BinningDriverForm"; diff --git a/frontend/src/pages/cases/index.js b/frontend/src/pages/cases/index.js index b6dd0d0e..b02bd628 100644 --- a/frontend/src/pages/cases/index.js +++ b/frontend/src/pages/cases/index.js @@ -1,2 +1,2 @@ -export { default as Case } from "./Case"; export { default as Cases } from "./Cases"; +export { default as Case } from "./Case"; diff --git a/frontend/src/pages/cases/layout/CaseWrapper.js b/frontend/src/pages/cases/layout/CaseWrapper.js new file mode 100644 index 00000000..1ee3c593 --- /dev/null +++ b/frontend/src/pages/cases/layout/CaseWrapper.js @@ -0,0 +1,160 @@ +import React, { useState, useRef } from "react"; +import "./case-wrapper.scss"; +import { useNavigate } from "react-router-dom"; +import { Row, Col, Steps, Layout, Affix, Button, Space, Alert } from "antd"; +import { ContentLayout } from "../../../components/layout"; +import { + ArrowLeftOutlined, + ArrowRightOutlined, + SettingOutlined, +} from "@ant-design/icons"; +import { CaseSettings } from "../components"; +import { stepPath, CaseUIState } from "../store"; + +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: "Enter your income data", + description: + "Enter current and feasible data for the five income drivers and its subcomponents for each segment", + }, + { + title: "Understand the income gap", + description: + "Explore the current income situation and the gap to reach your income target.", + }, + { + title: "Assess impact of mitigation strategies", + description: + "Analyze which drivers impact income increase the most, and how to close the gap.", + }, + { + title: "Closing the gap", + description: + "Save different scenarios to close the gap, and explore procurement practices.", + }, +]; + +const CaseSidebar = ({ step, caseId }) => { + const navigate = useNavigate(); + + const findStepPathValue = Object.values(stepPath).find( + (path) => path.label === step + )?.value; + + return ( + + navigate(`/case/${caseId}/${stepPath[`step${val + 1}`].label}`) + } + current={findStepPathValue ? findStepPathValue - 1 : 1} + /> + ); +}; + +const CaseWrapper = ({ children, step, caseId, currentCase }) => { + const caseButtonState = CaseUIState.useState((s) => s.caseButton); + const [caseSettingModalVisible, setCaseSettingModalVisible] = useState(false); + + // Use refs to store the functions + const backFunctionRef = useRef(() => {}); + const nextFunctionRef = useRef(() => {}); + + const handleBack = () => { + backFunctionRef.current(); + }; + + const handleNext = () => { + nextFunctionRef.current(); + }; + + return ( + + + + + + + + + + + + + } + onClick={() => setCaseSettingModalVisible(true)} + > + Case settings + + } + > + {currentCase.segments.filter((s) => s.id).length ? ( + React.isValidElement(children) ? ( + React.cloneElement(children, { + setbackfunction: (fn) => (backFunctionRef.current = fn), + setnextfunction: (fn) => (nextFunctionRef.current = fn), + }) + ) : null + ) : ( + // Show alert if current case doesn't have any segments + + )} + + + + + + + {/* Next Back Button */} + + + + + + + + setCaseSettingModalVisible(false)} + enableEditCase={true} + /> + + ); +}; + +export default CaseWrapper; diff --git a/frontend/src/pages/cases/layout/case-wrapper.scss b/frontend/src/pages/cases/layout/case-wrapper.scss new file mode 100644 index 00000000..a1fde2e8 --- /dev/null +++ b/frontend/src/pages/cases/layout/case-wrapper.scss @@ -0,0 +1,104 @@ +@import "../../../variables.scss"; + +#case-detail { + .case-sidebar-container { + border-right: 1px solid #e1e0da; + background: #fff; + box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), + 0px 4px 6px -2px rgba(16, 24, 40, 0.03); + height: 95vh; + padding: 24px 32px; + z-index: 2; + + .case-step-wrapper { + .ant-steps-item { + // not active + .ant-steps-item-tail::after { + border: 1px solid #e1e0da; + } + + .ant-steps-item-icon { + border: 1px solid #e3e3e3; + + .ant-steps-icon { + color: #979797; + text-align: center; + font-family: "RocGrotesk"; + font-size: 14px; + font-style: normal; + font-weight: 700; + line-height: 24px; + } + } + + .ant-steps-item-title { + font-family: "RocGrotesk"; + font-size: 14px; + font-style: normal; + font-weight: 700; + line-height: 24px; + } + .ant-steps-item-description { + color: #475467; + font-family: "TabletGothic"; + font-size: 13px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + + // when active + &.ant-steps-item-active { + .ant-steps-item-tail::after { + border: 1px solid $primary-color; + } + + .ant-steps-item-icon { + border: 1px solid $primary-color; + box-shadow: 0 0 2px 4px rgba(203, 244, 220, 1); + background: #eaf2f2; + + .ant-steps-icon { + color: $primary-color; + } + } + } + + // when finished + &.ant-steps-item-finish { + .ant-steps-item-icon { + border: 1px solid #48d985; + background: #48d985; + + .ant-steps-icon { + color: #fff; + } + } + } + } + } + } + + .case-content-container { + // rewrite content layout css for this page + .ant-breadcrumb { + padding: 0px 32px; + } + + .content-wrapper, + .title-wrapper { + padding: 10px 32px !important; + } + } + + .case-button-wrapper { + background: #fff; + border-top: 1px solid #e1e0da; + padding: 24px; + position: fixed; + bottom: 0; + right: 0; + width: 100%; + z-index: 1; + } +} diff --git a/frontend/src/pages/cases/layout/index.js b/frontend/src/pages/cases/layout/index.js new file mode 100644 index 00000000..369c420b --- /dev/null +++ b/frontend/src/pages/cases/layout/index.js @@ -0,0 +1 @@ +export { default as CaseWrapper } from "./CaseWrapper"; diff --git a/frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js b/frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js new file mode 100644 index 00000000..df22dec2 --- /dev/null +++ b/frontend/src/pages/cases/steps/AssessImpactMitigationStrategies.js @@ -0,0 +1,255 @@ +import React, { useCallback, useEffect, useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { stepPath, CurrentCaseState, CaseVisualState } from "../store"; +import { Row, Col, Card, Space, Carousel, Form } from "antd"; +import { + ChartBiggestImpactOnIncome, + ChartMonetaryImpactOnIncome, +} from "../visualizations"; +import { BinningDriverForm, SegmentSelector } from "../components"; +import { map, groupBy } from "lodash"; +import { commodities } from "../../../store/static"; + +/** + * STEP 4 + */ +const AssessImpactMitigationStrategies = ({ + setbackfunction, + setnextfunction, +}) => { + const [form] = Form.useForm(); + const navigate = useNavigate(); + const currentCase = CurrentCaseState.useState((s) => s); + const dashboardData = CaseVisualState.useState((s) => s.dashboardData); + // const sensitivityAnalysis = CaseVisualState.useState( + // (s) => s.sensitivityAnalysis + // ); + + const [selectedSegment, setSelectedSegment] = useState(null); + + const backFunction = useCallback(() => { + navigate(`/case/${currentCase.id}/${stepPath.step3.label}`); + }, [navigate, currentCase.id]); + + const nextFunction = useCallback(() => { + navigate(`/case/${currentCase.id}/${stepPath.step5.label}`); + }, [navigate, currentCase.id]); + + const updateCaseVisualSensitivityAnalysisState = (updatedValue) => { + CaseVisualState.update((s) => ({ + ...s, + sensitivityAnalysis: { + ...s.sensitivityAnalysis, + ...updatedValue, + }, + })); + }; + + useEffect(() => { + if (setbackfunction) { + setbackfunction(backFunction); + } + if (setnextfunction) { + setnextfunction(nextFunction); + } + }, [setbackfunction, setnextfunction, backFunction, nextFunction]); + + const dataSource = useMemo(() => { + if (!selectedSegment) { + return []; + } + const focusCommodity = currentCase?.case_commodities?.find( + (cm) => cm.commodity_type === "focus" + ); + const segmentData = dashboardData.find( + (segment) => segment.id === selectedSegment + ); + const answers = segmentData.answers; + const drivers = answers.filter( + (answer) => answer.question?.parent_id === 1 && answer.commodityFocus + ); + const data = map(groupBy(drivers, "question.id"), (d, i) => { + const currentQuestion = d[0].question; + const unitName = currentQuestion.unit + .split("/") + .map((u) => u.trim()) + .map((u) => + u === "crop" + ? commodities + .find((c) => c.id === focusCommodity?.commodity) + ?.name?.toLowerCase() || "" + : focusCommodity?.[u] + ) + .join(" / "); + return { + key: parseInt(i) - 1, + name: currentQuestion.text, + current: d.find((a) => a.name === "current")?.value || 0, + feasible: d.find((a) => a.name === "feasible")?.value || 0, + unitName: unitName, + }; + }); + const currencyUnit = focusCommodity["currency"]; + return [ + ...data, + { + key: data.length + 10, + name: "Diversified Income", + current: segmentData.total_current_diversified_income, + feasible: segmentData.total_feasible_diversified_income, + unitName: currencyUnit, + }, + { + key: data.length + 11, + name: "Total Primary Income", + current: segmentData.total_current_focus_income?.toFixed() || 0, + feasible: segmentData.total_feasible_focus_income?.toFixed() || 0, + unitName: currencyUnit, + }, + { + key: data.length + 12, + name: "Total Income", + current: segmentData.total_current_income?.toFixed() || 0, + feasible: segmentData.total_feasible_income?.toFixed() || 0, + unitName: currencyUnit, + }, + { + key: data.length + 13, + name: "Income Target", + current: segmentData.target?.toFixed() || 0, + unitName: currencyUnit, + render: (i) => { +
test {i}
; + }, + }, + ]; + }, [selectedSegment, dashboardData, currentCase?.case_commodities]); + + const onSensitivityAnalysisValuesChange = (changedValue, allValues) => { + const objectName = Object.keys(changedValue)[0]; + const [segmentId, valueName] = objectName.split("_"); + const value = changedValue[objectName]; + + if (valueName === "x-axis-driver") { + const dataValue = dataSource.find((d) => d.name === value); + allValues = { + ...allValues, + [`${segmentId}_x-axis-driver`]: dataValue?.name, + [`${segmentId}_x-axis-min-value`]: dataValue?.current, + [`${segmentId}_x-axis-max-value`]: dataValue?.feasible, + [`${segmentId}_x-axis-current-value`]: dataValue?.current, + [`${segmentId}_x-axis-feasible-value`]: dataValue?.feasible, + }; + } + if (valueName === "y-axis-driver") { + const dataValue = dataSource.find((d) => d.name === value); + allValues = { + ...allValues, + [`${segmentId}_y-axis-driver`]: dataValue?.name, + [`${segmentId}_y-axis-min-value`]: dataValue?.current, + [`${segmentId}_y-axis-max-value`]: dataValue?.feasible, + [`${segmentId}_y-axis-current-value`]: dataValue?.current, + [`${segmentId}_y-axis-feasible-value`]: dataValue?.feasible, + }; + } + if (valueName === "binning-driver-name") { + const dataValue = dataSource.find((d) => d.name === value); + allValues = { + ...allValues, + [`${segmentId}_binning-driver-name`]: dataValue?.name, + [`${segmentId}_binning-value-1`]: dataValue?.current, + [`${segmentId}_binning-value-2`]: dataValue + ? (dataValue.current + dataValue.feasible) / 2 + : dataValue, + [`${segmentId}_binning-value-3`]: dataValue?.feasible, + [`${segmentId}_binning-current-value`]: dataValue?.current, + [`${segmentId}_binning-feasible-value`]: dataValue?.feasible, + }; + } + updateCaseVisualSensitivityAnalysisState({ + case: currentCase.id, + config: allValues, + }); + form.setFieldsValue(allValues); + }; + + return ( + +
+ +
Explanatory text
+
+ 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 +
+
+ + + {/* #1 Chart */} + + + Which drivers have the highest impact on income change? + + + {/* Carousel */} + + +
+ +
+
+ +
+
+ + {/* EOL Carousel */} + + {/* #2 Sensitivity Analysis */} + + + Which pairs of drivers have a strong impact on income? + + + + + + + + + + + {dashboardData.map((segment, key) => ( + b.id === segment.id)?.selected + // } + hidden={selectedSegment !== segment.id} + // enableEditCase={enableEditCase} + /> + ))} + + + + + + {/* EOL Sensitivity Analysis */} + + ); +}; + +export default AssessImpactMitigationStrategies; diff --git a/frontend/src/pages/cases/steps/ClosingGap.js b/frontend/src/pages/cases/steps/ClosingGap.js new file mode 100644 index 00000000..55c3023f --- /dev/null +++ b/frontend/src/pages/cases/steps/ClosingGap.js @@ -0,0 +1,32 @@ +import React, { useCallback, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { stepPath, CurrentCaseState } from "../store"; + +/** + * STEP 5 + */ +const ClosingGap = ({ segment, setbackfunction, setnextfunction }) => { + const navigate = useNavigate(); + const currentCase = CurrentCaseState.useState((s) => s); + + const backFunction = useCallback(() => { + navigate(`/case/${currentCase.id}/${stepPath.step4.label}`); + }, [navigate, currentCase.id]); + + const nextFunction = useCallback(() => { + console.info("Finish"); + }, []); + + useEffect(() => { + if (setbackfunction) { + setbackfunction(backFunction); + } + if (setnextfunction) { + setnextfunction(nextFunction); + } + }, [setbackfunction, setnextfunction, backFunction, nextFunction]); + + return
ClosingGap {segment.name}
; +}; + +export default ClosingGap; diff --git a/frontend/src/pages/cases/steps/EnterIncomeData.js b/frontend/src/pages/cases/steps/EnterIncomeData.js new file mode 100644 index 00000000..20cedf06 --- /dev/null +++ b/frontend/src/pages/cases/steps/EnterIncomeData.js @@ -0,0 +1,284 @@ +import React, { useCallback, useEffect, useState, useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { + stepPath, + CurrentCaseState, + PrevCaseState, + CaseUIState, + CaseVisualState, +} from "../store"; +import { + api, + calculateIncomePercentage, + determineDecimalRound, + renderPercentageTag, + removeUndefinedObjectValue, +} from "../../../lib"; +import { Row, Col, Space, message } from "antd"; +import { EnterIncomeDataForm } from "../components"; +import { thousandFormatter } from "../../../components/chart/options/common"; +import { isEmpty, isEqual } from "lodash"; + +const rowColSpanSize = { + gutter: [8, 8], + label: 11, + value: 5, + percentage: 3, +}; + +const generateSegmentAnswersPayload = ({ + id: segmentId, + answers: answerValues, +}) => { + // Use reduce to accumulate answers grouped by question ID + const groupedAnswers = Object.keys(answerValues).reduce((acc, key) => { + const [fieldName, caseCommodityId, questionId] = key.split("-"); + const questionKey = `${caseCommodityId}-${questionId}`; + + if (!acc[questionKey]) { + acc[questionKey] = { + case_commodity: parseInt(caseCommodityId), + question: parseInt(questionId), + segment: segmentId, + }; + } + + if (fieldName === "current") { + acc[questionKey].current_value = answerValues[key] || null; + } else if (fieldName === "feasible") { + acc[questionKey].feasible_value = answerValues[key] || null; + } + + return acc; + }, {}); + // Convert the grouped object into an array + return Object.values(groupedAnswers); +}; + +/** + * STEP 2 + */ +const EnterIncomeData = ({ segment, setbackfunction, setnextfunction }) => { + const navigate = useNavigate(); + const currentCase = CurrentCaseState.useState((s) => s); + const prevCaseSegments = PrevCaseState.useState((s) => s.segments); + const [sectionTotalValues, setSectionTotalValues] = useState({}); + const incomeDataDrivers = CaseVisualState.useState( + (s) => s.incomeDataDrivers + ); + + const [messageApi, contextHolder] = message.useMessage(); + + const upateCaseButtonState = (value) => { + CaseUIState.update((s) => ({ + ...s, + caseButton: value, + })); + }; + + const handleSaveIncomeData = useCallback(() => { + const allAnswers = currentCase?.segments?.flatMap((s) => s.answers); + if (!isEmpty(allAnswers)) { + // detect is payload updated + const isUpdated = + prevCaseSegments + .map((prev) => { + prev = { + ...prev, + benchmark: null, // set to null + answers: removeUndefinedObjectValue(prev?.answers || {}), + }; + let findPayload = currentCase.segments.find( + (curr) => curr.id === prev.id + ); + if (!findPayload) { + // handle deleted segment + return true; + } + findPayload = { + ...findPayload, + benchmark: null, // set to null + answers: removeUndefinedObjectValue(findPayload?.answers || {}), + }; + const equal = isEqual( + removeUndefinedObjectValue(prev), + removeUndefinedObjectValue(findPayload) + ); + return !equal; + }) + .filter((x) => x)?.length > 0; + + const segmentPayloads = currentCase.segments.map((s) => { + let answerPayload = []; + if (!isEmpty(s?.answers)) { + answerPayload = generateSegmentAnswersPayload({ ...s }); + } + return { + id: s.id, + name: s.name, + case: s.case, + region: s.region, + target: s.target, + adult: s.adult, + child: s.child, + answers: answerPayload, + }; + }); + + upateCaseButtonState({ loading: true }); + api + .put(`/segment?updated=${isUpdated}`, segmentPayloads) + .then((res) => { + const { data } = res; + PrevCaseState.update((s) => ({ + ...s, + segments: data, + })); + messageApi.open({ + type: "success", + content: "Income data saved successfully.", + }); + setTimeout(() => { + navigate(`/case/${currentCase.id}/${stepPath.step3.label}`); + }, 100); + }) + .catch((e) => { + console.error(e); + const { status, data } = e.response; + let errorText = "Failed to save income data."; + if (status === 403) { + errorText = data.detail; + } + messageApi.open({ + type: "error", + content: errorText, + }); + }) + .finally(() => { + upateCaseButtonState({ loading: false }); + }); + } + }, [ + currentCase.id, + currentCase.segments, + messageApi, + navigate, + prevCaseSegments, + ]); + + const backFunction = useCallback(() => { + navigate(`/case/${currentCase.id}/${stepPath.step1.label}`); + }, [navigate, currentCase.id]); + + const nextFunction = useCallback(() => { + handleSaveIncomeData(); + }, [handleSaveIncomeData]); + + useEffect(() => { + if (setbackfunction) { + setbackfunction(backFunction); + } + if (setnextfunction) { + setnextfunction(nextFunction); + } + }, [setbackfunction, setnextfunction, backFunction, nextFunction]); + + const totalIncome = useMemo(() => { + if (isEmpty(sectionTotalValues)) { + return { + current: 0, + feasible: 0, + percentage: { + type: "default", + value: 0, + }, + }; + } + const current = Object.keys(sectionTotalValues) + .map((key) => { + const value = sectionTotalValues?.[key]?.current || 0; + return value; + }) + .reduce((a, b) => a + b); + const feasible = Object.keys(sectionTotalValues) + .map((key) => { + const value = sectionTotalValues?.[key]?.feasible || 0; + return value; + }) + .reduce((a, b) => a + b); + const percentage = calculateIncomePercentage({ current, feasible }); + return { + current, + feasible, + percentage: { + ...percentage, + }, + }; + }, [sectionTotalValues]); + + return ( +
+ {/* Header */} + +
+ Total Income + + + +
Current level per year
+
+ {thousandFormatter( + totalIncome.current, + determineDecimalRound(totalIncome.current) + )} +
+
+ + + +
Feasible level per year
+
+ {thousandFormatter( + totalIncome.feasible, + determineDecimalRound(totalIncome.feasible) + )} +
+
+ + + {renderPercentageTag( + totalIncome.percentage.type, + totalIncome.percentage.value + )} + + + + {/* Questions */} + + {incomeDataDrivers.map((driver, driverIndex) => ( + + + + ))} + + + {contextHolder} + + ); +}; + +export default EnterIncomeData; diff --git a/frontend/src/pages/cases/steps/SetIncomeTarget.js b/frontend/src/pages/cases/steps/SetIncomeTarget.js new file mode 100644 index 00000000..3bd7b752 --- /dev/null +++ b/frontend/src/pages/cases/steps/SetIncomeTarget.js @@ -0,0 +1,576 @@ +import React, { useCallback, useMemo, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Form, + Radio, + Row, + Col, + InputNumber, + Select, + Card, + Space, + message, + Alert, +} from "antd"; +import { + CurrentCaseState, + CaseUIState, + PrevCaseState, + stepPath, +} from "../store"; +import { yesNoOptions } from "../../../store/static"; +import { + InputNumberThousandFormatter, + selectProps, + api, + removeUndefinedObjectValue, +} from "../../../lib"; +import { thousandFormatter } from "../../../components/chart/options/common"; +import { isEmpty, isEqual } from "lodash"; + +const formStyle = { width: "100%" }; + +const calculateHouseholdSize = ({ adult = 0, child = 0 }) => { + // OECD average household size + // first adult = 1, next adult 0.5 + // 1 child = 0.3 + const adult_size = adult === 1 ? 1 : 1 + (adult - 1) * 0.5; + const children_size = child * 0.3; + return adult_size + children_size; +}; + +/** + * STEP 1 + */ +const SetIncomeTarget = ({ segment, setbackfunction, setnextfunction }) => { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const { enableEditCase } = CaseUIState.useState((s) => s.general); + const currentCase = CurrentCaseState.useState((s) => s); + const prevCaseSegments = PrevCaseState.useState((s) => s?.segments || []); + const stepSetIncomeTargetState = CaseUIState.useState( + (s) => s.stepSetIncomeTarget + ); + const setTargetYourself = Form.useWatch( + `${segment.id}-set_target_yourself`, + form + ); + const [messageApi, contextHolder] = message.useMessage(); + + const initialIncomeTargetValue = useMemo(() => { + const values = {}; + Object.keys(segment).map((key) => { + const value = segment[key]; + if (key === "region" && value && segment.target !== null) { + values[`${segment.id}-set_target_yourself`] = 0; // set income value by benchmark + } + if (key === "region" && !value && segment.target !== null) { + values[`${segment.id}-set_target_yourself`] = 1; // set income value by manual + } + if (["target", "region", "adult", "child"].includes(key)) { + values[`${segment.id}-${key}`] = value; + } + }); + return values; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateCurrentSegmentState = useCallback( + (updatedSegmentValue) => { + CurrentCaseState.update((s) => { + s.segments = s.segments.map((prev) => { + if (prev.id === segment.id) { + return { + ...prev, + ...updatedSegmentValue, + }; + } + return prev; + }); + }); + }, + [segment.id] + ); + + const upateCaseButtonState = (value) => { + CaseUIState.update((s) => ({ + ...s, + caseButton: value, + })); + }; + + const showBenchmarNotification = useCallback( + ({ currentCase }) => { + return messageApi.open({ + type: "error", + content: `No benchmark available in the specified currency (${currentCase.currency}). Consider switching to the local currency.`, + }); + }, + [messageApi] + ); + + const fetchBenchmark = useCallback( + ({ region }) => { + let url = `country_region_benchmark?country_id=${currentCase.country}`; + url = `${url}®ion_id=${region}&year=${currentCase.year}`; + api + .get(url) + .then((res) => { + // data represent LI Benchmark value + const { data } = res; + // if data value by currency not found or 0 return a NA notif + if ( + !data?.value?.[currentCase.currency.toLowerCase()] || + data?.value?.[currentCase.currency.toLowerCase()] === 0 + ) { + showBenchmarNotification({ currentCase }); + // reset benchmark + updateCurrentSegmentState({ + region: region, + benchmark: null, + adult: null, + child: null, + target: null, + }); + return; + } + // + const adult = Math.round(data.nr_adults); + const child = Math.round(data.household_size - data.nr_adults); + // setBenchmark(data); + const defHHSize = calculateHouseholdSize({ + adult, + child, + }); + // setHouseholdSize(defHHSize); + // set hh adult and children default value + form.setFieldsValue({ + [`${segment.id}-adult`]: adult, + [`${segment.id}-child`]: child, + }); + updateCurrentSegmentState({ + region: region, + benchmark: data, + adult: adult, + child: child, + }); + // + const targetHH = data.household_equiv; + // Use LCU if currency if not USE/EUR + const targetValue = + data.value?.[currentCase.currency.toLowerCase()] || data.value.lcu; + // with CPI calculation + // Case year LI Benchmark = Latest Benchmark*(1-CPI factor) + // INFLATION RATE HERE + if (data?.cpi_factor) { + const caseYearLIB = targetValue * (1 + data.cpi_factor); + // incorporate year multiplier + // const LITarget = (defHHSize / targetHH) * caseYearLIB * 12; + const LITarget = (defHHSize / targetHH) * caseYearLIB; + form.setFieldValue(`${segment.id}-target`, LITarget); + updateCurrentSegmentState({ target: LITarget }); + } else { + // incorporate year multiplier + // const LITarget = (defHHSize / targetHH) * targetValue * 12; + const LITarget = (defHHSize / targetHH) * targetValue; + form.setFieldValue(`${segment.id}-target`, LITarget); + updateCurrentSegmentState({ target: LITarget }); + } + }) + .catch((e) => { + // reset field and benchmark value + // resetBenchmark({ region: region }); + // show notification + const { statusText, data } = e.response; + const content = data?.detail || statusText; + messageApi.open({ + type: "error", + content: content, + }); + }); + }, + [ + currentCase, + form, + messageApi, + segment.id, + showBenchmarNotification, + updateCurrentSegmentState, + ] + ); + + const preventNegativeValue = (fieldName) => [ + () => ({ + validator(_, value) { + if (value >= 0) { + return Promise.resolve(); + } + form.setFieldValue(fieldName, null); + return Promise.reject(new Error("Negative value not allowed")); + }, + }), + ]; + + const handleRegionChange = (value) => { + if (value) { + fetchBenchmark({ region: value }); + } else { + updateCurrentSegmentState({ target: null }); + } + }; + + const handleChangeManualTarget = (value) => { + updateCurrentSegmentState({ + region: null, + benchmark: null, + adult: null, + child: null, + target: value, + }); + form.setFieldsValue({ + [`${segment.id}-region`]: null, + [`${segment.id}-adult`]: null, + [`${segment.id}-child`]: null, + [`${segment.id}-target`]: value, + }); + }; + + const handleChangeAdultChildField = (key, value) => { + // handle income target value when householdSize updated + const householdSize = calculateHouseholdSize({ + adult: segment.adult, + child: segment.child, + [key]: value, + }); + updateCurrentSegmentState({ + [key]: value, + }); + + if ( + segment?.benchmark && + !isEmpty(segment.benchmark) && + segment.benchmark !== "NA" + ) { + // show benchmark notification + if ( + segment.benchmark?.value?.[currentCase?.currency?.toLowerCase()] === 0 + ) { + showBenchmarNotification({ currentCase }); + // reset benchmark + updateCurrentSegmentState({ + region: null, + benchmark: null, + adult: null, + child: null, + target: null, + }); + return; + } + // Use LCU if currency if not USD/EUR + const targetValue = + segment.benchmark.value?.[currentCase.currency.toLowerCase()] || + segment.benchmark.value.lcu; + // with CPI calculation + // Case year LI Benchmark = Latest Benchmark*(1-CPI factor) + if (segment.benchmark?.cpi_factor) { + const caseYearLIB = targetValue * (1 + segment.benchmark.cpi_factor); + // incorporate year multiplier + // const LITarget = + // (householdSize / benchmark.household_equiv) * caseYearLIB * 12; + const LITarget = + (householdSize / segment.benchmark.household_equiv) * caseYearLIB; + updateCurrentSegmentState({ + target: LITarget, + }); + // setIncomeTarget(LITarget); + } else { + // incorporate year multiplier + // const LITarget = + // (householdSize / benchmark.household_equiv) * targetValue * 12; + const LITarget = + (householdSize / segment.benchmark.household_equiv) * targetValue; + updateCurrentSegmentState({ + target: LITarget, + }); + } + } + + if ( + isEmpty(segment.benchmark) && + segment.region && + segment.benchmark !== "NA" + ) { + fetchBenchmark({ + region: segment.region, + }); + } + }; + + const handleSaveSegment = useCallback(() => { + if (!isEmpty(currentCase.segments)) { + // detect is payload updated + const isUpdated = + prevCaseSegments + .map((prev) => { + prev = { + ...prev, + benchmark: null, // set to null + answers: removeUndefinedObjectValue(prev?.answers || {}), + }; + let findPayload = currentCase.segments.find( + (curr) => curr.id === prev.id + ); + if (!findPayload) { + // handle deleted segment + return true; + } + findPayload = { + ...findPayload, + benchmark: null, // set to null + answers: removeUndefinedObjectValue(findPayload?.answers || {}), + }; + const equal = isEqual( + removeUndefinedObjectValue(prev), + removeUndefinedObjectValue(findPayload) + ); + return !equal; + }) + .filter((x) => x)?.length > 0; + + const payloads = currentCase.segments.map((curr) => ({ + id: curr.id, + name: curr.name, + case: curr.case, + region: curr.region, + target: curr.target, + adult: curr.adult, + child: curr.child, + answers: [], + })); + upateCaseButtonState({ loading: true }); + api + .put(`/segment?updated=${isUpdated}`, payloads) + .then((res) => { + const { data } = res; + PrevCaseState.update((s) => ({ + ...s, + segments: data, + })); + messageApi.open({ + type: "success", + content: "Income target saved successfully.", + }); + setTimeout(() => { + navigate(`/case/${currentCase.id}/${stepPath.step2.label}`); + }, 100); + }) + .catch((e) => { + console.error(e); + const { status, data } = e.response; + let errorText = "Failed to save income target."; + if (status === 403) { + errorText = data.detail; + } + messageApi.open({ + type: "error", + content: errorText, + }); + }) + .finally(() => { + upateCaseButtonState({ loading: false }); + }); + } + }, [ + currentCase.id, + currentCase.segments, + prevCaseSegments, + messageApi, + navigate, + ]); + + const backFunction = useCallback(() => { + navigate("/cases"); + }, [navigate]); + + const nextFunction = useCallback(() => { + handleSaveSegment(); + }, [handleSaveSegment]); + + useEffect(() => { + if (setbackfunction) { + setbackfunction(backFunction); + } + if (setnextfunction) { + setnextfunction(nextFunction); + } + }, [setbackfunction, setnextfunction, backFunction, nextFunction]); + + const renderTargetInput = (key) => { + switch (key) { + case 1: // yes + return ( + + + + + + ); + case 0: // no + return ( + + + + + +
record.id} + columns={columns} + dataSource={dataSource} + pagination={false} + /> + + + + ); +}; + +export default CompareIncomeGap; diff --git a/frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js b/frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js new file mode 100644 index 00000000..c8104922 --- /dev/null +++ b/frontend/src/pages/cases/visualizations/EnterIncomeDataVisual.js @@ -0,0 +1,99 @@ +import React, { useMemo } from "react"; +import { Card, Row, Col, Space, Tag } from "antd"; +import { CaseUIState, CurrentCaseState, CaseVisualState } from "../store"; +import { thousandFormatter } from "../../../components/chart/options/common"; +import { VisualCardWrapper } from "../components"; +import Chart from "../../../components/chart"; + +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( + () => + currentCase.segments.find((segment) => segment.id === activeSegmentId) || + null, + [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; + } + + return ( + + + + +
Living income benchmark for a household
+ +
+ {thousandFormatter(currentSegment.target, 2)} +
+
/ year
+
+
+
+ + + + + + + + + Household Income Table + + + + ); +}; + +export default EnterIncomeDataVisual; diff --git a/frontend/src/pages/cases/visualizations/index.js b/frontend/src/pages/cases/visualizations/index.js index c6d653c2..3565572e 100644 --- a/frontend/src/pages/cases/visualizations/index.js +++ b/frontend/src/pages/cases/visualizations/index.js @@ -1,151 +1,7 @@ -import { - incomeTargetChartOption, - Legend, - Color, - TextStyle, - thousandFormatter, - AxisLabelFormatter, - backgroundColor, - Easing, - LabelStyle, - NoData, -} from "../../../components/chart/options/common"; -import isEmpty from "lodash/isEmpty"; - -export const getColumnStackBarOptions = ({ - xAxis = { name: "", axisLabel: {} }, - yAxis = { name: "", min: 0, max: 0 }, - origin = [], - series = [], - showLabel = false, - grid = {}, -}) => { - if (isEmpty(series) || !series) { - return NoData; - } - - const legends = series.map((x) => ({ - name: x.name, - icon: x?.symbol || "circle", - })); - const xAxisData = origin.map((x) => x.name); - - const options = { - legend: { - ...Legend, - data: legends, - top: 15, - left: "right", - orient: "vertical", - }, - tooltip: { - trigger: "axis", - axisPointer: { - type: "shadow", - }, - formatter: function (params) { - let res = "
"; - res += "" + params[0].axisValueLabel + ""; - res += "
    "; - params.forEach((param) => { - res += "
  • "; - res += ""; - res += param.marker; - res += param.seriesName; - res += ""; - res += - "" + - thousandFormatter(param.value) + - ""; - res += "
  • "; - }); - res += "
"; - res += "
"; - return res; - }, - backgroundColor: "#ffffff", - ...TextStyle, - }, - grid: { - top: grid?.top ? grid.top : 25, - left: grid?.left ? grid.left : 50, - right: grid?.right ? grid.right : 190, - bottom: grid?.bottom ? grid.bottom : 25, - show: true, - containLabel: true, - label: { - color: "#222", - ...TextStyle, - }, - }, - xAxis: { - ...xAxis, - nameTextStyle: { ...TextStyle }, - nameLocation: "middle", - nameGap: 50, - boundaryGap: true, - type: "category", - data: xAxisData, - axisLabel: { - width: 100, - interval: 0, - overflow: "break", - ...TextStyle, - color: "#4b4b4e", - formatter: AxisLabelFormatter?.formatter, - ...xAxis.axisLabel, - }, - axisTick: { - alignWithLabel: true, - }, - }, - yAxis: { - ...yAxis, - type: "value", - nameTextStyle: { ...TextStyle }, - nameLocation: "middle", - nameGap: 75, - axisLabel: { - formatter: (e) => thousandFormatter(e), - ...TextStyle, - color: "#9292ab", - }, - }, - series: series.map((s) => { - s = { - ...s, - barMaxWidth: 50, - emphasis: { - focus: "series", - }, - }; - if (s.type === "line") { - return { ...incomeTargetChartOption, ...s }; - } - return { - ...s, - label: { - ...LabelStyle.label, - show: showLabel, - position: "inside", - }, - }; - }), - ...Color, - ...backgroundColor, - ...Easing, - }; - return options; -}; - -export { default as ChartBigImpact } from "./ChartBigImpact"; -export { default as ChartBinningHeatmap } from "./ChartBinningHeatmap"; -export { default as ChartCurrentFeasible } from "./ChartCurrentFeasible"; +export { default as EnterIncomeDataVisual } from "./EnterIncomeDataVisual"; export { default as ChartIncomeGap } from "./ChartIncomeGap"; -export { default as ChartMonetaryContribution } from "./ChartMonetaryContribution"; -export { default as SegmentSelector } from "./SegmentSelector"; -export { default as ChartExploreBreakdownDrivers } from "./ChartExploreBreakdownDrivers"; -export { default as DriverDropdown } from "./DriverDropdown"; -export { default as ChartIncomeLevelPerCommodities } from "./ChartIncomeLevelPerCommodities"; -export { default as ChartScenarioModeling } from "./ChartScenarioModeling"; -export { default as ChartSensitivityAnalysisLine } from "./ChartSensitivityAnalysisLine"; +export { default as CompareIncomeGap } from "./CompareIncomeGap"; +export { default as ChartIncomeDriverAcrossSegments } from "./ChartIncomeDriverAcrossSegments"; +export { default as ChartExploreIncomeDriverBreakdown } from "./ChartExploreIncomeDriverBreakdown"; +export { default as ChartBiggestImpactOnIncome } from "./ChartBiggestImpactOnIncome"; +export { default as ChartMonetaryImpactOnIncome } from "./ChartMonetaryImpactOnIncome"; diff --git a/frontend/src/pages/landing/components/GetStarted.js b/frontend/src/pages/landing/components/GetStarted.js index bd5dd7fb..58531ce6 100644 --- a/frontend/src/pages/landing/components/GetStarted.js +++ b/frontend/src/pages/landing/components/GetStarted.js @@ -49,7 +49,7 @@ const GetStarted = () => {
{loggedIn ? ( - + Go to my cases ) : ( diff --git a/frontend/src/pages/old-cases/Case.js b/frontend/src/pages/old-cases/Case.js new file mode 100644 index 00000000..6a091a62 --- /dev/null +++ b/frontend/src/pages/old-cases/Case.js @@ -0,0 +1,512 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { ContentLayout } from "../../components/layout"; +import { + SideMenu, + CaseProfile, + IncomeDriverDataEntry, + IncomeDriverDashboard, + getFunctionDefaultValue, + customFormula, +} from "./components"; +import { Row, Col, Spin, Card, Alert } from "antd"; +import "./cases.scss"; +import { api, flatten } from "../../lib"; +import { CaseTitleIcon } from "../../lib/icon"; +import dayjs from "dayjs"; +import isEmpty from "lodash/isEmpty"; +import orderBy from "lodash/orderBy"; +import { UserState } from "../../store"; +import { adminRole } from "../../store/static"; + +const pageDependencies = { + "Income Driver Data Entry": ["Case Profile"], + "Income Driver Dashboard": ["Case Profile", "Income Driver Data Entry"], +}; + +const commodityOrder = ["focus", "secondary", "tertiary", "diversified"]; + +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 options = { + year: "numeric", + month: "long", + day: "numeric", +}; + +const Case = () => { + const { caseId } = useParams(); + const navigate = useNavigate(); + const [caseTitle, setCaseTitle] = useState("New Case"); + const [caseDescription, setCaseDescription] = useState(null); + const [page, setPage] = useState("Case Profile"); + const [formData, setFormData] = useState({}); + const [finished, setFinished] = useState([]); + const [commodityList, setCommodityList] = useState([]); + const [caseData, setCaseData] = useState([]); + const [questionGroups, setQuestionGroups] = useState([]); + const [currentCaseId, setCurrentCaseId] = useState(null); + const [loading, setLoading] = useState(false); + const [initialOtherCommodityTypes, setInitialCommodityTypes] = useState([]); + const [currentCase, setCurrentCase] = useState({}); + const showCaseTitle = false; // don't show title for now + + const { + role: userRole, + internal_user: userInternal, + case_access: userCaseAccess, + email: userEmail, + } = UserState.useState((s) => s); + + const enableEditCase = useMemo(() => { + const caseIdParam = caseId ? caseId : currentCaseId; + if (adminRole.includes(userRole)) { + return true; + } + // allow internal user to create new case + if (userInternal && !caseIdParam) { + return true; + } + // check user access + const userPermission = userCaseAccess.find( + (a) => a.case === parseInt(caseIdParam) + )?.permission; + // allow internal user case owner to edit case + if (userInternal && currentCase?.created_by === userEmail) { + return true; + } + if ((userInternal && !userPermission) || userPermission === "view") { + return false; + } + if (userPermission === "edit") { + return true; + } + return false; + }, [ + caseId, + currentCaseId, + userRole, + userEmail, + userCaseAccess, + userInternal, + currentCase?.created_by, + ]); + + useEffect(() => { + if (caseId && caseData.length) { + setFinished(["Case Profile", "Income Driver Data Entry"]); + } + }, [caseData, caseId]); + + const totalIncomeQuestion = useMemo(() => { + const qs = questionGroups.map((group) => { + if (!group) { + return []; + } + const questions = flatten(group.questions).filter((q) => !q.parent); + const commodity = commodityList.find( + (c) => c.commodity === group.commodity_id + ); + return questions.map((q) => `${commodity.case_commodity}-${q.id}`); + }); + return qs.flatMap((q) => q); + }, [questionGroups, commodityList]); + + const costQuestions = useMemo(() => { + const qs = questionGroups.map((group) => { + if (!group) { + return []; + } + const questions = flatten(group.questions).filter((q) => + q.text.toLowerCase().includes("cost") + ); + return questions.map((q) => ({ + ...q, + commodityId: group.commodity_id, + })); + }); + return qs.flatMap((q) => q); + }, [questionGroups]); + + const flattenedQuestionGroups = useMemo(() => { + const qg = questionGroups.map((group) => { + const questions = group ? flatten(group.questions) : []; + return questions.map((q) => ({ + ...q, + commodityId: group.commodity_id, + })); + }); + return qg.flatMap((q) => q); + }, [questionGroups]); + + const dashboardData = useMemo(() => { + const mappedData = caseData.map((d) => { + const answers = Object.keys(d.answers).map((k) => { + const [dataType, caseCommodityId, questionId] = k.split("-"); + const commodity = commodityList.find( + (x) => x.case_commodity === parseInt(caseCommodityId) + ); + const commodityId = commodity.commodity; + const commodityFocus = + commodity.commodity_type === "focus" ? true : false; + const totalCommodityQuestion = questionGroups + .map((group) => { + if (!group) { + return []; + } + const questions = flatten(group.questions).filter( + (q) => !q.parent && q.question_type === "aggregator" + ); + return questions; + }) + .flatMap((q) => q); + + const totalCommodityValue = totalCommodityQuestion.find( + (q) => q.id === parseInt(questionId) + ); + const cost = costQuestions.find( + (q) => + q.id === parseInt(questionId) && + q.parent === 1 && + q.commodityId === commodityId + ); + const question = flattenedQuestionGroups.find( + (q) => q.id === parseInt(questionId) && q.commodityId === commodityId + ); + const totalOtherDiversifiedIncome = + question?.question_type === "diversified" && !question.parent; + return { + name: dataType, + question: question, + commodityFocus: commodityFocus, + commodityType: commodity.commodity_type, + caseCommodityId: parseInt(caseCommodityId), + commodityId: parseInt(commodityId), + commodityName: commodityNames[commodityId], + questionId: parseInt(questionId), + value: d.answers?.[k] || 0, // if not found set as 0 to calculated inside array reduce + isTotalFeasibleFocusIncome: + totalCommodityValue && commodityFocus && dataType === "feasible" + ? true + : false, + isTotalFeasibleDiversifiedIncome: + totalCommodityValue && !commodityFocus && dataType === "feasible" + ? true + : totalOtherDiversifiedIncome && dataType === "feasible" + ? true + : false, + isTotalCurrentFocusIncome: + totalCommodityValue && commodityFocus && dataType === "current" + ? true + : false, + isTotalCurrentDiversifiedIncome: + totalCommodityValue && !commodityFocus && dataType === "current" + ? true + : totalOtherDiversifiedIncome && dataType === "current" + ? true + : false, + feasibleCost: + cost && d.answers[k] && dataType === "feasible" ? true : false, + currentCost: + cost && d.answers[k] && dataType === "current" ? true : false, + costName: cost ? cost.text : "", + }; + }); + const totalCostFeasible = answers + .filter((a) => a.feasibleCost) + .reduce((acc, curr) => acc + curr.value, 0); + const totalCostCurrent = answers + .filter((a) => a.currentCost) + .reduce((acc, curr) => acc + curr.value, 0); + const totalFeasibleFocusIncome = answers + .filter((a) => a.isTotalFeasibleFocusIncome) + .reduce((acc, curr) => acc + curr.value, 0); + const totalFeasibleDiversifiedIncome = answers + .filter((a) => a.isTotalFeasibleDiversifiedIncome) + .reduce((acc, curr) => acc + curr.value, 0); + const totalCurrentFocusIncome = answers + .filter((a) => a.isTotalCurrentFocusIncome) + .reduce((acc, curr) => acc + curr.value, 0); + const totalCurrentDiversifiedIncome = answers + .filter((a) => a.isTotalCurrentDiversifiedIncome) + .reduce((acc, curr) => acc + curr.value, 0); + + const focusCommodityAnswers = answers + .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 { + ...d, + 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: answers, + }; + }); + return orderBy(mappedData, ["id", "key"]); + }, [ + caseData, + commodityList, + costQuestions, + questionGroups, + flattenedQuestionGroups, + ]); + + useEffect(() => { + if (caseId && isEmpty(formData) && !loading) { + setCurrentCaseId(caseId); + setLoading(true); + api + .get(`case/${caseId}`) + .then((res) => { + const { data } = res; + setCurrentCase(data); + setCaseTitle(data.name); + setCaseDescription(data.description); + // set other commodities type + setInitialCommodityTypes( + data.case_commodities.map((x) => x.commodity_type) + ); + // set commodity list and order by id to match + // focus, secondary, tertiary, diversified order + const commodities = commodityOrder + .map((co) => { + const temp = data.case_commodities.find( + (d) => d.commodity_type === co + ); + if (!temp) { + return false; + } + return { + ...temp, + currency: data.currency, + case_commodity: temp.id, + }; + }) + .filter((x) => x); + setCommodityList(commodities); + // focus commodity + const focusCommodityValue = { + name: data.name, + description: data.description, + private: data?.private || false, + tags: data?.tags || [], + country: data.country, + focus_commodity: data.focus_commodity, + year: dayjs(String(data.year)), + currency: data.currency, + area_size_unit: data.area_size_unit, + volume_measurement_unit: data.volume_measurement_unit, + reporting_period: data.reporting_period, + company: data.company, + }; + // secondary + let secondaryCommodityValue = {}; + const secondaryCommodityTmp = data.case_commodities.find( + (val) => val.commodity_type === "secondary" + ); + if (secondaryCommodityTmp) { + Object.keys(secondaryCommodityTmp).forEach((key) => { + let val = secondaryCommodityTmp[key]; + if (key === "breakdown") { + val = val ? 1 : 0; + } + secondaryCommodityValue = { + ...secondaryCommodityValue, + [`1-${key}`]: val, + }; + }); + } + // tertiary + let tertiaryCommodityValue = {}; + const tertiaryCommodityTmp = data.case_commodities.find( + (val) => val.commodity_type === "tertiary" + ); + if (tertiaryCommodityTmp) { + Object.keys(tertiaryCommodityTmp).forEach((key) => { + let val = tertiaryCommodityTmp[key]; + if (key === "breakdown") { + val = val ? 1 : 0; + } + tertiaryCommodityValue = { + ...tertiaryCommodityValue, + [`2-${key}`]: val, + }; + }); + } + // set initial value + setFormData({ + ...focusCommodityValue, + ...secondaryCommodityValue, + ...tertiaryCommodityValue, + }); + }) + .catch((e) => { + console.error("Error fetching case profile data", e); + navigate("/not-found"); + }) + .finally(() => { + setTimeout(() => { + setLoading(false); + setFinished(["Case Profile"]); + }, 100); + }); + } + }, [caseId, formData, loading, navigate]); + + const setActive = (selected) => { + if (finished.includes(selected)) { + setPage(selected); + } else { + const dependencies = pageDependencies[selected]; + if (dependencies) { + if (dependencies.every((dependency) => finished.includes(dependency))) { + setPage(selected); + } + } + } + }; + + return ( + + {loading ? ( +
+ +
+ ) : ( + + + {/* Banner for Viewer */} + {!enableEditCase && ( +
+ + + )} + {/* EOL Banner for Viewer */} + {showCaseTitle && ( + + +

{caseTitle}

+ {caseDescription ?

{caseDescription}

: null} +
+ +
+
+ + )} + + {page === "Case Profile" && ( + + )} + {page === "Income Driver Data Entry" && ( + + )} + {page === "Income Driver Dashboard" && ( + + )} + + + )} + + ); +}; + +export default Case; diff --git a/frontend/src/pages/old-cases/Cases.js b/frontend/src/pages/old-cases/Cases.js new file mode 100644 index 00000000..eae3c2db --- /dev/null +++ b/frontend/src/pages/old-cases/Cases.js @@ -0,0 +1,423 @@ +import React, { useEffect, useState, useMemo } from "react"; +import { ContentLayout, TableContent } from "../../components/layout"; +import { Link, useSearchParams } from "react-router-dom"; +import { + EditOutlined, + UserSwitchOutlined, + SaveOutlined, + CloseOutlined, + EyeOutlined, + DeleteOutlined, +} from "@ant-design/icons"; +import { api } from "../../lib"; +import { UIState, UserState } from "../../store"; +import { + Select, + Space, + Button, + Row, + Col, + Popconfirm, + message, + Input, + InputNumber, +} from "antd"; +import { selectProps, DebounceSelect } from "./components"; +import { isEmpty } from "lodash"; +import { adminRole } from "../../store/static"; + +const perPage = 10; +const defData = { + current: 1, + data: [], + total: 0, + total_page: 1, +}; +const filterProps = { + ...selectProps, + style: { width: window.innerHeight * 0.175 }, +}; + +const Cases = () => { + const [searchParams] = useSearchParams(); + const caseOwner = searchParams.get("owner"); + + const [loading, setLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [search, setSearch] = useState(null); + const [data, setData] = useState(defData); + const [country, setCountry] = useState(null); + const [commodity, setCommodity] = useState(null); + const [tags, setTags] = useState([]); + const [email, setEmail] = useState(caseOwner || null); + const [year, setYear] = useState(null); + + const tagOptions = UIState.useState((s) => s.tagOptions); + const { + id: userID, + email: userEmail, + role: userRole, + internal_user: userInternal, + case_access: userCaseAccess, + } = UserState.useState((s) => s); + + const [showChangeOwnerForm, setShowChangeOwnerForm] = useState(null); + const [selectedUser, setSelectedUser] = useState(null); + const [refresh, setRefresh] = useState(false); + + const [messageApi, contextHolder] = message.useMessage(); + + const isCaseCreator = useMemo(() => { + if (adminRole.includes(userRole)) { + return true; + } + if (userInternal) { + return true; + } + return false; + }, [userRole, userInternal]); + + useEffect(() => { + if (userID || refresh) { + setLoading(true); + let url = `case?page=${currentPage}&limit=${perPage}`; + if (search) { + url = `${url}&search=${search}`; + } + if (country) { + url = `${url}&country=${country}`; + } + if (commodity) { + url = `${url}&focus_commodity=${commodity}`; + } + if (!isEmpty(tags)) { + const tagQuery = tags.join("&tags="); + url = `${url}&tags=${tagQuery}`; + } + if (email) { + url = `${url}&email=${email}`; + } + if (year) { + url = `${url}&year=${year}`; + } + api + .get(url) + .then((res) => { + setData(res.data); + }) + .catch((e) => { + console.error(e.response); + const { status } = e.response; + if (status === 404) { + setData(defData); + } + }) + .finally(() => { + setLoading(false); + setRefresh(false); + }); + } + }, [ + currentPage, + search, + userID, + commodity, + country, + tags, + refresh, + year, + email, + ]); + + const fetchUsers = (searchValue) => { + return api + .get(`user/search_dropdown?search=${searchValue}`) + .then((res) => res.data); + }; + + const handleOnUpdateCaseOwner = (caseRecord) => { + api + .put(`update_case_owner/${caseRecord.id}?user_id=${selectedUser.value}`) + .then(() => { + setRefresh(true); + setShowChangeOwnerForm(null); + }) + .catch((e) => { + console.error(e); + }); + }; + + const onConfirmDelete = (record) => { + api + .delete(`case/${record.id}`) + .then(() => { + setRefresh(true); + messageApi.open({ + type: "success", + content: "Case deleted successfully.", + }); + }) + .catch(() => { + messageApi.open({ + type: "error", + content: "Failed! Something went wrong.", + }); + }); + }; + + const columns = [ + { + title: "Country", + dataIndex: "country", + key: "country", + width: "10%", + defaultSortOrder: "descend", + sorter: (a, b) => a.country.localeCompare(b.country), + }, + { + title: "Case Name", + dataIndex: "name", + key: "case", + defaultSortOrder: "descend", + sorter: (a, b) => a.name.localeCompare(b.name), + }, + { + title: "Primary Commodity", + key: "primary_commodity", + render: (record) => { + const findPrimaryCommodity = commodityOptios.find( + (co) => co.value === record.focus_commodity + ); + if (!findPrimaryCommodity?.label) { + return "-"; + } + return findPrimaryCommodity.label; + }, + }, + { + title: "Tags", + key: "tags", + render: (record) => { + const tags = record.tags + .map((tag_id) => { + const findTag = tagOptions.find((x) => x.value === tag_id); + return findTag?.label || null; + }) + .filter((x) => x); + if (!tags.length) { + return "-"; + } + return tags.join(", "); + }, + }, + { + title: "Year", + dataIndex: "year", + key: "year", + defaultSortOrder: "descend", + sorter: (a, b) => a.year - b.year, + }, + { + title: "Case Owner", + key: "created_by", + width: "20%", + render: (row) => { + // case owner row.created_by !== userEmail + if (!adminRole.includes(userRole)) { + return row.created_by; + } + if (row.id === showChangeOwnerForm) { + return ( + + + setSelectedUser(value)} + style={{ + width: "100%", + }} + size="small" + /> + + + + + + + + + + ); +}; + +export default AreaUnitFields; diff --git a/frontend/src/pages/cases/components/CaseProfile.js b/frontend/src/pages/old-cases/components/CaseProfile.js similarity index 99% rename from frontend/src/pages/cases/components/CaseProfile.js rename to frontend/src/pages/old-cases/components/CaseProfile.js index 6debe536..a4e7ecbe 100644 --- a/frontend/src/pages/cases/components/CaseProfile.js +++ b/frontend/src/pages/old-cases/components/CaseProfile.js @@ -36,7 +36,7 @@ import { yesNoOptions, DebounceSelect, removeUndefinedObjectValue, -} from "./"; +} from "."; import { api } from "../../../lib"; import { UIState, UserState } from "../../../store"; import isEmpty from "lodash/isEmpty"; @@ -935,7 +935,7 @@ const CaseProfile = ({ diff --git a/frontend/src/pages/cases/components/DashboardIncomeOverview.js b/frontend/src/pages/old-cases/components/DashboardIncomeOverview.js similarity index 100% rename from frontend/src/pages/cases/components/DashboardIncomeOverview.js rename to frontend/src/pages/old-cases/components/DashboardIncomeOverview.js diff --git a/frontend/src/pages/cases/components/DashboardScenarioModeling.js b/frontend/src/pages/old-cases/components/DashboardScenarioModeling.js similarity index 99% rename from frontend/src/pages/cases/components/DashboardScenarioModeling.js rename to frontend/src/pages/old-cases/components/DashboardScenarioModeling.js index b7d04461..93fd61c2 100644 --- a/frontend/src/pages/cases/components/DashboardScenarioModeling.js +++ b/frontend/src/pages/old-cases/components/DashboardScenarioModeling.js @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { Row, Col, Card, Select, Tabs, Space } from "antd"; -import { Scenario, Step } from "./"; +import { Scenario, Step } from "."; import { orderBy } from "lodash"; import { PlusCircleFilled } from "@ant-design/icons"; diff --git a/frontend/src/pages/cases/components/DashboardSensitivityAnalysis.js b/frontend/src/pages/old-cases/components/DashboardSensitivityAnalysis.js similarity index 100% rename from frontend/src/pages/cases/components/DashboardSensitivityAnalysis.js rename to frontend/src/pages/old-cases/components/DashboardSensitivityAnalysis.js diff --git a/frontend/src/pages/cases/components/DataFields.js b/frontend/src/pages/old-cases/components/DataFields.js similarity index 99% rename from frontend/src/pages/cases/components/DataFields.js rename to frontend/src/pages/old-cases/components/DataFields.js index d784ec28..e3913517 100644 --- a/frontend/src/pages/cases/components/DataFields.js +++ b/frontend/src/pages/old-cases/components/DataFields.js @@ -35,7 +35,7 @@ import { IncomeDriverForm, IncomeDriverTarget, InputNumberThousandFormatter, -} from "./"; +} from "."; import Chart from "../../../components/chart"; import { SaveAsImageButton, ShowLabelButton } from "../../../components/utils"; import { api } from "../../../lib"; diff --git a/frontend/src/pages/old-cases/components/DebounceSelect.js b/frontend/src/pages/old-cases/components/DebounceSelect.js new file mode 100644 index 00000000..968c0e9e --- /dev/null +++ b/frontend/src/pages/old-cases/components/DebounceSelect.js @@ -0,0 +1,41 @@ +import React, { useState, useRef, useMemo } from "react"; +import debounce from "lodash/debounce"; +import { Select, Spin } from "antd"; + +const DebounceSelect = ({ fetchOptions, debounceTimeout = 500, ...props }) => { + const [fetching, setFetching] = useState(false); + const [options, setOptions] = useState([]); + const fetchRef = useRef(0); + + const debounceFetcher = useMemo(() => { + const loadOptions = (value) => { + fetchRef.current += 1; + const fetchId = fetchRef.current; + setOptions([]); + setFetching(true); + fetchOptions(value).then((newOptions) => { + if (fetchId !== fetchRef.current) { + // for fetch callback order + return; + } + setOptions(newOptions); + setFetching(false); + }); + }; + return debounce(loadOptions, debounceTimeout); + }, [fetchOptions, debounceTimeout]); + + return ( +