diff --git a/express-api/src/constants/projectMonetaryType.ts b/express-api/src/constants/projectMonetaryType.ts new file mode 100644 index 000000000..32ceb1877 --- /dev/null +++ b/express-api/src/constants/projectMonetaryType.ts @@ -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, +} diff --git a/express-api/src/controllers/lookup/lookupController.ts b/express-api/src/controllers/lookup/lookupController.ts index 26eec7272..134702ed6 100644 --- a/express-api/src/controllers/lookup/lookupController.ts +++ b/express-api/src/controllers/lookup/lookupController.ts @@ -245,6 +245,7 @@ export const lookupAll = async (req: Request, res: Response) => { IsOptional: true, Description: true, StatusId: true, + IsCalculated: true, }, order: { SortOrder: 'asc', diff --git a/express-api/src/services/projects/projectsServices.ts b/express-api/src/services/projects/projectsServices.ts index a6d627aaa..af74b2874 100644 --- a/express-api/src/services/projects/projectsServices.ts +++ b/express-api/src/services/projects/projectsServices.ts @@ -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); @@ -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 => { if (monetary.MonetaryTypeId == null) { throw new ErrorWithCode('Provided monetary was missing a required field.', 400); @@ -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[] = []; + 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]); } }; diff --git a/express-api/src/typeorm/Entities/MonetaryType.ts b/express-api/src/typeorm/Entities/MonetaryType.ts index 7ff47a51e..edb008b1a 100644 --- a/express-api/src/typeorm/Entities/MonetaryType.ts +++ b/express-api/src/typeorm/Entities/MonetaryType.ts @@ -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; diff --git a/express-api/src/typeorm/Migrations/1755124066594-IsCalculatedAddition.ts b/express-api/src/typeorm/Migrations/1755124066594-IsCalculatedAddition.ts new file mode 100644 index 000000000..96cd3c097 --- /dev/null +++ b/express-api/src/typeorm/Migrations/1755124066594-IsCalculatedAddition.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class IsCalculatedAddition1755124066594 implements MigrationInterface { + name = 'IsCalculatedAddition1755124066594'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "monetary_type" DROP COLUMN "is_calculated"`); + } +} diff --git a/express-api/tests/testUtils/factories.ts b/express-api/tests/testUtils/factories.ts index 32f18793c..c54850334 100644 --- a/express-api/tests/testUtils/factories.ts +++ b/express-api/tests/testUtils/factories.ts @@ -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(), diff --git a/express-api/tests/unit/services/projects/projectsServices.test.ts b/express-api/tests/unit/services/projects/projectsServices.test.ts index 47f9395e9..27a5e5922 100644 --- a/express-api/tests/unit/services/projects/projectsServices.test.ts +++ b/express-api/tests/unit/services/projects/projectsServices.test.ts @@ -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); diff --git a/react-app/src/components/common/FormFieldLabel.tsx b/react-app/src/components/common/FormFieldLabel.tsx new file mode 100644 index 000000000..57063f601 --- /dev/null +++ b/react-app/src/components/common/FormFieldLabel.tsx @@ -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 ( + + {`${label} `} + {tooltip && ( + + + + )} + + ); +}; + +export default FormFieldLabel; diff --git a/react-app/src/components/form/AutocompleteFormField.tsx b/react-app/src/components/form/AutocompleteFormField.tsx index 5d51a1cf1..e4221d680 100644 --- a/react-app/src/components/form/AutocompleteFormField.tsx +++ b/react-app/src/components/form/AutocompleteFormField.tsx @@ -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; @@ -39,6 +41,7 @@ const AutocompleteFormField = ( name, options, label, + tooltip, sx, required, allowNestedIndent, @@ -105,7 +108,7 @@ const AutocompleteFormField = ( {...params} error={!!formState.errors?.[name]} required={required} - label={label} + label={} helperText={formState.errors?.[name] ? 'This field is required.' : undefined} /> )} diff --git a/react-app/src/components/form/TextFormField.tsx b/react-app/src/components/form/TextFormField.tsx index 63ae1bdbe..9b8e9b094 100644 --- a/react-app/src/components/form/TextFormField.tsx +++ b/react-app/src/components/form/TextFormField.tsx @@ -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< @@ -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 ( { }} value={value ?? defaultVal} fullWidth - label={label} + label={} type={'text'} error={!!error && !!error.message} helperText={error?.message} diff --git a/react-app/src/components/projects/AddProject.tsx b/react-app/src/components/projects/AddProject.tsx index 24dd9d409..481af9e9c 100644 --- a/react-app/src/components/projects/AddProject.tsx +++ b/react-app/src/components/projects/AddProject.tsx @@ -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'; @@ -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(); @@ -29,7 +31,6 @@ const AddProject = () => { NetBook: 0, Market: 0, Appraised: 0, - ProgramCost: 0, SalesCost: 0, ExemptionNote: '', Approval: false, @@ -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({ @@ -123,95 +124,7 @@ const AddProject = () => { )} Financial Information - - - $, - }} - fullWidth - numeric - name={'Assessed'} - label={'Assessed value'} - rules={{ - min: { - value: 0.01, - message: 'Must be greater than 0.', - }, - }} - required - /> - - - $, - }} - fullWidth - numeric - name={'NetBook'} - label={'Net Book Value'} - rules={{ - min: { - value: 0.01, - message: 'Must be greater than 0.', - }, - }} - required - /> - - - $, - }} - numeric - fullWidth - name={'Market'} - label={'Estimated market value'} - rules={{ - min: { - value: 0.01, - message: 'Must be greater than 0.', - }, - }} - required - /> - - - $, - }} - numeric - fullWidth - name={'Appraised'} - label={'Appraised value'} - /> - - - $, - }} - numeric - fullWidth - name={'SalesCost'} - label={'Estimated sales cost'} - /> - - - $, - }} - numeric - fullWidth - name={'ProgramCost'} - label={'Estimated program recovery fees'} - /> - - + Documentation {tasksForAddState.map((task, idx) => ( @@ -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), diff --git a/react-app/src/components/projects/ProjectDetail.tsx b/react-app/src/components/projects/ProjectDetail.tsx index 0f53c4494..d93e29669 100644 --- a/react-app/src/components/projects/ProjectDetail.tsx +++ b/react-app/src/components/projects/ProjectDetail.tsx @@ -16,7 +16,6 @@ import usePimsApi from '@/hooks/usePimsApi'; import useDataLoader from '@/hooks/useDataLoader'; import { Project, - ProjectMetadata, ProjectMonetary, ProjectNote, ProjectTask, @@ -71,8 +70,6 @@ interface ProjectInfo extends Project { NetBookValue: number; EstimatedMarketValue: number; AppraisedValue: number; - EstimatedSalesCost: ProjectMetadata; - EstimatedProgramRecoveryFees: ProjectMetadata; SurplusDeclaration: boolean; TripleBottom: boolean; 'Fiscal Year of Disposal': number; @@ -241,19 +238,18 @@ const ProjectDetail = (props: IProjectDetail) => { }; const FinancialInformationData = useMemo(() => { - const salesCostType = lookupData?.MonetaryTypes?.find((a) => a.Name === 'SalesCost'); - const programCostType = lookupData?.MonetaryTypes?.find((a) => a.Name === 'ProgramCost'); + const monetaryEntries = {}; + lookupData?.MonetaryTypes?.forEach((mon) => { + monetaryEntries[mon.Name] = data?.parsedBody.Monetaries?.find( + (a) => a.MonetaryTypeId === mon.Id, + )?.Value; + }); return { AssessedValue: data?.parsedBody.Assessed, NetBookValue: data?.parsedBody.NetBook, EstimatedMarketValue: data?.parsedBody.Market, AppraisedValue: data?.parsedBody.Appraised, - EstimatedSalesCost: data?.parsedBody.Monetaries?.find( - (a) => a.MonetaryTypeId === salesCostType?.Id, - )?.Value, - EstimatedProgramRecoveryFees: data?.parsedBody.Monetaries?.find( - (a) => a.MonetaryTypeId === programCostType?.Id, - )?.Value, + ...monetaryEntries, }; }, [data, lookupData]); @@ -388,10 +384,12 @@ const ProjectDetail = (props: IProjectDetail) => { loading={isLoading} customFormatter={customFormatter} values={Object.fromEntries( - Object.entries(FinancialInformationData).map(([k, v]) => [ - k, - formatMoney(v != null ? v : 0), //This cast spaghetti sucks but hard to avoid when receiving money as a string from the API. - ]), + Object.entries(FinancialInformationData) + .map(([k, v]) => [ + k, + formatMoney(v != null ? v : 0), //This cast spaghetti sucks but hard to avoid when receiving money as a string from the API. + ]) + .sort(([a], [b]) => a.localeCompare(b)), )} title={financialInformation} id={financialInformation} @@ -407,7 +405,7 @@ const ProjectDetail = (props: IProjectDetail) => { onEdit={() => setOpenAgencyInterestDialog(true)} disableEdit={!isAdmin} > - {!data?.parsedBody.AgencyResponses?.length ? ( //TODO: Logic will depend on precense of agency responses + {!data?.parsedBody.AgencyResponses?.length ? ( No agencies registered. diff --git a/react-app/src/components/projects/ProjectDialog.tsx b/react-app/src/components/projects/ProjectDialog.tsx index fb7ec2af9..cc7aa35e2 100644 --- a/react-app/src/components/projects/ProjectDialog.tsx +++ b/react-app/src/components/projects/ProjectDialog.tsx @@ -29,6 +29,7 @@ import { MonetaryType } from '@/constants/monetaryTypes'; import BaseDialog from '../dialog/BaseDialog'; import { NotificationQueue } from '@/hooks/api/useProjectNotificationApi'; import useAgencyOptions from '@/hooks/useAgencyOptions'; +import { ProjectStatus } from '@/constants/projectStatuses'; interface IProjectGeneralInfoDialog { initialValues: Project; @@ -186,7 +187,9 @@ export const ProjectGeneralInfoDialog = (props: IProjectGeneralInfoDialog) => { }, [statusTypes, initialValues]); useEffect(() => { - setApprovedStatus(lookupData?.ProjectStatuses?.find((a) => a.Name === 'Approved for ERP')?.Id); + setApprovedStatus( + lookupData?.ProjectStatuses?.find((a) => a.Id === ProjectStatus.APPROVED_FOR_ERP)?.Id, + ); }, [lookupData]); const status = projectFormMethods.watch('StatusId'); const requireNotificationAcknowledge = @@ -270,6 +273,8 @@ export const ProjectGeneralInfoDialog = (props: IProjectGeneralInfoDialog) => { key={`${mon.Id}-${idx}`} name={`Monetaries.${idx}.Value`} label={columnNameFormatter(mon.Name)} + tooltip={mon.Description} + disabled={mon.IsCalculated} /> ))} @@ -330,7 +335,6 @@ export const ProjectFinancialDialog = (props: IProjectFinancialDialog) => { Market: 0, Appraised: 0, SalesCost: 0, - ProgramCost: 0, }, }); @@ -343,9 +347,6 @@ export const ProjectFinancialDialog = (props: IProjectFinancialDialog) => { SalesCost: initialValues?.Monetaries?.find((a) => a.MonetaryTypeId === MonetaryType.SALES_COST) ?.Value ?? 0, - ProgramCost: - initialValues?.Monetaries?.find((a) => a.MonetaryTypeId === MonetaryType.PROGRAM_COST) - ?.Value ?? 0, }); }, [initialValues, lookupData]); return ( @@ -356,7 +357,7 @@ export const ProjectFinancialDialog = (props: IProjectFinancialDialog) => { onConfirm={async () => { const isValid = await financialFormMethods.trigger(); if (isValid) { - const { Assessed, NetBook, Market, Appraised, ProgramCost, SalesCost } = + const { Assessed, NetBook, Market, Appraised, SalesCost } = financialFormMethods.getValues(); submit(initialValues.Id, { Id: initialValues.Id, @@ -369,10 +370,6 @@ export const ProjectFinancialDialog = (props: IProjectFinancialDialog) => { MonetaryTypeId: MonetaryType.SALES_COST, Value: SalesCost, }, - { - MonetaryTypeId: MonetaryType.PROGRAM_COST, - Value: ProgramCost, - }, ], ProjectProperties: initialValues.ProjectProperties, }).then(() => postSubmit()); diff --git a/react-app/src/components/projects/ProjectForms.tsx b/react-app/src/components/projects/ProjectForms.tsx index 63ba9aea8..5c83ef450 100644 --- a/react-app/src/components/projects/ProjectForms.tsx +++ b/react-app/src/components/projects/ProjectForms.tsx @@ -1,4 +1,4 @@ -import { Box, Grid, InputAdornment, Tooltip, Typography } from '@mui/material'; +import { Grid, InputAdornment, Typography } from '@mui/material'; import React, { useContext } from 'react'; import AutocompleteFormField from '../form/AutocompleteFormField'; import { UserContext } from '@/contexts/userContext'; @@ -10,7 +10,6 @@ import { Roles } from '@/constants/roles'; import { useFormContext } from 'react-hook-form'; import { formatFiscalYear } from '@/utilities/formatters'; import { generateNumberList } from '@/utilities/helperFunctions'; -import Help from '@mui/icons-material/Help'; interface IProjectGeneralInfoForm { projectStatuses: ISelectMenuItem[]; @@ -103,14 +102,8 @@ export const ProjectGeneralInfoForm = (props: IProjectGeneralInfoForm) => { - Risk Level - - - - - } + label={'Risk Level'} + tooltip="The risk associated with completion of the sale of a property during the forecasted fiscal year. Risk status on property sales can change through the sales process." required options={ lookupData?.Risks.map((risk) => ({ @@ -184,17 +177,6 @@ export const ProjectFinancialInfoForm = () => { required /> - - $, - }} - numeric - fullWidth - name={'Appraised'} - label={'Appraised value'} - /> - { fullWidth name={'SalesCost'} label={'Estimated sales cost'} + rules={{ + min: { + value: 0, + message: 'Must be 0 or greater.', + }, + }} + required /> @@ -213,8 +202,8 @@ export const ProjectFinancialInfoForm = () => { }} numeric fullWidth - name={'ProgramCost'} - label={'Estimated program recovery fees'} + name={'Appraised'} + label={'Appraised value'} /> diff --git a/react-app/src/components/projects/ProjectsTable.tsx b/react-app/src/components/projects/ProjectsTable.tsx index 908caf740..872f7ce90 100644 --- a/react-app/src/components/projects/ProjectsTable.tsx +++ b/react-app/src/components/projects/ProjectsTable.tsx @@ -165,7 +165,7 @@ const ProjectsTable = () => { 'Net Proceeds': project.Monetaries.find( (m) => m.MonetaryTypeId === MonetaryType.NET_PROCEEDS, )?.Value, - 'Program Cost': project.Monetaries.find( + 'Estimated Program Cost': project.Monetaries.find( (m) => m.MonetaryTypeId === MonetaryType.PROGRAM_COST, )?.Value, 'Gain Loss': project.Monetaries.find((m) => m.MonetaryTypeId === MonetaryType.OCG_GAIN_LOSS) diff --git a/react-app/src/hooks/api/useProjectsApi.ts b/react-app/src/hooks/api/useProjectsApi.ts index 0313234ca..59e53bc57 100644 --- a/react-app/src/hooks/api/useProjectsApi.ts +++ b/react-app/src/hooks/api/useProjectsApi.ts @@ -68,7 +68,6 @@ export interface Project { CreatedBy?: User; UpdatedOn?: string; UpdatedBy?: User; - Metadata?: ProjectMetadata; Tasks?: ProjectTask[]; Notifications?: ProjectNotification[]; StatusHistory?: ProjectStatusHistory[]; @@ -178,57 +177,6 @@ export interface ProjectAgencyResponse { Note: string | null; } -export interface ProjectMetadata { - // Exemption Fields - exemptionRequested?: boolean; - exemptionApprovedOn?: Date; - // ERP Fields - initialNotificationSentOn?: Date; - thirtyDayNotificationSentOn?: Date; - sixtyDayNotificationSentOn?: Date; - ninetyDayNotificationSentOn?: Date; - onHoldNotificationSentOn?: Date; - interestReceivedOn?: Date; - transferredWithinGreOn?: Date; - clearanceNotificationSentOn?: Date; - // SPL Fields - requestForSplReceivedOn?: Date; - approvedForSplOn?: Date; - marketedOn?: Date; - purchaser?: string; - offerAcceptedOn?: Date; - adjustedOn?: Date; - preliminaryFormSignedOn?: Date; - finalFormSignedOn?: Date; - priorYearAdjustmentOn?: Date; - disposedOn?: Date; - // Removing from SPL - removalFromSplRequestOn?: Date; - removalFromSplApprovedOn?: Date; - // Financials - assessedOn?: Date; - appraisedBy?: string; - appraisedOn?: Date; - salesCost?: number; - netProceeds?: number; - programCost?: number; - gainLoss?: number; - sppCapitalization?: number; - gainBeforeSpl?: number; - ocgFinancialStatement?: number; - interestComponent?: number; - plannedFutureUse?: string; - offerAmount?: number; - saleWithLeaseInPlace?: boolean; - priorYearAdjustment?: boolean; - priorYearAdjustmentAmount?: number; - realtor?: string; - realtorRate?: string; - realtorCommission?: number; - preliminaryFormSignedBy?: string; - finalFormSignedBy?: string; -} - // All values optional because it's not clear what filter will be used. export interface ProjectTask { CompletedOn?: Date;