diff --git a/package.json b/package.json index 719068ac87d..1529252086c 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@mui/styled-engine-sc": "^5.14.12", "@mui/styles": "^5.15.15", "@reduxjs/toolkit": "^1.9.3", - "@terraware/web-components": "^3.4.3", + "@terraware/web-components": "^3.4.4", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.3.0", "@testing-library/user-event": "^14.4.3", diff --git a/src/api/types/generated-schema.ts b/src/api/types/generated-schema.ts index f21b2770210..a2a5bd74b65 100644 --- a/src/api/types/generated-schema.ts +++ b/src/api/types/generated-schema.ts @@ -3720,6 +3720,10 @@ export interface components { */ mortalityRate?: number; notes?: string; + /** @description IDs of any newer monitoring plots that overlap with this one. */ + overlappedByPlotIds: number[]; + /** @description IDs of any older monitoring plots this one overlaps with. */ + overlapsWithPlotIds: number[]; photos: components["schemas"]["ObservationMonitoringPlotPhotoPayload"][]; /** * Format: int32 diff --git a/src/redux/features/observations/utils.ts b/src/redux/features/observations/utils.ts index ba598b2f557..7a5f011f8be 100644 --- a/src/redux/features/observations/utils.ts +++ b/src/redux/features/observations/utils.ts @@ -270,3 +270,11 @@ const mergeSpecies = ( }) ); }; + +export const has25mPlots = ( + subzones: ObservationPlantingSubzoneResults[] | ObservationPlantingSubzoneResultsPayload[] +) => { + return subzones + ?.flatMap((subzone: { monitoringPlots: any[] }) => subzone.monitoringPlots.flatMap((plot) => plot.sizeMeters)) + .some((size: number) => size.toString() === '25'); +}; diff --git a/src/scenes/ObservationsRouter/details/ObservationDetailsRenderer.tsx b/src/scenes/ObservationsRouter/details/ObservationDetailsRenderer.tsx index 3aa65c94c97..14174d4a805 100644 --- a/src/scenes/ObservationsRouter/details/ObservationDetailsRenderer.tsx +++ b/src/scenes/ObservationsRouter/details/ObservationDetailsRenderer.tsx @@ -4,6 +4,7 @@ import Link from 'src/components/common/Link'; import CellRenderer, { TableRowType } from 'src/components/common/table/TableCellRenderer'; import { RendererProps } from 'src/components/common/table/types'; import { APP_PATHS } from 'src/constants'; +import { has25mPlots } from 'src/redux/features/observations/utils'; import { MonitoringPlotStatus, getPlotStatus } from 'src/types/Observations'; const NO_DATA_FIELDS = ['totalPlants', 'totalSpecies', 'mortalityRate']; @@ -21,6 +22,7 @@ const ObservationDetailsRenderer = return ( {name as React.ReactNode} + {has25mPlots(row.plantingSubzones) ? '*' : ''} ); }; diff --git a/src/scenes/ObservationsRouter/details/index.tsx b/src/scenes/ObservationsRouter/details/index.tsx index 89c41d26615..381438af6b3 100644 --- a/src/scenes/ObservationsRouter/details/index.tsx +++ b/src/scenes/ObservationsRouter/details/index.tsx @@ -14,6 +14,7 @@ import { selectDetailsZoneNames, } from 'src/redux/features/observations/observationDetailsSelectors'; import { selectObservation } from 'src/redux/features/observations/observationsSelectors'; +import { has25mPlots } from 'src/redux/features/observations/utils'; import { selectPlantingSite } from 'src/redux/features/tracking/trackingSelectors'; import { useAppSelector } from 'src/redux/store'; import AggregatedPlantsStats from 'src/scenes/ObservationsRouter/common/AggregatedPlantsStats'; @@ -134,6 +135,13 @@ export default function ObservationDetails(props: ObservationDetailsProps): JSX. } }, [zoneNames, searchProps.filtersProps]); + const has25mPlotsZones = () => { + const allSubzones = details?.plantingZones.flatMap((zone) => zone.plantingSubzones.flatMap((subzone) => subzone)); + if (allSubzones) { + return has25mPlots(allSubzones); + } + }; + return ( diff --git a/src/scenes/ObservationsRouter/plot/index.tsx b/src/scenes/ObservationsRouter/plot/index.tsx index 5452e181198..c499d165846 100644 --- a/src/scenes/ObservationsRouter/plot/index.tsx +++ b/src/scenes/ObservationsRouter/plot/index.tsx @@ -5,8 +5,10 @@ import { Box, Grid, Typography, useTheme } from '@mui/material'; import { Textfield } from '@terraware/web-components'; import Card from 'src/components/common/Card'; +import Link from 'src/components/common/Link'; import { APP_PATHS } from 'src/constants'; import { useLocalization } from 'src/providers'; +import { searchObservationDetails } from 'src/redux/features/observations/observationDetailsSelectors'; import { selectObservationMonitoringPlot } from 'src/redux/features/observations/observationMonitoringPlotSelectors'; import { selectPlantingSite } from 'src/redux/features/tracking/trackingSelectors'; import { useAppSelector } from 'src/redux/store'; @@ -48,6 +50,19 @@ export default function ObservationMonitoringPlot(): JSX.Element { const plantingSite = useAppSelector((state) => selectPlantingSite(state, Number(plantingSiteId))); + const details = useAppSelector((state) => + searchObservationDetails( + state, + { + plantingSiteId: Number(plantingSiteId), + observationId: Number(observationId), + search: '', + zoneNames: [], + }, + defaultTimeZone.get().id + ) + ); + const gridSize = isMobile ? 12 : 4; const data: Record[] = useMemo(() => { @@ -105,6 +120,38 @@ export default function ObservationMonitoringPlot(): JSX.Element { } }, [navigate, monitoringPlot, observationId, plantingZoneId, plantingSiteId]); + const getReplacedPlotsNames = (): JSX.Element[] => { + const names = + monitoringPlot?.overlapsWithPlotIds.map((plotId, index) => { + const allPlots = details?.plantingZones?.flatMap((pz) => + pz.plantingSubzones.flatMap((subzone) => subzone.monitoringPlots.flatMap((plot) => plot)) + ); + const found = allPlots?.find((plot) => plot.monitoringPlotId === plotId); + if (found) { + return ( + + {found.monitoringPlotName} + + ); + } + return undefined; + }) || []; + + const elements = (names ?? []).filter((element): element is JSX.Element => element !== undefined); + return elements; + }; + return ( ))} + {monitoringPlot?.overlapsWithPlotIds && monitoringPlot.overlapsWithPlotIds.length > 0 && ( + + + {strings.PLOT_REPLACES_THIS_25M_PLOT} + + {getReplacedPlotsNames()} + + )} {title(strings.NUMBER_OF_LIVE_PLANTS_PER_SPECIES)} diff --git a/src/scenes/ObservationsRouter/schedule/ObservationSubzoneSelector.tsx b/src/scenes/ObservationsRouter/schedule/ObservationSubzoneSelector.tsx index 8d544d44dcb..539c0fa6f7b 100644 --- a/src/scenes/ObservationsRouter/schedule/ObservationSubzoneSelector.tsx +++ b/src/scenes/ObservationsRouter/schedule/ObservationSubzoneSelector.tsx @@ -1,10 +1,14 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; -import { Box, Grid, useTheme } from '@mui/material'; +import { Box, Grid, Typography, useTheme } from '@mui/material'; import { Checkbox } from '@terraware/web-components'; +import { selectObservations, selectObservationsResults } from 'src/redux/features/observations/observationsSelectors'; +import { useAppSelector } from 'src/redux/store'; import strings from 'src/strings'; +import { ObservationResultsPayload } from 'src/types/Observations'; import { PlantingSiteWithReportedPlants, PlantingZone } from 'src/types/Tracking'; +import { isAfter } from 'src/utils/dateUtils'; interface ObservationSubzoneSelectorProps { onChangeSelectedSubzones: (requestedSubzoneIds: number[]) => void; @@ -15,6 +19,58 @@ const ObservationSubzoneSelector = ({ onChangeSelectedSubzones, plantingSite }: const theme = useTheme(); const [selectedSubzones, setSelectedSubzones] = useState(new Map()); + const observationsData = useAppSelector(selectObservations); + + const allObservationsResults = useAppSelector(selectObservationsResults); + const plantingSiteObservations = allObservationsResults?.filter( + (observation) => observation.plantingSiteId === plantingSite.id + ); + const zoneObservations: ObservationResultsPayload[][] = []; + plantingSiteObservations?.forEach((observation) => { + observation.plantingZones.forEach((pz) => { + zoneObservations[pz.plantingZoneId] + ? zoneObservations[pz.plantingZoneId].push(observation) + : (zoneObservations[pz.plantingZoneId] = [observation]); + }); + }); + + const lastZoneObservation = (observationsList: ObservationResultsPayload[]) => { + const observationsToProcess = observationsList; + if (observationsToProcess && observationsToProcess.length > 0) { + let lastObs = observationsToProcess[0]; + observationsToProcess.forEach((obs) => { + if (isAfter(obs.startDate, lastObs.startDate)) { + lastObs = obs; + } + }); + return lastObs; + } + }; + + const lastSubZoneObservation = useCallback( + ( + zoneId: number, + subzoneId: number, + observationsList?: ObservationResultsPayload[] + ): ObservationResultsPayload | undefined => { + const observationsToProcess = observationsList ? observationsList : zoneObservations?.[zoneId]; + const lastZoneObs = lastZoneObservation(observationsToProcess); + const foundObs = observationsData?.find((ob) => ob.id === lastZoneObs?.observationId); + + if (foundObs) { + if (!foundObs.requestedSubzoneIds || foundObs.requestedSubzoneIds.includes(subzoneId)) { + return lastZoneObs; + } else { + const newZoneObservations = observationsToProcess.filter( + (ob) => ob.observationId !== lastZoneObs?.observationId + ); + + return lastSubZoneObservation(zoneId, subzoneId, newZoneObservations); + } + } + }, + [zoneObservations, observationsData] + ); useEffect(() => { // Initialize all subzone selections with subzoneId -> false unless they have totalPlants > 0 @@ -63,32 +119,66 @@ const ObservationSubzoneSelector = ({ onChangeSelectedSubzones, plantingSite }: return ( - {plantingSite.plantingZones?.map((zone, index) => ( - - onChangeZoneCheckbox(zone, value)} - value={isZoneFullySelected(zone)} - /> - - - {zone.plantingSubzones.map((subzone, _index) => ( - - ⚠️ {subzone.name}} - name='Limit Observation to Subzone' - onChange={(value) => onChangeSubzoneCheckbox(subzone.id, value)} - value={selectedSubzones.get(subzone.id)} - /> - - ))} - - - ))} + {plantingSite.plantingZones?.map((zone, index) => { + const lastZoneOb = lastZoneObservation(zoneObservations?.[zone.id]); + return ( + + onChangeZoneCheckbox(zone, value)} + value={isZoneFullySelected(zone)} + /> + + {lastZoneOb + ? strings.formatString(strings.LAST_OBSERVATION, lastZoneOb.startDate || '') + : strings.NO_OBSERVATIONS_HAVE_BEEN_SCHEDULED} + + + + {zone.plantingSubzones.map((subzone, _index) => { + const lastSubzoneOb = lastSubZoneObservation(zone.id, subzone.id); + return ( + + ⚠️ {subzone.name}} + name='Limit Observation to Subzone' + onChange={(value) => onChangeSubzoneCheckbox(subzone.id, value)} + value={selectedSubzones.get(subzone.id)} + /> + {lastZoneOb && ( + + {lastSubzoneOb + ? strings.formatString(strings.LAST_OBSERVATION, lastSubzoneOb.startDate || '') + : strings.NO_OBSERVATIONS_HAVE_BEEN_SCHEDULED} + + )} + + ); + })} + + + ); + })} ⚠️: {strings.NO_PLANTS} diff --git a/src/scenes/ObservationsRouter/zone/ObservationPlantingZoneRenderer.tsx b/src/scenes/ObservationsRouter/zone/ObservationPlantingZoneRenderer.tsx index 00f000cf1ac..068e9745556 100644 --- a/src/scenes/ObservationsRouter/zone/ObservationPlantingZoneRenderer.tsx +++ b/src/scenes/ObservationsRouter/zone/ObservationPlantingZoneRenderer.tsx @@ -29,6 +29,7 @@ const ObservationPlantingZoneRenderer = return ( {name as React.ReactNode} + {row.sizeMeters.toString() === '25' ? '*' : ''} ); }; diff --git a/src/scenes/ObservationsRouter/zone/index.tsx b/src/scenes/ObservationsRouter/zone/index.tsx index 4732991b5a5..0e6eb85e228 100644 --- a/src/scenes/ObservationsRouter/zone/index.tsx +++ b/src/scenes/ObservationsRouter/zone/index.tsx @@ -11,6 +11,7 @@ import Table from 'src/components/common/table'; import { APP_PATHS } from 'src/constants'; import { useLocalization, useOrganization } from 'src/providers'; import { searchObservationPlantingZone } from 'src/redux/features/observations/observationPlantingZoneSelectors'; +import { has25mPlots } from 'src/redux/features/observations/utils'; import { selectPlantingSite } from 'src/redux/features/tracking/trackingSelectors'; import { useAppSelector } from 'src/redux/store'; import AggregatedPlantsStats from 'src/scenes/ObservationsRouter/common/AggregatedPlantsStats'; @@ -184,6 +185,11 @@ export default function ObservationPlantingZone(): JSX.Element { plantingZoneId, setReplaceObservationPlot )} + tableComments={ + plantingZone?.plantingSubzones && has25mPlots(plantingZone.plantingSubzones) + ? strings.PLOTS_SIZE_NOTE + : undefined + } /> diff --git a/src/strings/csv/en.csv b/src/strings/csv/en.csv index b937dc8bd93..44ec5b96141 100644 --- a/src/strings/csv/en.csv +++ b/src/strings/csv/en.csv @@ -752,6 +752,7 @@ LAST_EDITED_BY,Last Edited By, LAST_MODIFIED_BY,Last modified by, LAST_MODIFIED_ON,Last modified on, LAST_NAME,Last Name, +LAST_OBSERVATION,Last Observation: {0}, LAST_OBSERVED,Last Observed, LAST_UPDATED,Last Updated, LATITUDE_LONGITUDE,"Latitude, Longitude", @@ -930,6 +931,7 @@ NO_LOCATION_FOUND,Location not found, NO_MAP_DATA,No map data to show for this planting site., NO_NOTIFICATIONS,No notifications to show., NO_NURSERIES_NON_ADMIN_MSG,"Before you can add and manage your nursery inventory, you’ll need to have a nursery and species within Terraware. Only admins can add nurseries and species, so please reach out to yours for assistance.", +NO_OBSERVATIONS_HAVE_BEEN_SCHEDULED,No observations have been scheduled, NO_PARTICIPANT,No Participant, NO_PLANTING_SITES_DESCRIPTION,Start by adding a planting site. Adding a planting site will allow you to track where seedlings go after they leave the nursery., NO_PLANTING_SITES_TITLE,"Just a moment, let’s add a planting site first.", @@ -1195,8 +1197,10 @@ PLANTS,Plants, PLANTS_CARD_DESCRIPTION,They’ve taken root! View all your plants in one place., PLANTS_PER_HECTARE,plants/ha, PLEASE_TRY_AGAIN,Please try again., +PLOT_REPLACES_THIS_25M_PLOT,Plot replaces this 25x25m plot: PLOT_TYPE,Plot Type, PLOTS_PERMANENT,Permanent Plots, +PLOTS_SIZE_NOTE,*Data collected with 25x25m plots, PLOTS_TEMPORARY,Temporary Plots, POSITION,Position, POUNDS,Pounds, diff --git a/yarn.lock b/yarn.lock index 4c11523b9a8..50fb850afb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3998,10 +3998,10 @@ dependencies: defer-to-connect "^2.0.1" -"@terraware/web-components@^3.4.3": - version "3.4.3" - resolved "https://registry.yarnpkg.com/@terraware/web-components/-/web-components-3.4.3.tgz#1027b0bddbd502eafe209191a3fdfe86030ed886" - integrity sha512-UhXXLt9p2piN05y7ritNG5HADryPGg7dJ3oUwKyPcZEEJYKY5Ze+w0TRu3gP8WhlVUlzYzDc0Xrg7eVct89Qxg== +"@terraware/web-components@^3.4.4": + version "3.4.4" + resolved "https://registry.yarnpkg.com/@terraware/web-components/-/web-components-3.4.4.tgz#d58ee7ad87f9c766618ca78a9b5b32e40d0c8602" + integrity sha512-9PvOr6Phl9NiGZuhwFb/padRRuphJtJaZZtHg2P7OR8AJjC0qlGAx2sUnEsRP/KHPdqkgmy7FtSqOXtb2uQvbg== dependencies: "@date-io/luxon" "^3.0.0" "@dnd-kit/core" "^6.0.7"