Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PSP-8314 : Record reason for rejecting or terminating a lease #4023

Merged
merged 5 commits into from
May 17, 2024
Merged
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
62 changes: 47 additions & 15 deletions source/backend/api/Services/LeaseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Globalization;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Logging;
using Pims.Api.Models.CodeTypes;
using Pims.Core.Exceptions;
Expand Down Expand Up @@ -31,6 +32,7 @@ public class LeaseService : BaseService, ILeaseService
private readonly ILeaseTenantRepository _tenantRepository;
private readonly IUserRepository _userRepository;
private readonly IPropertyService _propertyService;
private readonly ILookupRepository _lookupRepository;

public LeaseService(
ClaimsPrincipal user,
Expand All @@ -44,7 +46,8 @@ public LeaseService(
IInsuranceRepository insuranceRepository,
ILeaseTenantRepository tenantRepository,
IUserRepository userRepository,
IPropertyService propertyService)
IPropertyService propertyService,
ILookupRepository lookupRepository)
: base(user, logger)
{
_logger = logger;
Expand All @@ -59,6 +62,7 @@ public LeaseService(
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_propertyService = propertyService;
_lookupRepository = lookupRepository;
}

public bool IsRowVersionEqual(long leaseId, long rowVersion)
Expand Down Expand Up @@ -219,20 +223,9 @@ public PimsLease Update(PimsLease lease, IEnumerable<UserOverrideCode> userOverr

if (currentLease.LeaseStatusTypeCode != lease.LeaseStatusTypeCode)
{
_entityNoteRepository.Add(
new PimsLeaseNote()
{
LeaseId = currentLease.LeaseId,
AppCreateTimestamp = System.DateTime.Now,
AppCreateUserid = this.User.GetUsername(),
Note = new PimsNote()
{
IsSystemGenerated = true,
NoteTxt = $"Lease status changed from {currentLease.LeaseStatusTypeCode} to {lease.LeaseStatusTypeCode}",
AppCreateTimestamp = System.DateTime.Now,
AppCreateUserid = this.User.GetUsername(),
},
});
PimsLeaseNote newLeaseNote = GeneratePimsLeaseNote(currentLease, lease);

_entityNoteRepository.Add(newLeaseNote);
}

_leaseRepository.Update(lease, false);
Expand All @@ -256,6 +249,45 @@ public PimsLease Update(PimsLease lease, IEnumerable<UserOverrideCode> userOverr
return _leaseRepository.GetNoTracking(lease.LeaseId);
}

private PimsLeaseNote GeneratePimsLeaseNote(PimsLease currentLease, PimsLease lease)
{
var leaseStatuses = _lookupRepository.GetAllLeaseStatusTypes();
string currentStatusDescription = leaseStatuses.FirstOrDefault(x => x.Id == currentLease.LeaseStatusTypeCode).Description.ToUpper();
string newStatusDescription = leaseStatuses.FirstOrDefault(x => x.Id == lease.LeaseStatusTypeCode).Description.ToUpper();

StringBuilder leaseNoteTextValue = new();
PimsLeaseNote leaseNote = new()
{
LeaseId = currentLease.LeaseId,
AppCreateTimestamp = DateTime.Now,
AppCreateUserid = User.GetUsername(),
Note = new PimsNote()
{
IsSystemGenerated = true,
NoteTxt = leaseNoteTextValue.Append($"Lease status changed from {currentStatusDescription} to {newStatusDescription}").ToString(),
AppCreateTimestamp = DateTime.Now,
AppCreateUserid = User.GetUsername(),
},
};

if (lease.LeaseStatusTypeCode == LeaseStatusTypes.DISCARD.ToString() || lease.LeaseStatusTypeCode == LeaseStatusTypes.TERMINATED.ToString())
{
leaseNoteTextValue.Append(". Reason: ");
if (lease.LeaseStatusTypeCode == LeaseStatusTypes.DISCARD.ToString())
{
leaseNoteTextValue.Append($"{lease.CancellationReason}.");
}
else
{
leaseNoteTextValue.Append($"{lease.TerminationReason}.");
}

leaseNote.Note.NoteTxt = leaseNoteTextValue.ToString();
}

return leaseNote;
}

private IEnumerable<PimsPropertyLease> ReprojectPropertyLocationsToWgs84(IEnumerable<PimsPropertyLease> propertyLeases)
{
List<PimsPropertyLease> reprojectedProperties = new List<PimsPropertyLease>();
Expand Down
34 changes: 34 additions & 0 deletions source/backend/apimodels/CodeTypes/LeaseStatusTypes.cs
Original file line number Diff line number Diff line change
@@ -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,
}
}
4 changes: 4 additions & 0 deletions source/backend/apimodels/Models/Concepts/Lease/LeaseMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 4 additions & 0 deletions source/backend/apimodels/Models/Concepts/Lease/LeaseModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>
Expand Down
14 changes: 14 additions & 0 deletions source/backend/tests/unit/api/Services/LeaseServiceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,23 @@ public void Update_WithStatusNote()

var leaseRepository = this._helper.GetService<Mock<ILeaseRepository>>();
var userRepository = this._helper.GetService<Mock<IUserRepository>>();
var lookupRepository = this._helper.GetService<Mock<ILookupRepository>>();

leaseRepository.Setup(x => x.GetNoTracking(It.IsAny<long>())).Returns(currentLeaseEntity);
leaseRepository.Setup(x => x.Get(It.IsAny<long>())).Returns(EntityHelper.CreateLease(1));
userRepository.Setup(x => x.GetByKeycloakUserId(It.IsAny<Guid>())).Returns(new PimsUser());
lookupRepository.Setup(x => x.GetAllLeaseStatusTypes()).Returns(new List<PimsLeaseStatusType>() {
new PimsLeaseStatusType()
{
LeaseStatusTypeCode= "STATUS_A",
Description = "STATUS_A",
},
new PimsLeaseStatusType()
{
LeaseStatusTypeCode= "STATUS_B",
Description = "STATUS_B",
},
});

var noteRepository = this._helper.GetService<Mock<IEntityNoteRepository>>();

Expand Down
18 changes: 18 additions & 0 deletions source/frontend/src/features/leases/add/AddLeaseYupSchema.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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(),
}),
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"]`);
},
};
};

