diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 3e76a0ac7..4a3c26eb7 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -1,7 +1,6 @@ name: pull-request on: pull_request: - branches: [main, staging, release/**] jobs: test: runs-on: ubuntu-latest diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 66736957c..e1297e325 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -28,33 +28,39 @@ type Environment = { apiBaseUrl: string; userServiceUrl: string; jobServiceUrl: string; + entityServiceUrl: string; }; const ENVIRONMENTS: { [Property in EnvironmentName]: Environment } = { local: { apiBaseUrl: "http://localhost:8080", userServiceUrl: "http://localhost:4010", - jobServiceUrl: "http://localhost:4020" + jobServiceUrl: "http://localhost:4020", + entityServiceUrl: "http://localhost:4050" }, dev: { apiBaseUrl: "https://api-dev.terramatch.org", userServiceUrl: "https://api-dev.terramatch.org", - jobServiceUrl: "https://api-dev.terramatch.org" + jobServiceUrl: "https://api-dev.terramatch.org", + entityServiceUrl: "https://api-dev.terramatch.org" }, test: { apiBaseUrl: "https://api-test.terramatch.org", userServiceUrl: "https://api-test.terramatch.org", - jobServiceUrl: "https://api-test.terramatch.org" + jobServiceUrl: "https://api-test.terramatch.org", + entityServiceUrl: "https://api-test.terramatch.org" }, staging: { apiBaseUrl: "https://api-staging.terramatch.org", userServiceUrl: "https://api-staging.terramatch.org", - jobServiceUrl: "https://api-staging.terramatch.org" + jobServiceUrl: "https://api-staging.terramatch.org", + entityServiceUrl: "https://api-staging.terramatch.org" }, prod: { apiBaseUrl: "https://api.terramatch.org", userServiceUrl: "https://api.terramatch.org", - jobServiceUrl: "https://api.terramatch.org" + jobServiceUrl: "https://api.terramatch.org", + entityServiceUrl: "https://api.terramatch.org" } }; @@ -67,6 +73,7 @@ const DEFAULTS = ENVIRONMENTS[declaredEnv]; const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? DEFAULTS.apiBaseUrl; const userServiceUrl = process.env.NEXT_PUBLIC_USER_SERVICE_URL ?? DEFAULTS.userServiceUrl; const jobServiceUrl = process.env.NEXT_PUBLIC_JOB_SERVICE_URL ?? DEFAULTS.jobServiceUrl; +const entityServiceUrl = process.env.NEXT_PUBLIC_ENTITY_SERVICE_URL ?? DEFAULTS.entityServiceUrl; // The services defined in the v3 Node BE codebase. Although the URL path for APIs in the v3 space // are namespaced by feature set rather than service (a service may contain multiple namespaces), we @@ -74,7 +81,8 @@ const jobServiceUrl = process.env.NEXT_PUBLIC_JOB_SERVICE_URL ?? DEFAULTS.jobSer // the associated BE code is for a given FE API integration. const SERVICES = { "user-service": userServiceUrl, - "job-service": jobServiceUrl + "job-service": jobServiceUrl, + "entity-service": entityServiceUrl }; const config: Record = { diff --git a/package.json b/package.json index 602dceae6..22e502fb1 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "generate:api": "openapi-codegen gen api", "generate:jobService": "openapi-codegen gen jobService", "generate:userService": "openapi-codegen gen userService", - "generate:services": "yarn generate:userService && yarn generate:jobService", + "generate:entityService": "openapi-codegen gen entityService", + "generate:services": "yarn generate:userService && yarn generate:entityService && yarn generate:jobService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/admin/components/EntityEdit/EntityEdit.tsx b/src/admin/components/EntityEdit/EntityEdit.tsx index a81045de0..81ef49f10 100644 --- a/src/admin/components/EntityEdit/EntityEdit.tsx +++ b/src/admin/components/EntityEdit/EntityEdit.tsx @@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom"; import modules from "@/admin/modules"; import WizardForm from "@/components/extensive/WizardForm"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import EntityProvider from "@/context/entity.provider"; import FrameworkProvider, { Framework } from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, @@ -73,31 +74,33 @@ export const EntityEdit = () => {
- navigate("..")} - onChange={data => - updateEntity({ - pathParams: { uuid: entityUUID, entity: entityName }, - body: { answers: normalizedFormData(data, formSteps!) } - }) - } - formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} - onSubmit={() => navigate(createPath({ resource, id, type: "show" }))} - defaultValues={defaultValues} - title={title} - tabOptions={{ - markDone: true, - disableFutureTabs: true - }} - summaryOptions={{ - title: "Review Details", - downloadButtonText: "Download" - }} - roundedCorners - hideSaveAndCloseButton - /> + + navigate("..")} + onChange={data => + updateEntity({ + pathParams: { uuid: entityUUID, entity: entityName }, + body: { answers: normalizedFormData(data, formSteps!) } + }) + } + formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} + onSubmit={() => navigate(createPath({ resource, id, type: "show" }))} + defaultValues={defaultValues} + title={title} + tabOptions={{ + markDone: true, + disableFutureTabs: true + }} + summaryOptions={{ + title: "Review Details", + downloadButtonText: "Download" + }} + roundedCorners + hideSaveAndCloseButton + /> +
diff --git a/src/admin/components/ResourceTabs/MonitoredTab/MonitoredTab.tsx b/src/admin/components/ResourceTabs/MonitoredTab/MonitoredTab.tsx index b95842a46..c9fef6a55 100644 --- a/src/admin/components/ResourceTabs/MonitoredTab/MonitoredTab.tsx +++ b/src/admin/components/ResourceTabs/MonitoredTab/MonitoredTab.tsx @@ -1,19 +1,22 @@ import { FC } from "react"; import { TabbedShowLayout, TabProps } from "react-admin"; +import { EntityName } from "@/types/common"; + import DataCard from "./components/DataCard"; import HeaderMonitoredTab from "./components/HeaderMonitoredTab"; interface IProps extends Omit { label?: string; + type: EntityName; } -const MonitoredTab: FC = ({ label, ...rest }) => { +const MonitoredTab: FC = ({ label, type, ...rest }) => { return (
- - + +
); diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/DataCard.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/DataCard.tsx index f7842e01f..bb523eaee 100644 --- a/src/admin/components/ResourceTabs/MonitoredTab/components/DataCard.tsx +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/DataCard.tsx @@ -1,8 +1,13 @@ import { ColumnDef, RowData } from "@tanstack/react-table"; +import { useT } from "@transifex/react"; import classNames from "classnames"; +import { format } from "date-fns"; import React, { useEffect, useState } from "react"; -import { Else, If, Then, When } from "react-if"; +import { useBasename, useShowContext } from "react-admin"; +import { When } from "react-if"; +import { useNavigate } from "react-router-dom"; +import ExportProcessingAlert from "@/admin/components/Alerts/ExportProcessingAlert"; import CustomChipField from "@/admin/components/Fields/CustomChipField"; import Button from "@/components/elements/Button/Button"; import Dropdown from "@/components/elements/Inputs/Dropdown/Dropdown"; @@ -18,9 +23,25 @@ import Toggle, { TogglePropsItem } from "@/components/elements/Toggle/Toggle"; import Tooltip from "@/components/elements/Tooltip/Tooltip"; import TooltipMapMonitoring from "@/components/elements/TooltipMap/TooltipMapMonitoring"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; -import { DUMMY_DATA_TARGET_LAND_USE_TYPES_REPRESENTED } from "@/constants/dashboardConsts"; -import GraphicIconDashboard from "@/pages/dashboard/components/GraphicIconDashboard"; -import { OptionValue } from "@/types/common"; +import { + DEFAULT_POLYGONS_DATA, + DEFAULT_POLYGONS_DATA_ECOREGIONS, + DEFAULT_POLYGONS_DATA_STRATEGIES +} from "@/constants/dashboardConsts"; +import { useMonitoredDataContext } from "@/context/monitoredData.provider"; +import { useNotificationContext } from "@/context/notification.provider"; +import { fetchGetV2IndicatorsEntityUuidSlugExport } from "@/generated/apiComponents"; +import { EntityName, OptionValue } from "@/types/common"; +import { + parsePolygonsIndicatorDataForEcoRegion, + parsePolygonsIndicatorDataForLandUse, + parsePolygonsIndicatorDataForStrategies, + parseTreeCoverData +} from "@/utils/dashboardUtils"; +import { downloadFileBlob } from "@/utils/network"; + +import { useMonitoredData } from "../hooks/useMonitoredData"; +import MonitoredCharts from "./MonitoredCharts"; interface TableData { polygonName: string; @@ -50,13 +71,360 @@ export interface DataStructure extends React.HTMLAttributes { tableData: TableData[]; } -const DataCard = ({ ...rest }: React.HTMLAttributes) => { +const COMMON_COLUMNS: ColumnDef[] = [ + { + accessorKey: "poly_name", + header: "Polygon Name", + meta: { style: { width: "13.30%" } }, + cell: (props: any) => { + const value = props.getValue(); + return value == "" || value == "-" ? "-" : value; + } + }, + { + accessorKey: "size", + header: "Size (ha)", + meta: { style: { width: "9.01%" } } + }, + { accessorKey: "site_name", header: "Site Name", meta: { style: { width: "9.90%" } } }, + { + accessorKey: "status", + header: "Status", + cell: (props: any) => ( + + ), + meta: { style: { width: "7.65%" } } + }, + { + accessorKey: "plantstart", + header: "Plant Start Date", + cell: (props: any) => { + const value = props.getValue(); + return value == "-" ? "-" : format(new Date(value), "dd/MM/yyyy"); + }, + meta: { style: { width: "13.65%" } } + }, + { + accessorKey: "base_line", + header: "Baseline", + cell: (props: any) => { + const value = props.getValue(); + return format(new Date(value), "dd/MM/yyyy"); + }, + meta: { style: { width: "8.87%" } } + } +]; + +type CustomColumnDefInternal = ColumnDef & { type?: string }; + +const TABLE_COLUMNS_HECTARES_STRATEGY: ColumnDef[] = [ + ...COMMON_COLUMNS, + { + accessorKey: "data.tree_planting", + header: "Tree Planting", + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "11.95%" } } + }, + { + accessorKey: "data.assisted_natural_regeneration", + header: () => ( + <> + Asst. Nat. +
+ Regeneration + + ), + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "12.09%" } } + }, + { + accessorKey: "data.direct_seeding", + header: () => ( + <> + Direct +
+ Seeding + + ), + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "8.57%" } } + }, + { + accessorKey: "more", + header: "", + enableSorting: false, + cell: props => ( +
+ +
+ ), + meta: { style: { width: "5%" } } + } +]; + +const TABLE_COLUMNS_HECTARES_ECO_REGION: ColumnDef[] = [ + ...COMMON_COLUMNS, + { + accessorKey: "data.australasian", + header: "Australasian", + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "11.45%" } } + }, + { + accessorKey: "data.afrotropical", + header: "Afrotropical", + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "11.05%" } } + }, + { + accessorKey: "data.paleartic", + header: "Paleartic11", + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "10.33%" } } + }, + { + accessorKey: "more", + header: "", + enableSorting: false, + cell: props => ( +
+ +
+ ), + meta: { style: { width: "5%" } } + } +]; + +const TABLE_COLUMNS_HECTARES_LAND_USE: ColumnDef[] = [ + ...COMMON_COLUMNS, + { + accessorKey: "data.agroforest", + header: "Agroforest", + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "11.95%" } } + }, + { + accessorKey: "data.natural_forest", + header: "Natural Forest", + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "12.09%" } } + }, + { + accessorKey: "data.mangrove", + header: "Mangrove", + cell: (props: any) => { + const value = props.getValue(); + return value ?? "-"; + }, + meta: { style: { width: "8.57%" } } + }, + { + accessorKey: "more", + header: "", + enableSorting: false, + cell: props => ( +
+ +
+ ), + meta: { style: { width: "5%" } } + } +]; + +const DROPDOWN_OPTIONS = [ + { + title: "Tree Cover Loss", + value: "1", + slug: "treeCoverLoss" + }, + { + title: "Tree Cover Loss from Fire", + value: "2", + slug: "treeCoverLossFires" + }, + { + title: "Hectares Under Restoration By WWF EcoRegion", + value: "3", + slug: "restorationByEcoRegion" + }, + { + title: "Hectares Under Restoration By Strategy", + value: "4", + slug: "restorationByStrategy" + }, + { + title: "Hectares Under Restoration By Target Land Use System", + value: "5", + slug: "restorationByLandUse" + } +]; + +const toggleItems: TogglePropsItem[] = [ + { + key: "dashboard", + render: ( + + Table + + ) + }, + { + key: "table", + render: ( + + Graph + + ) + }, + { + key: "table", + render: ( + + Map + + ) + } +]; + +const noDataGraph = ( +
+ + No Data to Display + +
+ + RUN ANALYSUS ON PROJECT POLYGONS TO SEE DATA + + + + +
+
+); + +const indicatorDescription1 = + "From the 23 August 2024 analysis, 12.2M out of 20M hectares are being restored. Of those, Direct Seeding was the most prevalent strategy used with more 765,432ha, followed by Tree Planting with 453,89ha and Assisted Natural Regeneration with 93,345ha."; +const indicatorDescription2 = + "The numbers and reports below display data related to Indicator 2: Hectares Under Restoration described in TerraFund’s MRV framework. Please refer to the linked MRV framework for details on how these numbers are sourced and verified."; + +const noDataMap = ( +
+
+
+ + Indicator Description + +
+ + {indicatorDescription1} + + + {indicatorDescription2} + +
+
+
+
+
+ + No Data to Display + +
+ + RUN ANALYSUS ON PROJECT POLYGONS TO SEE DATA + + + + +
+
+
+
+); + +const DataCard = ({ + type, + ...rest +}: React.HTMLAttributes & { + type?: EntityName; +}) => { const [tabActive, setTabActive] = useState(0); const [selected, setSelected] = useState(["1"]); + const [selectedPolygonUuid, setSelectedPolygonUuid] = useState("0"); + const basename = useBasename(); const mapFunctions = useMap(); + const { record } = useShowContext(); + const { polygonsIndicator, treeCoverLossData, treeCoverLossFiresData, isLoadingIndicator, polygonOptions } = + useMonitoredData(type!, record.uuid); + const filteredPolygonsIndicator = + selectedPolygonUuid !== "0" + ? polygonsIndicator?.filter((polygon: any) => polygon.poly_id === selectedPolygonUuid) + : polygonsIndicator; + + const filteredTreeCoverLossData = + selectedPolygonUuid !== "0" + ? treeCoverLossData?.filter((data: any) => data.poly_id === selectedPolygonUuid) + : treeCoverLossData; + + const filteredTreeCoverLossFiresData = + selectedPolygonUuid !== "0" + ? treeCoverLossFiresData?.filter((data: any) => data.poly_id === selectedPolygonUuid) + : treeCoverLossFiresData; + + const parsedData = parseTreeCoverData(filteredTreeCoverLossData, filteredTreeCoverLossFiresData); + const { setSearchTerm, setIndicatorSlug, indicatorSlug, setSelectPolygonFromMap, selectPolygonFromMap } = + useMonitoredDataContext(); + const navigate = useNavigate(); + const { openNotification } = useNotificationContext(); + const [exporting, setExporting] = useState(false); + const t = useT(); + const totalHectaresRestoredGoal = record?.total_hectares_restored_goal + ? Number(record?.total_hectares_restored_goal) + : +record?.hectares_to_restore_goal; + const landUseData = filteredPolygonsIndicator + ? parsePolygonsIndicatorDataForLandUse(filteredPolygonsIndicator, totalHectaresRestoredGoal) + : DEFAULT_POLYGONS_DATA; + const strategiesData = filteredPolygonsIndicator + ? parsePolygonsIndicatorDataForStrategies(filteredPolygonsIndicator) + : DEFAULT_POLYGONS_DATA_STRATEGIES; + + const ecoRegionData: any = filteredPolygonsIndicator + ? parsePolygonsIndicatorDataForEcoRegion(filteredPolygonsIndicator) + : DEFAULT_POLYGONS_DATA_ECOREGIONS; const [topHeaderFirstTable, setTopHeaderFirstTable] = useState("102px"); const [topHeaderSecondTable, setTopHeaderSecondTable] = useState("70px"); + const totalElemIndicator = filteredPolygonsIndicator?.length ? filteredPolygonsIndicator?.length - 1 : null; useEffect(() => { if (typeof window !== "undefined") { @@ -66,36 +434,40 @@ const DataCard = ({ ...rest }: React.HTMLAttributes) => { } }, []); - const TABLE_COLUMNS: ColumnDef[] = [ + const TABLE_COLUMNS_TREE_COVER_LOSS: CustomColumnDefInternal[] = [ { id: "mainInfo", meta: { style: { top: `${topHeaderSecondTable}`, borderBottomWidth: 0, borderRightWidth: 0 } }, header: "", columns: [ { - accessorKey: "polygonName", + accessorKey: "poly_name", header: "Polygon Name", - meta: { style: { top: `${topHeaderFirstTable}`, borderRadius: "0" } } + meta: { style: { top: `${topHeaderFirstTable}`, borderRadius: "0", width: "11%" } } }, { accessorKey: "size", header: "Size (ha)", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "7%" } } + }, + { + accessorKey: "site_name", + header: "Site Name", + meta: { style: { top: `${topHeaderFirstTable}`, width: "8%" } } }, - { accessorKey: "siteName", header: "Site Name", meta: { style: { top: `${topHeaderFirstTable}` } } }, { accessorKey: "status", header: "Status", - meta: { style: { top: `${topHeaderFirstTable}` } }, + meta: { style: { top: `${topHeaderFirstTable}`, width: "7%" } }, cell: (props: any) => ( ) }, { - accessorKey: "plantDate", + accessorKey: "plantstart", header: () => ( <> Plant @@ -103,79 +475,81 @@ const DataCard = ({ ...rest }: React.HTMLAttributes) => { Start Date ), - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "8%" } } } ] }, { id: "analysis2024", - header: "Analysis: April 25, 2024", + header: totalElemIndicator + ? `Analysis: ${format(new Date(polygonsIndicator?.[totalElemIndicator]?.created_at!), "MMMM d, yyyy")}` + : "Analysis:", meta: { style: { top: `${topHeaderSecondTable}`, borderBottomWidth: 0 } }, columns: [ { - accessorKey: "2024-2015", + accessorKey: "data.2015", header: "2015", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2016", + accessorKey: "data.2016", header: "2016", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2017", + accessorKey: "data.2017", header: "2017", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2018", + accessorKey: "data.2018", header: "2018", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2019", + accessorKey: "data.2019", header: "2019", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2020", + accessorKey: "data.2020", header: "2020", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2021", + accessorKey: "data.2021", header: "2021", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2022", + accessorKey: "data.2022", header: "2022", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2023", + accessorKey: "data.2023", header: "2023", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } }, { - accessorKey: "2024-2024", + accessorKey: "data.2024", header: "2024", - meta: { style: { top: `${topHeaderFirstTable}` } } + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } } ] }, { id: "moreInfo", header: " ", - meta: { style: { top: `${topHeaderSecondTable}`, borderBottomWidth: 0 } }, + meta: { style: { top: `${topHeaderSecondTable}`, borderBottomWidth: 0, width: "5%" } }, columns: [ { accessorKey: "more", header: "", enableSorting: false, cell: props => ( -
- +
+
), meta: { style: { top: `${topHeaderFirstTable}`, borderRadius: "0" } } @@ -184,698 +558,153 @@ const DataCard = ({ ...rest }: React.HTMLAttributes) => { } ]; - const TABLE_COLUMNS_HECTARES: ColumnDef[] = [ - { accessorKey: "polygonName", header: "Polygon Name" }, - { - accessorKey: "size", - header: "Size (ha)" - }, - { accessorKey: "siteName", header: "Site Name" }, - { - accessorKey: "status", - header: "Status", - cell: (props: any) => ( - - ) - }, - { - accessorKey: "plantDate", - header: "Plant Start Date" - }, - { - accessorKey: "baseline", - header: "Baseline" - }, - { - accessorKey: "treePlanting", - header: "Tree Planting" - }, - { - accessorKey: "regeneration", - header: () => ( - <> - Asst. Nat. -
- Regeneration - - ) - }, - { - accessorKey: "seeding", - header: () => ( - <> - Direct -
- Seeding - - ) - }, - { - accessorKey: "more", - header: "", - enableSorting: false, - cell: props => ( -
- -
- ) - } - ]; + const TABLE_COLUMNS_MAPPING: Record = { + treeCoverLoss: TABLE_COLUMNS_TREE_COVER_LOSS, + treeCoverLossFires: TABLE_COLUMNS_TREE_COVER_LOSS, + restorationByEcoRegion: TABLE_COLUMNS_HECTARES_ECO_REGION, + restorationByStrategy: TABLE_COLUMNS_HECTARES_STRATEGY, + restorationByLandUse: TABLE_COLUMNS_HECTARES_LAND_USE + }; - const TABLE_DATA = [ - { - polygonName: "ABA", - siteName: "Palm Oil", - status: "Draft", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Adison Thaochu A", - siteName: "Palm Oil", - status: "Submitted", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "AEK Nabara Selatan", - siteName: "Palm Oil", - status: "Needs Info", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "AEK Raso", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "AEK Torup", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Africas", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Agoue Iboe", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Agrajaya Baktitama", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Agralsa", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Africas", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Agoue Iboe", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Agrajaya Baktitama", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Agralsa", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "ABA", - siteName: "Palm Oil", - status: "Draft", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "Adison Thaochu A", - siteName: "Palm Oil", - status: "Submitted", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "AEK Nabara Selatan", - siteName: "Palm Oil", - status: "Needs Info", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "AEK Raso", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - }, - { - polygonName: "AEK Torup", - siteName: "Palm Oil", - status: "Approved", - size: "7,473", - plantDate: "9/26/24", - baseline: "25/4/24", - treePlanting: "0.423", - regeneration: "0.120", - seeding: "0.120", - "2024-2015": "0.423", - "2024-2016": "0.120", - "2024-2017": "0.655", - "2024-2018": "0.208", - "2024-2019": "0.654", - "2024-2020": "0.466", - "2024-2021": "0.151", - "2024-2022": "0.385", - "2024-2023": "0.457", - "2024-2024": "0.966" - } - ]; + const handleExport = async () => { + try { + setExporting(true); + const blob = await fetchGetV2IndicatorsEntityUuidSlugExport({ + pathParams: { entity: type!, uuid: record.uuid, slug: indicatorSlug! } + }); + downloadFileBlob(blob!, `Indicator (${DROPDOWN_OPTIONS.find(item => item.slug === indicatorSlug)?.title}).csv`); - const DROPDOWN_OPTIONS = [ - { - title: "Tree Cover Loss", - value: "1" - }, - { - title: "Tree Cover Loss from Fire", - value: "2" - }, - { - title: "Hectares Under Restoration By WWF EcoRegion", - value: "3" - }, - { - title: "Hectares Under Restoration By Strategy", - value: "4" - }, - { - title: "Hectares Under Restoration By Target Land Use System", - value: "5" - }, - { - title: "Tree Count", - value: "6" + openNotification("success", t("Success! Export completed."), t("The export has been completed successfully.")); + setExporting(false); + } catch (error) { + openNotification("error", t("Error! Export failed."), t("The export has failed. Please try again.")); + setExporting(false); + } finally { + setExporting(false); } - ]; + }; - const toggleItems: TogglePropsItem[] = [ - { - key: "dashboard", - render: ( - - Table - - ) - }, - { - key: "table", - render: ( - - Graph - - ) - }, - { - key: "table", - render: ( - - Map - - ) + useEffect(() => { + if (selectPolygonFromMap?.isOpen) { + setSelectPolygonFromMap?.({ isOpen: false, uuid: "" }); } - ]; - - const POLYGONS = [ - { title: "Agrariala Palma", value: "1" }, - { title: "Agraisa", value: "2" }, - { title: "Agrajaya Batitama", value: "3" }, - { title: "Agoue Iboe", value: "4" }, - { title: "Africas", value: "5" }, - { title: "AEK Torup", value: "6" }, - { title: "AEK Raso", value: "7" }, - { title: "AEK Nabara Selatan", value: "8" }, - { title: "Adison Thaochu A", value: "9" }, - { title: "ABA", value: "10" } - ]; - - const indicatorDescription1 = - "From the 23 August 2024 analysis, 12.2M out of 20M hectares are being restored. Of those, Direct Seeding was the most prevalent strategy used with more 765,432ha, followed by Tree Planting with 453,89ha and Assisted Natural Regeneration with 93,345ha."; - const indicatorDescription2 = - "The numbers and reports below display data related to Indicator 2: Hectares Under Restoration described in TerraFund’s MRV framework. Please refer to the linked MRV framework for details on how these numbers are sourced and verified."; - - const noDataGraph = ( -
- - No Data to Display - -
- - RUN ANALYSUS ON PROJECT POLYGONS TO SEE DATA - - - - -
-
- ); - - const noDataMap = ( -
-
-
- - Indicator Description - -
- - {indicatorDescription1} - - - {indicatorDescription2} - -
-
-
-
-
- - No Data to Display - -
- - RUN ANALYSUS ON PROJECT POLYGONS TO SEE DATA - - - - -
-
-
-
- ); - + }, [selectPolygonFromMap]); return ( -
-
-
-
- - { - setSelected(option); - }} - variant={VARIANT_DROPDOWN_SIMPLE} - inputVariant="text-14-semibold" - className="z-50" - defaultValue={[DROPDOWN_OPTIONS[0].value]} - optionsClassName="w-max z-50" - /> -
- -
- - {}} variant={FILTER_SEARCH_MONITORING} /> - - + <> +
+
+
+
+ + { + setIndicatorSlug?.(DROPDOWN_OPTIONS.find(item => item.value === option[0])?.slug!); + setSelected(option); + }} + variant={VARIANT_DROPDOWN_SIMPLE} + inputVariant="text-14-semibold" + className="z-50" + defaultValue={[DROPDOWN_OPTIONS.find(item => item.slug === indicatorSlug)?.value!]} + optionsClassName="w-max z-50" + /> +
- -
-
- -
- - - - - -
+ + { + setSearchTerm(e); + }} + variant={FILTER_SEARCH_MONITORING} /> - - + + + + + - - -
- {}} - /> -
- - Indicator Description - -
- - {indicatorDescription1} - - - {indicatorDescription2} + +
+
{ + navigate(`${basename}/site/${row?.site_id}/show/1`); + setSelectPolygonFromMap?.({ isOpen: true, uuid: row?.poly_id }); + }} + /> + + + +
+ setSelectedPolygonUuid(option[0])} + /> +
+ + Indicator Description +
+ + {indicatorDescription1} + + + {indicatorDescription2} + +
+ + {noDataGraph}
- - - - - - - - - - - - - -
- + + +
+
+
- - {noDataGraph} -
-
- -
-
- + + {noDataMap}
- - {noDataMap} -
-
+ +
- + + ); }; diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/EcoRegionDoughnutChart.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/EcoRegionDoughnutChart.tsx new file mode 100644 index 000000000..fd8c26abd --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/EcoRegionDoughnutChart.tsx @@ -0,0 +1,127 @@ +import React, { useState } from "react"; +import { Cell, Label, Legend, Pie, PieChart, ResponsiveContainer, Sector, Tooltip } from "recharts"; + +interface ChartDataItem { + name: string; + value: number; +} + +export interface EcoRegionData { + chartData: ChartDataItem[]; + total: number; +} + +interface EcoRegionDoughnutChartProps { + data: EcoRegionData; +} + +type LegendPayload = { + value: string; + id?: string; + type?: string; + color?: string; +}; + +interface CustomLegendProps { + payload?: LegendPayload[]; +} + +const COLORS = ["#FFD699", "#90EE90", "#2F4F4F", "#BDB76B", "#98FB98"]; + +const CustomLegend = ({ payload }: CustomLegendProps) => { + if (!payload) return null; + return ( +
    + {payload.map((entry, index) => ( +
  • + + {entry.value} +
  • + ))} +
+ ); +}; + +const CustomTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + return ( +
+

{payload[0].name}

+

{`Value: ${payload[0].value}`}

+
+ ); + } + return null; +}; + +const renderActiveShape = (props: any) => { + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill } = props; + return ( + + + + ); +}; + +const EcoRegionDoughnutChart: React.FC = ({ data }) => { + const { chartData } = data; + const [activeIndex, setActiveIndex] = useState(undefined); + + const onPieEnter = (_: any, index: number) => { + setActiveIndex(index); + }; + + const onPieLeave = () => { + setActiveIndex(undefined); + }; + + return ( +
+ + + } /> + + + {chartData.map((entry, index) => ( + + ))} + + } + layout="vertical" + align="right" + verticalAlign="middle" + wrapperStyle={{ + right: "calc(50% - 261px)", + paddingLeft: 0 + }} + /> + + +
+ ); +}; + +export default EcoRegionDoughnutChart; diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/HeaderMonitoredTab.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/HeaderMonitoredTab.tsx index fab6e2d1a..bd1573449 100644 --- a/src/admin/components/ResourceTabs/MonitoredTab/components/HeaderMonitoredTab.tsx +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/HeaderMonitoredTab.tsx @@ -1,3 +1,6 @@ +import { useShowContext } from "react-admin"; +import { When } from "react-if"; + import Button from "@/components/elements/Button/Button"; import LinearProgressBarMonitored from "@/components/elements/ProgressBar/LinearProgressBar/LineProgressBarMonitored"; import Text from "@/components/elements/Text/Text"; @@ -6,29 +9,16 @@ import { ModalId } from "@/components/extensive/Modal/ModalConst"; import ModalNotes from "@/components/extensive/Modal/ModalNotes"; import ModalRunAnalysis from "@/components/extensive/Modal/ModalRunAnalysis"; import { useModalContext } from "@/context/modal.provider"; +import { useMonitoredDataContext } from "@/context/monitoredData.provider"; +import { EntityName } from "@/types/common"; -const HeaderMonitoredTab = () => { - const { openModal, closeModal } = useModalContext(); +import { useMonitoredData } from "../hooks/useMonitoredData"; - const dataPolygonOverview = [ - { - status: "Draft", - count: 12.5, - color: "bg-grey-200" - }, - { - status: "Submitted", - count: 42.5 - }, - { - status: "Needs Info", - count: 22.5 - }, - { - status: "Approved", - count: 22.5 - } - ]; +const HeaderMonitoredTab = ({ type }: { type?: EntityName }) => { + const { openModal, closeModal } = useModalContext(); + const { record } = useShowContext(); + const { headerBarPolygonStatus, totalPolygonsStatus, polygonMissingAnalysis } = useMonitoredData(type, record?.uuid); + const { loadingAnalysis } = useMonitoredDataContext(); const openRunAnalysis = () => { openModal( @@ -37,6 +27,9 @@ const HeaderMonitoredTab = () => { title="Update Analysis " content="Project Developers may submit one or all polygons for review." primaryButtonText="Run" + projectName={record?.project ? record?.project?.name : record?.name} + entityType={type} + entityUuid={record?.uuid} primaryButtonProps={{ className: "px-8 py-3", variant: "primary", @@ -60,9 +53,9 @@ const HeaderMonitoredTab = () => { ModalId.MODAL_NOTES, +
+ {loadingAnalysis ? ( + + ) : null} +
- +
@@ -96,17 +94,19 @@ graphs and tables below by clicking update analysis button to your right. " No. of Polygons - 45 - -
-
- - No. of Sites - - - 12 + {totalPolygonsStatus}
+ +
+ + No. of Sites + + + {record?.project ? record?.project?.total_sites : record?.total_sites} + +
+
diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/MonitoredCharts.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/MonitoredCharts.tsx new file mode 100644 index 000000000..7f315ba4c --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/MonitoredCharts.tsx @@ -0,0 +1,166 @@ +import classNames from "classnames"; +import React, { useEffect, useState } from "react"; +import { ReactNode } from "react"; +import { When } from "react-if"; + +import SimpleBarChart from "@/pages/dashboard/charts/SimpleBarChart"; +import GraphicIconDashboard from "@/pages/dashboard/components/GraphicIconDashboard"; +import SecDashboard from "@/pages/dashboard/components/SecDashboard"; +import { TOTAL_HECTARES_UNDER_RESTORATION_TOOLTIP } from "@/pages/dashboard/constants/tooltips"; + +import EcoRegionDoughnutChart from "./EcoRegionDoughnutChart"; +import { LoadingState } from "./MonitoredLoading"; +import { NoDataState } from "./NoDataState"; +import TreeLossBarChart from "./TreesLossBarChart"; + +const ChartContainer = ({ + children, + isLoading, + hasNoData +}: { + children: ReactNode; + isLoading: boolean; + hasNoData: boolean; +}): JSX.Element | null => { + if (isLoading) { + return ; + } + + if (hasNoData) { + return ; + } + + return <>{children}; +}; + +interface RecordType { + total_hectares_restored_sum: number; +} + +const RestorationMetrics = ({ + record, + totalHectaresRestoredGoal, + strategiesData +}: { + record: RecordType; + totalHectaresRestoredGoal: number; + strategiesData: any[]; +}) => ( +
+ + +
+); + +interface MonitoredChartsProps { + selected: React.Key[]; + isLoadingIndicator: boolean; + parsedData: any[]; + ecoRegionData: any; + strategiesData: any[]; + landUseData: any; + record: RecordType; + totalHectaresRestoredGoal: number; +} + +const MonitoredCharts = ({ + selected, + isLoadingIndicator, + parsedData, + ecoRegionData, + strategiesData, + landUseData, + record, + totalHectaresRestoredGoal +}: MonitoredChartsProps) => { + const [hasNoData, setHasNoData] = useState(false); + + useEffect(() => { + const noData = selected.some(chartId => { + switch (chartId) { + case "1": + case "2": + return !parsedData?.length; + case "3": + return !ecoRegionData?.chartData?.length; + case "4": + return !strategiesData?.length; + case "5": + return !landUseData?.graphicTargetLandUseTypes?.length; + default: + return false; + } + }); + setHasNoData(noData); + }, [selected, parsedData, ecoRegionData, strategiesData, landUseData]); + + const renderChart = (chartId: React.Key) => { + switch (chartId) { + case "1": + case "2": + return ( + + + + ); + + case "3": + return ( + + + + ); + + case "4": + return ( + + + + ); + + case "5": + return ( + +
+ +
+
+ ); + + default: + return null; + } + }; + + return ( +
+ {selected.map( + (id: React.Key | null | undefined) => + id != null && ( + + {renderChart(id)} + + ) + )} +
+ ); +}; + +export default MonitoredCharts; diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/MonitoredLoading.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/MonitoredLoading.tsx new file mode 100644 index 000000000..d96df4169 --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/MonitoredLoading.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +import Loader from "@/components/generic/Loading/Loader"; + +export const LoadingState = () => ( +
+ +
+); diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/NoDataState.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/NoDataState.tsx new file mode 100644 index 000000000..fdac5578f --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/NoDataState.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +import Text from "@/components/elements/Text/Text"; +import Tooltip from "@/components/elements/Tooltip/Tooltip"; +import Icon from "@/components/extensive/Icon/Icon"; +import { IconNames } from "@/components/extensive/Icon/Icon"; + +export const NoDataState = () => ( +
+ + No Data to Display + +
+ + RUN ANALYSIS ON PROJECT POLYGONS TO SEE DATA + + + + +
+
+); diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/TreesLossBarChart.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/TreesLossBarChart.tsx new file mode 100644 index 000000000..7ca07f205 --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/TreesLossBarChart.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +import CustomBar from "@/pages/dashboard/charts/CustomBarJobsCreated"; + +type TreeLossData = { + name: number; + treeCoverLoss: number; + treeCoverLossFires: number; +}; + +interface TreeLossBarChartProps { + data: TreeLossData[]; + className?: string; +} + +const TreeLossBarChart = ({ data, className = "" }: TreeLossBarChartProps) => { + const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: any[]; label?: string }) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry, index) => ( +
+
+ {entry.name} + + {Number(entry.value).toFixed(1).toLocaleString()} ha + +
+ ))} +
+ ); + } + return null; + }; + + return ( +
+

Tree Loss Retrospective (ha)

+

2015-2024

+ + + + + `${value.toLocaleString()}`} + className="text-12" + /> + } cursor={{ fill: "rgba(0, 0, 0, 0.05)" }} /> + + + } + /> + + +
+ ); +}; + +export default TreeLossBarChart; diff --git a/src/admin/components/ResourceTabs/MonitoredTab/hooks/useMonitoredData.ts b/src/admin/components/ResourceTabs/MonitoredTab/hooks/useMonitoredData.ts new file mode 100644 index 000000000..00d374bd9 --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/hooks/useMonitoredData.ts @@ -0,0 +1,297 @@ +import { useT } from "@transifex/react"; +import { useEffect, useMemo, useState } from "react"; + +import { ModalId } from "@/components/extensive/Modal/ModalConst"; +import { useModalContext } from "@/context/modal.provider"; +import { useMonitoredDataContext } from "@/context/monitoredData.provider"; +import { useNotificationContext } from "@/context/notification.provider"; +import { + fetchGetV2IndicatorsEntityUuidSlugVerify, + useGetV2IndicatorsEntityUuid, + useGetV2IndicatorsEntityUuidSlug, + useGetV2IndicatorsEntityUuidSlugVerify, + usePostV2IndicatorsSlug +} from "@/generated/apiComponents"; +import { IndicatorPolygonsStatus, Indicators } from "@/generated/apiSchemas"; +import { EntityName } from "@/types/common"; + +const dataPolygonOverview = [ + { + status: "Draft", + status_key: "draft", + count: 12.5, + color: "bg-grey-200" + }, + { + status: "Submitted", + status_key: "submitted", + count: 42.5 + }, + { + status: "Needs Info", + status_key: "needs-more-information", + count: 22.5 + }, + { + status: "Approved", + status_key: "approved", + count: 22.5 + } +]; + +const DROPDOWN_OPTIONS = [ + { + title: "Tree Cover Loss", + value: "1", + slug: "treeCoverLoss" + }, + { + title: "Tree Cover Loss from Fire", + value: "2", + slug: "treeCoverLossFires" + }, + { + title: "Hectares Under Restoration By WWF EcoRegion", + value: "3", + slug: "restorationByEcoRegion" + }, + { + title: "Hectares Under Restoration By Strategy", + value: "4", + slug: "restorationByStrategy" + }, + { + title: "Hectares Under Restoration By Target Land Use System", + value: "5", + slug: "restorationByLandUse" + } +]; + +const SLUGS_INDICATORS = [ + "treeCoverLoss", + "treeCoverLossFires", + "restorationByEcoRegion", + "restorationByStrategy", + "restorationByLandUse" +]; + +type InterfaceIndicatorPolygonsStatus = { + draft: number; + submitted: number; + "needs-more-information": number; + approved: number; +}; + +interface PolygonOption { + title: string; + value: string; +} + +export const useMonitoredData = (entity?: EntityName, entity_uuid?: string) => { + const t = useT(); + const { searchTerm, indicatorSlug, setLoadingAnalysis, setIndicatorSlugAnalysis } = useMonitoredDataContext(); + const { modalOpened } = useModalContext(); + const [isLoadingVerify, setIsLoadingVerify] = useState(false); + const { openNotification } = useNotificationContext(); + const [treeCoverLossData, setTreeCoverLossData] = useState([]); + const [polygonOptions, setPolygonOptions] = useState([{ title: "All Polygons", value: "0" }]); + const [treeCoverLossFiresData, setTreeCoverLossFiresData] = useState([]); + const [analysisToSlug, setAnalysisToSlug] = useState({ + treeCoverLoss: [], + treeCoverLossFires: [], + restorationByEcoRegion: [], + restorationByStrategy: [], + restorationByLandUse: [] + }); + const [dropdownAnalysisOptions, setDropdownAnalysisOptions] = useState(DROPDOWN_OPTIONS); + + const { + data: indicatorData, + refetch: refetchDataIndicators, + isLoading: isLoadingIndicator + } = useGetV2IndicatorsEntityUuidSlug( + { + pathParams: { + entity: entity!, + uuid: entity_uuid!, + slug: indicatorSlug! + } + }, + { + enabled: !!indicatorSlug && !!entity_uuid + } + ); + + const getComplementarySlug = (slug: string) => (slug === "treeCoverLoss" ? "treeCoverLossFires" : "treeCoverLoss"); + + const { data: complementaryData } = useGetV2IndicatorsEntityUuidSlug( + { + pathParams: { + entity: entity!, + uuid: entity_uuid!, + slug: getComplementarySlug(indicatorSlug || "") + } + }, + { + enabled: (indicatorSlug === "treeCoverLoss" || indicatorSlug === "treeCoverLossFires") && !!entity_uuid + } + ); + + useEffect(() => { + if (indicatorSlug === "treeCoverLoss") { + setTreeCoverLossData(indicatorData || []); + setTreeCoverLossFiresData(complementaryData || []); + } else if (indicatorSlug === "treeCoverLossFires") { + setTreeCoverLossFiresData(indicatorData || []); + setTreeCoverLossData(complementaryData || []); + } + }, [indicatorData, complementaryData, indicatorSlug]); + + const { mutate, isLoading } = usePostV2IndicatorsSlug({ + onSuccess: () => { + openNotification( + "success", + t("Success! Analysis completed."), + t("The analysis has been completed successfully.") + ); + refetchDataIndicators(); + setLoadingAnalysis?.(false); + setIndicatorSlugAnalysis?.("treeCoverLoss"); + }, + onError: () => { + openNotification("error", t("Error! Analysis failed."), t("The analysis has failed. Please try again.")); + refetchDataIndicators(); + setLoadingAnalysis?.(false); + setIndicatorSlugAnalysis?.("treeCoverLoss"); + } + }); + + const { data: indicatorPolygonsStatus } = useGetV2IndicatorsEntityUuid( + { + pathParams: { + entity: entity!, + uuid: entity_uuid! + } + }, + { + enabled: !!entity_uuid + } + ); + + const filteredPolygons = useMemo(() => { + if (!indicatorData) return []; + + return indicatorData + .filter( + (polygon: Indicators) => + polygon?.poly_name?.toLowerCase().includes(searchTerm?.toLowerCase()) || + polygon?.site_name?.toLowerCase().includes(searchTerm?.toLowerCase()) + ) + .sort((a, b) => (a.poly_name || "").localeCompare(b.poly_name || "")); + }, [indicatorData, searchTerm]); + + useEffect(() => { + if (!indicatorData) return; + + const options = [ + { title: "All Polygons", value: "0" }, + ...indicatorData + .map((item: any) => ({ + title: item.poly_name || "", + value: item.poly_id || "" + })) + .sort((a, b) => a.title.localeCompare(b.title)) + ]; + + setPolygonOptions(options); + }, [indicatorData]); + + const headerBarPolygonStatus = dataPolygonOverview.map(status => { + const key = status.status_key as keyof InterfaceIndicatorPolygonsStatus; + return { + ...status, + count: indicatorPolygonsStatus?.[key] ?? 0 + }; + }); + + const totalPolygonsApproved = headerBarPolygonStatus.find(item => item.status_key === "approved")?.count; + + const { data: dataToMissingPolygonVerify } = useGetV2IndicatorsEntityUuidSlugVerify( + { + pathParams: { + entity: entity!, + uuid: entity_uuid!, + slug: indicatorSlug! + } + }, + { + enabled: !!indicatorSlug + } + ); + + // @ts-ignore + const polygonMissingAnalysis = dataToMissingPolygonVerify?.message + ? totalPolygonsApproved + : totalPolygonsApproved! - Object?.keys(dataToMissingPolygonVerify ?? {})?.length; + + const verifySlug = async (slug: string) => + fetchGetV2IndicatorsEntityUuidSlugVerify({ + pathParams: { + entity: entity!, + uuid: entity_uuid!, + slug: slug! + } + }); + + useEffect(() => { + const fetchSlugs = async () => { + setIsLoadingVerify(true); + const slugVerify = await Promise.all(SLUGS_INDICATORS.map(verifySlug)); + const slugToAnalysis = SLUGS_INDICATORS.reduce>((acc, slug, index) => { + acc[slug] = slugVerify[index]; + return acc; + }, {}); + const updateTitleDropdownOptions = () => { + return DROPDOWN_OPTIONS.map(option => { + if (slugToAnalysis[`${option.slug}`]?.message) { + return { + ...option, + title: `${option.title} (0 polygons not run)` + }; + } + if (!slugToAnalysis[`${option.slug}`]) { + return option; + } + return { + ...option, + title: `${option.title} (${Object?.keys(slugToAnalysis[`${option.slug}`]).length} polygons not run)` + }; + }); + }; + setAnalysisToSlug(slugToAnalysis); + await setDropdownAnalysisOptions(updateTitleDropdownOptions); + setIsLoadingVerify(false); + }; + if (modalOpened(ModalId.MODAL_RUN_ANALYSIS)) { + fetchSlugs(); + } + }, [entity]); + + return { + polygonsIndicator: filteredPolygons, + polygonOptions, + indicatorPolygonsStatus, + headerBarPolygonStatus, + totalPolygonsStatus: totalPolygonsApproved, + runAnalysisIndicator: mutate, + loadingAnalysis: isLoading, + loadingVerify: isLoadingVerify, + isLoadingIndicator, + setIsLoadingVerify, + dropdownAnalysisOptions, + analysisToSlug, + polygonMissingAnalysis, + treeCoverLossData, + treeCoverLossFiresData + }; +}; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/VersionHistory.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/VersionHistory.tsx index aced65845..3dff79d6f 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/VersionHistory.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/VersionHistory.tsx @@ -358,10 +358,11 @@ const VersionHistory = ({ suffixLabelView={true} labelClassName="capitalize" labelVariant="text-14-light" - optionsClassName="!h-[182px] lg:!h-[195px] wide:h-[266px]" + optionsClassName="!max-h-[182px] lg:!max-h-[195px] wide:max-h-[266px]" placeholder="Select Polygon Version" options={polygonVersionData ?? []} optionVariant="text-12-light" + titleClassname="one-line-text !w-[96%] !text-nowrap" defaultValue={[selectPolygonVersion?.uuid ?? selectedPolygon?.uuid] as string[]} onChange={e => { const polygonVersionData = (data as SitePolygonsDataResponse)?.find(item => item.uuid === e[0]); diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx index 921264199..9117e6456 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx @@ -26,6 +26,7 @@ import LinearProgressBarMonitored from "@/components/elements/ProgressBar/Linear import Table from "@/components/elements/Table/Table"; import { VARIANT_TABLE_SITE_POLYGON_REVIEW } from "@/components/elements/Table/TableVariants"; import Text from "@/components/elements/Text/Text"; +import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon from "@/components/extensive/Icon/Icon"; import { IconNames } from "@/components/extensive/Icon/Icon"; import ModalAdd from "@/components/extensive/Modal/ModalAdd"; @@ -34,6 +35,7 @@ import { ModalId } from "@/components/extensive/Modal/ModalConst"; import { useLoading } from "@/context/loaderAdmin.provider"; import { useMapAreaContext } from "@/context/mapArea.provider"; import { useModalContext } from "@/context/modal.provider"; +import { useMonitoredDataContext } from "@/context/monitoredData.provider"; import { useNotificationContext } from "@/context/notification.provider"; import { SitePolygonDataProvider } from "@/context/sitePolygon.provider"; import { @@ -146,6 +148,7 @@ const ContentForApproval = ({ const PolygonReviewTab: FC = props => { const { isLoading: ctxLoading, record, refetch: refreshEntity } = useShowContext(); + const { selectPolygonFromMap } = useMonitoredDataContext(); const [files, setFiles] = useState([]); const [saveFlags, setSaveFlags] = useState(false); const [polygonFromMap, setPolygonFromMap] = useState({ isOpen: false, uuid: "" }); @@ -166,6 +169,12 @@ const PolygonReviewTab: FC = props => { const { openNotification } = useNotificationContext(); + useEffect(() => { + if (selectPolygonFromMap?.uuid) { + setPolygonFromMap(selectPolygonFromMap); + flyToPolygonBounds(selectPolygonFromMap.uuid); + } + }, [polygonList]); const onSave = (geojson: any, record: any) => { storePolygon(geojson, record, refetch, setPolygonFromMap, refreshEntity); }; @@ -620,7 +629,9 @@ const PolygonReviewTab: FC = props => {
Site Status - + + + {record?.readable_status} @@ -629,7 +640,9 @@ const PolygonReviewTab: FC = props => {
Polygon Overview - + + + @@ -647,7 +660,9 @@ const PolygonReviewTab: FC = props => {
Add or Edit Polygons - + + + Add, remove or edit polygons that are associated to a site. Polygons may be edited in the map diff --git a/src/admin/components/extensive/Modal/ModalApprove.tsx b/src/admin/components/extensive/Modal/ModalApprove.tsx index 62f9cea7a..0ce4dfcc1 100644 --- a/src/admin/components/extensive/Modal/ModalApprove.tsx +++ b/src/admin/components/extensive/Modal/ModalApprove.tsx @@ -169,9 +169,13 @@ const ModalApprove: FC = ({ {content} - - Select All - handleSelectAll((e.target as HTMLInputElement).checked)} /> + + handleSelectAll((e.target as HTMLInputElement).checked)} + /> + Select All
diff --git a/src/admin/modules/projects/components/ProjectShow.tsx b/src/admin/modules/projects/components/ProjectShow.tsx index aae73d9d2..40fca8a9d 100644 --- a/src/admin/modules/projects/components/ProjectShow.tsx +++ b/src/admin/modules/projects/components/ProjectShow.tsx @@ -43,7 +43,8 @@ const ProjectShow = () => { - + {/* In Progress */} + diff --git a/src/admin/modules/sites/components/SiteShow.tsx b/src/admin/modules/sites/components/SiteShow.tsx index 287325785..fb0bef090 100644 --- a/src/admin/modules/sites/components/SiteShow.tsx +++ b/src/admin/modules/sites/components/SiteShow.tsx @@ -42,7 +42,7 @@ const SiteShow: FC = () => { - + diff --git a/src/assets/icons/add-button.svg b/src/assets/icons/add-button.svg new file mode 100644 index 000000000..b2d5c5208 --- /dev/null +++ b/src/assets/icons/add-button.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/edit-ta.svg b/src/assets/icons/edit-ta.svg new file mode 100644 index 000000000..85d3d0d07 --- /dev/null +++ b/src/assets/icons/edit-ta.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/new-tag-tree-species.svg b/src/assets/icons/new-tag-tree-species.svg new file mode 100644 index 000000000..e79ab3b38 --- /dev/null +++ b/src/assets/icons/new-tag-tree-species.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/non-scientific name.svg b/src/assets/icons/non-scientific name.svg new file mode 100644 index 000000000..7bb70d7f4 --- /dev/null +++ b/src/assets/icons/non-scientific name.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/trash-ta.svg b/src/assets/icons/trash-ta.svg new file mode 100644 index 000000000..551bd1526 --- /dev/null +++ b/src/assets/icons/trash-ta.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/elements/Drawer/Drawer.tsx b/src/components/elements/Drawer/Drawer.tsx index ac734c19e..dfef681c8 100644 --- a/src/components/elements/Drawer/Drawer.tsx +++ b/src/components/elements/Drawer/Drawer.tsx @@ -2,6 +2,7 @@ import classNames from "classnames"; import React, { ReactNode, useEffect, useState } from "react"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import { useMonitoredDataContext } from "@/context/monitoredData.provider"; import Button from "../Button/Button"; import { DRAWER_VARIANT_DEFAULT, DrawerVariant } from "./DrawerVariants"; @@ -29,6 +30,7 @@ const Drawer = (props: DrawerProps) => { const [isScrolled, setIsScrolled] = useState(isScrolledDefault); const [isScrollingDown, setIsScrollingDown] = useState(isScrolledDefault); const [prevScrollPos, setPrevScrollPos] = useState(0); + const { setSelectPolygonFromMap } = useMonitoredDataContext(); useEffect(() => { const handleScroll = () => { @@ -70,6 +72,7 @@ const Drawer = (props: DrawerProps) => { onClick={() => { setIsOpen(false); setPolygonFromMap && setPolygonFromMap({ isOpen: false, uuid: "" }); + setSelectPolygonFromMap?.({ uuid: "", isOpen: false }); }} > diff --git a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx index 57c6b16dc..827ed397d 100644 --- a/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx +++ b/src/components/elements/Inputs/AutoCompleteInput/AutoCompleteInput.tsx @@ -1,9 +1,11 @@ import { Popover, Transition } from "@headlessui/react"; import { useT } from "@transifex/react"; +import classNames from "classnames"; import { ChangeEvent, forwardRef, Fragment, Ref, useState } from "react"; import { Else, If, Then } from "react-if"; import { useDebounce } from "@/hooks/useDebounce"; +import { useValueChanged } from "@/hooks/useValueChanged"; import Text from "../../Text/Text"; import Input, { InputProps } from "../Input/Input"; @@ -11,13 +13,19 @@ import Input, { InputProps } from "../Input/Input"; export interface AutoCompleteInputProps extends InputProps { onSearch: (query: string) => Promise; disableAutoComplete?: boolean; + classNameMenu?: string; } +const SEARCH_RESET = { list: [], query: "" }; + //TODO: Bugfix: Users can enter space in this input const AutoCompleteInput = forwardRef( - ({ onSearch, disableAutoComplete, ...inputProps }: AutoCompleteInputProps, ref?: Ref) => { + ( + { onSearch, disableAutoComplete, classNameMenu, ...inputProps }: AutoCompleteInputProps, + ref?: Ref + ) => { const t = useT(); - const [list, setList] = useState([]); + const [searchResult, setSearchResult] = useState<{ list: string[]; query: string }>(SEARCH_RESET); const [loading, setLoading] = useState(false); const onSelect = (item: string) => { @@ -27,35 +35,34 @@ const AutoCompleteInput = forwardRef( inputProps.onChange?.({ target: { name: inputProps.name, value: item } } as ChangeEvent); } - setList([]); + // Avoid showing the search result list unless the name changes again. + setSearchResult({ list: [], query: item }); }; const search = useDebounce(async (query: string) => { + if (query === searchResult.query) return; + setLoading(true); - onSearch(query) - .then(resp => { - setList(resp); - setLoading(false); - }) - .catch(() => { - setList([]); - setLoading(false); - }); + try { + setSearchResult({ list: await onSearch(query), query }); + setLoading(false); + } catch { + setSearchResult(SEARCH_RESET); + setLoading(false); + } }); + useValueChanged(inputProps.value, () => search(String(inputProps.value ?? ""))); + return ( - !disableAutoComplete && search(e.currentTarget.value)} - /> + 0 || !!loading} + show={searchResult.list.length > 0 || !!loading} enter="transition duration-100 ease-out" enterFrom="transform scale-95 opacity-0" enterTo="transform scale-100 opacity-100" @@ -63,7 +70,10 @@ const AutoCompleteInput = forwardRef( leaveFrom="transform scale-100 opacity-100" leaveTo="transform scale-95 opacity-0" > - + @@ -71,7 +81,7 @@ const AutoCompleteInput = forwardRef( - {list.map(item => ( + {searchResult.list.map(item => ( void; onInternalError?: (error: ErrorOption) => void; showSelectAll?: boolean; + titleClassname?: string; } const otherKey = "other#value#key"; const getAllowedValues = (values: OptionValue[], options: Option[]) => @@ -208,7 +209,7 @@ const Dropdown = (props: PropsWithChildren) => {
, HTMLInputElement>, "type" | "form"> { name: string; - variant?: "secondary" | "default" | "login" | "signup" | "monitored"; + variant?: "secondary" | "default" | "login" | "signup" | "monitored" | "treePlanted"; formHook?: UseFormReturn; clearable?: boolean; iconButtonProps?: IconButtonProps; @@ -112,6 +112,11 @@ const Input = forwardRef( true, "pl-4": inputProps.type === "number", "border-neutral-300": !error + }, + treePlanted: { + "py-[7.5px] py-1.5 !w-[100px] text-center border border-blueCustom-700 rounded hover:border-primary hover:shadow-blue-border opacity-60 outline-none text-14-light !font-primary": + true, + "text-center": inputProps.type === "number" } }; diff --git a/src/components/elements/Inputs/Map/RHFMap.tsx b/src/components/elements/Inputs/Map/RHFMap.tsx index cd929ad06..2c5bc2301 100644 --- a/src/components/elements/Inputs/Map/RHFMap.tsx +++ b/src/components/elements/Inputs/Map/RHFMap.tsx @@ -6,6 +6,7 @@ import InputWrapper, { InputWrapperProps } from "@/components/elements/Inputs/In import MapContainer from "@/components/elements/Map-mapbox/Map"; import { FORM_POLYGONS } from "@/constants/statuses"; import { useMapAreaContext } from "@/context/mapArea.provider"; +import { useMonitoredDataContext } from "@/context/monitoredData.provider"; import { SitePolygonDataProvider } from "@/context/sitePolygon.provider"; import { fetchGetV2TerrafundPolygonBboxUuid, useGetV2TerrafundProjectPolygon } from "@/generated/apiComponents"; import { SitePolygonsDataResponse } from "@/generated/apiSchemas"; @@ -47,6 +48,7 @@ const RHFMap = ({ const [polygonDataMap, setPolygonDataMap] = useState({}); const [polygonFromMap, setPolygonFromMap] = useState(null); const { setSiteData } = useMapAreaContext(); + const { setSelectPolygonFromMap } = useMonitoredDataContext(); const refetchData = () => { reloadProjectPolygonData(); @@ -84,6 +86,7 @@ const RHFMap = ({ if (!projectPolygon?.project_polygon) { setPolygonDataMap({ [FORM_POLYGONS]: [] }); setPolygonFromMap({ isOpen: false, uuid: "" }); + setSelectPolygonFromMap?.({ uuid: "", isOpen: false }); } else { setBbboxAndZoom(); setPolygonDataMap({ [FORM_POLYGONS]: [projectPolygon?.project_polygon?.poly_uuid] }); diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index ca047e0cd..01ef90959 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -1,19 +1,27 @@ import { useT } from "@transifex/react"; -import { remove } from "lodash"; -import { Fragment, KeyboardEvent, useCallback, useId, useRef } from "react"; +import classNames from "classnames"; +import { isEmpty, remove } from "lodash"; +import { Fragment, KeyboardEvent, useCallback, useId, useMemo, useRef, useState } from "react"; import { FieldError, FieldErrors } from "react-hook-form"; -import { When } from "react-if"; +import { Else, If, Then, When } from "react-if"; import { v4 as uuidv4 } from "uuid"; -import { IconNames } from "@/components/extensive/Icon/Icon"; +import { useAutocompleteSearch } from "@/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; +import { ModalId } from "@/components/extensive/Modal/ModalConst"; +import { EstablishmentEntityType, useEstablishmentTrees } from "@/connections/EstablishmentTrees"; +import { useEntityContext } from "@/context/entity.provider"; +import { useModalContext } from "@/context/modal.provider"; import { useDebounce } from "@/hooks/useDebounce"; +import { isReportModelName } from "@/types/common"; import { updateArrayState } from "@/utils/array"; import Button from "../../Button/Button"; import ErrorMessage from "../../ErrorMessage/ErrorMessage"; import IconButton from "../../IconButton/IconButton"; import Text from "../../Text/Text"; +import AutoCompleteInput from "../AutoCompleteInput/AutoCompleteInput"; import Input from "../Input/Input"; import InputWrapper, { InputWrapperProps } from "../InputElements/InputWrapper"; @@ -21,7 +29,6 @@ export interface TreeSpeciesInputProps extends Omit title: string; buttonCaptionSuffix: string; withNumbers?: boolean; - withTreeSearch?: boolean; value: TreeSpeciesValue[]; onChange: (value: any[]) => void; clearErrors: () => void; @@ -31,15 +38,84 @@ export interface TreeSpeciesInputProps extends Omit error?: FieldErrors[]; } -export type TreeSpeciesValue = { uuid?: string; name?: string; amount?: number }; +export type TreeSpeciesValue = { uuid?: string; name?: string; taxon_id?: string; amount?: number }; + +const NonScientificConfirmationModal = ({ onConfirm }: { onConfirm: () => void }) => { + const t = useT(); + const { closeModal } = useModalContext(); + + return ( +
+
+ + + {t("Your input is a not a scientific name")} + +
+
+
+
+ + {t("You can add this species, but it will be pending review from Admin.")} + +
+
+
+ + +
+
+
+ ); +}; const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const id = useId(); const t = useT(); const lastInputRef = useRef(null); + const autoCompleteRef = useRef(null); + + const [valueAutoComplete, setValueAutoComplete] = useState(""); + const [searchResult, setSearchResult] = useState(); + const [editIndex, setEditIndex] = useState(null); + const [deleteIndex, setDeleteIndex] = useState(null); + const [editValue, setEditValue] = useState(null); + const refPlanted = useRef(null); + const refTreeSpecies = useRef(null); + const { openModal } = useModalContext(); + + const { autocompleteSearch, findTaxonId } = useAutocompleteSearch(); const { onChange, value, clearErrors, collection } = props; + const { entityUuid, entityName } = useEntityContext(); + const isEntity = entityName != null && entityUuid != null; + const isReport = isEntity && isReportModelName(entityName); + const handleBaseEntityTrees = isReport || (isEntity && ["sites", "nurseries"].includes(entityName)); + + const entity = (handleBaseEntityTrees ? entityName : undefined) as EstablishmentEntityType; + const uuid = handleBaseEntityTrees ? entityUuid : undefined; + const [, { establishmentTrees, previousPlantingCounts }] = useEstablishmentTrees({ entity, uuid }); + + const totalWithPrevious = useMemo( + () => + props.value.reduce( + (total, { name, amount }) => total + (amount ?? 0) + (previousPlantingCounts?.[name ?? ""] ?? 0), + 0 + ), + [previousPlantingCounts, props.value] + ); + const handleCreate = useDebounce( useCallback( (treeValue: TreeSpeciesValue) => { @@ -73,9 +149,50 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { const addValue = (e: React.MouseEvent | KeyboardEvent) => { e.preventDefault(); - if (!props.error) { - handleCreate?.({ uuid: uuidv4(), name: undefined, amount: undefined }); - lastInputRef.current && lastInputRef.current.focus(); + if (props.error) return; + + const taxonId = findTaxonId(valueAutoComplete); + + const doAdd = () => { + handleCreate?.({ + uuid: uuidv4(), + name: valueAutoComplete, + taxon_id: taxonId, + amount: props.withNumbers ? 0 : undefined + }); + + setValueAutoComplete(""); + lastInputRef.current?.focus(); + }; + + if (!isEmpty(searchResult) && taxonId == null) { + // In this case the use had valid values to choose from, but decided to add a value that isn't + // on the list, so they haven't been shown the warning yet. + openModal(ModalId.ERROR_MODAL, ); + } else { + doAdd(); + } + }; + + const updateValue = () => { + const taxonId = findTaxonId(valueAutoComplete); + + const doUpdate = () => { + setEditIndex(null); + + handleUpdate({ + ...editValue, + name: valueAutoComplete, + taxon_id: findTaxonId(valueAutoComplete) + }); + + setValueAutoComplete(""); + }; + + if (!isEmpty(searchResult) && taxonId == null) { + openModal(ModalId.ERROR_MODAL, ); + } else { + doUpdate(); } }; @@ -88,78 +205,239 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { return (
-
- - {props.title} ({props.value.length}) + {handleBaseEntityTrees && ( +
+ + {t( + "If you would like to add a species not included on the original Restoration Project, it will be flagged to the admin as new information pending review." + )} +
+ )} +
+ + {t("Scientific Name:")} - - - {t(`Total Count: ({number})`, { number: props.value.reduce((total, v) => total + (v.amount || 0), 0) })} +
+
+ setValueAutoComplete(e.target.value)} + onSearch={async search => { + const result = await autocompleteSearch(search); + setSearchResult(result); + return result; + }} + /> + 0}> + + +
+ + + + + + + + + +
+
+ +
+ + {t("No matches available")} + +
+ + + {t("You can add this species, but it will be pending review from Admin.")} + +
+
+
+
+
+ + {props.title} + + + {props.value.length} +
+
+ + {isReport ? t("SPECIES PLANTED:") : t("TREES TO BE PLANTED:")} + + + {props.withNumbers ? props.value.reduce((total, v) => total + (v.amount || 0), 0).toLocaleString() : "0"} + +
+ +
+ + {t("TOTAL PLANTED TO DATE:")} + + + {totalWithPrevious.toLocaleString()} + +
- ( -
- handleUpdate({ ...value, name: e.target.value })} - placeholder={t("Species Name")} - error={props.error?.[index]?.name ? ({} as FieldError) : undefined} - onKeyDownCapture={onKeyDownCapture} - containerClassName="flex-1" - /> - +
+ +
+ + {t(`Are you sure you want to delete “${value.name}”?`)} + +
+ + +
+
+
+ +
+ + + {t("Editing: {name}", { name: value.name })} + +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ + {t(value.name)} + +
+
+
handleUpdate({ ...value, amount: +e.target.value })} + onChange={e => (props.withNumbers ? handleUpdate({ ...value, amount: +e.target.value }) : {})} onKeyDownCapture={onKeyDownCapture} - containerClassName="flex-3" + containerClassName="" /> +
+ + + {(previousPlantingCounts?.[value.name ?? ""] ?? 0).toLocaleString()} + - handleDelete(props.value?.[index]?.uuid)} - /> +
+ { + setValueAutoComplete(value.name ?? ""); + setEditIndex(value.uuid ?? null); + setEditValue(value); + autoCompleteRef.current?.focus(); + }} + /> + setDeleteIndex(value.uuid ?? null)} + /> +
)} /> -
); diff --git a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot index 84810eee2..9810e81fc 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot +++ b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot @@ -9,7 +9,7 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = ` data-testid="txt" htmlFor=":r23:" > - Tree Species Grown * + ADD TREE SPECIES *

- ( - 1 - ) + Scientific Name:

-
-
- +
+
+ +
+
-
+
+
- Add Another undefined - - +
+
+
+
+
+

+ Test +

+
+
+
+
+
+ +
+
+
+
+ + +
+
+
`; @@ -104,9 +227,9 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput With Number 1`]

- ( - 1 - ) + Scientific Name:

-

- Total Count: (23) -

-
-
- -
-
-
-
- +
+
+ +
+
- +

+

+ 1 +

+
+
+

+ TREES TO BE PLANTED: +

+

+ 23 +

+
+
+
+
+
+
+
+
+
+

+ Test +

+
+
+
+
+
+ +
+
+
+
+ + +
+
+
`; diff --git a/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts new file mode 100644 index 000000000..a49376af8 --- /dev/null +++ b/src/components/elements/Inputs/TreeSpeciesInput/useAutocompleteSearch.ts @@ -0,0 +1,71 @@ +import { isEmpty } from "lodash"; +import { useCallback, useMemo } from "react"; + +import { getAccessToken } from "@/admin/apiProvider/utils/token"; +import { resolveUrl } from "@/generated/v3/utils"; + +type ScientificName = { taxonId: string; scientificName: string }; + +async function searchRequest(search: string) { + const headers: HeadersInit = { "Content-Type": "application/json" }; + const accessToken = typeof window !== "undefined" && getAccessToken(); + if (accessToken != null) headers.Authorization = `Bearer ${accessToken}`; + + const url = resolveUrl(`/trees/v3/scientific-names`, { search }); + const response = await fetch(url, { headers }); + if (!response.ok) { + let error; + try { + error = { + statusCode: response.status, + ...(await response.json()) + }; + } catch (e) { + error = { statusCode: -1 }; + } + + throw error; + } + + const payload = await response.json(); + const data = payload.data as { id: string; attributes: { scientificName: string } }[]; + return data.map(({ id, attributes: { scientificName } }) => ({ taxonId: id, scientificName } as ScientificName)); +} + +/** + * This accesses the v3 tree species search endpoint, but skips the Connection system and the + * top level redux caching. Instead, it provides a simple method to issue a search and will return + * the locally cached result if the same search is issued multiple times (as can happen if a user + * types some characters, then backspaces a couple to type new ones). + */ +export function useAutocompleteSearch() { + const cache = useMemo(() => new Map(), []); + + const autocompleteSearch = useCallback( + async (search: string): Promise => { + const mapNames = (names: ScientificName[]) => names.map(({ scientificName }) => scientificName); + + if (isEmpty(search)) return []; + if (cache.has(search)) return mapNames(cache.get(search) as ScientificName[]); + + const names = await searchRequest(search); + cache.set(search, names); + return mapNames(names); + }, + [cache] + ); + + const findTaxonId = useCallback( + (name: string) => { + for (const names of cache.values()) { + const found = names.find(({ scientificName }) => scientificName === name); + if (found != null) return found.taxonId; + } + + return undefined; + }, + [cache] + ); + + return { autocompleteSearch, findTaxonId }; +} diff --git a/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot b/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot index d9cc8e856..619833a88 100644 --- a/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot +++ b/src/components/elements/Inputs/textArea/__snapshots__/TextArea.stories.storyshot @@ -7,7 +7,7 @@ exports[`Storyshots Components/Elements/Inputs/TextArea Default 1`] = ` @@ -19,11 +19,11 @@ exports[`Storyshots Components/Elements/Inputs/TextArea Default 1`] = ` } } data-testid="txt" - id=":r28:-description" + id=":r2d:-description" />