Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions express-api/src/constants/projectMonetaryType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export enum ProjectMonetaryType {
GAIN_BEFORE_SPL = 1,
INTEREST_COMPONENT = 2,
NET_PROCEEDS = 3,
OCG_GAIN_LOSS = 4,
OCG_FINANCIAL_STATEMENT = 5,
OFFER_AMOUNT = 6,
PROGRAM_COST = 7,
REALTOR_COMMISION = 8,
SALES_COST = 9,
}
1 change: 1 addition & 0 deletions express-api/src/controllers/lookup/lookupController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ export const lookupAll = async (req: Request, res: Response) => {
IsOptional: true,
Description: true,
StatusId: true,
IsCalculated: true,
},
order: {
SortOrder: 'asc',
Expand Down
45 changes: 43 additions & 2 deletions express-api/src/services/projects/projectsServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { Roles } from '@/constants/roles';
import { PimsRequestUser } from '@/middleware/userAuthCheck';
import { User } from '@/typeorm/Entities/User';
import { ProjectStatusNotification } from '@/typeorm/Entities/ProjectStatusNotification';
import { ProjectMonetaryType } from '@/constants/projectMonetaryType';
import { MonetaryType } from '@/typeorm/Entities/MonetaryType';

const projectRepo = AppDataSource.getRepository(Project);

Expand Down Expand Up @@ -606,7 +608,18 @@ const handleProjectMonetary = async (
queryRunner: QueryRunner,
) => {
if (newProject?.Monetaries?.length) {
const saveTimestamps = newProject.Monetaries.map(
// Remove values on incoming project that are pre-calculated fields. We don't save those in case they were manually tampered with.
const preCalculatedFields = (
await queryRunner.manager.find(MonetaryType, {
where: { IsCalculated: true },
})
).map((m) => m.Id);
const remainingMonetaries = newProject.Monetaries?.filter(
(m) => !preCalculatedFields.includes(m.MonetaryTypeId),
);

// Save the remaining monetaries that are not pre-calculated.
const saveMonetaries = remainingMonetaries.map(
async (monetary): Promise<InsertResult | void> => {
if (monetary.MonetaryTypeId == null) {
throw new ErrorWithCode('Provided monetary was missing a required field.', 400);
Expand All @@ -627,7 +640,35 @@ const handleProjectMonetary = async (
);
},
);
return Promise.all(saveTimestamps);

// Some values are autocalculated based on the existing monetaries.
// If the monetaries that trigger these calculations were provided, we must recalculate them.
const calculatedMonetaries: Promise<void | InsertResult>[] = [];
const netProceeds = newProject.Monetaries.find(
(m) => m.MonetaryTypeId === ProjectMonetaryType.NET_PROCEEDS, // Net Proceeds
);

if (netProceeds != null) {
const exists = await queryRunner.manager.findOne(ProjectMonetary, {
where: { ProjectId: newProject.Id, MonetaryTypeId: ProjectMonetaryType.PROGRAM_COST },
});
// Program Cost = Net Proceeds @ 1% if Project is Tier2+
const programCost = newProject.TierLevelId >= 2 ? netProceeds.Value * 0.01 : 0;
calculatedMonetaries.push(
queryRunner.manager.upsert(
ProjectMonetary,
{
ProjectId: newProject.Id,
Value: programCost,
MonetaryTypeId: ProjectMonetaryType.PROGRAM_COST,
CreatedById: exists ? exists.CreatedById : newProject.CreatedById,
UpdatedById: exists ? newProject.UpdatedById : undefined,
},
['ProjectId', 'MonetaryTypeId'],
),
);
}
return Promise.all([...saveMonetaries, ...calculatedMonetaries]);
}
};

Expand Down
3 changes: 3 additions & 0 deletions express-api/src/typeorm/Entities/MonetaryType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export class MonetaryType extends BaseEntity {
@Column('boolean')
IsOptional: boolean;

@Column('boolean', { default: false })
IsCalculated: boolean;

// Status Relation
@Column({ name: 'status_id', type: 'int', nullable: true })
StatusId: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class IsCalculatedAddition1755124066594 implements MigrationInterface {
name = 'IsCalculatedAddition1755124066594';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "monetary_type" ADD "is_calculated" boolean NOT NULL DEFAULT false`,
);

await queryRunner.query(
"UPDATE monetary_type SET is_calculated = true WHERE name = 'ProgramCost'",
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "monetary_type" DROP COLUMN "is_calculated"`);
}
}
1 change: 1 addition & 0 deletions express-api/tests/testUtils/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,7 @@ export const produceMonetaryType = (): MonetaryType => {
IsOptional: false,
StatusId: faker.number.int(),
Status: undefined,
IsCalculated: false,
CreatedById: randomUUID(),
CreatedBy: undefined,
CreatedOn: new Date(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,7 @@ describe('UNIT - Project Services', () => {
manager: {
findOne: async () => monetary,
upsert: async () => [monetary],
find: async () => [monetary],
},
};
const result = await projectServices.handleProjectMonetary(project, queryRunner);
Expand Down
24 changes: 24 additions & 0 deletions react-app/src/components/common/FormFieldLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Help from '@mui/icons-material/Help';
import { Box, Tooltip } from '@mui/material';
import React from 'react';

interface FormFieldLabelProps {
label: string;
tooltip?: string;
}

const FormFieldLabel = (props: FormFieldLabelProps) => {
const { label, tooltip } = props;
return (
<Box display={'inline-flex'} alignItems={'center'}>
{`${label} `}
{tooltip && (
<Tooltip title={tooltip}>
<Help sx={{ ml: '4px' }} fontSize="small" />
</Tooltip>
)}
</Box>
);
};

export default FormFieldLabel;
7 changes: 5 additions & 2 deletions react-app/src/components/form/AutocompleteFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
} from '@mui/material';
import { ISelectMenuItem } from './SelectFormField';
import { Controller, useFormContext } from 'react-hook-form';
import FormFieldLabel from '@/components/common/FormFieldLabel';

type AutocompleteFormProps = {
name: string;
label: string | JSX.Element;
label: string;
tooltip?: string;
required?: boolean;
allowNestedIndent?: boolean;
disableOptionsFunction?: (option: ISelectMenuItem) => boolean;
Expand All @@ -39,6 +41,7 @@ const AutocompleteFormField = (
name,
options,
label,
tooltip,
sx,
required,
allowNestedIndent,
Expand Down Expand Up @@ -105,7 +108,7 @@ const AutocompleteFormField = (
{...params}
error={!!formState.errors?.[name]}
required={required}
label={label}
label={<FormFieldLabel label={label} tooltip={tooltip} />}
helperText={formState.errors?.[name] ? 'This field is required.' : undefined}
/>
)}
Expand Down
17 changes: 15 additions & 2 deletions react-app/src/components/form/TextFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import React from 'react';
import { TextField, TextFieldProps } from '@mui/material';
import { Controller, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form';
import { pidFormatter } from '@/utilities/formatters';
import FormFieldLabel from '@/components/common/FormFieldLabel';

type TextFormFieldProps = {
defaultVal?: string;
name: string;
label: string;
tooltip?: string;
numeric?: boolean;
isPid?: boolean;
rules?: Omit<
Expand All @@ -17,7 +19,18 @@ type TextFormFieldProps = {

const TextFormField = (props: TextFormFieldProps) => {
const { control, setValue } = useFormContext();
const { name, label, rules, numeric, isPid, defaultVal, disabled, onBlur, ...restProps } = props;
const {
name,
label,
rules,
numeric,
isPid,
defaultVal,
disabled,
onBlur,
tooltip,
...restProps
} = props;
return (
<Controller
control={control}
Expand Down Expand Up @@ -53,7 +66,7 @@ const TextFormField = (props: TextFormFieldProps) => {
}}
value={value ?? defaultVal}
fullWidth
label={label}
label={<FormFieldLabel label={label} tooltip={tooltip} />}
type={'text'}
error={!!error && !!error.message}
helperText={error?.message}
Expand Down
98 changes: 5 additions & 93 deletions react-app/src/components/projects/AddProject.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Grid, InputAdornment, Typography, useTheme } from '@mui/material';
import { Box, Grid, Typography, useTheme } from '@mui/material';
import { useContext, useEffect, useState } from 'react';
import { NavigateBackButton } from '../display/DetailViewNavigation';
import TextFormField from '../form/TextFormField';
Expand All @@ -17,6 +17,8 @@ import { MonetaryType } from '@/constants/monetaryTypes';
import { NoteTypes } from '@/constants/noteTypes';
import useHistoryAwareNavigate from '@/hooks/useHistoryAwareNavigate';
import { getFiscalYear } from '@/utilities/helperFunctions';
import { ProjectFinancialInfoForm } from '@/components/projects/ProjectForms';
import { ProjectStatus } from '@/constants/projectStatuses';

const AddProject = () => {
const { goToFromStateOrSetRoute } = useHistoryAwareNavigate();
Expand All @@ -29,7 +31,6 @@ const AddProject = () => {
NetBook: 0,
Market: 0,
Appraised: 0,
ProgramCost: 0,
SalesCost: 0,
ExemptionNote: '',
Approval: false,
Expand All @@ -50,7 +51,7 @@ const AddProject = () => {
return;
} else {
const defaultState = lookupData?.ProjectStatuses.find(
(a) => a.Name === 'Required Documentation',
(a) => a.Id === ProjectStatus.REQUIRED_DOCUMENTATION,
);
const addTasks = lookupData?.Tasks.filter((task) => task.StatusId === defaultState.Id);
addTasks.push({
Expand Down Expand Up @@ -123,95 +124,7 @@ const AddProject = () => {
</Typography>
)}
<Typography variant="h5">Financial Information</Typography>
<Grid container spacing={2}>
<Grid item xs={6}>
<TextFormField
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
fullWidth
numeric
name={'Assessed'}
label={'Assessed value'}
rules={{
min: {
value: 0.01,
message: 'Must be greater than 0.',
},
}}
required
/>
</Grid>
<Grid item xs={6}>
<TextFormField
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
fullWidth
numeric
name={'NetBook'}
label={'Net Book Value'}
rules={{
min: {
value: 0.01,
message: 'Must be greater than 0.',
},
}}
required
/>
</Grid>
<Grid item xs={6}>
<TextFormField
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
numeric
fullWidth
name={'Market'}
label={'Estimated market value'}
rules={{
min: {
value: 0.01,
message: 'Must be greater than 0.',
},
}}
required
/>
</Grid>
<Grid item xs={6}>
<TextFormField
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
numeric
fullWidth
name={'Appraised'}
label={'Appraised value'}
/>
</Grid>
<Grid item xs={6}>
<TextFormField
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
numeric
fullWidth
name={'SalesCost'}
label={'Estimated sales cost'}
/>
</Grid>
<Grid item xs={6}>
<TextFormField
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
numeric
fullWidth
name={'ProgramCost'}
label={'Estimated program recovery fees'}
/>
</Grid>
</Grid>
<ProjectFinancialInfoForm />
<Typography variant="h5">Documentation</Typography>
<Grid container spacing={2}>
{tasksForAddState.map((task, idx) => (
Expand Down Expand Up @@ -278,7 +191,6 @@ const AddProject = () => {
ReportedFiscalYear: getFiscalYear(),
ActualFiscalYear: getFiscalYear(),
Monetaries: [
{ MonetaryTypeId: MonetaryType.PROGRAM_COST, Value: formValues.ProgramCost },
{ MonetaryTypeId: MonetaryType.SALES_COST, Value: formValues.SalesCost },
],
Tasks: formValues.Tasks.filter((a) => a.IsCompleted),
Expand Down
Loading