Expand Down Expand Up @@ -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 () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't there be tests for terminated as well?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

const { container, getCancellationReason } = await setup({});

await act(async () => {
fillInput(container, 'statusTypeCode', ApiGen_CodeTypes_LeaseStatusTypes.DISCARD, 'select');
});

expect(getCancellationReason()).toBeInTheDocument();
});

it('displays the termination reason textbox whe status is changed to "Terminated"', async () => {
const { container, getTerminationReason } = await setup({});

await act(async () => {
fillInput(container, 'statusTypeCode', ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED, 'select');
});

expect(getTerminationReason()).toBeInTheDocument();
});
});
79 changes: 78 additions & 1 deletion source/frontend/src/features/leases/add/LeaseDetailSubForm.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { FormikProps } from 'formik/dist/types';
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';

Expand All @@ -17,8 +19,63 @@ export const LeaseDetailSubForm: React.FunctionComponent<ILeaseDetailsSubFormPro
formikProps,
}) => {
const { getOptionsByType } = useLookupCodeHelpers();

const { values, setFieldValue } = formikProps;
const { statusTypeCode, terminationReason, cancellationReason } = values;

const { setModalContent, setDisplayModal } = useModalContext();

const leaseStatusTypes = getOptionsByType(API.LEASE_STATUS_TYPES);
const paymentReceivableTypes = getOptionsByType(API.LEASE_PAYMENT_RECEIVABLE_TYPES);

const statusChangeModalContent = (status: string): React.ReactNode => {
return (
<>
<p>
The lease is no longer in <strong>{status}</strong> state. The reason for doing so will be
cleared from the file details and can only be viewed in the notes tab.
<br />
Do you want to proceed?
</p>
</>
);
};

const onLeaseStatusChanged = (newStatus: string): void => {
if (
(statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD ||
statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED) &&
(terminationReason || cancellationReason)
) {
setModalContent({
variant: 'info',
title: 'Are you sure?',
message:
statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD
? statusChangeModalContent('Cancelled')
: statusChangeModalContent('Terminated'),
okButtonText: 'Yes',
handleOk: () => {
setFieldValue('statusTypeCode', newStatus);
if (statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD) {
setFieldValue('cancellationReason', '');
} else if (statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED) {
setFieldValue('terminationReason', '');
}
setDisplayModal(false);
},
cancelButtonText: 'No',
handleCancel: () => {
setFieldValue('statusTypeCode', statusTypeCode);
setDisplayModal(false);
},
});
setDisplayModal(true);
} else {
setFieldValue('statusTypeCode', newStatus);
}
};

return (
<Section>
<SectionField label="Ministry project" labelWidth="2">
Expand All @@ -28,10 +85,30 @@ export const LeaseDetailSubForm: React.FunctionComponent<ILeaseDetailsSubFormPro
<Select
placeholder="Select Status"
field="statusTypeCode"
value={statusTypeCode}
options={leaseStatusTypes}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const selectedValue = [].slice
.call(e.target.selectedOptions)
.map((option: HTMLOptionElement & number) => option.value)[0];
onLeaseStatusChanged(selectedValue);
}}
required
/>
</SectionField>

{statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.TERMINATED && (
<SectionField label="Termination reason" contentWidth="12" required>
<TextArea field="terminationReason" />
</SectionField>
)}

{statusTypeCode === ApiGen_CodeTypes_LeaseStatusTypes.DISCARD && (
<SectionField label="Cancellation reason" contentWidth="12" required>
<TextArea field="cancellationReason" />
</SectionField>
)}

<SectionField label="Account type" labelWidth="2" contentWidth="5" required>
<Select field="paymentReceivableTypeCode" options={paymentReceivableTypes} />
</SectionField>
Expand Down
Loading
Loading