From 9afc4fea3a25da3e7a1d5afb3251bf50ad180196 Mon Sep 17 00:00:00 2001 From: GrahamS-Quartech <112989452+GrahamS-Quartech@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:43:02 -0700 Subject: [PATCH 1/3] PIMS-1345/1435: Add Property Form (#2226) Co-authored-by: TaylorFries <78506153+TaylorFries@users.noreply.github.com> Co-authored-by: Dylan Barkowsky <37922247+dbarkowsky@users.noreply.github.com> --- react-app/package.json | 2 + .../src/components/dialog/DeleteDialog.tsx | 10 +- .../src/components/form/BoxedIconRadio.tsx | 49 +++ .../src/components/form/DateFormField.tsx | 29 ++ .../src/components/form/SelectFormField.tsx | 2 +- .../src/components/form/TextFormField.tsx | 50 ++- .../src/components/property/AddProperty.tsx | 406 ++++++++++++++++++ react-app/src/components/users/UserDetail.tsx | 12 +- react-app/src/pages/AccessRequest.tsx | 43 +- react-app/src/pages/DevZone.tsx | 9 +- react-app/src/themes/appTheme.ts | 9 +- 11 files changed, 563 insertions(+), 58 deletions(-) create mode 100644 react-app/src/components/form/BoxedIconRadio.tsx create mode 100644 react-app/src/components/form/DateFormField.tsx create mode 100644 react-app/src/components/property/AddProperty.tsx diff --git a/react-app/package.json b/react-app/package.json index 8ee8f6b8f..9f79accde 100644 --- a/react-app/package.json +++ b/react-app/package.json @@ -23,6 +23,8 @@ "@mui/icons-material": "5.15.6", "@mui/material": "5.15.6", "@mui/x-data-grid": "6.19.2", + "@mui/x-date-pickers": "6.19.5", + "dayjs": "1.11.10", "node-xlsx": "0.23.0", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/react-app/src/components/dialog/DeleteDialog.tsx b/react-app/src/components/dialog/DeleteDialog.tsx index e20b8ad52..31d7e0a7b 100644 --- a/react-app/src/components/dialog/DeleteDialog.tsx +++ b/react-app/src/components/dialog/DeleteDialog.tsx @@ -18,8 +18,14 @@ const DeleteDialog = (props: IDeleteDialog) => { { + await onDelete(); + setTextFieldValue(''); + }} + onCancel={async () => { + await onClose(); + setTextFieldValue(''); + }} confirmButtonText={deleteText ?? 'Delete'} confirmButtonProps={{ color: 'warning', disabled: textFieldValue.toLowerCase() != 'delete' }} > diff --git a/react-app/src/components/form/BoxedIconRadio.tsx b/react-app/src/components/form/BoxedIconRadio.tsx new file mode 100644 index 000000000..a78731482 --- /dev/null +++ b/react-app/src/components/form/BoxedIconRadio.tsx @@ -0,0 +1,49 @@ +import { Box, Radio, Icon, Typography, useTheme, SxProps } from '@mui/material'; +import React from 'react'; + +type BoxedIconRadioProps = { + onClick: React.MouseEventHandler; + checked: boolean; + value: string; + icon: string; + mainText: string; + subText?: string; + iconScale?: number; + boxSx?: SxProps; +}; + +const BoxedIconRadio = (props: BoxedIconRadioProps) => { + const { onClick, checked, value, icon, mainText, subText, iconScale, boxSx } = props; + const theme = useTheme(); + return ( + + + + + + + {mainText} + {subText} + + + ); +}; + +export default BoxedIconRadio; diff --git a/react-app/src/components/form/DateFormField.tsx b/react-app/src/components/form/DateFormField.tsx new file mode 100644 index 000000000..485ad67d7 --- /dev/null +++ b/react-app/src/components/form/DateFormField.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { DateField, LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +type DateFieldFormProps = { + name: string; + label: string; +}; + +const DateFormField = (props: DateFieldFormProps) => { + const { control } = useFormContext(); + const { name, label } = props; + return ( + { + return ( + + + + ); + }} + /> + ); +}; + +export default DateFormField; diff --git a/react-app/src/components/form/SelectFormField.tsx b/react-app/src/components/form/SelectFormField.tsx index 147848e40..57976b303 100644 --- a/react-app/src/components/form/SelectFormField.tsx +++ b/react-app/src/components/form/SelectFormField.tsx @@ -10,7 +10,7 @@ export interface ISelectMenuItem { interface ISelectInputProps { name: string; - label: string; + label: string | JSX.Element; required: boolean; options: ISelectMenuItem[]; } diff --git a/react-app/src/components/form/TextFormField.tsx b/react-app/src/components/form/TextFormField.tsx index 8b245a632..ae08e820f 100644 --- a/react-app/src/components/form/TextFormField.tsx +++ b/react-app/src/components/form/TextFormField.tsx @@ -1,16 +1,48 @@ import React from 'react'; import { TextField, TextFieldProps } from '@mui/material'; -import { useFormContext } from 'react-hook-form'; +import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form'; -const TextFormField = (props: TextFieldProps) => { - const { register, formState } = useFormContext(); - const { name } = props; +type TextFormFieldProps = { + defaultVal?: string; + name: string; + label: string; + numeric?: boolean; + rules?: Omit< + RegisterOptions, + 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs' + >; +} & TextFieldProps; + +const TextFormField = (props: TextFormFieldProps) => { + const { control } = useFormContext(); + const { name, label, rules, numeric, defaultVal, ...restProps } = props; return ( - { + return ( + { + if (numeric === undefined) { + onChange(event); + return; + } + if (event.target.value === '' || /^[0-9]*\.?[0-9]*$/.test(event.target.value)) { + onChange(event); + } + }} + value={value ?? defaultVal} + fullWidth + label={label} + type="text" + error={!!error && !!error.message} + helperText={error?.message} + /> + ); + }} /> ); }; diff --git a/react-app/src/components/property/AddProperty.tsx b/react-app/src/components/property/AddProperty.tsx new file mode 100644 index 000000000..b09544938 --- /dev/null +++ b/react-app/src/components/property/AddProperty.tsx @@ -0,0 +1,406 @@ +import { Box, Button, Grid, InputAdornment, RadioGroup, Tooltip, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import AutocompleteFormField from '../form/AutocompleteFormField'; +import SelectFormField from '../form/SelectFormField'; +import TextFormField from '../form/TextFormField'; +import { Help } from '@mui/icons-material'; +import DateFormField from '../form/DateFormField'; +import dayjs from 'dayjs'; +import BuildingIcon from '@/assets/icons/building.svg'; +import ParcelIcon from '@/assets/icons/parcel.svg'; +import BoxedIconRadio from '../form/BoxedIconRadio'; + +type PropetyType = 'Building' | 'Parcel'; + +interface IAssessedValue { + years: number[]; +} + +const AssessedValue = (props: IAssessedValue) => { + const { years } = props; + + return ( + <> + + Assessed Value + + + {years.map((yr, idx) => { + return ( + + + $, + }} + sx={{ minWidth: 'calc(33.3% - 1rem)' }} + name={`Evaluations.${idx}.Value`} + numeric + label={'Value'} + /> + + ); + })} + + + ); +}; + +const BuildingInformation = () => { + return ( + <> + {`Building information`} + + + + + + + + + + + + + + + + Sq. M }} + /> + + + + val <= formVals.TotalArea || + `Cannot be larger than Total area: ${val} <= ${formVals?.TotalArea}`, + }} + label={'Net usable area'} + fullWidth + numeric + InputProps={{ endAdornment: Sq. M }} + /> + + + % }} + /> + + + + + + + ); +}; + +const ParcelInformation = () => { + return ( + <> + + Parcel information + + + + + + + Hectacres, + }} + /> + + + + Sensitive information{' '} + + + + + } + name={'IsSensitive'} + options={[ + { label: 'Yes', value: true }, + { label: 'No (Non-confidential)', value: false }, + ]} + required={false} + /> + + + + + + + + + + ); +}; + +interface INetBookValue { + years: number[]; +} + +const NetBookValue = (props: INetBookValue) => { + return ( + + {props.years.map((yr, idx) => { + return ( + + + + + + + + + $, + }} + /> + + + ); + })} + + ); +}; + +const AddProperty = () => { + const years = [new Date().getFullYear(), new Date().getFullYear() - 1]; + const [propertyType, setPropertyType] = useState('Building'); + const [showErrorText, setShowErrorTest] = useState(false); + + const formMethods = useForm({ + defaultValues: { + NotOwned: true, + Address1: '', + PIN: '', + PID: '', + PostalCode: '', + AdministrativeArea: '', + Latitude: '', + Longitude: '', + LandArea: '', + IsSensitive: '', + ClassificationId: '', + Description: '', + Name: '', + BuildingPredominateUse: '', + BuildingConstructionType: '', + TotalArea: '', + RentableArea: '', + BuildingTenancy: '', + BuildingTenancyUpdatedOn: dayjs(), + Fiscals: years.map((yr) => ({ + Year: yr, + Value: '', + })), + Evaluations: years.map((yr) => ({ + Year: yr, + EffectiveDate: dayjs(), + Value: '', + })), + }, + }); + + return ( + + + + Add new property + + Property type + + setPropertyType('Parcel')} + checked={propertyType === 'Parcel'} + value={'Parcel'} + icon={ParcelIcon} + mainText={'Parcel'} + subText={`PID (Parcel Identifier) is required to proceed.`} + /> + setPropertyType('Building')} + checked={propertyType === 'Building'} + value={'Building'} + icon={BuildingIcon} + mainText={'Building'} + subText={`Street address with postal code is required to proceed.`} + boxSx={{ mt: '1rem' }} + /> + + + Address + + + + + + + String(val).length <= 9 || 'PIN is too long.' }} + /> + + + + + + + + + val?.length == 6 || 'Should be exactly 6 characters.' }} + /> + + + Math.abs(val) <= 90 || 'Outside valid range.' }} + /> + + + Math.abs(val) <= 180 || 'Outside valid range.' }} + /> + + + + {propertyType === 'Parcel' && ( + <> + + Does your agency own the parcel? + + + + )} + {propertyType === 'Parcel' ? : } + + Net book value + + + + + {showErrorText && ( + + Please correct issues in the form input. + + )} + + + ); +}; + +export default AddProperty; diff --git a/react-app/src/components/users/UserDetail.tsx b/react-app/src/components/users/UserDetail.tsx index 85aaf220d..8332a1daf 100644 --- a/react-app/src/components/users/UserDetail.tsx +++ b/react-app/src/components/users/UserDetail.tsx @@ -6,7 +6,6 @@ import DeleteDialog from '../dialog/DeleteDialog'; import { deleteAccountConfirmText } from '@/constants/strings'; import ConfirmDialog from '../dialog/ConfirmDialog'; import { FormProvider, useForm } from 'react-hook-form'; -import TextInput from '@/components/form/TextFormField'; import AutocompleteFormField from '@/components/form/AutocompleteFormField'; import usePimsApi from '@/hooks/usePimsApi'; import useDataLoader from '@/hooks/useDataLoader'; @@ -14,6 +13,7 @@ import { User } from '@/hooks/api/useUsersApi'; import { AuthContext } from '@/contexts/authContext'; import { Agency } from '@/hooks/api/useAgencyApi'; import { Role } from '@/hooks/api/useRolesApi'; +import TextFormField from '../form/TextFormField'; import DetailViewNavigation from '../display/DetailViewNavigation'; import { useGroupedAgenciesApi } from '@/hooks/api/useGroupedAgenciesApi'; import { useParams } from 'react-router-dom'; @@ -169,16 +169,16 @@ const UserDetail = ({ onClose }: IUserDetail) => { - + - + - + - + { /> - + diff --git a/react-app/src/pages/AccessRequest.tsx b/react-app/src/pages/AccessRequest.tsx index 6a8657bd7..3053c82d3 100644 --- a/react-app/src/pages/AccessRequest.tsx +++ b/react-app/src/pages/AccessRequest.tsx @@ -1,7 +1,6 @@ import React, { useContext } from 'react'; import pendingImage from '@/assets/images/pending.svg'; import { Box, Button, Grid, Paper, Typography } from '@mui/material'; -import TextInput from '@/components/form/TextFormField'; import AutocompleteFormField from '@/components/form/AutocompleteFormField'; import { useKeycloak } from '@bcgov/citz-imb-kc-react'; import { FormProvider, useForm } from 'react-hook-form'; @@ -10,6 +9,7 @@ import usePimsApi from '@/hooks/usePimsApi'; import { AccessRequest as AccessRequestType } from '@/hooks/api/useUsersApi'; import { AuthContext } from '@/contexts/authContext'; import { Navigate } from 'react-router-dom'; +import TextFormField from '@/components/form/TextFormField'; import { useGroupedAgenciesApi } from '@/hooks/api/useGroupedAgenciesApi'; const AccessPending = () => { @@ -48,40 +48,16 @@ const RequestForm = ({ submitHandler }: { submitHandler: (d: any) => void }) => - + - + - + - + void }) => /> - + - + diff --git a/react-app/src/pages/DevZone.tsx b/react-app/src/pages/DevZone.tsx index 11b6e9a81..3c3ad03f2 100644 --- a/react-app/src/pages/DevZone.tsx +++ b/react-app/src/pages/DevZone.tsx @@ -1,15 +1,10 @@ /* eslint-disable no-console */ //Simple component testing area. import React from 'react'; -import { Box } from '@mui/material'; -import PropertyDetail from '@/components/property/PropertyDetail'; +import AddProperty from '@/components/property/AddProperty'; const Dev = () => { - return ( - - - - ); + return ; }; export default Dev; diff --git a/react-app/src/themes/appTheme.ts b/react-app/src/themes/appTheme.ts index 5c1cda149..359ce396e 100644 --- a/react-app/src/themes/appTheme.ts +++ b/react-app/src/themes/appTheme.ts @@ -87,10 +87,15 @@ const appTheme = createTheme({ }, }, components: { - MuiTextField: { + MuiOutlinedInput: { styleOverrides: { root: { - color: 'secondary', + '& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button': { + display: 'none', + }, + '& input[type=number]': { + MozAppearance: 'textfield', + }, }, }, }, From 442eaf85c1a5f4a2ae47ede0984b56b7c4ecbb06 Mon Sep 17 00:00:00 2001 From: GrahamS-Quartech <112989452+GrahamS-Quartech@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:50:39 -0700 Subject: [PATCH 2/3] PIMS-1343/1344: Properties Data UI Changes (#2257) Co-authored-by: LawrenceLau2020 <68400651+LawrenceLau2020@users.noreply.github.com> Co-authored-by: dbarkowsky --- react-app/src/components/display/DataCard.tsx | 2 +- .../property/ParcelNetValueTable.tsx | 25 +- .../property/PropertyAssessedValueTable.tsx | 82 +++++ .../components/property/PropertyDetail.tsx | 287 +++++++----------- .../src/components/property/PropertyTable.tsx | 201 +++++------- react-app/src/hooks/api/useBuildingsApi.ts | 56 ++++ react-app/src/hooks/api/useParcelsApi.ts | 57 ++++ react-app/src/hooks/usePimsApi.ts | 6 + react-app/src/pages/DevZone.tsx | 65 +++- react-app/src/utils/formatters.tsx | 10 + 10 files changed, 480 insertions(+), 311 deletions(-) create mode 100644 react-app/src/components/property/PropertyAssessedValueTable.tsx create mode 100644 react-app/src/hooks/api/useBuildingsApi.ts create mode 100644 react-app/src/hooks/api/useParcelsApi.ts diff --git a/react-app/src/components/display/DataCard.tsx b/react-app/src/components/display/DataCard.tsx index 98c7ac55e..04f05c346 100644 --- a/react-app/src/components/display/DataCard.tsx +++ b/react-app/src/components/display/DataCard.tsx @@ -52,7 +52,7 @@ const DataCard = (props: DataCardProps) => { /> {props.children ?? - Object.keys(values).map((key, idx) => ( + Object.keys(values ?? {}).map((key, idx) => ( diff --git a/react-app/src/components/property/ParcelNetValueTable.tsx b/react-app/src/components/property/ParcelNetValueTable.tsx index 4e361374e..9fa58999a 100644 --- a/react-app/src/components/property/ParcelNetValueTable.tsx +++ b/react-app/src/components/property/ParcelNetValueTable.tsx @@ -1,8 +1,12 @@ -import { dateFormatter } from '@/utils/formatters'; +import { dateFormatter, formatMoney } from '@/utils/formatters'; import { DataGrid, GridColDef } from '@mui/x-data-grid'; import React from 'react'; -const ParcelNetValueTable = () => { +interface IParcelNetValueTable { + rows: Record[]; +} + +const ParcelNetValueTable = (props: IParcelNetValueTable) => { const columns: GridColDef[] = [ { field: 'FiscalYear', @@ -12,24 +16,13 @@ const ParcelNetValueTable = () => { field: 'EffectiveDate', headerName: 'Effective Date', flex: 1, + renderCell: (params) => (params.value ? dateFormatter(params.value) : 'Not provided'), }, { field: 'Value', headerName: 'Net Book Value', flex: 1, - }, - ]; - - const testData = [ - { - FiscalYear: '24/23', - EffectiveDate: dateFormatter(new Date()), - Value: '$34000000', - }, - { - FiscalYear: '23/22', - EffectiveDate: dateFormatter(new Date()), - Value: '$145000000', + valueFormatter: (params) => formatMoney(params.value), }, ]; @@ -48,7 +41,7 @@ const ParcelNetValueTable = () => { hideFooter getRowId={(row) => row.FiscalYear} columns={columns} - rows={testData} + rows={props.rows} /> ); }; diff --git a/react-app/src/components/property/PropertyAssessedValueTable.tsx b/react-app/src/components/property/PropertyAssessedValueTable.tsx new file mode 100644 index 000000000..37c699fae --- /dev/null +++ b/react-app/src/components/property/PropertyAssessedValueTable.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { PinnedColumnDataGrid } from '../table/DataTable'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { formatMoney } from '@/utils/formatters'; + +interface IPropertyAssessedValueTable { + rows: Record[]; + isBuilding: boolean; + parcelRelatedBuildingsNum: number; +} + +const PropertyAssessedValueTable = (props: IPropertyAssessedValueTable) => { + const { rows, isBuilding, parcelRelatedBuildingsNum } = props; + const willOverflow = Object.keys(props.rows).length >= 4; + const assesValCol: GridColDef[] = [ + { + field: 'Year', + headerName: 'Year', + flex: willOverflow ? 0 : 1, + }, + { + field: isBuilding ? 'Value' : 'Land', + headerName: isBuilding ? 'Value' : 'Land', + flex: willOverflow ? 0 : 1, + valueFormatter: (params) => formatMoney(params.value), + }, + ...[...Array(parcelRelatedBuildingsNum).keys()].map((idx) => ({ + field: `Building${idx + 1}`, + headerName: `Building (${idx + 1})`, + flex: willOverflow ? 0 : 1, + valueFormatter: (params) => formatMoney(params.value), + })), + ]; + + return willOverflow ? ( + row.Year} + pinnedFields={['Year', 'Land']} + columns={assesValCol} + rows={rows} + scrollableSxProps={{ + borderStyle: 'none', + '& .MuiDataGrid-columnHeaders': { + borderBottom: 'none', + }, + '& div div div div >.MuiDataGrid-cell': { + borderBottom: 'none', + borderTop: '1px solid rgba(224, 224, 224, 1)', + }, + }} + pinnedSxProps={{ + borderStyle: 'none', + '& .MuiDataGrid-columnHeaders': { + borderBottom: 'none', + }, + '& div div div div >.MuiDataGrid-cell': { + borderBottom: 'none', + }, + }} + /> + ) : ( + .MuiDataGrid-cell': { + borderBottom: 'none', + borderTop: '1px solid rgba(224, 224, 224, 1)', + }, + }} + hideFooter + columns={assesValCol} + rows={rows} + getRowId={(row) => row.Year} + /> + ); +}; + +export default PropertyAssessedValueTable; diff --git a/react-app/src/components/property/PropertyDetail.tsx b/react-app/src/components/property/PropertyDetail.tsx index 1ac3c24bc..aabc2e598 100644 --- a/react-app/src/components/property/PropertyDetail.tsx +++ b/react-app/src/components/property/PropertyDetail.tsx @@ -1,131 +1,93 @@ -import React from 'react'; +import React, { useEffect, useMemo } from 'react'; import DetailViewNavigation from '../display/DetailViewNavigation'; -import { Box, Typography, useTheme } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import DataCard from '../display/DataCard'; import { ClassificationInline } from './ClassificationIcon'; import CollapsibleSidebar from '../layout/CollapsibleSidebar'; import ParcelNetValueTable from './ParcelNetValueTable'; -import { PinnedColumnDataGrid } from '../table/DataTable'; -import { GridColDef } from '@mui/x-data-grid'; +import usePimsApi from '@/hooks/usePimsApi'; +import useDataLoader from '@/hooks/useDataLoader'; +import { useClassificationStyle } from './PropertyTable'; +import PropertyAssessedValueTable from './PropertyAssessedValueTable'; -const PropertyDetail = () => { - const buildings1 = [ - { - Id: 1, - ClassificationId: 0, - BuildingName: 'Aqua Center', - }, - { - Id: 2, - ClassificationId: 1, - BuildingName: 'Hydraulic Press', - }, - { - Id: 3, - ClassificationId: 2, - BuildingName: 'Iron Processing', - }, - { - Id: 7, - ClassificationId: 2, - BuildingName: 'St. Patricks Building', - }, - ]; +interface IPropertyDetail { + parcelId?: number; + buildingId?: number; + onClose: () => void; +} - const assessedValue = [ - { - FiscalYear: '2024', - Land: '$2450000', - Building1: '$350000', - Building2: '$5000000', - Building3: '$5000000', - Building4: '$2000000', - Building5: '$8090000', - }, - { - FiscalYear: '23/22', - Land: '$2450000', - Building1: '$350000', - Building2: '$5000000', - Building3: '$5000000', - Building4: '$2000000', - Building5: '$8090000', - }, - ]; +const PropertyDetail = (props: IPropertyDetail) => { + const { parcelId, buildingId } = props; + const api = usePimsApi(); + const { data: parcel, refreshData: refreshParcel } = useDataLoader(() => + api.parcels.getParcelById(parcelId), + ); + const { data: building, refreshData: refreshBuilding } = useDataLoader(() => + api.buildings.getBuildingById(buildingId), + ); + const { data: relatedBuildings, refreshData: refreshRelated } = useDataLoader( + () => parcel && api.buildings.getBuildingsByPid(parcel.PID), + ); + useEffect(() => { + refreshBuilding(); + }, [buildingId]); - const assesValCol: GridColDef[] = [ - { - field: 'FiscalYear', - headerName: 'Year', - }, - { - field: 'Land', - headerName: 'Land', - }, - { - field: 'Building1', - headerName: 'Building (1)', - }, - { - field: 'Building2', - headerName: 'Building (2)', - }, - { - field: 'Building3', - headerName: 'Building (3)', - }, - { - field: 'Building4', - headerName: 'Building (4)', - }, - { - field: 'Building5', - headerName: 'Building (5)', - }, - ]; + useEffect(() => { + refreshParcel(); + }, [parcelId]); - const data = { - //Id: 1, - PID: '010-113-1332', - Classification: 1, - Agency: { Name: 'Smith & Weston' }, - Address: '1450 Whenever Pl', - ProjectNumbers: 'FX1234', - Corporation: 'asdasda', - Ownership: 'BC Gov', - Sensitive: true, - UpdatedOn: new Date(), - }; - const theme = useTheme(); - const classificationColorMap = { - 0: { - textColor: theme.palette.blue.main, - bgColor: theme.palette.blue.light, - text: 'Core operational', - }, - 1: { - textColor: theme.palette.success.main, - bgColor: theme.palette.success.light, - text: 'Core strategic', - }, - 2: { textColor: theme.palette.info.main, bgColor: theme.palette.info.light, text: 'Surplus' }, - 3: { textColor: theme.palette.info.main, bgColor: theme.palette.info.light, text: 'Surplus' }, - 4: { - textColor: theme.palette.warning.main, - bgColor: theme.palette.warning.light, - text: 'Disposal', - }, - 5: { - textColor: theme.palette.warning.main, - bgColor: theme.palette.warning.light, - text: 'Disposal', - }, - 6: { - textColor: theme.palette.warning.main, - bgColor: theme.palette.warning.light, - text: 'Disposal', - }, - }; + useEffect(() => { + refreshRelated(); + }, [parcel]); + + const classification = useClassificationStyle(); + + const assessedValues = useMemo(() => { + if (parcelId && parcel) { + //We only want latest two years accroding to PO requirements. + const lastTwoYrs = parcel.Evaluations.sort( + (a, b) => b.Date.getFullYear() - a.Date.getFullYear(), + ).slice(0, 2); + const evaluations = []; + for (const parcelEval of lastTwoYrs) { + //This is a parcel. So first, get fields for the parcel. + const evaluation = { Year: parcelEval.Date.getFullYear(), Land: parcelEval.Value }; + //If exists, iterate over relatedBuildings. + relatedBuildings?.forEach((building, idx) => { + //We need to find evaluations with the matching year of the parcel evaluations. + //We can't just sort naively in the same way since we can't guarantee both lists have the same years. + const buildingEval = building.Evaluations.find( + (e) => e.Date.getFullYear() === parcelEval.Date.getFullYear(), + ); + if (buildingEval) { + evaluation[`Building${idx + 1}`] = buildingEval.Value; + } + }); + evaluations.push(evaluation); + } + return evaluations; + } else if (buildingId && building) { + const lastTwoYrs = building.Evaluations.sort( + (a, b) => b.Date.getFullYear() - a.Date.getFullYear(), + ).slice(0, 2); + return lastTwoYrs.map((ev) => ({ + Year: ev.Date.getFullYear(), + Value: ev.Value, + })); + } else { + return []; + } + }, [parcel, building, relatedBuildings]); + + const netBookValues = useMemo(() => { + if (parcelId && parcel) { + return parcel.Fiscals; + } else if (buildingId && building) { + return building.Fiscals; + } else { + return []; + } + }, [parcel, building]); const customFormatter = (key: any, val: any) => { if (key === 'Agency' && val) { @@ -133,25 +95,38 @@ const PropertyDetail = () => { } else if (key === 'Classification') { return ( ); - } else if (key === 'Sensitive') { + } else if (key === 'IsSensitive') { return val ? Yes : No; } }; + const buildingOrParcel = building ? 'Building' : 'Parcel'; + const mainInformation = useMemo(() => { + const data: any = parcel ?? building; + if (!data) { + return {}; + } else { + return { + Classification: data.Classification, + PID: data.PID, + PIN: data.PIN, + Address: data.Address1, + LotSize: data.TotalArea, + IsSensitive: data.IsSensitive, + Description: data.Description, + }; + } + }, [parcel, building]); return ( ({ - title: `Building information (${idx + 1})`, - subTitle: a.BuildingName, - })), + { title: `${buildingOrParcel} information` }, + { title: `${buildingOrParcel} net book value` }, { title: 'Assessed value' }, ]} > @@ -166,67 +141,35 @@ const PropertyDetail = () => { > {}} - onBackClick={() => {}} + onBackClick={() => props.onClose()} /> {}} /> {}} > - + - {buildings1.map((building, idx) => { - return ( - {}} - /> - ); - })} {}} > - row.FiscalYear} - pinnedFields={['FiscalYear', 'Land']} - columns={assesValCol} - rows={assessedValue} - scrollableSxProps={{ - borderStyle: 'none', - '& .MuiDataGrid-columnHeaders': { - borderBottom: 'none', - }, - '& div div div div >.MuiDataGrid-cell': { - borderBottom: 'none', - borderTop: '1px solid rgba(224, 224, 224, 1)', - }, - }} - pinnedSxProps={{ - borderStyle: 'none', - '& .MuiDataGrid-columnHeaders': { - borderBottom: 'none', - }, - '& div div div div >.MuiDataGrid-cell': { - borderBottom: 'none', - }, - }} + diff --git a/react-app/src/components/property/PropertyTable.tsx b/react-app/src/components/property/PropertyTable.tsx index c70009490..fbaab580e 100644 --- a/react-app/src/components/property/PropertyTable.tsx +++ b/react-app/src/components/property/PropertyTable.tsx @@ -1,36 +1,77 @@ -import React, { MutableRefObject } from 'react'; +import React, { MutableRefObject, useEffect, useState } from 'react'; import { CustomMenuItem, FilterSearchDataGrid } from '../table/DataTable'; import { Box, SxProps, Tooltip, lighten, useTheme } from '@mui/material'; import { GridApiCommunity } from '@mui/x-data-grid/internals'; import { Check } from '@mui/icons-material'; -import { GridColDef, GridColumnHeaderTitle } from '@mui/x-data-grid'; +import { GridColDef, GridColumnHeaderTitle, GridEventListener } from '@mui/x-data-grid'; import { dateFormatter } from '@/utils/formatters'; -import { ClassificationInline, ClassificationIcon } from './ClassificationIcon'; +import { ClassificationInline } from './ClassificationIcon'; +import { useKeycloak } from '@bcgov/citz-imb-kc-react'; -const PropertyTable = () => { +interface IPropertyTable { + rowClickHandler: GridEventListener<'rowClick'>; + data: Record[]; + isLoading: boolean; + refreshData: () => void; + error: unknown; +} + +export const useClassificationStyle = () => { const theme = useTheme(); - const classificationColorMap = { - 0: { textColor: theme.palette.blue.main, bgColor: theme.palette.blue.light }, - 1: { textColor: theme.palette.success.main, bgColor: theme.palette.success.light }, - 2: { textColor: theme.palette.info.main, bgColor: theme.palette.info.light }, - 3: { textColor: theme.palette.info.main, bgColor: theme.palette.info.light }, - 4: { textColor: theme.palette.warning.main, bgColor: theme.palette.warning.light }, - 5: { textColor: theme.palette.warning.main, bgColor: theme.palette.warning.light }, - 6: { textColor: theme.palette.warning.main, bgColor: theme.palette.warning.light }, + return { + 0: { + textColor: lighten(theme.palette.success.main, 0.3), + bgColor: theme.palette.success.light, + }, + 1: { textColor: lighten(theme.palette.blue.main, 0.4), bgColor: theme.palette.blue.light }, + 2: { textColor: lighten(theme.palette.info.main, 0.3), bgColor: theme.palette.info.light }, + 3: { textColor: lighten(theme.palette.info.main, 0.3), bgColor: theme.palette.info.light }, + 4: { + textColor: lighten(theme.palette.warning.main, 0.2), + bgColor: theme.palette.warning.light, + }, + 5: { + textColor: lighten(theme.palette.warning.main, 0.2), + bgColor: theme.palette.warning.light, + }, + 6: { + textColor: lighten(theme.palette.warning.main, 0.2), + bgColor: theme.palette.warning.light, + }, }; +}; + +const PropertyTable = (props: IPropertyTable) => { + const { rowClickHandler, data, isLoading, refreshData, error } = props; + const [properties, setProperties] = useState([]); + const classification = useClassificationStyle(); + const theme = useTheme(); + const { state } = useKeycloak(); + useEffect(() => { + if (error) { + console.error(error); + } + if (data && data.length > 0) { + console.log('Will set property rows.'); + setProperties(data); + } else { + console.log('Will refresh rows.'); + refreshData(); + } + }, [state, data]); const columns: GridColDef[] = [ { - field: 'PID', - headerName: 'PID', + field: 'Type', + headerName: 'Type', flex: 1, }, { field: 'ClassificationId', headerName: 'Classification', flex: 1, - minWidth: 260, + minWidth: 200, renderHeader: (params) => { return ( { ); }, renderCell: (params) => { - const reduced = params.row.Buildings.reduce((acc, curr) => { - const colorKey = classificationColorMap[curr.ClassificationId].bgColor; - if (!acc[colorKey]) { - acc[colorKey] = 1; - } else { - acc[colorKey]++; - } - return acc; - }, {}); return ( - - - {Object.entries(reduced).map(([key, val]) => ( - c.bgColor === key).textColor - } - backgroundColor={key} - /> - ))} - + ); }, }, + { + field: 'PID', + headerName: 'PID', + flex: 1, + renderCell: (params) => params.value ?? 'N/A', + }, { field: 'Agency', headerName: 'Agency', @@ -109,11 +133,6 @@ const PropertyTable = () => { headerName: 'Main Address', flex: 1, }, - { - field: 'ProjectNumbers', // right field?? - headerName: 'Title Number', - flex: 1, - }, { field: 'Corporation', headerName: 'Corporation', @@ -123,6 +142,7 @@ const PropertyTable = () => { field: 'Ownership', headerName: 'Ownership', flex: 1, + renderCell: (params) => (params.value ? `${params.value}%` : ''), }, { field: 'IsSensitive', @@ -142,80 +162,15 @@ const PropertyTable = () => { }, ]; - const buildings1 = [ - { - Id: 1, - ClassificationId: 0, - }, - { - Id: 2, - ClassificationId: 1, - }, - { - Id: 3, - ClassificationId: 2, - }, - { - Id: 7, - ClassificationId: 2, - }, - ]; - - const buildings2 = [ - { - Id: 4, - ClassificationId: 3, - }, - { - Id: 5, - ClassificationId: 4, - }, - { - Id: 6, - ClassificationId: 5, - }, - { - Id: 8, - ClassificationId: 5, - }, - ]; - - const rows = [ - { - Id: 1, - PID: '010-113-1332', - ClassificationId: 1, - AgencyId: 1, - Agency: { Name: 'Smith & Weston' }, - Address1: '1450 Whenever Pl', - ProjectNumbers: 'FX1234', - Corporation: 'asdasda', - Ownership: 'BC Gov', - IsSensitive: true, - UpdatedOn: new Date(), - Buildings: buildings1, - }, - { - Id: 2, - PID: '330-11-4335', - ClassificationId: 2, - AgencyId: 2, - Agency: { Name: 'Burger King' }, - Address1: '1143 Bigapple Rd', - ProjectNumbers: 'FX121a4', - Corporation: 'Big Corp', - Ownership: 'BC Gov', - IsSensitive: false, - UpdatedOn: new Date(), - Buildings: buildings2, - }, - ]; - const selectPresetFilter = (value: string, ref: MutableRefObject) => { switch (value) { case 'All Properties': ref.current.setFilterModel({ items: [] }); break; + case 'Building': + case 'Parcel': + ref.current.setFilterModel({ items: [{ value, operator: 'contains', field: 'Type' }] }); + break; default: ref.current.setFilterModel({ items: [] }); } @@ -235,17 +190,25 @@ const PropertyTable = () => { > row.Id} + getRowId={(row) => row.Id + row.Type} defaultFilter={'All Properties'} + onRowClick={rowClickHandler} presetFilterSelectOptions={[ All Properties , + + Buildings + , + + Parcels + , ]} + loading={isLoading} tableHeader={'Properties Overview'} excelTitle={'Properties'} columns={columns} - rows={rows} + rows={properties} addTooltip="Add a new property" /> diff --git a/react-app/src/hooks/api/useBuildingsApi.ts b/react-app/src/hooks/api/useBuildingsApi.ts new file mode 100644 index 000000000..f87e2d58b --- /dev/null +++ b/react-app/src/hooks/api/useBuildingsApi.ts @@ -0,0 +1,56 @@ +const buildings1 = [ + { + Id: 1, + ClassificationId: 0, + Classification: { + Name: 'Core operational', + Id: 0, + }, + Address1: '2345 Example St.', + AgencyId: 1, + Agency: { Name: 'Smith & Weston' }, + PID: 111333444, + IsSensitive: true, + UpdatedOn: new Date(), + Evaluations: [{ Value: 99888, Date: new Date() }], + Fiscals: [{ Value: 1235000, FiscalYear: 2024 }], + }, + { + Id: 2, + ClassificationId: 5, + Classification: { + Name: 'Disposed', + Id: 5, + }, + Address1: '6432 Nullabel Ln.', + AgencyId: 1, + Agency: { Name: 'Smith & Weston' }, + PID: 676555444, + IsSensitive: false, + UpdatedOn: new Date(), + Evaluations: [{ Value: 999988, Date: new Date() }], + Fiscals: [{ Value: 1235000, FiscalYear: 2024 }], + }, +]; + +const useBuildingsApi = () => { + const getBuildings = async () => { + return buildings1; + }; + + const getBuildingById = async (id: number) => { + return buildings1.find((b) => b.Id === id); + }; + + const getBuildingsByPid = async (pid: number) => { + return buildings1.filter((b) => b.PID === pid); + }; + + return { + getBuildings, + getBuildingById, + getBuildingsByPid, + }; +}; + +export default useBuildingsApi; diff --git a/react-app/src/hooks/api/useParcelsApi.ts b/react-app/src/hooks/api/useParcelsApi.ts new file mode 100644 index 000000000..fd3bd3e59 --- /dev/null +++ b/react-app/src/hooks/api/useParcelsApi.ts @@ -0,0 +1,57 @@ +const parcels = [ + { + Id: 1, + PID: 676555444, + PIN: 1234, + ClassificationId: 0, + Classification: { + Name: 'Core operational', + Id: 0, + }, + AgencyId: 1, + Agency: { Name: 'Smith & Weston' }, + Address1: '1450 Whenever Pl', + ProjectNumbers: 'FX1234', + Corporation: 'asdasda', + Ownership: 50, + IsSensitive: true, + UpdatedOn: new Date(), + Evaluations: [{ Value: 12300, Date: new Date() }], + Fiscals: [{ Value: 1235000, FiscalYear: 2024 }], + }, + { + Id: 2, + PID: 678456334, + PIN: 1234, + ClassificationId: 1, + Classification: { + Name: 'Core strategic', + Id: 1, + }, + AgencyId: 2, + Agency: { Name: 'Burger King' }, + Address1: '1143 Bigapple Rd', + ProjectNumbers: 'FX121a4', + Corporation: 'Big Corp', + Ownership: 99, + IsSensitive: false, + UpdatedOn: new Date(), + Evaluations: [{ Value: 129900, Date: new Date() }], + Fiscals: [{ Value: 11256777, FiscalYear: 2019 }], + }, +]; + +const useParcelsApi = () => { + const getParcels = async () => { + return parcels; + }; + const getParcelById = async (id: number) => { + return parcels.find((p) => p.Id === id); + }; + return { + getParcels, + getParcelById, + }; +}; + +export default useParcelsApi; diff --git a/react-app/src/hooks/usePimsApi.ts b/react-app/src/hooks/usePimsApi.ts index 6f2663329..1554ec6c4 100644 --- a/react-app/src/hooks/usePimsApi.ts +++ b/react-app/src/hooks/usePimsApi.ts @@ -5,6 +5,8 @@ import useUsersApi from './api/useUsersApi'; import useAgencyApi from './api/useAgencyApi'; import useRolesApi from './api/useRolesApi'; import useReportsApi from '@/hooks/api/useReportsApi'; +import useBuildingsApi from './api/useBuildingsApi'; +import useParcelsApi from './api/useParcelsApi'; /** * usePimsApi - This stores all the sub-hooks we need to make calls to our API and helps manage authentication state for them. @@ -18,12 +20,16 @@ const usePimsApi = () => { const agencies = useAgencyApi(fetch); const roles = useRolesApi(fetch); const reports = useReportsApi(fetch); + const buildings = useBuildingsApi(); + const parcels = useParcelsApi(); return { users, agencies, roles, reports, + buildings, + parcels, }; }; diff --git a/react-app/src/pages/DevZone.tsx b/react-app/src/pages/DevZone.tsx index 3c3ad03f2..1ad80acd0 100644 --- a/react-app/src/pages/DevZone.tsx +++ b/react-app/src/pages/DevZone.tsx @@ -1,10 +1,69 @@ /* eslint-disable no-console */ //Simple component testing area. -import React from 'react'; -import AddProperty from '@/components/property/AddProperty'; +import React, { useMemo, useState } from 'react'; +// import PropertyDetail from '@/components/property/PropertyDetail'; +import PropertyTable from '@/components/property/PropertyTable'; +import PropertyDetail from '@/components/property/PropertyDetail'; +import useDataLoader from '@/hooks/useDataLoader'; +import usePimsApi from '@/hooks/usePimsApi'; const Dev = () => { - return ; + const api = usePimsApi(); + + const { + data: parcels, + isLoading: parcelsLoading, + refreshData: refreshParcels, + error: parcelError, + } = useDataLoader(api.parcels.getParcels); + const { + data: buildings, + isLoading: buildingsLoading, + refreshData: refreshBuildings, + error: buildingError, + } = useDataLoader(api.buildings.getBuildings); + + const properties = useMemo( + () => [ + ...(buildings?.map((b) => ({ ...b, Type: 'Building' })) ?? []), + ...(parcels?.map((p) => ({ ...p, Type: 'Parcel' })) ?? []), + ], + [buildings, parcels], + ); + + const loading = parcelsLoading || buildingsLoading; + const refresh = () => { + refreshBuildings(); + refreshParcels(); + }; + const error = buildingError ?? parcelError; + + const [selectedParcelId, setSelectedParcelId] = useState(null); + const [selectedBuildingId, setSelectedBuildingId] = useState(null); + return selectedParcelId || selectedBuildingId ? ( + { + setSelectedBuildingId(null); + setSelectedParcelId(null); + }} + /> + ) : ( + { + if (params.row.Type === 'Building') { + setSelectedBuildingId(params.row.Id); + } else { + setSelectedParcelId(params.row.Id); + } + }} + error={error} + /> + ); }; export default Dev; diff --git a/react-app/src/utils/formatters.tsx b/react-app/src/utils/formatters.tsx index ea39f6ce9..bca15e2d7 100644 --- a/react-app/src/utils/formatters.tsx +++ b/react-app/src/utils/formatters.tsx @@ -37,3 +37,13 @@ export const statusChipFormatter = (value: ChipStatus) => { ); }; + +export const formatMoney = (value?: number | ''): string => { + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); + return formatter.format(value || 0); +}; From 0125c530263b5b76786b46a3cfb4961d84a51ce2 Mon Sep 17 00:00:00 2001 From: Dylan Barkowsky <37922247+dbarkowsky@users.noreply.github.com> Date: Tue, 19 Mar 2024 13:04:18 -0700 Subject: [PATCH 3/3] Bug Fix: Expand Agency Public Schema (#2261) --- .../agencies/agenciesController.ts | 28 +++++++++++-------- .../src/services/agencies/agencySchema.ts | 1 + .../agencies/agencyController.test.ts | 10 ++++--- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/express-api/src/controllers/agencies/agenciesController.ts b/express-api/src/controllers/agencies/agenciesController.ts index 96efa723b..c89a88a54 100644 --- a/express-api/src/controllers/agencies/agenciesController.ts +++ b/express-api/src/controllers/agencies/agenciesController.ts @@ -1,4 +1,4 @@ -import { Request, Response } from 'express'; +import { NextFunction, Request, Response } from 'express'; import * as agencyService from '@/services/agencies/agencyServices'; import { AgencyFilterSchema, AgencyPublicResponseSchema } from '@/services/agencies/agencySchema'; import { z } from 'zod'; @@ -11,7 +11,7 @@ import { Roles } from '@/constants/roles'; * @param {Response} res Outgoing response * @returns {Response} A 200 status with a list of agencies. */ -export const getAgencies = async (req: Request, res: Response) => { +export const getAgencies = async (req: Request, res: Response, next: NextFunction) => { /** * #swagger.tags = ['Agencies - Admin'] * #swagger.description = 'Gets a paged list of agencies.' @@ -19,17 +19,21 @@ export const getAgencies = async (req: Request, res: Response) => { "bearerAuth": [] }] */ - const kcUser = req.user as KeycloakUser; - const filter = AgencyFilterSchema.safeParse(req.query); - if (filter.success) { - const agencies = await agencyService.getAgencies(filter.data); - if (!kcUser.client_roles || !kcUser.client_roles.includes(Roles.ADMIN)) { - const trimmed = AgencyPublicResponseSchema.array().parse(agencies); - return res.status(200).send(trimmed); + try { + const kcUser = req.user as KeycloakUser; + const filter = AgencyFilterSchema.safeParse(req.query); + if (filter.success) { + const agencies = await agencyService.getAgencies(filter.data); + if (!kcUser.client_roles || !kcUser.client_roles.includes(Roles.ADMIN)) { + const trimmed = AgencyPublicResponseSchema.array().parse(agencies); + return res.status(200).send(trimmed); + } + return res.status(200).send(agencies); + } else { + return res.status(400).send('Could not parse filter.'); } - return res.status(200).send(agencies); - } else { - return res.status(400).send('Could not parse filter.'); + } catch (e) { + next(e); } }; diff --git a/express-api/src/services/agencies/agencySchema.ts b/express-api/src/services/agencies/agencySchema.ts index c49b4a8ff..755885938 100644 --- a/express-api/src/services/agencies/agencySchema.ts +++ b/express-api/src/services/agencies/agencySchema.ts @@ -38,6 +38,7 @@ export const AgencyPublicResponseSchema = z.object({ Code: z.string(), Description: z.string().nullable(), IsDisabled: z.boolean(), + ParentId: z.number().int().nullable(), }); export type Agency = z.infer; diff --git a/express-api/tests/unit/controllers/agencies/agencyController.test.ts b/express-api/tests/unit/controllers/agencies/agencyController.test.ts index bc080009f..1076e1199 100644 --- a/express-api/tests/unit/controllers/agencies/agencyController.test.ts +++ b/express-api/tests/unit/controllers/agencies/agencyController.test.ts @@ -15,6 +15,8 @@ let mockRequest: Request & MockReq, mockResponse: Response & MockRes; // const { getAgencies, addAgency, updateAgencyById, getAgencyById, deleteAgencyById } = // controllers.admin; +const _nextFunction = jest.fn(); + const _getAgencies = jest.fn().mockImplementation(() => [produceAgency()]); const _postAgency = jest.fn().mockImplementation((agency) => agency); const _getAgencyById = jest @@ -47,13 +49,13 @@ describe('UNIT - Agencies Admin', () => { describe('Controller getAgencies', () => { it('should return status 200 and a list of agencies', async () => { - await controllers.getAgencies(mockRequest, mockResponse); + await controllers.getAgencies(mockRequest, mockResponse, _nextFunction); expect(mockResponse.statusValue).toBe(200); }); it('should return status 200 and a list of agencies', async () => { _getKeycloakUserRoles.mockImplementationOnce(() => []); - await controllers.getAgencies(mockRequest, mockResponse); + await controllers.getAgencies(mockRequest, mockResponse, _nextFunction); expect(mockResponse.statusValue).toBe(200); }); @@ -63,7 +65,7 @@ describe('UNIT - Agencies Admin', () => { parentId: '0', id: '1', }; - await controllers.getAgencies(mockRequest, mockResponse); + await controllers.getAgencies(mockRequest, mockResponse, _nextFunction); expect(mockResponse.statusValue).toBe(200); }); @@ -72,7 +74,7 @@ describe('UNIT - Agencies Admin', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any name: 0 as any, }; - await controllers.getAgencies(mockRequest, mockResponse); + await controllers.getAgencies(mockRequest, mockResponse, _nextFunction); expect(mockResponse.statusValue).toBe(400); }); });