diff --git a/.github/workflows/deploy-github-storybook.yml b/.github/workflows/deploy-github-storybook.yml new file mode 100644 index 000000000..066c07517 --- /dev/null +++ b/.github/workflows/deploy-github-storybook.yml @@ -0,0 +1,29 @@ +# Workflow taken from the Storybook docs: https://storybook.js.org/docs/sharing/publish-storybook#github-pages +name: Build and Publish Storybook to GitHub Pages + +on: + push: + branches: + - 'staging' + +permissions: + contents: read + pages: write + id-token: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - uses: bitovi/github-actions-storybook-to-github-pages@v1.0.3 + with: + install_command: yarn install + build_command: yarn build-storybook + path: storybook-static + checkout: false diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 194b0c659..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 @@ -14,3 +13,5 @@ jobs: run: yarn - name: Test run: yarn run jest --ci + - name: Build + run: NEXT_PUBLIC_TARGET_ENV=staging yarn build diff --git a/README.md b/README.md index 52675d4fa..9c1717f31 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # 🌲 Terramatch Web Platform 🌲 +### Storybook +We host the `staging` build of Storybook on [Github Pages](https://wri.github.io/wri-terramatch-website/) + ### Installation ``` diff --git a/next.config.js b/next.config.js index 2add3246d..a7f8b6f5e 100644 --- a/next.config.js +++ b/next.config.js @@ -45,8 +45,8 @@ const userSentryWebpackPluginOptions = { // Suppresses source map uploading logs during build silent: true, - org: process.env.SENTRY_ORG || "3-sided-cube", - project: process.env.SENTRY_PROJECT || "wri-web-platform-version-2", + org: process.env.SENTRY_ORG ?? "wri-terramatch", + project: process.env.SENTRY_PROJECT ?? "terramatch-frontend", authToken: process.env.SENTRY_AUTH_TOKEN }; diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 9d6e54a2f..e1297e325 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -27,28 +27,40 @@ type EnvironmentName = (typeof ENVIRONMENT_NAMES)[number]; 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" + userServiceUrl: "http://localhost:4010", + jobServiceUrl: "http://localhost:4020", + entityServiceUrl: "http://localhost:4050" }, dev: { apiBaseUrl: "https://api-dev.terramatch.org", - userServiceUrl: "https://api-dev.terramatch.org" + userServiceUrl: "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" + userServiceUrl: "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" + userServiceUrl: "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" + userServiceUrl: "https://api.terramatch.org", + jobServiceUrl: "https://api.terramatch.org", + entityServiceUrl: "https://api.terramatch.org" } }; @@ -60,13 +72,17 @@ if (!ENVIRONMENT_NAMES.includes(declaredEnv as EnvironmentName)) { 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 // isolate the generated API integration by service to make it easier for a developer to find where // the associated BE code is for a given FE API integration. const SERVICES = { - "user-service": userServiceUrl + "user-service": userServiceUrl, + "job-service": jobServiceUrl, + "entity-service": entityServiceUrl }; const config: Record = { diff --git a/package.json b/package.json index a61cc42ed..22e502fb1 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "generate:api": "openapi-codegen gen api", + "generate:jobService": "openapi-codegen gen jobService", "generate:userService": "openapi-codegen gen userService", - "generate:services": "npm run generate:userService", + "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/public/images/graphic-1.png b/public/images/graphic-1.png index 41f753875..777d7a19d 100644 Binary files a/public/images/graphic-1.png and b/public/images/graphic-1.png differ diff --git a/public/images/graphic-8.svg b/public/images/graphic-8.svg new file mode 100644 index 000000000..113ed40e7 --- /dev/null +++ b/public/images/graphic-8.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/map-img.png b/public/images/map-img.png index 5f69f1eb2..29933c437 100644 Binary files a/public/images/map-img.png and b/public/images/map-img.png differ diff --git a/public/images/monitoring-graph-1.png b/public/images/monitoring-graph-1.png new file mode 100644 index 000000000..484dfd4e4 Binary files /dev/null and b/public/images/monitoring-graph-1.png differ diff --git a/public/images/monitoring-graph-2.png b/public/images/monitoring-graph-2.png new file mode 100644 index 000000000..765826d33 Binary files /dev/null and b/public/images/monitoring-graph-2.png differ diff --git a/public/images/monitoring-graph-3.png b/public/images/monitoring-graph-3.png new file mode 100644 index 000000000..5ad1de793 Binary files /dev/null and b/public/images/monitoring-graph-3.png differ diff --git a/public/images/monitoring-graph-4.png b/public/images/monitoring-graph-4.png new file mode 100644 index 000000000..0fdd79c07 Binary files /dev/null and b/public/images/monitoring-graph-4.png differ diff --git a/public/images/monitoring-graph-5.png b/public/images/monitoring-graph-5.png new file mode 100644 index 000000000..3c143200f Binary files /dev/null and b/public/images/monitoring-graph-5.png differ diff --git a/src/admin/components/Alerts/DelayedJobsProgressAlert.tsx b/src/admin/components/Alerts/DelayedJobsProgressAlert.tsx new file mode 100644 index 000000000..1a8c1bfef --- /dev/null +++ b/src/admin/components/Alerts/DelayedJobsProgressAlert.tsx @@ -0,0 +1,80 @@ +import { Alert, AlertTitle, CircularProgress } from "@mui/material"; +import { FC, useEffect, useState } from "react"; +import { useStore } from "react-redux"; + +import ApiSlice from "@/store/apiSlice"; +import { AppStore } from "@/store/store"; + +type DelayedJobsProgressAlertProps = { + show: boolean; + title?: string; + setIsLoadingDelayedJob?: (value: boolean) => void; +}; + +const DelayedJobsProgressAlert: FC = ({ show, title, setIsLoadingDelayedJob }) => { + const [delayedJobProcessing, setDelayedJobProcessing] = useState(0); + const [delayedJobTotal, setDalayedJobTotal] = useState(0); + const [progressMessage, setProgressMessage] = useState("Running 0 out of 0 polygons (0%)"); + + const store = useStore(); + useEffect(() => { + let intervalId: any; + if (show) { + intervalId = setInterval(() => { + const { total_content, processed_content, progress_message } = store.getState().api; + setDalayedJobTotal(total_content); + setDelayedJobProcessing(processed_content); + if (progress_message != "") { + setProgressMessage(progress_message); + } + }, 1000); + } + + return () => { + if (intervalId) { + setDelayedJobProcessing(0); + setDalayedJobTotal(0); + setProgressMessage("Running 0 out of 0 polygons (0%)"); + clearInterval(intervalId); + } + }; + }, [show]); + + const abortDelayedJob = () => { + ApiSlice.abortDelayedJob(true); + ApiSlice.addTotalContent(0); + ApiSlice.addProgressContent(0); + ApiSlice.addProgressMessage("Running 0 out of 0 polygons (0%)"); + setDelayedJobProcessing(0); + setDalayedJobTotal(0); + setIsLoadingDelayedJob?.(false); + }; + + if (!show) return null; + + const calculatedProgress = delayedJobTotal! > 0 ? Math.round((delayedJobProcessing! / delayedJobTotal!) * 100) : 0; + + const severity = calculatedProgress >= 75 ? "success" : calculatedProgress >= 50 ? "info" : "warning"; + + return ( +
+ } + action={ + + } + > + {title} + {progressMessage ?? "Running 0 out of 0 polygons (0%)"} + +
+ ); +}; + +export default DelayedJobsProgressAlert; 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/Fields/CustomChipField.tsx b/src/admin/components/Fields/CustomChipField.tsx index c52fa01de..66f1e51c9 100644 --- a/src/admin/components/Fields/CustomChipField.tsx +++ b/src/admin/components/Fields/CustomChipField.tsx @@ -5,6 +5,7 @@ const STATUS_CLASSNAME_MAP: { [key: string]: string } = { Approved: "bg-green-30 text-green-100", Rejected: "bg-yellow-300 text-yellow-700", "Under Review": "bg-yellow-300 text-yellow-700", + Submitted: "bg-blue-200 text-blue", "Awaiting approval": "bg-blue-200 text-blue", "Awaiting Review": "bg-blue-200 text-blue", "Planting In Progress": "bg-yellow-300 text-yellow-700", @@ -12,17 +13,28 @@ const STATUS_CLASSNAME_MAP: { [key: string]: string } = { Draft: "bg-grey-200 text-grey-500", Started: "bg-grey-200 text-grey-500", Unknown: "bg-grey-200 text-grey-500", + "Needs Info": "bg-tertiary-50 text-tertiary-650", "Needs more information": "bg-tertiary-50 text-tertiary-650", "More info requested": "bg-tertiary-50 text-tertiary-650", - "No Update": "bg-grey-200 text-grey-500" + "No Update": "bg-grey-200 text-grey-500", + approved: "bg-green-30 text-green-100", + submitted: "bg-blue-200 text-blue", + "needs-more-information": "bg-tertiary-50 text-tertiary-650" }; -const CustomChipField = ({ label = "" }: { label: string | undefined }) => { +const CustomChipField = ({ + label = "", + classNameChipField +}: { + label: string | undefined; + classNameChipField?: string; +}) => { return (
{label == "Unknown" ? "Started" : label} diff --git a/src/admin/components/ResourceTabs/AuditLogTab/AuditLogTab.tsx b/src/admin/components/ResourceTabs/AuditLogTab/AuditLogTab.tsx index 32459384f..0dcc8dc09 100644 --- a/src/admin/components/ResourceTabs/AuditLogTab/AuditLogTab.tsx +++ b/src/admin/components/ResourceTabs/AuditLogTab/AuditLogTab.tsx @@ -9,6 +9,7 @@ import { NURSERY_REPORT, PROJECT_REPORT, SITE_REPORT } from "@/constants/entitie import useAuditLogActions from "@/hooks/AuditStatus/useAuditLogActions"; import AuditLogSiteTabSelection from "./components/AuditLogSiteTabSelection"; +import AuditLogTable from "./components/AuditLogTable"; import SiteAuditLogEntityStatus from "./components/SiteAuditLogEntityStatus"; import SiteAuditLogEntityStatusSide from "./components/SiteAuditLogEntityStatusSide"; import SiteAuditLogProjectStatus from "./components/SiteAuditLogProjectStatus"; @@ -58,6 +59,10 @@ const AuditLogTab: FC = ({ label, entity, ...rest }) => { loadEntityList(); }, [buttonToggle]); + const isSite = buttonToggle === AuditLogButtonStates.SITE; + const redirectTo = `${basename}/${modules.site.ResourceName}/${selected?.uuid}/show/6`; + const title = () => selected?.title ?? selected?.name; + const verifyEntity = ["reports", "nursery"].some(word => ReverseButtonStates2[entity!].includes(word)); const verifyEntityReport = () => { @@ -133,6 +138,32 @@ const AuditLogTab: FC = ({ label, entity, ...rest }) => { /> +
+ + + History and Discussion for {record && record?.name} + + {auditLogData && } + + + <> +
+ {!isSite && !verifyEntity && History and Discussion for {title()}} + {(isSite || verifyEntity) && ( + + History and Discussion for{" "} + + {title()} + + + )} +
+ + + + +
+
); diff --git a/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogEntityStatus.tsx b/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogEntityStatus.tsx index d8815bae6..21a51288a 100644 --- a/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogEntityStatus.tsx +++ b/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogEntityStatus.tsx @@ -63,24 +63,26 @@ const SiteAuditLogEntityStatus: FC = ({
-
- {!isSite && !verifyEntity && History and Discussion for {title()}} - {(isSite || verifyEntity) && ( - - History and Discussion for{" "} - {viewPD ? ( - - {title()} - - ) : ( - - {title()} - - )} - - )} -
- + +
+ {!isSite && !verifyEntity && History and Discussion for {title()}} + {(isSite || verifyEntity) && ( + + History and Discussion for{" "} + {viewPD ? ( + + {title()} + + ) : ( + + {title()} + + )} + + )} +
+
+ diff --git a/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogProjectStatus.tsx b/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogProjectStatus.tsx index a9d23ffc1..57684ba9c 100644 --- a/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogProjectStatus.tsx +++ b/src/admin/components/ResourceTabs/AuditLogTab/components/SiteAuditLogProjectStatus.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { When } from "react-if"; import Text from "@/components/elements/Text/Text"; import { AuditStatusResponse, ProjectLiteRead } from "@/generated/apiSchemas"; @@ -10,13 +11,15 @@ export interface SiteAuditLogProjectStatusProps { auditLogData?: { data: AuditStatusResponse[] }; auditData?: { entity: string; entity_uuid: string }; refresh?: () => void; + viewPD?: boolean; } const SiteAuditLogProjectStatus: FC = ({ record, auditLogData, auditData, - refresh + refresh, + viewPD = false }) => (
@@ -27,8 +30,10 @@ const SiteAuditLogProjectStatus: FC = ({ Update the project status, view updates, or add comments
- History and Discussion for {record && record?.name} - {auditLogData && } + + History and Discussion for {record && record?.name} + {auditLogData && } +
); diff --git a/src/admin/components/ResourceTabs/MonitoredTab/MonitoredTab.tsx b/src/admin/components/ResourceTabs/MonitoredTab/MonitoredTab.tsx new file mode 100644 index 000000000..c9fef6a55 --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/MonitoredTab.tsx @@ -0,0 +1,25 @@ +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, type, ...rest }) => { + return ( + +
+ + +
+
+ ); +}; + +export default MonitoredTab; diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/DataCard.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/DataCard.tsx new file mode 100644 index 000000000..3abdfde48 --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/DataCard.tsx @@ -0,0 +1,712 @@ +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 { 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"; +import { VARIANT_DROPDOWN_SIMPLE } from "@/components/elements/Inputs/Dropdown/DropdownVariant"; +import { useMap } from "@/components/elements/Map-mapbox/hooks/useMap"; +import MapContainer from "@/components/elements/Map-mapbox/Map"; +import Table from "@/components/elements/Table/Table"; +import { VARIANT_TABLE_MONITORED } from "@/components/elements/Table/TableVariants"; +import FilterSearchBox from "@/components/elements/TableFilters/Inputs/FilterSearchBox"; +import { FILTER_SEARCH_MONITORING } from "@/components/elements/TableFilters/Inputs/FilterSearchBoxVariants"; +import Text from "@/components/elements/Text/Text"; +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 { + 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; + size: string; + siteName: string; + status: string; + plantDate?: string; + baseline?: string; + treePlanting?: string; + regeneration?: string; + seeding?: string; + "2024-2015"?: string; + "2024-2016"?: string; + "2024-2017"?: string; + "2024-2018"?: string; + "2024-2019"?: string; + "2024-2020"?: string; + "2024-2021"?: string; + "2024-2022"?: string; + "2024-2023"?: string; + "2024-2024"?: string; +} + +export interface DataStructure extends React.HTMLAttributes { + label: string; + tooltipContent: string; + tableData: TableData[]; +} + +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 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(1); + 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") { + const width = window.innerWidth; + setTopHeaderFirstTable(width > 1900 ? "110px" : "106px"); + setTopHeaderSecondTable(width > 1900 ? "77px" : "72px"); + } + }, []); + + const TABLE_COLUMNS_TREE_COVER_LOSS: CustomColumnDefInternal[] = [ + { + id: "mainInfo", + meta: { style: { top: `${topHeaderSecondTable}`, borderBottomWidth: 0, borderRightWidth: 0 } }, + header: "", + columns: [ + { + accessorKey: "poly_name", + header: "Polygon Name", + meta: { style: { top: `${topHeaderFirstTable}`, borderRadius: "0", width: "11%" } } + }, + { + accessorKey: "size", + header: "Size (ha)", + meta: { style: { top: `${topHeaderFirstTable}`, width: "7%" } } + }, + { + accessorKey: "site_name", + header: "Site Name", + meta: { style: { top: `${topHeaderFirstTable}`, width: "8%" } } + }, + { + accessorKey: "status", + header: "Status", + meta: { style: { top: `${topHeaderFirstTable}`, width: "7%" } }, + cell: (props: any) => ( + + ) + }, + { + accessorKey: "plantstart", + header: () => ( + <> + Plant +
+ Start Date + + ), + meta: { style: { top: `${topHeaderFirstTable}`, width: "8%" } } + } + ] + }, + { + id: "analysis2024", + header: totalElemIndicator + ? `Analysis: ${format(new Date(polygonsIndicator?.[totalElemIndicator]?.created_at!), "MMMM d, yyyy")}` + : "Analysis:", + meta: { style: { top: `${topHeaderSecondTable}`, borderBottomWidth: 0 } }, + columns: [ + { + accessorKey: "data.2015", + header: "2015", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2016", + header: "2016", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2017", + header: "2017", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2018", + header: "2018", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2019", + header: "2019", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2020", + header: "2020", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2021", + header: "2021", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2022", + header: "2022", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2023", + header: "2023", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + }, + { + accessorKey: "data.2024", + header: "2024", + meta: { style: { top: `${topHeaderFirstTable}`, width: "5.4%" } } + } + ] + }, + { + id: "moreInfo", + header: " ", + meta: { style: { top: `${topHeaderSecondTable}`, borderBottomWidth: 0, width: "5%" } }, + columns: [ + { + accessorKey: "more", + header: "", + enableSorting: false, + cell: props => ( +
+ +
+ ), + meta: { style: { top: `${topHeaderFirstTable}`, borderRadius: "0" } } + } + ] + } + ]; + + 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 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 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`); + + 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); + } + }; + + useEffect(() => { + if (selectPolygonFromMap?.isOpen) { + setSelectPolygonFromMap?.({ isOpen: false, uuid: "" }); + } + }, [selectPolygonFromMap]); + return ( + <> +
+
+
+
+ + { + setIndicatorSlug?.(DROPDOWN_OPTIONS.find(item => item.value === option[0])?.slug!); + setSelected(option); + }} + variant={VARIANT_DROPDOWN_SIMPLE} + inputVariant="text-18-semibold" + className="z-50" + defaultValue={[DROPDOWN_OPTIONS.find(item => item.slug === indicatorSlug)?.value!]} + optionsClassName="w-max z-50" + /> +
+ +
+ + { + setSearchTerm(e); + }} + variant={FILTER_SEARCH_MONITORING} + /> + + + + +
+
+ +
+ + + + +
+ setSelectedPolygonUuid(option[0])} + /> +
+ + Indicator Description + +
+ + {indicatorDescription1} + + + {indicatorDescription2} + +
+
+ + {noDataGraph} +
+
+ +
+
+ +
+ + {noDataMap} +
+
+ + + + + ); +}; + +export default DataCard; 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..3b00446ab --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/EcoRegionDoughnutChart.tsx @@ -0,0 +1,146 @@ +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) { + const value = payload[0].value; + const total = payload[0].payload.total; + const percentage = (value / total) * 100; + + const formattedValue = value.toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1 + }); + + const formattedPercentage = percentage.toFixed(0); + + return ( +
+

{payload[0].name}

+

{`${formattedValue}ha (${formattedPercentage}%)`}

+
+ ); + } + 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 total = chartData.reduce((sum, item) => sum + item.value, 0); + + const enhancedChartData = chartData.map(item => ({ + ...item, + total + })); + + const onPieEnter = (_: any, index: number) => { + setActiveIndex(index); + }; + + const onPieLeave = () => { + setActiveIndex(undefined); + }; + + return ( +
+

Hectares Under Restoration By WWF EcoRegion

+ + + } /> + + + {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 new file mode 100644 index 000000000..bd1573449 --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/HeaderMonitoredTab.tsx @@ -0,0 +1,135 @@ +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"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +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"; + +import { useMonitoredData } from "../hooks/useMonitoredData"; + +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( + ModalId.MODAL_RUN_ANALYSIS, + { + closeModal(ModalId.MODAL_RUN_ANALYSIS); + } + }} + onClose={() => closeModal(ModalId.MODAL_RUN_ANALYSIS)} + secondaryButtonText="Cancel" + secondaryButtonProps={{ + className: "px-8 py-3", + variant: "white-page-admin", + onClick: () => closeModal(ModalId.MODAL_RUN_ANALYSIS) + }} + /> + ); + }; + + const openNotes = () => { + openModal( + ModalId.MODAL_NOTES, + { + closeModal(ModalId.MODAL_NOTES); + } + }} + onClose={() => closeModal(ModalId.MODAL_NOTES)} + /> + ); + }; + + return ( +
+
+
+
+ + Polygon Overview + + +
+ {loadingAnalysis ? ( + + ) : null} +
+
+
+ +
+
+
+
+ + No. of Polygons + + + {totalPolygonsStatus} + +
+ +
+ + No. of Sites + + + {record?.project ? record?.project?.total_sites : record?.total_sites} + +
+
+
+
+
+ + +
+
+ ); +}; + +export default HeaderMonitoredTab; diff --git a/src/admin/components/ResourceTabs/MonitoredTab/components/LegendItem.tsx b/src/admin/components/ResourceTabs/MonitoredTab/components/LegendItem.tsx new file mode 100644 index 000000000..fa248c6e8 --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/LegendItem.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import Text from "@/components/elements/Text/Text"; +import { TextVariants } from "@/types/common"; + +interface LegendItemProps { + key: string | number; + backgroundColor: string; + label: string; + percentage: string; + textVariant?: TextVariants; +} + +const LegendItem = (props: LegendItemProps) => { + const { key, backgroundColor, label, percentage, textVariant } = props; + return ( +
+
+
+ + {label} + + + {percentage} + +
+
+ ); +}; + +export default LegendItem; 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..95da0709c --- /dev/null +++ b/src/admin/components/ResourceTabs/MonitoredTab/components/MonitoredCharts.tsx @@ -0,0 +1,174 @@ +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(() => { + if (isLoadingIndicator) { + setHasNoData(false); + } + 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, isLoadingIndicator]); + + 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..66f3b0172 --- /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..bb8af6a2c 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,12 @@ 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-full !text-nowrap" + titleContainerClassName="!w-[calc(100%-25px)] !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/components/PolygonItem.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonItem.tsx index 85f2a3600..6a431a492 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonItem.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonItem.tsx @@ -1,3 +1,5 @@ +import Box from "@mui/material/Box"; +import LinearProgress from "@mui/material/LinearProgress"; import { useT } from "@transifex/react"; import classNames from "classnames"; import { DetailedHTMLProps, HTMLAttributes, useEffect, useState } from "react"; @@ -45,7 +47,7 @@ const PolygonItem = ({ }: MapMenuPanelItemProps & { isChecked: boolean; onCheckboxChange: (uuid: string, isChecked: boolean) => void }) => { let imageStatus = `IC_${status.toUpperCase().replace(/-/g, "_")}`; const [openCollapse, setOpenCollapse] = useState(false); - const [validationStatus, setValidationStatus] = useState(undefined); + const [validationStatus, setValidationStatus] = useState(undefined); const [showWarning, setShowWarning] = useState(false); const { polygonCriteriaMap: polygonMap } = useMapAreaContext(); const t = useT(); @@ -59,10 +61,10 @@ const PolygonItem = ({ const criteriaData = polygonMap[uuid]; if (criteriaData?.criteria_list && criteriaData.criteria_list.length > 0) { setPolygonValidationData(parseValidationData(criteriaData)); - setValidationStatus(isValidCriteriaData(criteriaData)); + setValidationStatus(isValidCriteriaData(criteriaData) ? "passed" : "failed"); setShowWarning(hasCompletedDataWhitinStimatedAreaCriteriaInvalid(criteriaData)); - } else { - setValidationStatus(undefined); + } else if (criteriaData?.criteria_list && criteriaData.criteria_list.length === 0) { + setValidationStatus("notChecked"); } }, [polygonMap]); @@ -75,7 +77,7 @@ const PolygonItem = ({ {...props} className={classNames( className, - "flex flex-col rounded-lg border-2 border-grey-350 bg-white p-2 hover:border-primary" + "flex flex-col rounded-lg border-2 border-grey-350 bg-white p-2 shadow-monitored hover:border-primary" )} >
@@ -83,7 +85,7 @@ const PolygonItem = ({
@@ -91,7 +93,7 @@ const PolygonItem = ({ {t(title)} - -
-
+
+ 0}> + + {polygonSitePolygonCount} of{" "} + {props.totalPolygons} polygons loaded + + + + + +
+
{polygonMenu.map(item => (
{ type: EntityName; label: string; + setIsLoadingDelayedJob?: (isLoading: boolean) => void; + isLoadingDelayedJob?: boolean; + setAlertTitle?: (value: string) => void; } export interface IPolygonItem { id: string; @@ -92,7 +101,8 @@ const PolygonReviewAside: FC<{ setPolygonFromMap: any; refresh?: () => void; mapFunctions: any; -}> = ({ type, data, polygonFromMap, setPolygonFromMap, refresh, mapFunctions }) => { + totalPolygons?: number; +}> = ({ type, data, polygonFromMap, setPolygonFromMap, refresh, mapFunctions, totalPolygons }) => { switch (type) { case "sites": return ( @@ -102,6 +112,7 @@ const PolygonReviewAside: FC<{ setPolygonFromMap={setPolygonFromMap} mapFunctions={mapFunctions} refresh={refresh} + totalPolygons={totalPolygons} /> ); default: @@ -137,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: "" }); @@ -157,11 +169,23 @@ 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); }; const mapFunctions = useMap(onSave); - const { data: sitePolygonData, refetch, polygonCriteriaMap, loading } = useLoadCriteriaSite(record.uuid, "sites"); + const { + data: sitePolygonData, + refetch, + polygonCriteriaMap, + loading, + total + } = useLoadCriteriaSite(record.uuid, "sites"); const { data: modelFilesData } = useGetV2MODELUUIDFiles({ pathParams: { model: "sites", uuid: record.uuid } @@ -208,6 +232,8 @@ const PolygonReviewTab: FC = props => { const polygonDataMap = parsePolygonData(sitePolygonData); + const dataPolygonOverview = countStatuses(sitePolygonData); + const { openModal, closeModal } = useModalContext(); const flyToPolygonBounds = async (uuid: string) => { @@ -274,9 +300,13 @@ const PolygonReviewTab: FC = props => { }, [errorMessage]); useEffect(() => { - setPolygonCriteriaMap(polygonCriteriaMap); setPolygonData(sitePolygonData); }, [loading]); + + useEffect(() => { + setPolygonCriteriaMap(polygonCriteriaMap); + }, [polygonCriteriaMap]); + useEffect(() => { if (shouldRefetchValidation) { refetch(); @@ -326,23 +356,31 @@ const PolygonReviewTab: FC = props => { setSubmitPolygonLoaded(false); hideLoader(); } catch (error) { - if (error && typeof error === "object" && "message" in error) { - let errorMessage = error.message; - if (typeof errorMessage === "string") { - const parsedMessage = JSON.parse(errorMessage); - if (parsedMessage && typeof parsedMessage === "object" && "message" in parsedMessage) { - errorMessage = parsedMessage.message; + let errorMessage; + + if (error && typeof error === "object" && "error" in error) { + const nestedError = error.error; + if (typeof nestedError === "string") { + try { + const parsedNestedError = JSON.parse(nestedError); + if (parsedNestedError && typeof parsedNestedError === "object" && "message" in parsedNestedError) { + errorMessage = parsedNestedError.message; + } else { + errorMessage = nestedError; + } + } catch (parseError) { + errorMessage = nestedError; } + } else { + errorMessage = nestedError; } - if (errorMessage && typeof errorMessage === "object" && "message" in errorMessage) { - errorMessage = errorMessage.message; - } - openNotification("error", t("Error uploading file"), errorMessage); - hideLoader(); + } else if (error && typeof error === "object" && "message" in error) { + errorMessage = error.message; } else { - openNotification("error", t("Error uploading file"), t("An unknown error occurred")); - hideLoader(); + errorMessage = t("An unknown error occurred"); } + openNotification("error", t("Error uploading file"), errorMessage || t("An unknown error occurred")); + hideLoader(); } }; @@ -586,11 +624,56 @@ const PolygonReviewTab: FC = props => { -
+
+
+
+ + Site Status + + + + + + {record?.readable_status} + +
+
+ + Polygon Overview + + + + + + + + + + + + + + +
+
- - Polygon Review + + Add or Edit Polygons Add, remove or edit polygons that are associated to a site. Polygons may be edited in the map @@ -598,7 +681,7 @@ const PolygonReviewTab: FC = props => { application.
-
+
= props => {
-
- - Site Status - -
- -
-
= props => { tooltipType="edit" sitePolygonData={sitePolygonData} modelFilesData={modelFilesData?.data} + setIsLoadingDelayedJob={props.setIsLoadingDelayedJob} + isLoadingDelayedJob={props.isLoadingDelayedJob} + setAlertTitle={props.setAlertTitle} />
@@ -675,7 +753,7 @@ const PolygonReviewTab: FC = props => { pagination: { pageSize: 10000000 } }} columns={[ - { header: "Polygon Name", accessorKey: "polygon-name" }, + { header: "Polygon Name", accessorKey: "polygon-name", meta: { style: { width: "14.63%" } } }, { header: "Restoration Practice", accessorKey: "restoration-practice", @@ -684,15 +762,28 @@ const PolygonReviewTab: FC = props => { return ( ); - } + }, + meta: { style: { width: "17.63%" } } + }, + { + header: "Target Land Use System", + accessorKey: "target-land-use-system", + meta: { style: { width: "20.63%" } } + }, + { + header: "Tree Distribution", + accessorKey: "tree-distribution", + meta: { style: { width: "15.63%" } } + }, + { + header: "Planting Start Date", + accessorKey: "planting-start-date", + meta: { style: { width: "17.63%" } } }, - { header: "Target Land Use System", accessorKey: "target-land-use-system" }, - { header: "Tree Distribution", accessorKey: "tree-distribution" }, - { header: "Planting Start Date", accessorKey: "planting-start-date" }, - { header: "Source", accessorKey: "source" }, + { header: "Source", accessorKey: "source", meta: { style: { width: "10.63%" } } }, { header: "", accessorKey: "ellipse", @@ -727,6 +818,7 @@ const PolygonReviewTab: FC = props => { setPolygonFromMap={setPolygonFromMap} mapFunctions={mapFunctions} refresh={refetch} + totalPolygons={total} /> diff --git a/src/admin/components/Tables/TreeSpeciesTableTF.tsx b/src/admin/components/Tables/TreeSpeciesTableTF.tsx new file mode 100644 index 000000000..f862292d4 --- /dev/null +++ b/src/admin/components/Tables/TreeSpeciesTableTF.tsx @@ -0,0 +1,139 @@ +import { Card, Divider, Typography } from "@mui/material"; +import { Box, Stack } from "@mui/system"; +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import { useT } from "@transifex/react"; +import { FC } from "react"; +import { Else, If, Then } from "react-if"; + +import Text from "@/components/elements/Text/Text"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import { EstablishmentEntityType } from "@/connections/EstablishmentTrees"; +import { useGetV2TreeSpeciesEntityUUID } from "@/generated/apiComponents"; + +type TreeSpeciesTableTFProps = { + uuid: string; + entity: EstablishmentEntityType; + total?: number; + totalText?: string; + title?: string; + countColumnName?: string; + collection?: string; +}; + +const TreeSpeciesTableTF: FC = ({ + uuid, + entity, + total, + totalText, + title, + countColumnName, + collection = "tree-planted" +}) => { + const t = useT(); + const { data: rows } = useGetV2TreeSpeciesEntityUUID({ + pathParams: { + uuid, + entity: entity?.replace("Report", "-report")! + } + }); + + const columns: GridColDef[] = [ + { + field: "name", + headerName: "SPECIES NAME", + flex: 1, + sortable: false, + filterable: false + }, + { + field: "amount", + headerName: countColumnName ?? "TREE COUNT", + type: "number", + flex: 1, + headerAlign: "left", + align: "left", + sortable: false, + filterable: false + } + ]; + + if (!rows || !rows.data) return null; + + return ( + + + + + {title ?? "N/A"} + + + +
+
+ +
+ + {totalText}: + + + {new Intl.NumberFormat("en-US").format(total!) ?? "N/A"} + +
+
+ + + + 0}> + + row.collection == collection) as any} + columns={columns.map(column => + column.field === "name" + ? { + ...column, + renderCell: params => ( +
+ + {params?.row?.name} + +
+ +
+
+ ) + } + : column + )} + getRowId={row => row.uuid} + sx={{ + border: "none", + "& .MuiDataGrid-columnHeader": { + paddingX: 3 + }, + "& .MuiDataGrid-cell": { + paddingX: 3 + } + }} + pageSizeOptions={[5, 10, 25, 50]} + initialState={{ + pagination: { + paginationModel: { page: 0, pageSize: 5 } + } + }} + /> +
+ + + No {title} Recorded + + +
+
+
+ ); +}; + +export default TreeSpeciesTableTF; 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/organisations/components/OrganisationShow.tsx b/src/admin/modules/organisations/components/OrganisationShow.tsx index 327746960..1e8cf6302 100644 --- a/src/admin/modules/organisations/components/OrganisationShow.tsx +++ b/src/admin/modules/organisations/components/OrganisationShow.tsx @@ -185,7 +185,7 @@ export const OrganisationShow = () => { /> - + { - In Progress + {/* In Progress */} + diff --git a/src/admin/modules/sites/components/SiteShow.tsx b/src/admin/modules/sites/components/SiteShow.tsx index 216c5e366..fb0bef090 100644 --- a/src/admin/modules/sites/components/SiteShow.tsx +++ b/src/admin/modules/sites/components/SiteShow.tsx @@ -1,19 +1,24 @@ -import { FC } from "react"; +import { FC, useState } from "react"; import { Show, TabbedShowLayout } from "react-admin"; import ShowActions from "@/admin/components/Actions/ShowActions"; +import DelayedJobsProgressAlert from "@/admin/components/Alerts/DelayedJobsProgressAlert"; import AuditLogTab from "@/admin/components/ResourceTabs/AuditLogTab/AuditLogTab"; import { AuditLogButtonStates } from "@/admin/components/ResourceTabs/AuditLogTab/constants/enum"; import ChangeRequestsTab from "@/admin/components/ResourceTabs/ChangeRequestsTab/ChangeRequestsTab"; import DocumentTab from "@/admin/components/ResourceTabs/DocumentTab/DocumentTab"; import GalleryTab from "@/admin/components/ResourceTabs/GalleryTab/GalleryTab"; import InformationTab from "@/admin/components/ResourceTabs/InformationTab"; +import MonitoredTab from "@/admin/components/ResourceTabs/MonitoredTab/MonitoredTab"; import PolygonReviewTab from "@/admin/components/ResourceTabs/PolygonReviewTab"; import ShowTitle from "@/admin/components/ShowTitle"; import { RecordFrameworkProvider } from "@/context/framework.provider"; import { MapAreaProvider } from "@/context/mapArea.provider"; const SiteShow: FC = () => { + const [isLoadingDelayedJob, setIsLoadingDelayedJob] = useState(false); + const [alertTitle, setAlertTitle] = useState(""); + return ( record?.name} />} @@ -25,16 +30,27 @@ const SiteShow: FC = () => { - + - In Progress + + ); }; diff --git a/src/admin/modules/user/components/UserShow.tsx b/src/admin/modules/user/components/UserShow.tsx index a739d34bc..e1f481c62 100644 --- a/src/admin/modules/user/components/UserShow.tsx +++ b/src/admin/modules/user/components/UserShow.tsx @@ -26,7 +26,7 @@ function ManagedProjects() { return ( - + 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/analized.svg b/src/assets/icons/analized.svg new file mode 100644 index 000000000..2483143e0 --- /dev/null +++ b/src/assets/icons/analized.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/assisted-natural-regeneration.svg b/src/assets/icons/assisted-natural-regeneration.svg new file mode 100644 index 000000000..fc129bce4 --- /dev/null +++ b/src/assets/icons/assisted-natural-regeneration.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/dashboard.svg b/src/assets/icons/dashboard.svg new file mode 100644 index 000000000..03173cf27 --- /dev/null +++ b/src/assets/icons/dashboard.svg @@ -0,0 +1,3 @@ + + + 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/float-notification.svg b/src/assets/icons/float-notification.svg new file mode 100644 index 000000000..88910cc4d --- /dev/null +++ b/src/assets/icons/float-notification.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/ic-info-white-black.svg b/src/assets/icons/ic-info-white-black.svg new file mode 100644 index 000000000..07496c927 --- /dev/null +++ b/src/assets/icons/ic-info-white-black.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/ic-info.svg b/src/assets/icons/ic-info.svg index f90755e06..bd1b9f9cf 100644 --- a/src/assets/icons/ic-info.svg +++ b/src/assets/icons/ic-info.svg @@ -1,4 +1,4 @@ - + diff --git a/src/assets/icons/ic_expand.svg b/src/assets/icons/ic_expand.svg new file mode 100644 index 000000000..8227f99dc --- /dev/null +++ b/src/assets/icons/ic_expand.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/ic_shrink.svg b/src/assets/icons/ic_shrink.svg new file mode 100644 index 000000000..b5edd7a06 --- /dev/null +++ b/src/assets/icons/ic_shrink.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/loading.svg b/src/assets/icons/loading.svg new file mode 100644 index 000000000..e6fe47bc2 --- /dev/null +++ b/src/assets/icons/loading.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/map.svg b/src/assets/icons/map.svg new file mode 100644 index 000000000..83829dcb8 --- /dev/null +++ b/src/assets/icons/map.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/monitoring-profile.svg b/src/assets/icons/monitoring-profile.svg new file mode 100644 index 000000000..7e5efbda2 --- /dev/null +++ b/src/assets/icons/monitoring-profile.svg @@ -0,0 +1,4 @@ + + + + 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 custom.svg b/src/assets/icons/non-scientific name custom.svg new file mode 100644 index 000000000..e92802b97 --- /dev/null +++ b/src/assets/icons/non-scientific name custom.svg @@ -0,0 +1,9 @@ + + + + + + + + + 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/notification.svg b/src/assets/icons/notification.svg new file mode 100644 index 000000000..18848a595 --- /dev/null +++ b/src/assets/icons/notification.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/run-analysis.svg b/src/assets/icons/run-analysis.svg new file mode 100644 index 000000000..1db8144b8 --- /dev/null +++ b/src/assets/icons/run-analysis.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/table.svg b/src/assets/icons/table.svg new file mode 100644 index 000000000..6cf46e077 --- /dev/null +++ b/src/assets/icons/table.svg @@ -0,0 +1,3 @@ + + + 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/assets/icons/tree-dashboard.svg b/src/assets/icons/tree-dashboard.svg new file mode 100644 index 000000000..db1ba8975 --- /dev/null +++ b/src/assets/icons/tree-dashboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/elements/Button/Button.tsx b/src/components/elements/Button/Button.tsx index 441889867..68a1a6837 100644 --- a/src/components/elements/Button/Button.tsx +++ b/src/components/elements/Button/Button.tsx @@ -31,6 +31,7 @@ export interface IButtonProps extends Omit, "as"> { | "orange" | "about-us" | "transparent-toggle" + | "purple" | "white-button-map"; fullWidth?: boolean; shallow?: boolean; @@ -53,9 +54,9 @@ const Button: FC = props => { const variantClasses = useMemo(() => { const nonTextClasses = "rounded-md px-4 uppercase disabled:bg-neutral-300 disabled:text-neutral-800 transition whitespace-nowrap text-black min-h-10"; - const nonTextSpanClasses = "flex items-center text-bold-caption-200"; + const nonTextSpanClasses = "flex items-center text-bold-caption-200 !leading-[normal]"; const newText = - "flex items-center font-inter font-bold text-16 leading-snug tracking-tighter uppercase text-primary"; + "flex items-center font-inter font-bold text-16 leading-snug tracking-tighter uppercase text-primary !leading-[normal]"; switch (variant) { case "primary": @@ -84,7 +85,7 @@ const Button: FC = props => { return { container: "group bg-white border border-primary-500 uppercase leading-[normal] px-4 py-[10.5px] rounded-lg hover:bg-grey-900 disabled:border-transparent disabled:bg-grey-750", - span: "text-primary-500 text-12-bold group-disabled:text-grey-730" + span: "text-primary-500 text-12-bold group-disabled:text-grey-730 !leading-[normal]" }; case "white": return { @@ -94,7 +95,7 @@ const Button: FC = props => { case "white-page-admin": return { container: "py-2 px-3 bg-white rounded-lg text-darkCustom-100 border border-grey-750 hover:bg-grey-900", - span: "flex items-center text-bold-caption-200 text-inherit uppercase" + span: "flex items-center text-bold-caption-200 text-inherit uppercase !leading-[normal]" }; case "sky": return { @@ -105,7 +106,7 @@ const Button: FC = props => { return { container: "group py-2 px-3 bg-primary-200 rounded-lg text-darkCustom-100 border border-grey-750 hover:text-primary-500", - span: "flex items-center text-bold-caption-200 text-inherit uppercase" + span: "flex items-center text-bold-caption-200 text-inherit uppercase !leading-[normal]" }; case "text": return { container: "", span: "text-12-bold" }; @@ -128,14 +129,14 @@ const Button: FC = props => { return { container: "group bg-white border-[3px] border-grey-500 hover:border-primary-500 disabled:border-neutral-1000 px-4 py-2 rounded-lg", - span: "uppercase text-14-bold text-grey-500 group-hover:text-primary-500" + span: "uppercase text-14-bold text-grey-500 group-hover:text-primary-500 !leading-[normal]" }; case "orange": return { container: "group bg-tertiary-600 py-1.5 px-5 rounded-lg hover:opacity-90 disabled:bg-tertiary-600 disabled:opacity-70", - span: "normal-case text-10-bold text-white h-min" + span: "normal-case text-10-bold text-white h-min !leading-[normal]" }; case "semi-red": @@ -147,22 +148,28 @@ const Button: FC = props => { case "white-toggle": return { container: "group bg-white py-1 px-3 rounded", - span: "text-14-semibold text-darkCustom" + span: "text-14-semibold text-darkCustom !leading-[normal]" }; case "transparent-toggle": return { container: "group bg-transparent px-3 py-1 rounded", - span: "text-14-light text-darkCustom-100" + span: "text-14-light text-darkCustom-100 !leading-[normal]" }; case "white-button-map": return { container: "h-fit rounded-lg bg-white px-4 py-2 shadow hover:bg-neutral-200", - span: "flex items-center gap-2" + span: "flex items-center gap-2 !leading-[normal]" + }; + case "purple": + return { + container: + "h-fit rounded-lg px-4 py-1 hover:bg-purpleCustom-60 text-purpleCustom-500 bg-purpleCustom-50 border border-purpleCustom-60", + span: "flex items-center gap-2 text-purpleCustom-500 text-14 !leading-[normal]" }; case "about-us": return { container: "h-fit rounded-lg bg-green-200 px-5 py-[18px] hover:bg-green-60 text-white", - span: "flex items-center text-16-bold" + span: "flex items-center text-16-bold !leading-[normal]" }; default: return { container: "", span: "" }; diff --git a/src/components/elements/Button/__snapshots__/Button.stories.storyshot b/src/components/elements/Button/__snapshots__/Button.stories.storyshot index 654b3e4d2..6b1f1a829 100644 --- a/src/components/elements/Button/__snapshots__/Button.stories.storyshot +++ b/src/components/elements/Button/__snapshots__/Button.stories.storyshot @@ -7,7 +7,7 @@ exports[`Storyshots Components/Elements/Buttons Disabled Link 1`] = ` href="/" > Disabled Link @@ -19,7 +19,7 @@ exports[`Storyshots Components/Elements/Buttons Primary 1`] = ` className="bg-primary-500 hover:bg-primary-400 py-2 !text-white rounded-md px-4 uppercase disabled:bg-neutral-300 disabled:text-neutral-800 transition whitespace-nowrap text-black min-h-10 flex items-center justify-center gap-1.5 tracking-wide w-fit-content" > Primary @@ -32,7 +32,7 @@ exports[`Storyshots Components/Elements/Buttons Primary Disabled 1`] = ` disabled={true} > Primary @@ -52,7 +52,7 @@ exports[`Storyshots Components/Elements/Buttons Primary Icon 1`] = ` } /> Primary @@ -65,7 +65,7 @@ exports[`Storyshots Components/Elements/Buttons Primary Link 1`] = ` href="/" > Primary Link @@ -77,7 +77,7 @@ exports[`Storyshots Components/Elements/Buttons Secondary 1`] = ` className="bg-white border border-neutral-1000 hover:border-primary-500 disabled:border-neutral-1000 py-[10.5px] rounded-md px-4 uppercase disabled:bg-neutral-300 disabled:text-neutral-800 transition whitespace-nowrap text-black min-h-10 flex items-center justify-center gap-1.5 tracking-wide w-fit-content" > Secondary @@ -90,7 +90,7 @@ exports[`Storyshots Components/Elements/Buttons Secondary Disabled 1`] = ` disabled={true} > Secondary @@ -110,7 +110,7 @@ exports[`Storyshots Components/Elements/Buttons Secondary Icon 1`] = ` } /> Secondary diff --git a/src/components/elements/Cards/FundingCard/__snapshots__/FundingCard.stories.storyshot b/src/components/elements/Cards/FundingCard/__snapshots__/FundingCard.stories.storyshot index 3a835078c..c72dd6b56 100644 --- a/src/components/elements/Cards/FundingCard/__snapshots__/FundingCard.stories.storyshot +++ b/src/components/elements/Cards/FundingCard/__snapshots__/FundingCard.stories.storyshot @@ -66,7 +66,7 @@ exports[`Storyshots Components/Elements/Cards/FundingCard Default 1`] = ` target="_blank" > Read More @@ -80,7 +80,7 @@ exports[`Storyshots Components/Elements/Cards/FundingCard Default 1`] = ` onTouchStart={[Function]} > Apply Now diff --git a/src/components/elements/Cards/Generic/__snapshots__/GenericCard.stories.storyshot b/src/components/elements/Cards/Generic/__snapshots__/GenericCard.stories.storyshot index 68669f0a6..6adf8306c 100644 --- a/src/components/elements/Cards/Generic/__snapshots__/GenericCard.stories.storyshot +++ b/src/components/elements/Cards/Generic/__snapshots__/GenericCard.stories.storyshot @@ -114,7 +114,7 @@ exports[`Storyshots Components/Elements/Cards/GenericCard Default 1`] = ` className="bg-white border border-neutral-1000 hover:border-primary-500 disabled:border-neutral-1000 py-[10.5px] rounded-md px-4 uppercase disabled:bg-neutral-300 disabled:text-neutral-800 transition whitespace-nowrap text-black min-h-10 flex items-center gap-1.5 tracking-wide w-full justify-center" > View diff --git a/src/components/elements/Cards/UpcomingOpportunitiesCard/__snapshots__/UpcomingOpportunitiesCard.stories.storyshot b/src/components/elements/Cards/UpcomingOpportunitiesCard/__snapshots__/UpcomingOpportunitiesCard.stories.storyshot index 47369020c..9b549c93c 100644 --- a/src/components/elements/Cards/UpcomingOpportunitiesCard/__snapshots__/UpcomingOpportunitiesCard.stories.storyshot +++ b/src/components/elements/Cards/UpcomingOpportunitiesCard/__snapshots__/UpcomingOpportunitiesCard.stories.storyshot @@ -24,7 +24,7 @@ exports[`Storyshots Components/Elements/Cards/UpcomingOpportunitiesCard Primary onTouchStart={[Function]} > Find out more 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/Drawer/__snapshots__/Drawer.stories.storyshot b/src/components/elements/Drawer/__snapshots__/Drawer.stories.storyshot index baf314c2c..2da708b1c 100644 --- a/src/components/elements/Drawer/__snapshots__/Drawer.stories.storyshot +++ b/src/components/elements/Drawer/__snapshots__/Drawer.stories.storyshot @@ -39,7 +39,7 @@ exports[`Storyshots Components/Elements/Drawer Primary 1`] = ` onClick={[Function]} > Open diff --git a/src/components/elements/Field/TextField.tsx b/src/components/elements/Field/TextField.tsx index 24fabb75a..344b79e12 100644 --- a/src/components/elements/Field/TextField.tsx +++ b/src/components/elements/Field/TextField.tsx @@ -2,18 +2,20 @@ import { DetailedHTMLProps, FC, HTMLAttributes } from "react"; import Text from "@/components/elements/Text/Text"; import { withFrameworkShow } from "@/context/framework.provider"; +import { TextVariants } from "@/types/common"; import BaseField from "./BaseField"; export interface TextFieldProps extends DetailedHTMLProps, HTMLDivElement> { label: string; value: string; + variantLabel?: TextVariants; } -const TextField: FC = ({ label, value, className, ...rest }) => ( +const TextField: FC = ({ label, value, variantLabel = "text-16-bold", className, ...rest }) => (
- {label} + {label} {value || "N/A"}
diff --git a/src/components/elements/Field/__snapshots__/ButtonField.stories.storyshot b/src/components/elements/Field/__snapshots__/ButtonField.stories.storyshot index f15e70e7a..3fe1b5087 100644 --- a/src/components/elements/Field/__snapshots__/ButtonField.stories.storyshot +++ b/src/components/elements/Field/__snapshots__/ButtonField.stories.storyshot @@ -21,7 +21,7 @@ exports[`Storyshots Components/Elements/Fields/ButtonField Default 1`] = ` className="bg-primary-500 hover:bg-primary-400 py-2 !text-white rounded-md px-4 uppercase disabled:bg-neutral-300 disabled:text-neutral-800 transition whitespace-nowrap text-black min-h-10 flex items-center justify-center gap-1.5 tracking-wide w-fit-content" > View diff --git a/src/components/elements/Field/__snapshots__/FieldsExpander.stories.storyshot b/src/components/elements/Field/__snapshots__/FieldsExpander.stories.storyshot index fecd13e93..3cd317126 100644 --- a/src/components/elements/Field/__snapshots__/FieldsExpander.stories.storyshot +++ b/src/components/elements/Field/__snapshots__/FieldsExpander.stories.storyshot @@ -96,7 +96,7 @@ exports[`Storyshots Components/Elements/Fields/FieldsExpander Default 1`] = ` onClick={[Function]} > Download diff --git a/src/components/elements/ImageGallery/ImageGallery.tsx b/src/components/elements/ImageGallery/ImageGallery.tsx index cb4c373cb..f6277c5dd 100644 --- a/src/components/elements/ImageGallery/ImageGallery.tsx +++ b/src/components/elements/ImageGallery/ImageGallery.tsx @@ -105,7 +105,11 @@ const ImageGallery = ({ setSortLabel(sortOrder === "asc" ? t("Oldest to Newest") : t("Newest to Oldest")); }, [sortOrder]); - const tabs = ["All Images", "Geotagged", "Not Geotagged"]; + const tabs = [ + { key: "0", render: "All Images" }, + { key: "1", render: "Geotagged" }, + { key: "2", render: "Not Geotagged" } + ]; const getFilteredMenu = (entity: string) => { return [ { @@ -334,7 +338,7 @@ const ImageGallery = ({ placeholder={"Search..."} className="w-64" /> - +
@@ -175,11 +175,11 @@ Array [ className="line-clamp-1 text-light-body-300 with-inner-html" dangerouslySetInnerHTML={ Object { - "__html": "Drag and drop or browse your device", + "__html": "Drag and drop or browse your device", } } data-testid="txt" - title="Drag and drop or browse your device" + title="Drag and drop or browse your device" />
@@ -263,12 +263,7 @@ Array [ > or

-

- drag and drop -

+ drag and drop
, diff --git a/src/components/elements/Inputs/Input/Input.tsx b/src/components/elements/Inputs/Input/Input.tsx index 13b0c4e30..a1ae18309 100644 --- a/src/components/elements/Inputs/Input/Input.tsx +++ b/src/components/elements/Inputs/Input/Input.tsx @@ -14,7 +14,7 @@ export interface InputProps extends InputWrapperProps, Omit, HTMLInputElement>, "type" | "form"> { name: string; - variant?: "secondary" | "default" | "login" | "signup"; + variant?: "secondary" | "default" | "login" | "signup" | "monitored" | "treePlanted"; formHook?: UseFormReturn; clearable?: boolean; iconButtonProps?: IconButtonProps; @@ -106,6 +106,17 @@ const Input = forwardRef( true, "pl-4": inputProps.type === "number", "border-neutral-300": !error + }, + monitored: { + "px-3 py-1.5 border border-darkCustom-100 rounded-xl w-full hover:border-primary hover:shadow-blue-border text-dark-700 opacity-60 outline-none text-14-light !font-primary": + 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/RadioGroup/RadioGroup.tsx b/src/components/elements/Inputs/RadioGroup/RadioGroup.tsx index 2d81aacc7..048256717 100644 --- a/src/components/elements/Inputs/RadioGroup/RadioGroup.tsx +++ b/src/components/elements/Inputs/RadioGroup/RadioGroup.tsx @@ -11,7 +11,9 @@ export interface RadioGroupProps extends InputWrapperProps { onChange?: (value: OptionValueWithBoolean) => void; contentClassName?: string; radioClassName?: string; + contentRadioClassName?: string; variantTextRadio?: TextVariants; + classNameRadio?: string; labelRadio?: string; } @@ -21,7 +23,9 @@ const RadioGroup = ({ options, contentClassName, radioClassName, + contentRadioClassName, variantTextRadio, + classNameRadio, ...inputWrapperProps }: RadioGroupProps) => { return ( @@ -30,12 +34,12 @@ const RadioGroup = ({ {options.map(option => ( {({ checked }) => ( -
+
onChange && onChange(option.value)} - className="flex flex-row-reverse items-center justify-end gap-3 " + className={classNames("flex flex-row-reverse items-center justify-end gap-3 ", classNameRadio)} variantText={variantTextRadio} labelRadio={radioClassName} /> diff --git a/src/components/elements/Inputs/TreeSpeciesInput/NonScientificConfirmationModal.tsx b/src/components/elements/Inputs/TreeSpeciesInput/NonScientificConfirmationModal.tsx new file mode 100644 index 000000000..b25b5ad9a --- /dev/null +++ b/src/components/elements/Inputs/TreeSpeciesInput/NonScientificConfirmationModal.tsx @@ -0,0 +1,36 @@ +import { useT } from "@transifex/react"; + +import Button from "@/components/elements/Button/Button"; +import TreeSpeciesModal from "@/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesModal"; +import { ModalId } from "@/components/extensive/Modal/ModalConst"; +import { useModalContext } from "@/context/modal.provider"; + +const NonScientificConfirmationModal = ({ onConfirm }: { onConfirm: () => void }) => { + const t = useT(); + const { closeModal } = useModalContext(); + + return ( + + + + + } + /> + ); +}; + +export default NonScientificConfirmationModal; diff --git a/src/components/elements/Inputs/TreeSpeciesInput/RHFSeedingTableInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/RHFSeedingTableInput.tsx index 62b487e34..5dd44724d 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/RHFSeedingTableInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/RHFSeedingTableInput.tsx @@ -9,7 +9,7 @@ export interface RHFSeedingTableInputProps UseControllerProps { collection?: string; onChangeCapture?: () => void; - formHook?: UseFormReturn; + formHook: UseFormReturn; } /** @@ -19,12 +19,14 @@ export interface RHFSeedingTableInputProps const RHFSeedingTableInput = (props: PropsWithChildren) => { const t = useT(); const { - field: { value, onChange } + field: { onChange } } = useController(props); const { formHook, collection } = props; + const value = formHook.watch(props.name); + const clearErrors = useCallback(() => { - formHook?.clearErrors(props.name); + formHook.clearErrors(props.name); }, [formHook, props.name]); return ( @@ -32,12 +34,14 @@ const RHFSeedingTableInput = (props: PropsWithChildren - props.formHook?.setError(props.name, { message: t("One or more values are missing"), type: "required" }) + props.formHook.setError(props.name, { message: t("One or more values are missing"), type: "required" }) } /> ); diff --git a/src/components/elements/Inputs/TreeSpeciesInput/RHFTreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/RHFTreeSpeciesInput.tsx index 7e38b4951..4a2dc829b 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/RHFTreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/RHFTreeSpeciesInput.tsx @@ -7,7 +7,7 @@ import TreeSpeciesInput, { TreeSpeciesInputProps } from "./TreeSpeciesInput"; export interface RHFTreeSpeciesInputProps extends Omit, UseControllerProps { - formHook?: UseFormReturn; + formHook: UseFormReturn; } /** @@ -17,25 +17,29 @@ export interface RHFTreeSpeciesInputProps const RHFTreeSpeciesInput = (props: PropsWithChildren) => { const t = useT(); const { - field: { value, onChange } + field: { onChange } } = useController(props); const { formHook, collection } = props; + const value = formHook.watch(props.name); + const clearErrors = useCallback(() => { - formHook?.clearErrors(props.name); + formHook.clearErrors(props.name); }, [formHook, props.name]); return ( - props.formHook?.setError(props.name, { message: t("One or more values are missing"), type: "required" }) + props.formHook.setError(props.name, { message: t("One or more values are missing"), type: "required" }) } /> ); diff --git a/src/components/elements/Inputs/TreeSpeciesInput/SpeciesAlreadyExistsModal.tsx b/src/components/elements/Inputs/TreeSpeciesInput/SpeciesAlreadyExistsModal.tsx new file mode 100644 index 000000000..a9036af1e --- /dev/null +++ b/src/components/elements/Inputs/TreeSpeciesInput/SpeciesAlreadyExistsModal.tsx @@ -0,0 +1,27 @@ +import { useT } from "@transifex/react"; + +import Button from "@/components/elements/Button/Button"; +import TreeSpeciesModal from "@/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesModal"; +import { ModalId } from "@/components/extensive/Modal/ModalConst"; +import { useModalContext } from "@/context/modal.provider"; + +const SpeciesAlreadyExistsModal = ({ speciesName }: { speciesName: string }) => { + const { closeModal } = useModalContext(); + const t = useT(); + + return ( + + + + } + /> + ); +}; + +export default SpeciesAlreadyExistsModal; diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx index 6e0df7760..f3fbe7609 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx @@ -27,6 +27,8 @@ export const Default: Story = { label: "Tree Species Grown", description: "List the tree species that you expect to restore on this project, across all sites. Please enter the scientific name for each tree species.", + withPreviousCounts: false, + useTaxonomicBackbone: true, required: true, value: [ { diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx index ca047e0cd..4187c8b11 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.tsx @@ -1,27 +1,40 @@ 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 NonScientificConfirmationModal from "@/components/elements/Inputs/TreeSpeciesInput/NonScientificConfirmationModal"; +import SpeciesAlreadyExistsModal from "@/components/elements/Inputs/TreeSpeciesInput/SpeciesAlreadyExistsModal"; +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 { useValueChanged } from "@/hooks/useValueChanged"; +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"; export interface TreeSpeciesInputProps extends Omit { title: string; + label?: string; buttonCaptionSuffix: string; withNumbers?: boolean; - withTreeSearch?: boolean; + withPreviousCounts: boolean; + useTaxonomicBackbone: boolean; value: TreeSpeciesValue[]; onChange: (value: any[]) => void; clearErrors: () => void; @@ -31,22 +44,78 @@ export interface TreeSpeciesInputProps extends Omit error?: FieldErrors[]; } -export type TreeSpeciesValue = { uuid?: string; name?: string; amount?: number }; +export type TreeSpeciesValue = { + uuid?: string; + name?: string; + collection?: string; + taxon_id?: string; + amount?: number; +}; 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 = + props.withPreviousCounts && (isReport || (isEntity && ["sites", "nurseries"].includes(entityName))); + const displayPreviousCounts = props.withPreviousCounts && isReport; + + const entity = (handleBaseEntityTrees ? entityName : undefined) as EstablishmentEntityType; + const uuid = handleBaseEntityTrees ? entityUuid : undefined; + const [establishmentLoaded, { establishmentTrees, previousPlantingCounts }] = useEstablishmentTrees({ + entity, + uuid, + collection + }); + const shouldPrepopulate = value.length == 0 && Object.values(previousPlantingCounts ?? {}).length > 0; + useValueChanged(shouldPrepopulate, function () { + if (shouldPrepopulate) { + onChange( + Object.entries(previousPlantingCounts!).map(([name, previousCount]) => ({ + uuid: uuidv4(), + name, + taxon_id: previousCount.taxonId, + amount: 0, + collection: props.collection + })) + ); + } + }); + + const totalWithPrevious = useMemo( + () => + props.value.reduce( + (total, { name, amount }) => total + (amount ?? 0) + (previousPlantingCounts?.[name ?? ""]?.amount ?? 0), + 0 + ), + [previousPlantingCounts, props.value] + ); + const handleCreate = useDebounce( useCallback( (treeValue: TreeSpeciesValue) => { - onChange([...value, { ...treeValue, collection }]); + onChange([...value, { ...treeValue }]); clearErrors(); }, - [onChange, value, collection, clearErrors] + [onChange, value, clearErrors] ) ); @@ -73,9 +142,54 @@ 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: props.useTaxonomicBackbone ? taxonId : undefined, + amount: props.withNumbers ? 0 : undefined, + collection + }); + + setValueAutoComplete(""); + lastInputRef.current?.focus(); + }; + + if (value.find(({ name }) => name === valueAutoComplete) != null) { + openModal(ModalId.ERROR_MODAL, ); + setValueAutoComplete(""); + } else if (!isEmpty(searchResult) && taxonId == null) { + // In this case the user 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: props.useTaxonomicBackbone ? taxonId : undefined + }); + + setValueAutoComplete(""); + }; + + if (!isEmpty(searchResult) && taxonId == null) { + openModal(ModalId.ERROR_MODAL, ); + } else { + doUpdate(); } }; @@ -85,81 +199,259 @@ const TreeSpeciesInput = (props: TreeSpeciesInputProps) => { addValue(e); }; + if (!establishmentLoaded || shouldPrepopulate) return null; + 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 => { + if (!props.useTaxonomicBackbone) return []; + + 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("TOTAL PLANTED THIS REPORT:") : 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 ?? ""]?.amount ?? 0).toLocaleString()} + - handleDelete(props.value?.[index]?.uuid)} - /> +
+ { + setValueAutoComplete(value.name ?? ""); + setEditIndex(value.uuid ?? null); + setEditValue(value); + autoCompleteRef.current?.focus(); + }} + /> + name === value.name) == null + } + > + setDeleteIndex(value.uuid ?? null)} + /> + +
)} /> -
); diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesModal.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesModal.tsx new file mode 100644 index 000000000..11076cdf1 --- /dev/null +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesModal.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from "react"; + +import Text from "@/components/elements/Text/Text"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; + +type TreeSpeciesModalProps = { + title: string; + content: string; + buttons: ReactNode; +}; + +const TreeSpeciesModal = ({ title, content, buttons }: TreeSpeciesModalProps) => ( +
+
+ + + {title} + +
+
+
+
+ + {content} + +
+
+
{buttons}
+
+
+); + +export default TreeSpeciesModal; diff --git a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot index 1e1feb841..bd502d0a0 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot +++ b/src/components/elements/Inputs/TreeSpeciesInput/__snapshots__/TreeSpeciesInput.stories.storyshot @@ -23,76 +23,199 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput Default 1`] = ` />

- ( - 1 - ) + Scientific Name:

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

+ Test +

+
+
+
+
+
+ +
+
+
+
+ + +
+
+
`; @@ -104,7 +227,7 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput With Number 1`] @@ -116,107 +239,202 @@ exports[`Storyshots Components/Elements/Inputs/TreeSpeciesInput With Number 1`] } } data-testid="txt" - id=":r25:-description" + id=":r28:-description" />

- ( - 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" />