From b3613582257f20a52691cfb9d0b33c1cbd176b1b Mon Sep 17 00:00:00 2001 From: Herrera Date: Wed, 15 May 2024 18:04:04 -0700 Subject: [PATCH 1/3] PSP-8314 : Record reason for rejecting or terminating a lease --- .../apimodels/CodeTypes/LeaseStatusTypes.cs | 34 +++++++ .../Models/Concepts/Lease/LeaseMap.cs | 4 + .../Models/Concepts/Lease/LeaseModel.cs | 4 + .../features/leases/add/AddLeaseYupSchema.ts | 18 ++++ .../leases/add/LeaseDetailSubForm.test.tsx | 39 +++++++- .../leases/add/LeaseDetailSubForm.tsx | 88 ++++++++++++++++++- .../detail/LeasePages/LeasePageForm.tsx | 60 ++++++++++++- .../LeaseSearchResults/LeaseSearchResults.tsx | 2 +- source/frontend/src/features/leases/models.ts | 8 ++ .../mapSideBar/lease/LeaseContainer.tsx | 24 ++++- .../mapSideBar/lease/detail/LeaseTab.tsx | 2 +- source/frontend/src/mocks/lease.mock.ts | 2 + .../ApiGen_CodeTypes_LeaseStatusTypes.ts | 14 +++ .../api/generated/ApiGen_Concepts_Lease.ts | 2 + .../src/models/defaultInitializers.ts | 2 + 15 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 source/backend/apimodels/CodeTypes/LeaseStatusTypes.cs create mode 100644 source/frontend/src/models/api/generated/ApiGen_CodeTypes_LeaseStatusTypes.ts diff --git a/source/backend/apimodels/CodeTypes/LeaseStatusTypes.cs b/source/backend/apimodels/CodeTypes/LeaseStatusTypes.cs new file mode 100644 index 0000000000..b96e36add6 --- /dev/null +++ b/source/backend/apimodels/CodeTypes/LeaseStatusTypes.cs @@ -0,0 +1,34 @@ +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace Pims.Api.Models.CodeTypes +{ + [JsonConverter(typeof(JsonStringEnumMemberConverter))] + + public enum LeaseStatusTypes + { + [EnumMember(Value = "ACTIVE")] + ACTIVE, + + [EnumMember(Value = "ARCHIVED")] + ARCHIVED, + + [EnumMember(Value = "DISCARD")] + DISCARD, + + [EnumMember(Value = "DRAFT")] + DRAFT, + + [EnumMember(Value = "DUPLICATE")] + DUPLICATE, + + [EnumMember(Value = "EXPIRED")] + EXPIRED, + + [EnumMember(Value = "INACTIVE")] + INACTIVE, + + [EnumMember(Value = "TERMINATED")] + TERMINATED, + } +} diff --git a/source/backend/apimodels/Models/Concepts/Lease/LeaseMap.cs b/source/backend/apimodels/Models/Concepts/Lease/LeaseMap.cs index d763697e3a..242a94a6d5 100644 --- a/source/backend/apimodels/Models/Concepts/Lease/LeaseMap.cs +++ b/source/backend/apimodels/Models/Concepts/Lease/LeaseMap.cs @@ -53,6 +53,8 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.HasDigitalLicense, src => src.HasDigitalLicense) .Map(dest => dest.HasDigitalFile, src => src.HasDigitalFile) .Map(dest => dest.HasPhysicalLicense, src => src.HasPhysicialLicense) + .Map(dest => dest.CancellationReason, src => src.CancellationReason) + .Map(dest => dest.TerminationReason, src => src.TerminationReason) .Map(dest => dest.Project, src => src.Project) .Map(dest => dest.Tenants, src => src.PimsLeaseTenants) .Map(dest => dest.Terms, src => src.PimsLeaseTerms); @@ -93,6 +95,8 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.HasPhysicialLicense, src => src.HasPhysicalLicense) .Map(dest => dest.HasDigitalFile, src => src.HasDigitalFile) .Map(dest => dest.HasDigitalLicense, src => src.HasDigitalLicense) + .Map(dest => dest.CancellationReason, src => src.CancellationReason) + .Map(dest => dest.TerminationReason, src => src.TerminationReason) .Map(dest => dest.ProjectId, src => src.Project != null ? src.Project.Id : (long?)null) .IgnoreNullValues(true); } diff --git a/source/backend/apimodels/Models/Concepts/Lease/LeaseModel.cs b/source/backend/apimodels/Models/Concepts/Lease/LeaseModel.cs index 420d4344ac..ec39cbfc74 100644 --- a/source/backend/apimodels/Models/Concepts/Lease/LeaseModel.cs +++ b/source/backend/apimodels/Models/Concepts/Lease/LeaseModel.cs @@ -186,6 +186,10 @@ public class LeaseModel : FileModel public bool? HasDigitalLicense { get; set; } + public string CancellationReason { get; set; } + + public string TerminationReason { get; set; } + public bool IsExpired { get; set; } /// diff --git a/source/frontend/src/features/leases/add/AddLeaseYupSchema.ts b/source/frontend/src/features/leases/add/AddLeaseYupSchema.ts index 3c1195ffda..4fbc201a82 100644 --- a/source/frontend/src/features/leases/add/AddLeaseYupSchema.ts +++ b/source/frontend/src/features/leases/add/AddLeaseYupSchema.ts @@ -1,6 +1,8 @@ /* eslint-disable no-template-curly-in-string */ import * as Yup from 'yup'; +import { ApiGen_CodeTypes_LeaseStatusTypes } from '@/models/api/generated/ApiGen_CodeTypes_LeaseStatusTypes'; + import { isLeaseCategoryVisible } from './AdministrationSubForm'; export const AddLeaseYupSchema = Yup.object().shape({ @@ -79,4 +81,20 @@ export const AddLeaseYupSchema = Yup.object().shape({ .max(2000, 'Other Description must be at most ${max} characters'), }), ), + cancellationReason: Yup.string().when('statusTypeCode', { + is: (statusTypeCode: string) => + statusTypeCode && statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD.toString(), + then: Yup.string() + .required('Cancellation reason is required.') + .max(500, 'Cancellation reason must be at most ${max} characters'), + otherwise: Yup.string().nullable(), + }), + terminationReason: Yup.string().when('statusTypeCode', { + is: (statusTypeCode: string) => + statusTypeCode && statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED.toString(), + then: Yup.string() + .required('Termination reason is required.') + .max(500, 'Termination reason must be at most ${max} characters'), + otherwise: Yup.string().nullable(), + }), }); diff --git a/source/frontend/src/features/leases/add/LeaseDetailSubForm.test.tsx b/source/frontend/src/features/leases/add/LeaseDetailSubForm.test.tsx index 52f31560ec..ff0a283eac 100644 --- a/source/frontend/src/features/leases/add/LeaseDetailSubForm.test.tsx +++ b/source/frontend/src/features/leases/add/LeaseDetailSubForm.test.tsx @@ -5,11 +5,20 @@ import noop from 'lodash/noop'; import { useProjectTypeahead } from '@/hooks/useProjectTypeahead'; import { mockLookups } from '@/mocks/lookups.mock'; import { lookupCodesSlice } from '@/store/slices/lookupCodes'; -import { act, fillInput, renderAsync, RenderOptions, userEvent, waitFor } from '@/utils/test-utils'; +import { + act, + fillInput, + fireEvent, + renderAsync, + RenderOptions, + userEvent, + waitFor, +} from '@/utils/test-utils'; import { getDefaultFormLease } from '../models'; import { AddLeaseYupSchema } from './AddLeaseYupSchema'; import LeaseDetailSubForm, { ILeaseDetailsSubFormProps } from './LeaseDetailSubForm'; +import { ApiGen_CodeTypes_LeaseStatusTypes } from '@/models/api/generated/ApiGen_CodeTypes_LeaseStatusTypes'; const history = createMemoryHistory(); const storeState = { @@ -42,12 +51,20 @@ describe('LeaseDetailSubForm component', () => { return { ...component, // Finding elements + getStatusDropDown: () => + component.container.querySelector(`select[name="statusTypeCode"]`) as HTMLInputElement, getProjectSelector: () => { return document.querySelector(`input[name="typeahead-project"]`); }, findProjectSelectorItems: async () => { return document.querySelectorAll(`a[id^="typeahead-project-item"]`); }, + getTerminationReason: () => { + return document.querySelector(`textarea[name="terminationReason"]`); + }, + getCancellationReason: () => { + return document.querySelector(`textarea[name="cancellationReason"]`); + }, }; }; @@ -94,4 +111,24 @@ describe('LeaseDetailSubForm component', () => { expect(items[0]).toHaveTextContent(/MOCK TEST PROJECT/i); expect(items[1]).toHaveTextContent(/ANOTHER MOCK/i); }); + + it('displays the cancellation reason textbox whe status is changed to "Cancelled"', async () => { + const { container, getCancellationReason } = await setup({}); + + await act(async () => { + fillInput(container, 'statusTypeCode', ApiGen_CodeTypes_LeaseStatusTypes.DISCARD, 'select'); + }); + + expect(getCancellationReason()).toBeInTheDocument(); + }); + + it('displays the confirmation modal when the status changed from "Cancelled" to other status', async () => { + const { container, getCancellationReason } = await setup({}); + + await act(async () => { + fillInput(container, 'statusTypeCode', ApiGen_CodeTypes_LeaseStatusTypes.DISCARD, 'select'); + }); + + expect(getCancellationReason()).toBeInTheDocument(); + }); }); diff --git a/source/frontend/src/features/leases/add/LeaseDetailSubForm.tsx b/source/frontend/src/features/leases/add/LeaseDetailSubForm.tsx index 8360d22423..2f41cbc1cc 100644 --- a/source/frontend/src/features/leases/add/LeaseDetailSubForm.tsx +++ b/source/frontend/src/features/leases/add/LeaseDetailSubForm.tsx @@ -1,11 +1,14 @@ import { FormikProps } from 'formik/dist/types'; +import { useEffect, useState } from 'react'; import { Col, Row } from 'react-bootstrap'; -import { FastDatePicker, ProjectSelector, Select } from '@/components/common/form'; +import { FastDatePicker, ProjectSelector, Select, TextArea } from '@/components/common/form'; import { Section } from '@/components/common/Section/Section'; import { SectionField } from '@/components/common/Section/SectionField'; import * as API from '@/constants/API'; import { useLookupCodeHelpers } from '@/hooks/useLookupCodeHelpers'; +import { useModalContext } from '@/hooks/useModalContext'; +import { ApiGen_CodeTypes_LeaseStatusTypes } from '@/models/api/generated/ApiGen_CodeTypes_LeaseStatusTypes'; import { LeaseFormModel } from '../models'; @@ -17,8 +20,71 @@ export const LeaseDetailSubForm: React.FunctionComponent { const { getOptionsByType } = useLookupCodeHelpers(); + + const { values, setFieldValue } = formikProps; + const { statusTypeCode, terminationReason, cancellationReason } = values; + + const [currentlLeaseStatus, setLeaseStatus] = useState(statusTypeCode); + const { setModalContent, setDisplayModal } = useModalContext(); + const leaseStatusTypes = getOptionsByType(API.LEASE_STATUS_TYPES); const paymentReceivableTypes = getOptionsByType(API.LEASE_PAYMENT_RECEIVABLE_TYPES); + + useEffect(() => { + if (statusTypeCode !== currentlLeaseStatus) { + setLeaseStatus(statusTypeCode); + } + }, [currentlLeaseStatus, statusTypeCode]); + + const statusChangeModalContent = (status: string): React.ReactNode => { + return ( + <> +

