diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx index 8f7ba714f..44fbbb71c 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx @@ -1,25 +1,22 @@ import { When } from "react-if"; -import Commentary from "@/components/elements/Commentary/Commentary"; import CommentaryBox from "@/components/elements/CommentaryBox/CommentaryBox"; import Text from "@/components/elements/Text/Text"; import Loader from "@/components/generic/Loading/Loader"; import { useGetAuthMe } from "@/generated/apiComponents"; const CommentarySection = ({ - auditLogData, refresh, record, entity, viewCommentsList = true, - attachmentRefetch + loading = false }: { - auditLogData?: any; - refresh?: any; + refresh?: () => void; record?: any; entity?: "Project" | "SitePolygon" | "Site"; viewCommentsList?: boolean; - attachmentRefetch?: any; + loading?: boolean; }) => { const { data: authMe } = useGetAuthMe({}) as { data: { @@ -40,27 +37,11 @@ const CommentarySection = ({ entity={entity} /> -
- {auditLogData ? ( - auditLogData.length > 0 ? ( - auditLogData - .filter((item: any) => item.type === "comment") - .map((item: any) => ( - - )) - ) : ( - <> - ) - ) : ( + {loading && ( +
- )} -
+
+ )}
); diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx new file mode 100644 index 000000000..f185d5f06 --- /dev/null +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx @@ -0,0 +1,226 @@ +import { Divider } from "@mui/material"; +import { useEffect, useState } from "react"; +import { Else, If, Then, When } from "react-if"; + +import Accordion from "@/components/elements/Accordion/Accordion"; +import Button from "@/components/elements/Button/Button"; +import { StatusEnum } from "@/components/elements/Status/constants/statusMap"; +import Status from "@/components/elements/Status/Status"; +import Text from "@/components/elements/Text/Text"; +import { useSitePolygonData } from "@/context/sitePolygon.provider"; +import { + fetchPostV2TerrafundValidationPolygon, + useGetV2TerrafundValidationCriteriaData +} from "@/generated/apiComponents"; +import { SitePolygon } from "@/generated/apiSchemas"; + +import CommentarySection from "../CommentarySection/CommentarySection"; +import StatusDisplay from "../PolygonStatus/StatusDisplay"; +import AttributeInformation from "./components/AttributeInformation"; +import PolygonValidation from "./components/PolygonValidation"; +import VersionHistory from "./components/VersionHistory"; + +const statusColor: Record = { + draft: "bg-pinkCustom", + submitted: "bg-blue", + approved: "bg-green", + "needs-more-information": "bg-tertiary-600" +}; + +const validationLabels: any = { + 3: "No Overlapping Polygon", + 4: "No Self-Intersection", + 6: "Inside Size Limit", + 7: "Within Country", + 8: "No Spike", + 10: "Polygon Type", + 12: "Within Total Area Expected", + 14: "Data Completed" +}; + +export interface ICriteriaCheckItem { + id: string; + status: boolean; + label: string; + date?: string; +} + +const ESTIMATED_AREA_CRITERIA_ID = 12; + +const PolygonDrawer = ({ + polygonSelected, + isPolygonStatusOpen, + refresh +}: { + polygonSelected: string; + isPolygonStatusOpen: any; + refresh?: () => void; +}) => { + const [buttonToogle, setButtonToogle] = useState(true); + const [selectedPolygonData, setSelectedPolygonData] = useState(); + const [statusSelectedPolygon, setStatusSelectedPolygon] = useState(""); + const [openAttributes, setOpenAttributes] = useState(true); + const [checkPolygonValidation, setCheckPolygonValidation] = useState(false); + const [validationStatus, setValidationStatus] = useState(false); + const [polygonValidationData, setPolygonValidationData] = useState(); + const [criteriaValidation, setCriteriaValidation] = useState(); + + const context = useSitePolygonData(); + const sitePolygonData = context?.sitePolygonData as undefined | Array; + const openEditNewPolygon = context?.isUserDrawingEnabled; + const selectedPolygon = sitePolygonData?.find((item: SitePolygon) => item?.poly_id === polygonSelected); + const { data: criteriaData, refetch: reloadCriteriaValidation } = useGetV2TerrafundValidationCriteriaData( + { + queryParams: { + uuid: polygonSelected + } + }, + { + enabled: !!polygonSelected + } + ); + + const validatePolygon = async () => { + await fetchPostV2TerrafundValidationPolygon({ queryParams: { uuid: polygonSelected } }); + reloadCriteriaValidation(); + setCheckPolygonValidation(false); + }; + + useEffect(() => { + if (checkPolygonValidation) { + validatePolygon(); + reloadCriteriaValidation(); + } + }, [checkPolygonValidation]); + + useEffect(() => { + setButtonToogle(!isPolygonStatusOpen); + }, [isPolygonStatusOpen]); + + useEffect(() => { + if (criteriaData?.criteria_list) { + const transformedData: ICriteriaCheckItem[] = criteriaData.criteria_list.map((criteria: any) => ({ + id: criteria.criteria_id, + date: criteria.latest_created_at, + status: criteria.valid === 1, + label: validationLabels[criteria.criteria_id] + })); + setPolygonValidationData(transformedData); + setValidationStatus(true); + } else { + setValidationStatus(false); + } + }, [criteriaData]); + + useEffect(() => { + if (Array.isArray(sitePolygonData)) { + const PolygonData = sitePolygonData.find((data: SitePolygon) => data.poly_id === polygonSelected); + setSelectedPolygonData(PolygonData ?? {}); + setStatusSelectedPolygon(PolygonData?.status ?? ""); + } else { + setSelectedPolygonData({}); + setStatusSelectedPolygon(""); + } + }, [polygonSelected, sitePolygonData]); + useEffect(() => { + if (openEditNewPolygon) { + setButtonToogle(true); + setOpenAttributes(true); + } + }, [openEditNewPolygon]); + + const isValidCriteriaData = (criteriaData: any) => { + if (!criteriaData?.criteria_list?.length) { + return true; + } + return criteriaData.criteria_list.some( + (criteria: any) => criteria.criteria_id !== ESTIMATED_AREA_CRITERIA_ID && criteria.valid !== 1 + ); + }; + + useEffect(() => { + const fetchCriteriaValidation = async () => { + if (!buttonToogle) { + const criteriaData = await fetchPostV2TerrafundValidationPolygon({ + queryParams: { + uuid: polygonSelected + } + }); + setCriteriaValidation(criteriaData); + } + }; + + fetchCriteriaValidation(); + }, [buttonToogle, selectedPolygonData]); + + return ( +
+
+ {`Polygon ID: ${selectedPolygonData?.id}`} + + {selectedPolygonData?.poly_name ? selectedPolygonData?.poly_name : "Unnamed Polygon"} +
+ +
+
+ + +
+ + +
+
+ + Status: + + + + +
+ + +
+
+ +
+ + + + + + {selectedPolygonData && } + + + + + + +
+
+
+
+ ); +}; + +export default PolygonDrawer; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx new file mode 100644 index 000000000..39c09986c --- /dev/null +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx @@ -0,0 +1,252 @@ +import { useT } from "@transifex/react"; +import { useEffect, useState } from "react"; + +import Button from "@/components/elements/Button/Button"; +import Dropdown from "@/components/elements/Inputs/Dropdown/Dropdown"; +import Input from "@/components/elements/Inputs/Input/Input"; +import Text from "@/components/elements/Text/Text"; +import { useSitePolygonData } from "@/context/sitePolygon.provider"; +import { fetchPutV2TerrafundSitePolygonUuid } from "@/generated/apiComponents"; +import { SitePolygon } from "@/generated/apiSchemas"; + +const dropdownOptionsRestoration = [ + { + title: "Tree Planting", + value: "Tree Planting" + }, + { + title: "Direct Seeding", + value: "Direct Seeding" + }, + { + title: "Assisted Natural Regeneration", + value: "Assisted Natural Regeneration" + } +]; +const dropdownOptionsTarget = [ + { + title: "Agroforest", + value: "Agroforest" + }, + { + title: "Natural Forest", + value: "Natural Forest" + }, + { + title: "Mangrove", + value: "Mangrove" + }, + { + title: "Peatland", + value: "Peatland" + }, + { + title: "Riparian Area or Wetland", + value: "Riparian Area or Wetland" + }, + { + title: "Silvopasture", + value: "Silvopasture" + }, + { + title: "Woodlot or Plantation", + value: "Woodlot or Plantation" + }, + { + title: "Urban Forest", + value: "Urban Forest" + } +]; + +const dropdownOptionsTree = [ + { + title: "Single Line", + value: "Single Line" + }, + { + title: "Partial", + value: "Partial" + }, + { + title: "Full Coverage", + value: "Full Coverage" + } +]; +const AttributeInformation = ({ selectedPolygon }: { selectedPolygon: SitePolygon }) => { + const [polygonName, setPolygonName] = useState(); + const [plantStartDate, setPlantStartDate] = useState(); + const [plantEndDate, setPlantEndDate] = useState(); + const [restorationPractice, setRestorationPractice] = useState([]); + const [targetLandUseSystem, setTargetLandUseSystem] = useState([]); + const [treeDistribution, setTreeDistribution] = useState([]); + const [treesPlanted, setTreesPlanted] = useState(selectedPolygon?.num_trees); + const [calculatedArea, setCalculatedArea] = useState(selectedPolygon?.calc_area ?? 0); + const [formattedArea, setFormattedArea] = useState(); + const contextSite = useSitePolygonData(); + const reloadSiteData = contextSite?.reloadSiteData; + + const t = useT(); + + useEffect(() => { + setPolygonName(selectedPolygon?.poly_name ?? ""); + setPlantStartDate(selectedPolygon?.plantstart ?? ""); + setPlantEndDate(selectedPolygon?.plantend ?? ""); + setTreesPlanted(selectedPolygon?.num_trees ?? 0); + setCalculatedArea(selectedPolygon?.calc_area ?? 0); + const restorationPracticeArray = selectedPolygon?.practice + ? selectedPolygon?.practice.split(",").map(function (item) { + return item.trim(); + }) + : []; + setRestorationPractice(restorationPracticeArray); + + const targetLandUseSystemArray = selectedPolygon?.target_sys + ? selectedPolygon?.target_sys.split(",").map(function (item) { + return item.trim(); + }) + : []; + setTargetLandUseSystem(targetLandUseSystemArray); + + const treeDistributionArray = selectedPolygon?.distr + ? selectedPolygon?.distr.split(",").map(function (item) { + return item.trim(); + }) + : []; + setTreeDistribution(treeDistributionArray); + }, [selectedPolygon]); + + useEffect(() => { + const format = + calculatedArea?.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) ?? ""; + setFormattedArea(format ?? ""); + }, [calculatedArea]); + + const savePolygonData = async () => { + if (selectedPolygon?.uuid) { + const restorationPracticeToSend = restorationPractice.join(", "); + const landUseSystemToSend = targetLandUseSystem.join(", "); + const treeDistributionToSend = treeDistribution.join(", "); + const updatedPolygonData = { + poly_name: polygonName, + plantstart: plantStartDate, + plantend: plantEndDate, + practice: restorationPracticeToSend, + target_sys: landUseSystemToSend, + distr: treeDistributionToSend, + num_trees: treesPlanted + }; + try { + await fetchPutV2TerrafundSitePolygonUuid({ + body: updatedPolygonData, + pathParams: { uuid: selectedPolygon.uuid } + }); + reloadSiteData?.(); + } catch (error) { + console.error("Error updating polygon data:", error); + } + } + }; + + return ( +
+ setPolygonName((e.target as HTMLInputElement).value)} + /> + + + setRestorationPractice(e as string[])} + options={dropdownOptionsRestoration} + /> + setTargetLandUseSystem(e as string[])} + /> + setTreeDistribution(e as string[])} + /> + ) => setTreesPlanted(Number(e.target.value))} + /> + +
+ + +
+
+ ); +}; + +export default AttributeInformation; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/PolygonReviewButtons.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/PolygonReviewButtons.tsx new file mode 100644 index 000000000..1b422ff9c --- /dev/null +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/PolygonReviewButtons.tsx @@ -0,0 +1,109 @@ +import React from "react"; + +import Button from "@/components/elements/Button/Button"; +import Menu from "@/components/elements/Menu/Menu"; +import StepProgressbar from "@/components/elements/ProgressBar/StepProgressbar/StepProgressbar"; +import Text from "@/components/elements/Text/Text"; +import { IconNames } from "@/components/extensive/Icon/Icon"; +import { useSitePolygonData } from "@/context/sitePolygon.provider"; + +const PolygonReviewButtons = ({ + openFormModalHandlerAddPolygon, + downloadSiteGeoJsonPolygons, + openFormModalHandlerSubmitPolygon, + record, + openFormModalHandlerUploadImages +}: { + openFormModalHandlerAddPolygon: () => void; + downloadSiteGeoJsonPolygons: (uuid: string) => void; + openFormModalHandlerSubmitPolygon: () => void; + record: { uuid: string }; + openFormModalHandlerUploadImages: () => void; +}) => { + const context = useSitePolygonData(); + const { toggleUserDrawing } = context ?? {}; + + const addMenuItems = [ + { + id: "1", + render: () => Create Polygons, + onClick: () => toggleUserDrawing?.(true) + }, + { + id: "2", + render: () => Add Polygon Data, + onClick: openFormModalHandlerAddPolygon + }, + { + id: "3", + render: () => Upload Images, + onClick: openFormModalHandlerUploadImages + } + ]; + + const polygonStatusLabels = [ + { id: "1", label: "Draft" }, + { id: "2", label: "Awaiting Approval" }, + { id: "3", label: "Needs More Information" }, + { id: "4", label: "Planting In Progress" }, + { id: "5", label: "Approved" } + ]; + + return ( +
+
+
+ + Polygon Review + + + Add, remove or edit polygons that are associated to a site. Polygons may be edited in the map below; + exported, modified in QGIS or ArcGIS and imported again; or fed through the mobile application. + +
+
+ + + + + +
+
+
+ + Site Status + +
+ +
+
+
+ ); +}; + +export default PolygonReviewButtons; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonReviewAside/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonReviewAside/index.tsx new file mode 100644 index 000000000..9dbf3c45a --- /dev/null +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonReviewAside/index.tsx @@ -0,0 +1,25 @@ +import { Stack } from "@mui/material"; + +import Polygons, { IpolygonFromMap, IPolygonItem } from "../Polygons"; + +interface SitePolygonReviewAsideProps { + data: IPolygonItem[]; + polygonFromMap?: IpolygonFromMap; + setPolygonFromMap?: any; + refresh?: () => void; + mapFunctions: any; +} + +const SitePolygonReviewAside = (props: SitePolygonReviewAsideProps) => ( + + + +); + +export default SitePolygonReviewAside; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx index a5aae6936..c73258dbd 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx @@ -82,6 +82,8 @@ export interface StatusProps { name: any; refetchPolygon?: any; setSelectedPolygon?: any; + tab?: string; + checkPolygonsSite?: any; } const menuOptionsMap = { diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/Polygons.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/Polygons.tsx new file mode 100644 index 000000000..a5eba0889 --- /dev/null +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/Polygons.tsx @@ -0,0 +1,239 @@ +import React, { useEffect, useRef, useState } from "react"; + +import Button from "@/components/elements/Button/Button"; +import Drawer from "@/components/elements/Drawer/Drawer"; +import Menu from "@/components/elements/Menu/Menu"; +import { MENU_PLACEMENT_LEFT_BOTTOM } from "@/components/elements/Menu/MenuVariant"; +import { MENU_ITEM_VARIANT_DIVIDER } from "@/components/elements/MenuItem/MenuItemVariant"; +import Text from "@/components/elements/Text/Text"; +import Icon from "@/components/extensive/Icon/Icon"; +import { IconNames } from "@/components/extensive/Icon/Icon"; +import ModalConfirm from "@/components/extensive/Modal/ModalConfirm"; +import { useModalContext } from "@/context/modal.provider"; +import { useSitePolygonData } from "@/context/sitePolygon.provider"; +import { + fetchDeleteV2TerrafundPolygonUuid, + fetchGetV2TerrafundGeojsonComplete, + fetchGetV2TerrafundPolygonBboxUuid +} from "@/generated/apiComponents"; + +import PolygonDrawer from "./PolygonDrawer/PolygonDrawer"; + +export interface IPolygonItem { + id: string; + status: "draft" | "submitted" | "approved" | "needs-more-information"; + label: string; + uuid: string; +} + +export interface IpolygonFromMap { + isOpen: boolean; + uuid: string; +} +export interface IPolygonProps { + menu: IPolygonItem[]; + polygonFromMap?: IpolygonFromMap; + setPolygonFromMap?: any; + refresh?: () => void; + mapFunctions: any; +} +const statusColor = { + draft: "bg-pinkCustom", + submitted: "bg-blue", + approved: "bg-green", + "needs-more-information": "bg-tertiary-600" +}; + +export const polygonData = [ + { id: "1", name: "Site-polygon001.geojson", status: "We are processing your polygon", isUploaded: false }, + { id: "2", name: "Site-polygon002.geojson", status: "We are processing your polygon", isUploaded: false }, + { id: "3", name: "Site-polygon003.geojson", status: "We are processing your polygon", isUploaded: true }, + { id: "4", name: "Site-polygon004.geojson", status: "We are processing your polygon", isUploaded: true }, + { id: "5", name: "Site-polygon005.geojson", status: "We are processing your polygon", isUploaded: true } +]; + +const Polygons = (props: IPolygonProps) => { + const [isOpenPolygonDrawer, setIsOpenPolygonDrawer] = useState(false); + const [polygonMenu, setPolygonMenu] = useState(props.menu); + const { polygonFromMap, setPolygonFromMap, mapFunctions } = props; + const { map } = mapFunctions; + const containerRef = useRef(null); + const { openModal, closeModal } = useModalContext(); + const [selectedPolygon, setSelectedPolygon] = useState(); + const [isPolygonStatusOpen, setIsPolygonStatusOpen] = useState(false); + const context = useSitePolygonData(); + const reloadSiteData = context?.reloadSiteData; + const { toggleUserDrawing } = context ?? {}; + + useEffect(() => { + setPolygonMenu(props.menu); + }, [props.menu]); + + useEffect(() => { + if (polygonFromMap?.isOpen) { + const newSelectedPolygon = polygonMenu.find(polygon => polygon.uuid === polygonFromMap.uuid); + setSelectedPolygon(newSelectedPolygon); + setIsOpenPolygonDrawer(true); + } else { + setIsOpenPolygonDrawer(false); + setSelectedPolygon(undefined); + } + }, [polygonFromMap, polygonMenu]); + + const downloadGeoJsonPolygon = async (polygon: IPolygonItem) => { + const polygonGeojson = await fetchGetV2TerrafundGeojsonComplete({ + queryParams: { uuid: polygon.uuid } + }); + const blob = new Blob([JSON.stringify(polygonGeojson)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `polygon.geojson`; + link.click(); + URL.revokeObjectURL(url); + }; + + const flyToPolygonBounds = async (polygon: IPolygonItem) => { + const bbox = await fetchGetV2TerrafundPolygonBboxUuid({ pathParams: { uuid: polygon.uuid } }); + const bounds: any = bbox.bbox; + if (!map.current) { + return; + } + map.current.fitBounds(bounds, { + padding: 100, + linear: false + }); + }; + + const deletePolygon = async (polygon: IPolygonItem) => { + const response: any = await fetchDeleteV2TerrafundPolygonUuid({ pathParams: { uuid: polygon.uuid } }); + if (response?.uuid) { + reloadSiteData?.(); + closeModal(); + } + }; + + const openFormModalHandlerConfirm = (item: any) => { + openModal( + { + deletePolygon(item); + }} + /> + ); + }; + + const polygonMenuItems = (item: any) => [ + { + id: "1", + render: () => ( +
+ + Edit Polygon +
+ ), + onClick: () => { + setSelectedPolygon(item); + setPolygonFromMap({ isOpen: true, uuid: item.uuid }); + setIsOpenPolygonDrawer(true); + setIsPolygonStatusOpen(false); + } + }, + { + id: "2", + render: () => ( +
+ + Zoom to +
+ ), + onClick: () => { + flyToPolygonBounds(item); + } + }, + { + id: "3", + render: () => ( +
+ + Download +
+ ), + onClick: () => { + downloadGeoJsonPolygon(item); + } + }, + { + id: "4", + render: () => ( +
+ + Comment +
+ ), + onClick: () => { + setSelectedPolygon(item); + setIsOpenPolygonDrawer(true); + setIsPolygonStatusOpen(true); + } + }, + { + id: "5", + render: () =>
, + MenuItemVariant: MENU_ITEM_VARIANT_DIVIDER + }, + { + id: "6", + render: () => ( +
+ + Delete Polygon +
+ ), + onClick: () => { + openFormModalHandlerConfirm(item); + } + } + ]; + + return ( +
+ + + +
+ + Polygons + + +
+
+ {polygonMenu.map(item => ( +
+
+
+ {item.label} +
+ + + +
+ ))} +
+
+ ); +}; + +export default Polygons; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx new file mode 100644 index 000000000..54d5c06c5 --- /dev/null +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx @@ -0,0 +1,552 @@ +import { Grid, Stack } from "@mui/material"; +import { LngLatBoundsLike } from "mapbox-gl"; +import { FC, useEffect, useState } from "react"; +import { TabbedShowLayout, TabProps, useShowContext } from "react-admin"; + +import Button from "@/components/elements/Button/Button"; +import { VARIANT_FILE_INPUT_MODAL_ADD_IMAGES } from "@/components/elements/Inputs/FileInput/FileInputVariants"; +import { BBox } from "@/components/elements/Map-mapbox/GeoJSON"; +import { useMap } from "@/components/elements/Map-mapbox/hooks/useMap"; +import { MapContainer } from "@/components/elements/Map-mapbox/Map"; +import { addSourcesToLayers, mapPolygonData } from "@/components/elements/Map-mapbox/utils"; +import Menu from "@/components/elements/Menu/Menu"; +import { MENU_PLACEMENT_RIGHT_BOTTOM, MENU_PLACEMENT_RIGHT_TOP } from "@/components/elements/Menu/MenuVariant"; +import Table from "@/components/elements/Table/Table"; +import { VARIANT_TABLE_SITE_POLYGON_REVIEW } from "@/components/elements/Table/TableVariants"; +import Text from "@/components/elements/Text/Text"; +import Icon from "@/components/extensive/Icon/Icon"; +import { IconNames } from "@/components/extensive/Icon/Icon"; +import ModalAdd from "@/components/extensive/Modal/ModalAdd"; +import ModalApprove from "@/components/extensive/Modal/ModalApprove"; +import ModalConfirm from "@/components/extensive/Modal/ModalConfirm"; +import { useModalContext } from "@/context/modal.provider"; +import { SitePolygonDataProvider } from "@/context/sitePolygon.provider"; +import { + fetchDeleteV2TerrafundPolygonUuid, + fetchGetV2TerrafundGeojsonSite, + fetchGetV2TerrafundPolygonBboxUuid, + fetchPostV2TerrafundPolygon, + fetchPostV2TerrafundSitePolygonUuidSiteUuid, + fetchPostV2TerrafundUploadGeojson, + fetchPostV2TerrafundUploadKml, + fetchPostV2TerrafundUploadShapefile, + useGetV2SitesSiteBbox, + useGetV2SitesSitePolygon +} from "@/generated/apiComponents"; +import { PolygonBboxResponse, SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import { EntityName, FileType, UploadedFile } from "@/types/common"; + +import SitePolygonReviewAside from "./components/PolygonReviewAside"; +import { IpolygonFromMap } from "./components/Polygons"; +import SitePolygonStatus from "./components/SitePolygonStatus/SitePolygonStatus"; + +interface IProps extends Omit { + type: EntityName; + label: string; +} +export interface IPolygonItem { + id: string; + status: "draft" | "submitted" | "approved" | "needs-more-information"; + label: string; + uuid: string; +} + +interface TableItemMenuProps { + ellipse: boolean; + "planting-start-date": string | null; + "polygon-name": string; + "restoration-practice": string; + source?: string; + "target-land-use-system": string | null; + "tree-distribution": string | null; + uuid: string; +} + +interface DeletePolygonProps { + uuid: string; + message: string; +} + +const PolygonReviewAside: FC<{ + type: EntityName; + data: IPolygonItem[]; + polygonFromMap: IpolygonFromMap; + setPolygonFromMap: any; + refresh?: () => void; + mapFunctions: any; +}> = ({ type, data, polygonFromMap, setPolygonFromMap, refresh, mapFunctions }) => { + switch (type) { + case "sites": + return ( + + ); + default: + return null; + } +}; + +const PolygonReviewTab: FC = props => { + const { isLoading: ctxLoading, record } = useShowContext(); + const [files, setFiles] = useState([]); + const [saveFlags, setSaveFlags] = useState(false); + const [isUserDrawing, setIsUserDrawing] = useState(false); + + const [polygonFromMap, setPolygonFromMap] = useState({ isOpen: false, uuid: "" }); + + async function storePolygon(geojson: any, record: any) { + if (geojson?.length) { + const response = await fetchPostV2TerrafundPolygon({ + body: { geometry: JSON.stringify(geojson[0].geometry) } + }); + const polygonUUID = response.uuid; + if (polygonUUID) { + const site_id = record.uuid; + await fetchPostV2TerrafundSitePolygonUuidSiteUuid({ + body: {}, + pathParams: { uuid: polygonUUID, siteUuid: site_id } + }).then(() => { + refetch(); + setPolygonFromMap({ uuid: polygonUUID, isOpen: true }); + }); + } + } + } + + const mapFunctions = useMap(storePolygon); + + const { data: sitePolygonData, refetch } = useGetV2SitesSitePolygon({ + pathParams: { + site: record.uuid + } + }); + + const { data: sitePolygonBbox, refetch: refetchSiteBbox } = useGetV2SitesSiteBbox({ + pathParams: { + site: record.uuid + } + }); + + const siteBbox = sitePolygonBbox?.bbox as BBox; + const sitePolygonDataTable = (sitePolygonData ?? []).map((data: SitePolygon, index) => ({ + "polygon-name": data.poly_name ?? `Unnamed Polygon`, + "restoration-practice": data.practice, + "target-land-use-system": data.target_sys, + "tree-distribution": data.distr, + "planting-start-date": data.plantstart, + source: data.org_name, + uuid: data.poly_id, + ellipse: index === ((sitePolygonData ?? []) as SitePolygon[]).length - 1 + })); + + const transformedSiteDataForList = (sitePolygonData ?? []).map((data: SitePolygon, index: number) => ({ + id: (index + 1).toString(), + status: data.status, + label: data.poly_name ?? `Unnamed Polygon`, + uuid: data.poly_id + })); + + const polygonDataMap = mapPolygonData(sitePolygonData); + + const { openModal, closeModal } = useModalContext(); + + const flyToPolygonBounds = async (uuid: string) => { + const bbox: PolygonBboxResponse = await fetchGetV2TerrafundPolygonBboxUuid({ pathParams: { uuid } }); + const bboxArray = bbox?.bbox; + const { map } = mapFunctions; + if (bboxArray && map?.current) { + const bounds: LngLatBoundsLike = [ + [bboxArray[0], bboxArray[1]], + [bboxArray[2], bboxArray[3]] + ]; + map.current.fitBounds(bounds, { + padding: 100, + linear: false + }); + } else { + console.error("Bounding box is not in the expected format"); + } + }; + + const deletePolygon = (uuid: string) => { + fetchDeleteV2TerrafundPolygonUuid({ pathParams: { uuid } }) + .then((response: DeletePolygonProps | undefined) => { + if (response && response?.uuid) { + reloadSiteData?.(); + const { map } = mapFunctions; + if (map?.current) { + addSourcesToLayers(map.current, polygonDataMap); + } + closeModal(); + } + }) + .catch(error => { + console.error("Error deleting polygon:", error); + }); + }; + + const openFormModalHandlerConfirmDeletion = (uuid: string) => { + openModal( + { + deletePolygon(uuid); + }} + /> + ); + }; + + const downloadSiteGeoJsonPolygons = async (siteUuid: string) => { + const polygonGeojson = await fetchGetV2TerrafundGeojsonSite({ + queryParams: { uuid: siteUuid } + }); + const blob = new Blob([JSON.stringify(polygonGeojson)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `SitePolygons.geojson`; + link.click(); + URL.revokeObjectURL(url); + }; + + useEffect(() => { + if (files && files.length > 0 && saveFlags) { + uploadFiles(); + setSaveFlags(false); + } + }, [files, saveFlags]); + + const uploadFiles = async () => { + const uploadPromises = []; + + for (const file of files) { + const fileToUpload = file.rawFile as File; + const site_uuid = record.uuid; + const formData = new FormData(); + const fileType = getFileType(file); + formData.append("file", fileToUpload); + formData.append("uuid", site_uuid); + let newRequest: any = formData; + + switch (fileType) { + case "geojson": + uploadPromises.push(fetchPostV2TerrafundUploadGeojson({ body: newRequest })); + break; + case "shapefile": + uploadPromises.push(fetchPostV2TerrafundUploadShapefile({ body: newRequest })); + break; + case "kml": + uploadPromises.push(fetchPostV2TerrafundUploadKml({ body: newRequest })); + break; + default: + break; + } + } + + await Promise.all(uploadPromises); + + refetch(); + refetchSiteBbox(); + closeModal(); + }; + + const getFileType = (file: UploadedFile) => { + const fileType = file?.file_name.split(".").pop()?.toLowerCase(); + if (fileType === "geojson") return "geojson"; + if (fileType === "zip") return "shapefile"; + if (fileType === "kml") return "kml"; + return null; + }; + const openFormModalHandlerAddPolygon = () => { + openModal( + + TerraMatch upload limits:  + 50 MB per upload +
+ } + onClose={closeModal} + content="Start by adding polygons to your site." + primaryButtonText="Save" + primaryButtonProps={{ className: "px-8 py-3", variant: "primary", onClick: () => setSaveFlags(true) }} + acceptedTYpes={FileType.ShapeFiles.split(",") as FileType[]} + setFile={setFiles} + /> + ); + }; + const reloadSiteData = () => { + refetch(); + }; + const openFormModalHandlerConfirm = () => { + openModal( + {}} + /> + ); + }; + + const openFormModalHandlerUploadImages = () => { + openModal( + + Uploaded Files + + } + onClose={closeModal} + content="Start by adding images for processing." + primaryButtonText="Save" + primaryButtonProps={{ className: "px-8 py-3", variant: "primary", onClick: closeModal }} + /> + ); + }; + + const openFormModalHandlerSubmitPolygon = () => { + openModal( + { + closeModal(); + openFormModalHandlerConfirm(); + } + }} + secondaryButtonText="Cancel" + secondaryButtonProps={{ className: "px-8 py-3", variant: "white-page-admin", onClick: closeModal }} + /> + ); + }; + + const isLoading = ctxLoading; + + if (isLoading) return null; + + const addMenuItems = [ + { + id: "1", + render: () => Create Polygons, + onClick: () => setIsUserDrawing(true) + }, + { + id: "2", + render: () => Add Polygon Data, + onClick: openFormModalHandlerAddPolygon + }, + { + id: "3", + render: () => Upload Images, + onClick: openFormModalHandlerUploadImages + } + ]; + + const tableItemMenu = (props: TableItemMenuProps) => [ + { + id: "1", + render: () => ( +
setPolygonFromMap({ isOpen: true, uuid: props.uuid })}> + + Open Polygon +
+ ) + }, + { + id: "2", + render: () => ( +
flyToPolygonBounds(props.uuid)}> + + Zoom to +
+ ) + }, + { + id: "3", + render: () => ( +
openFormModalHandlerConfirmDeletion(props.uuid)}> + + Delete Polygon +
+ ) + } + ]; + + const contentForApproval = ( + + Are you sure you want to approve the polygons for  + {record.name}? + + ); + + return ( + + + + + +
+
+
+ + Polygon Review + + + Add, remove or edit polygons that are associated to a site. Polygons may be edited in the map + below; exported, modified in QGIS or ArcGIS and imported again; or fed through the mobile + application. + +
+
+ + + + + +
+
+
+ + Site Status + +
+ +
+
+
+ +
+
+ + Site Attribute Table + + + Edit attribute table for all polygons quickly through the table below. Alternatively, open a polygon + and edit the attributes in the map above. + +
+ { + const placeholder = props.getValue() as string; + return ( + + ); + } + }, + { 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: "", + accessorKey: "ellipse", + enableSorting: false, + cell: props => ( + +
+ +
+
+ ) + } + ]} + data={sitePolygonDataTable} + >
+
+
+
+ + + +
+
+
+ ); +}; + +export default PolygonReviewTab; diff --git a/src/admin/modules/sites/components/SiteShow.tsx b/src/admin/modules/sites/components/SiteShow.tsx index b6a76ce08..e621dbad9 100644 --- a/src/admin/modules/sites/components/SiteShow.tsx +++ b/src/admin/modules/sites/components/SiteShow.tsx @@ -7,6 +7,7 @@ import ChangeRequestsTab from "@/admin/components/ResourceTabs/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 PolygonReviewTab from "@/admin/components/ResourceTabs/PolygonReviewTab"; import ShowTitle from "@/admin/components/ShowTitle"; const SiteShow: FC = () => { @@ -17,6 +18,9 @@ const SiteShow: FC = () => { > + + + diff --git a/src/assets/icons/ic-government.svg b/src/assets/icons/ic-government.svg new file mode 100644 index 000000000..f38db0292 --- /dev/null +++ b/src/assets/icons/ic-government.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/ic-investor.svg b/src/assets/icons/ic-investor.svg new file mode 100644 index 000000000..01e0feffc --- /dev/null +++ b/src/assets/icons/ic-investor.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/ic-projectdeveloper.svg b/src/assets/icons/ic-projectdeveloper.svg new file mode 100644 index 000000000..bfab33728 --- /dev/null +++ b/src/assets/icons/ic-projectdeveloper.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/icons/ic-public.svg b/src/assets/icons/ic-public.svg new file mode 100644 index 000000000..20d148185 --- /dev/null +++ b/src/assets/icons/ic-public.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/ic_approved.svg b/src/assets/icons/ic_approved.svg new file mode 100644 index 000000000..b5b7d251b --- /dev/null +++ b/src/assets/icons/ic_approved.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/ic_draft.svg b/src/assets/icons/ic_draft.svg new file mode 100644 index 000000000..46d8c72a8 --- /dev/null +++ b/src/assets/icons/ic_draft.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/ic_needs-more-information.svg b/src/assets/icons/ic_needs-more-information.svg new file mode 100644 index 000000000..f604957ed --- /dev/null +++ b/src/assets/icons/ic_needs-more-information.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/ic_submitted.svg b/src/assets/icons/ic_submitted.svg new file mode 100644 index 000000000..5504b7573 --- /dev/null +++ b/src/assets/icons/ic_submitted.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/elements/CommentaryBox/CommentaryBox.tsx b/src/components/elements/CommentaryBox/CommentaryBox.tsx index 8385e66fe..8a05ee505 100644 --- a/src/components/elements/CommentaryBox/CommentaryBox.tsx +++ b/src/components/elements/CommentaryBox/CommentaryBox.tsx @@ -1,4 +1,5 @@ import { useT } from "@transifex/react"; +import { useState } from "react"; import { When } from "react-if"; import Button from "@/components/elements/Button/Button"; @@ -6,27 +7,88 @@ import TextArea from "@/components/elements/Inputs/textArea/TextArea"; import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import Notification from "../Notification/Notification"; + export interface CommentaryBoxProps { name: string; lastName: string; buttonSendOnBox?: boolean; mutate?: any; - refresh?: any; + refresh?: () => void; record?: any; entity?: string; } const CommentaryBox = (props: CommentaryBoxProps) => { - const { name, lastName, buttonSendOnBox } = props; + const { name, lastName, buttonSendOnBox, record, entity } = props; + const [files, setFiles] = useState([]); + const [comment, setComment] = useState(""); + const [error, setError] = useState(""); + const [charCount, setCharCount] = useState(0); + const [showNotification] = useState(false); + const [loading, setLoading] = useState(false); + const [warning, setWarning] = useState(""); const t = useT(); + const validFileTypes = [ + "application/pdf", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "image/jpeg", + "image/png", + "image/tiff" + ]; + const maxFileSize = 10 * 1024 * 1024; + const maxFiles = 5; + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files) { + const file = e.target.files[0]; + if (files.length >= maxFiles) { + setError("You can upload a maximum of 5 files."); + return; + } + if (!validFileTypes.includes(file?.type)) { + setError("Invalid file type. Only PDF, XLS, DOC, XLSX, DOCX, JPG, PNG, and TIFF are allowed."); + return; + } + if (file.size > maxFileSize) { + setError("File size must be less than 10MB."); + return; + } + setFiles(prevFiles => [...prevFiles, file]); + setError(""); + } + }; + const submitComment = () => { + const body = new FormData(); + body.append("entity_uuid", record?.uuid); + body.append("status", record?.status); + body.append("entity", entity as string); + body.append("comment", comment); + body.append("type", "comment"); + files.forEach((element: File, index: number) => { + body.append(`file[${index}]`, element); + }); + setLoading(true); + }; + const handleCommentChange = (e: any) => { + setComment(e.target.value); + setCharCount(e.target.value.length); + if (charCount >= 255) { + setWarning("Your comment exceeds 255 characters."); + } else { + setWarning(""); + } + }; return (
- {name[0]} - {lastName[0]} + {name?.[0]} + {lastName?.[0]}