+ The lease is no longer in {status} state. The reason for doing so will be + cleared from the file details and can only be viewed in the notes tab. +
+ Do you want to proceed? +

+ + ); + }; + + const onLeaseStatusChanged = (newStatus: string): void => { + if ( + (currentlLeaseStatus === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD || + currentlLeaseStatus === ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED) && + (terminationReason || cancellationReason) && + newStatus !== currentlLeaseStatus + ) { + setModalContent({ + variant: 'info', + title: 'Are you sure?', + message: + currentlLeaseStatus === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD + ? statusChangeModalContent('Cancelled') + : statusChangeModalContent('Terminated'), + okButtonText: 'Yes', + handleOk: () => { + setFieldValue('statusTypeCode', newStatus); + if (currentlLeaseStatus === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD) { + setFieldValue('cancellationReason', ''); + } else if (currentlLeaseStatus === ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED) { + setFieldValue('terminationReason', ''); + } + setDisplayModal(false); + }, + cancelButtonText: 'No', + handleCancel: () => { + setFieldValue('statusTypeCode', currentlLeaseStatus); + setDisplayModal(false); + }, + }); + setDisplayModal(true); + } else { + setFieldValue('statusTypeCode', newStatus); + } + }; + return (
@@ -28,10 +94,30 @@ export const LeaseDetailSubForm: React.FunctionComponent) => { + const selectedValue = [].slice + .call(e.target.selectedOptions) + .map((option: HTMLOptionElement & number) => option.value)[0]; + onLeaseStatusChanged(selectedValue); + }} required /> + + {statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED && ( + +