diff --git a/source/backend/api/Areas/Disposition/Controllers/DispositionFileController.cs b/source/backend/api/Areas/Disposition/Controllers/DispositionFileController.cs index c9dfb26ebe..6a2f5e677c 100644 --- a/source/backend/api/Areas/Disposition/Controllers/DispositionFileController.cs +++ b/source/backend/api/Areas/Disposition/Controllers/DispositionFileController.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Pims.Api.Areas.Acquisition.Controllers; +using Pims.Api.Helpers.Exceptions; using Pims.Api.Models.Concepts.DispositionFile; using Pims.Api.Models.Concepts.DispositionFile; using Pims.Api.Policies; @@ -346,6 +347,69 @@ public IActionResult GetDispositionFileSales([FromRoute]long id) return new JsonResult(_mapper.Map(dispositionSale)); } + [HttpPost("{id:long}/sale")] + [HasPermission(Permissions.DispositionEdit)] + [Produces("application/json")] + [ProducesResponseType(typeof(DispositionFileSaleModel), 201)] + [SwaggerOperation(Tags = new[] { "dispositionfile" })] + [TypeFilter(typeof(NullJsonResultFilter))] + public IActionResult AddDispositionFileSale([FromRoute] long id, [FromBody] DispositionFileSaleModel dispositionFileSale) + { + _logger.LogInformation( + "Request received by Controller: {Controller}, Action: {ControllerAction}, User: {User}, DateTime: {DateTime}", + nameof(DispositionFileController), + nameof(AddDispositionFileSale), + User.GetUsername(), + DateTime.Now); + + _logger.LogInformation("Dispatching to service: {Service}", _dispositionService.GetType()); + + try + { + if (id != dispositionFileSale.DispositionFileId) + { + throw new BadRequestException("Invalid dispositionFileId."); + } + + var dispositionSaleEntity = _mapper.Map(dispositionFileSale); + var newDispositionSale = _dispositionService.AddDispositionFileSale(dispositionSaleEntity); + + return new JsonResult(_mapper.Map(newDispositionSale)); + } + catch (DuplicateEntityException e) + { + return Conflict(e.Message); + } + } + + [HttpPut("{id:long}/sale/{saleId:long}")] + [HasPermission(Permissions.DispositionEdit)] + [Produces("application/json")] + [ProducesResponseType(typeof(DispositionFileSaleModel), 200)] + [SwaggerOperation(Tags = new[] { "dispositionfile" })] + [TypeFilter(typeof(NullJsonResultFilter))] + public IActionResult UpdateDispositionFileSale([FromRoute]long id, [FromRoute]long saleId, [FromBody] DispositionFileSaleModel dispositionFileSale) + { + _logger.LogInformation( + "Request received by Controller: {Controller}, Action: {ControllerAction}, User: {User}, DateTime: {DateTime}", + nameof(DispositionFileController), + nameof(UpdateDispositionFileSale), + User.GetUsername(), + DateTime.Now); + + _logger.LogInformation("Dispatching to service: {Service}", _dispositionService.GetType()); + + if (id != dispositionFileSale.DispositionFileId || dispositionFileSale.Id != saleId) + { + throw new BadRequestException("Invalid dispositionFileId."); + } + + var dispositionSaleEntity = _mapper.Map(dispositionFileSale); + var updatedSale = _dispositionService.UpdateDispositionFileSale(dispositionSaleEntity); + + return new JsonResult(_mapper.Map(updatedSale)); + } + [HttpGet("{id:long}/appraisal")] [HasPermission(Permissions.DispositionView)] [Produces("application/json")] diff --git a/source/backend/api/Services/DispositionFileService.cs b/source/backend/api/Services/DispositionFileService.cs index 6e24f5446e..20d2bfe3d1 100644 --- a/source/backend/api/Services/DispositionFileService.cs +++ b/source/backend/api/Services/DispositionFileService.cs @@ -103,7 +103,7 @@ public PimsDispositionFile Update(long id, PimsDispositionFile dispositionFile, if (!userOverrides.Contains(UserOverrideCode.DispositionFileFinalStatus)) { var doNotAddToStatuses = new List() { EnumDispositionFileStatusTypeCode.COMPLETE.ToString(), EnumDispositionFileStatusTypeCode.ARCHIVED.ToString() }; - if(doNotAddToStatuses.Contains(dispositionFile.DispositionFileStatusTypeCode)) + if (doNotAddToStatuses.Contains(dispositionFile.DispositionFileStatusTypeCode)) { throw new UserOverrideException(UserOverrideCode.DispositionFileFinalStatus, "You are changing this file to a non-editable state. Only system administrators can edit the file when set to Archived, Cancelled or Completed state). Do you wish to continue?"); } @@ -246,6 +246,34 @@ public PimsDispositionSale GetDispositionFileSale(long dispositionFileId) return _dispositionFileRepository.GetDispositionFileSale(dispositionFileId); } + public PimsDispositionSale UpdateDispositionFileSale(PimsDispositionSale dispositionSale) + { + _logger.LogInformation("Updating disposition file Sale with DispositionFileId: {id}", dispositionSale.DispositionSaleId); + _user.ThrowIfNotAuthorized(Permissions.DispositionEdit); + + var updatedSale = _dispositionFileRepository.UpdateDispositionFileSale(dispositionSale); + _dispositionFileRepository.CommitTransaction(); + + return updatedSale; + } + + public PimsDispositionSale AddDispositionFileSale(PimsDispositionSale dispositionSale) + { + _logger.LogInformation("Adding disposition file Sale to Disposition File with Id: {id}", dispositionSale.DispositionFileId); + _user.ThrowIfNotAuthorized(Permissions.DispositionEdit); + + var dispositionFileParent = _dispositionFileRepository.GetById(dispositionSale.DispositionFileId); + if (dispositionFileParent.PimsDispositionSales.Count > 0) + { + throw new DuplicateEntityException("Invalid Disposition Sale. A Sale has been already created for this Disposition File"); + } + + _dispositionFileRepository.AddDispositionFileSale(dispositionSale); + _dispositionFileRepository.CommitTransaction(); + + return dispositionSale; + } + public PimsDispositionAppraisal GetDispositionFileAppraisal(long dispositionFileId) { _logger.LogInformation("Getting disposition file appraisal with DispositionFileId: {id}", dispositionFileId); @@ -265,7 +293,7 @@ public PimsDispositionAppraisal AddDispositionFileAppraisal(long dispositionFile throw new BadRequestException("Invalid dispositionFileId."); } - if(dispositionFileParent.PimsDispositionAppraisals.Count > 0) + if (dispositionFileParent.PimsDispositionAppraisals.Count > 0) { throw new DuplicateEntityException("Invalid Disposition Appraisal. An Appraisal has been already created for this Disposition File"); } diff --git a/source/backend/api/Services/IDispositionFileService.cs b/source/backend/api/Services/IDispositionFileService.cs index d1f23a1cce..b94692dd33 100644 --- a/source/backend/api/Services/IDispositionFileService.cs +++ b/source/backend/api/Services/IDispositionFileService.cs @@ -33,11 +33,15 @@ public interface IDispositionFileService PimsDispositionSale GetDispositionFileSale(long dispositionFileId); + PimsDispositionSale AddDispositionFileSale(PimsDispositionSale dispositionSale); + + PimsDispositionSale UpdateDispositionFileSale(PimsDispositionSale dispositionSale); + PimsDispositionAppraisal GetDispositionFileAppraisal(long dispositionFileId); PimsDispositionAppraisal AddDispositionFileAppraisal(long dispositionFileId, PimsDispositionAppraisal dispositionAppraisal); - PimsDispositionAppraisal UpdateDispositionFileAppraisal(long dispositionFileId,long appraisalId, PimsDispositionAppraisal dispositionAppraisal); + PimsDispositionAppraisal UpdateDispositionFileAppraisal(long dispositionFileId, long appraisalId, PimsDispositionAppraisal dispositionAppraisal); IEnumerable GetChecklistItems(long id); diff --git a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileMap.cs b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileMap.cs index 717731e23c..bcdab9acc8 100644 --- a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileMap.cs +++ b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileMap.cs @@ -1,8 +1,6 @@ using System.Collections.Generic; using System.Linq; using Mapster; -using Pims.Api.Models.Concepts.DispositionFile; -using Pims.Dal.Entities; using Entity = Pims.Dal.Entities; namespace Pims.Api.Models.Concepts.DispositionFile diff --git a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileModel.cs b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileModel.cs index 0beb1db2e8..d90d63289e 100644 --- a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileModel.cs +++ b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileModel.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using Pims.Api.Models.Base; using Pims.Api.Models.Concepts.File; -using Pims.Api.Models.Concepts.DispositionFile; /* * Frontend model diff --git a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleMap.cs b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleMap.cs index 4bc2e553f9..6110969c67 100644 --- a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleMap.cs +++ b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleMap.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.Linq; using Mapster; using Entity = Pims.Dal.Entities; @@ -22,8 +24,8 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.SppAmount, src => src.SppAmt) .Map(dest => dest.RemediationAmount, src => src.RemediationAmt) .Map(dest => dest.DispositionPurchasers, src => src.PimsDispositionPurchasers) - .Map(dest => dest.DispositionPurchaserAgents, src => src.PimsDspPurchAgents) - .Map(dest => dest.DispositionPurchaserSolicitors, src => src.PimsDspPurchSolicitors); + .Map(dest => dest.DispositionPurchaserAgent, src => src.PimsDspPurchAgents.FirstOrDefault()) + .Map(dest => dest.DispositionPurchaserSolicitor, src => src.PimsDspPurchSolicitors.FirstOrDefault()); config.NewConfig() .Map(dest => dest.DispositionSaleId, src => src.Id) @@ -40,8 +42,8 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.SppAmt, src => src.SppAmount) .Map(dest => dest.RemediationAmt, src => src.RemediationAmount) .Map(dest => dest.PimsDispositionPurchasers, src => src.DispositionPurchasers) - .Map(dest => dest.PimsDspPurchAgents, src => src.DispositionPurchaserAgents) - .Map(dest => dest.PimsDspPurchSolicitors, src => src.DispositionPurchaserSolicitors); + .Map(dest => dest.PimsDspPurchAgents, src => src.DispositionPurchaserAgent == null ? null : new List { src.DispositionPurchaserAgent }) + .Map(dest => dest.PimsDspPurchSolicitors, src => src.DispositionPurchaserSolicitor == null ? null : new List { src.DispositionPurchaserSolicitor }); } } } diff --git a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleModel.cs b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleModel.cs index 23673fb5cf..e750d7edce 100644 --- a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleModel.cs +++ b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionFileSaleModel.cs @@ -61,16 +61,6 @@ public class DispositionFileSaleModel : BaseConcurrentModel /// public decimal? TotalCostAmount { get; set; } - /// - /// Disposition Sale Net Proceeds Before Spp Amount. - /// - public decimal? NetProceedsBeforeSppAmount { get; set; } - - /// - /// Disposition Sale Net Proceeds After Spp Amount. - /// - public decimal? NetProceedsAfterSppAmount { get; set; } - /// /// Disposition Sale Spp Amount. /// @@ -87,13 +77,13 @@ public class DispositionFileSaleModel : BaseConcurrentModel public IList DispositionPurchasers { get; set; } /// - /// get/set - A list of disposition Sale Purchaser(s) Agents. + /// get/set - Disposition Sale Purchaser(s)'s Agents. /// - public IList DispositionPurchaserAgents { get; set; } + public DispositionSalePurchaserAgentModel DispositionPurchaserAgent { get; set; } /// - /// get/set - A list of disposition Sale Purchaser(s) Solicitors. + /// get/set - Disposition Sale Purchase Solicitor. /// - public IList DispositionPurchaserSolicitors { get; set; } + public DispositionSalePurchaserSolicitorModel DispositionPurchaserSolicitor { get; set; } } } diff --git a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserAgentMap.cs b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserAgentMap.cs index f4b7c90655..d4d26547c7 100644 --- a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserAgentMap.cs +++ b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserAgentMap.cs @@ -1,6 +1,5 @@ using Mapster; using Pims.Api.Models.Base; -using Pims.Api.Models.Concepts.DispositionFile; using Entity = Pims.Dal.Entities; namespace Pims.Api.Models.Concepts.DispositionFile diff --git a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserMap.cs b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserMap.cs index cf192e1ed7..394b2ed2aa 100644 --- a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserMap.cs +++ b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserMap.cs @@ -1,6 +1,5 @@ using Mapster; using Pims.Api.Models.Base; -using Pims.Api.Models.Concepts.DispositionFile; using Entity = Pims.Dal.Entities; namespace Pims.Api.Models.Concepts.DispositionFile diff --git a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserSolicitorMap.cs b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserSolicitorMap.cs index 228f4b41e6..c95a346818 100644 --- a/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserSolicitorMap.cs +++ b/source/backend/apimodels/Models/Concepts/DispositionFile/DispositionSalePurchaserSolicitorMap.cs @@ -1,6 +1,5 @@ using Mapster; using Pims.Api.Models.Base; -using Pims.Api.Models.Concepts.DispositionFile; using Entity = Pims.Dal.Entities; namespace Pims.Api.Models.Concepts.DispositionFile diff --git a/source/backend/dal/Repositories/DispositionFileRepository.cs b/source/backend/dal/Repositories/DispositionFileRepository.cs index df215faf27..bd3ea70061 100644 --- a/source/backend/dal/Repositories/DispositionFileRepository.cs +++ b/source/backend/dal/Repositories/DispositionFileRepository.cs @@ -325,6 +325,26 @@ public PimsDispositionSale GetDispositionFileSale(long dispositionId) .Where(x => x.DispositionFileId == dispositionId).FirstOrDefault(); } + public PimsDispositionSale AddDispositionFileSale(PimsDispositionSale dispositionSale) + { + Context.PimsDispositionSales.Add(dispositionSale); + + return dispositionSale; + } + + public PimsDispositionSale UpdateDispositionFileSale(PimsDispositionSale dispositionSale) + { + var existingSale = Context.PimsDispositionSales + .FirstOrDefault(x => x.DispositionSaleId.Equals(dispositionSale.DispositionSaleId)) ?? throw new KeyNotFoundException(); + + Context.Entry(existingSale).CurrentValues.SetValues(dispositionSale); + Context.UpdateChild(p => p.PimsDispositionPurchasers, dispositionSale.Internal_Id, dispositionSale.PimsDispositionPurchasers.ToArray()); + Context.UpdateChild(p => p.PimsDspPurchAgents, dispositionSale.Internal_Id, dispositionSale.PimsDspPurchAgents.ToArray()); + Context.UpdateChild(p => p.PimsDspPurchSolicitors, dispositionSale.Internal_Id, dispositionSale.PimsDspPurchSolicitors.ToArray()); + + return existingSale; + } + public PimsDispositionAppraisal GetDispositionFileAppraisal(long dispositionId) { return Context.PimsDispositionAppraisals.AsNoTracking() diff --git a/source/backend/dal/Repositories/Interfaces/IDispositionFileRepository.cs b/source/backend/dal/Repositories/Interfaces/IDispositionFileRepository.cs index 33b1c927ab..855e324cc7 100644 --- a/source/backend/dal/Repositories/Interfaces/IDispositionFileRepository.cs +++ b/source/backend/dal/Repositories/Interfaces/IDispositionFileRepository.cs @@ -30,6 +30,10 @@ public interface IDispositionFileRepository : IRepository PimsDispositionSale GetDispositionFileSale(long dispositionId); + PimsDispositionSale AddDispositionFileSale(PimsDispositionSale dispositionSale); + + PimsDispositionSale UpdateDispositionFileSale(PimsDispositionSale dispositionSale); + PimsDispositionAppraisal GetDispositionFileAppraisal(long dispositionId); PimsDispositionAppraisal AddDispositionFileAppraisal(PimsDispositionAppraisal dispositionAppraisal); diff --git a/source/backend/tests/unit/api/Controllers/Disposition/DispositionControllerTest.cs b/source/backend/tests/unit/api/Controllers/Disposition/DispositionControllerTest.cs index ecda629df4..d7c7169008 100644 --- a/source/backend/tests/unit/api/Controllers/Disposition/DispositionControllerTest.cs +++ b/source/backend/tests/unit/api/Controllers/Disposition/DispositionControllerTest.cs @@ -147,6 +147,47 @@ public void UpdateDispositionFile_Success() // Assert this._service.Verify(m => m.Update(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Once()); } + + /// + /// Make a successful request to POST a disposition file Sale to the Disposition File. + /// + [Fact] + public void AddDispositionFileSale_Success() + { + // Arrange + var dispFileSale = new PimsDispositionSale(); + dispFileSale.DispositionFileId = 1; + + this._service.Setup(m => m.AddDispositionFileSale(It.IsAny())).Returns(dispFileSale); + + // Act + var model = _mapper.Map(dispFileSale); + var result = this._controller.AddDispositionFileSale(1, model); + + // Assert + this._service.Verify(m => m.AddDispositionFileSale(It.IsAny()), Times.Once()); + } + + /// + /// Make a successful request to PUT a disposition file Sale. + /// + [Fact] + public void UpdateDispositionFileSale_Success() + { + // Arrange + var dispFileSale = new PimsDispositionSale(); + dispFileSale.DispositionFileId = 1; + dispFileSale.DispositionSaleId = 10; + + this._service.Setup(m => m.UpdateDispositionFileSale(It.IsAny())).Returns(dispFileSale); + + // Act + var model = _mapper.Map(dispFileSale); + var result = this._controller.UpdateDispositionFileSale(1, 10, model); + + // Assert + this._service.Verify(m => m.UpdateDispositionFileSale(It.IsAny()), Times.Once()); + } #endregion } } diff --git a/source/backend/tests/unit/api/Services/DispositionFileServiceTest.cs b/source/backend/tests/unit/api/Services/DispositionFileServiceTest.cs index 11a1ac0927..060513e00f 100644 --- a/source/backend/tests/unit/api/Services/DispositionFileServiceTest.cs +++ b/source/backend/tests/unit/api/Services/DispositionFileServiceTest.cs @@ -622,6 +622,8 @@ public void UpdateChecklist_NoPermission() } #endregion + #region Appraisal + [Fact] public void GetDispositionAppraisal_Should_Fail_NoPermission() { @@ -736,7 +738,7 @@ public void AddDispositionFileAppraisal_Success() repository.Setup(x => x.GetById(1)).Returns(new PimsDispositionFile() { DispositionFileId = 1, - PimsDispositionOffers = new List() { }, + PimsDispositionAppraisals = new List() { }, }); repository.Setup(x => x.AddDispositionFileAppraisal(It.IsAny())).Returns(new PimsDispositionAppraisal() { @@ -756,6 +758,8 @@ public void AddDispositionFileAppraisal_Success() repository.Verify(x => x.AddDispositionFileAppraisal(It.IsAny()), Times.Once); } + #endregion + #region Offers [Fact] @@ -1444,5 +1448,152 @@ public void GetDispositionFileExport_Success_Sales_Purchasers() #endregion #endregion + + #region Sale + + [Fact] + public void GetDisposition_Sale_Should_Fail_NoPermission() + { + // Arrange + var service = this.CreateDispositionServiceWithPermissions(); + + // Act + Action act = () => service.GetDispositionFileSale(1); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddDispositionFile_Sale_Should_Fail_NoPermission() + { + // Arrange + var service = this.CreateDispositionServiceWithPermissions(); + + // Act + Action act = () => service.AddDispositionFileSale(new() + { + DispositionFileId = 1, + DispositionSaleId = 1, + }); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddDispositionFile_Sale_Should_Fail_Sale_Exists() + { + // Arrange + var service = this.CreateDispositionServiceWithPermissions(Permissions.DispositionEdit); + var repository = this._helper.GetService>(); + + repository.Setup(x => x.GetById(1)).Returns(new PimsDispositionFile() + { + DispositionFileId = 1, + PimsDispositionSales = new List() { + new PimsDispositionSale() + { + DispositionSaleId = 10, + DispositionFileId = 1, + }, + }, + }); + + // Act + Action act = () => service.AddDispositionFileSale(new() + { + DispositionFileId = 1, + }); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddDispositionFile_Sale_Success() + { + // Arrange + var service = this.CreateDispositionServiceWithPermissions(Permissions.DispositionEdit); + var repository = this._helper.GetService>(); + + repository.Setup(x => x.GetById(1)).Returns(new PimsDispositionFile() + { + DispositionFileId = 1, + PimsDispositionOffers = new List() { }, + }); + repository.Setup(x => x.AddDispositionFileSale(It.IsAny())).Returns(new PimsDispositionSale() + { + DispositionFileId = 1, + DispositionSaleId = 100, + }); + + // Act + var result = service.AddDispositionFileSale(new() + { + DispositionFileId = 1, + DispositionSaleId = 0, + }); + + // Assert + Assert.NotNull(result); + repository.Verify(x => x.AddDispositionFileSale(It.IsAny()), Times.Once); + } + + [Fact] + public void UpdateDispositionFile_Sale_Should_Fail_NoPermission() + { + // Arrange + var service = this.CreateDispositionServiceWithPermissions(); + + // Act + Action act = () => service.UpdateDispositionFileSale(new() + { + DispositionFileId = 1, + DispositionSaleId = 10, + }); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void UpdateDispositionFile_Sale_Success() + { + // Arrange + var service = this.CreateDispositionServiceWithPermissions(Permissions.DispositionEdit); + var repository = this._helper.GetService>(); + + repository.Setup(x => x.GetById(1)).Returns(new PimsDispositionFile() + { + DispositionFileId = 1, + PimsDispositionSales = new List() { + new PimsDispositionSale() + { + DispositionFileId = 1, + DispositionSaleId = 10 + } + }, + }); + repository.Setup(x => x.UpdateDispositionFileSale(It.IsAny())).Returns(new PimsDispositionSale() + { + DispositionFileId = 1, + DispositionSaleId = 10, + }); + + // Act + var result = service.UpdateDispositionFileSale(new() + { + DispositionFileId = 1, + DispositionSaleId = 10, + SaleFinalAmt = 2000, + }); + + // Assert + Assert.NotNull(result); + repository.Verify(x => x.UpdateDispositionFileSale(It.IsAny()), Times.Once); + } + + #endregion } } diff --git a/source/backend/tests/unit/dal/Repositories/DispositionFileRepositoryTest.cs b/source/backend/tests/unit/dal/Repositories/DispositionFileRepositoryTest.cs index 4fc9ddc45b..f8b99b2ad6 100644 --- a/source/backend/tests/unit/dal/Repositories/DispositionFileRepositoryTest.cs +++ b/source/backend/tests/unit/dal/Repositories/DispositionFileRepositoryTest.cs @@ -72,7 +72,7 @@ public void Update_Success() { // Arrange var dispositionFile = EntityHelper.CreateDispositionFile(); - + var repository = CreateRepositoryWithPermissions(Permissions.DispositionView); _helper.AddAndSaveChanges(dispositionFile); @@ -103,7 +103,6 @@ public void Update_KeyNotFound() } #endregion - #region GetById [Fact] public void GetById_Success() @@ -188,7 +187,7 @@ public void GetTeamMembers_Success() // Assert result.Should().NotBeNull(); - result.Should().BeAssignableTo< List>(); + result.Should().BeAssignableTo>(); result.Should().HaveCount(1); } #endregion @@ -200,7 +199,7 @@ public void GetDispositionOffers_Success() // Arrange var repository = CreateRepositoryWithPermissions(Permissions.DispositionView); var dispFile = EntityHelper.CreateDispositionFile(); - dispFile.PimsDispositionOffers = new List() { new PimsDispositionOffer() { OfferName = "offer"} }; + dispFile.PimsDispositionOffers = new List() { new PimsDispositionOffer() { OfferName = "offer" } }; _helper.AddAndSaveChanges(dispFile); // Act @@ -305,6 +304,80 @@ public void UpdateDisposition_Appraisal_Success() #endregion + #region DispositionSale + + [Fact] + public void AddDisposition_Sale_Success() + { + // Arrange + var repository = CreateRepositoryWithPermissions(Permissions.DispositionView); + var dispFile = EntityHelper.CreateDispositionFile(); + _helper.AddAndSaveChanges(dispFile); + + var sale = new PimsDispositionSale() { DispositionFileId = 1 }; + + // Act + var result = repository.AddDispositionFileSale(sale); + + // Assert + result.Should().NotBeNull(); + result.Should().BeAssignableTo(); + } + + [Fact] + public void UpdateDisposition_Sale_NotFound() + { + // Arrange + var repository = CreateRepositoryWithPermissions(Permissions.DispositionView); + var dispFile = EntityHelper.CreateDispositionFile(); + dispFile.PimsDispositionSales = new List() { + new PimsDispositionSale() + { + DispositionSaleId = 1, + SaleFinalAmt = 2000, + } + }; + _helper.AddAndSaveChanges(dispFile); + + var sale = new PimsDispositionSale(); + sale.DispositionSaleId = 2; + + // Act + Action act = () => repository.UpdateDispositionFileSale(sale); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void UpdateDisposition_Sale_Success() + { + // Arrange + var repository = CreateRepositoryWithPermissions(Permissions.DispositionView); + var dispFile = EntityHelper.CreateDispositionFile(); + dispFile.PimsDispositionSales = new List() { + new PimsDispositionSale() + { + DispositionSaleId = 1, + SaleFinalAmt = 2000, + } + }; + _helper.AddAndSaveChanges(dispFile); + + var sale = dispFile.PimsDispositionSales.FirstOrDefault(); + sale.SaleFinalAmt = 3000; + + // Act + var result = repository.UpdateDispositionFileSale(sale); + + // Assert + result.Should().NotBeNull(); + result.Should().BeAssignableTo(); + result.SaleFinalAmt.Should().Be(3000); + } + + #endregion + #region AddDispositionOffer [Fact] public void AddDispositionOffer_Success() diff --git a/source/frontend/src/features/mapSideBar/disposition/form/DispositionTeamSubForm.tsx b/source/frontend/src/features/mapSideBar/disposition/form/DispositionTeamSubForm.tsx index d5a602b66e..641bd9ca29 100644 --- a/source/frontend/src/features/mapSideBar/disposition/form/DispositionTeamSubForm.tsx +++ b/source/frontend/src/features/mapSideBar/disposition/form/DispositionTeamSubForm.tsx @@ -78,7 +78,7 @@ const DispositionTeamSubForm: React.FunctionComponent< {teamMember.contact?.organizationId && !teamMember.contact?.personId && ( - + { + const mockDispositionFileApi = mockDispositionSaleApi(10, 1); + + it('It Generates the Model from the API entity response', () => { + const defaultValuesModel = new DispositionSaleFormModel(null, 1, null); + + expect(defaultValuesModel.id).toBe(null); + expect(defaultValuesModel.dispositionFileId).toBe(1); + expect(defaultValuesModel.rowVersion).toBe(null); + }); + + it('It Generates the Model from the API entity response', () => { + const modelFromApi = DispositionSaleFormModel.fromApi(mockDispositionFileApi); + + expect(modelFromApi.id).toBe(10); + expect(modelFromApi.dispositionFileId).toBe(1); + expect(modelFromApi.finalConditionRemovalDate).toBe('2024-01-26'); + expect(modelFromApi.saleCompletionDate).toBe('2024-01-27'); + expect(modelFromApi.saleFiscalYear).toBe('2023'); + expect(modelFromApi.finalSaleAmount).toBe(2500000); + expect(modelFromApi.realtorCommissionAmount).toBe(1000); + expect(modelFromApi.isGstRequired).toBe(true); + expect(modelFromApi.gstCollectedAmount).toBe(125000); + expect(modelFromApi.netBookAmount).toBe(2000); + expect(modelFromApi.totalCostAmount).toBe(3000); + expect(modelFromApi.sppAmount).toBe(4000); + expect(modelFromApi.remediationAmount).toBe(5000); + expect(modelFromApi.netProceedsBeforeSppAmount).toBe(2369000); + expect(modelFromApi.netProceedsAfterSppAmount).toBe(2365000); + + expect(modelFromApi.dispositionPurchasers).toHaveLength(3); + + expect(modelFromApi.dispositionPurchaserAgent).not.toBeNull(); + expect(modelFromApi.dispositionPurchaserAgent?.id).toBe(300); + expect(modelFromApi.dispositionPurchaserAgent?.contact).not.toBeNull(); + expect(modelFromApi.dispositionPurchaserAgent?.contact?.organizationId).toBe(3); + expect(modelFromApi.dispositionPurchaserAgent?.primaryContactId).toBe('3'); + + expect(modelFromApi.dispositionPurchaserSolicitor).not.toBeNull(); + + expect(modelFromApi.rowVersion).toBe(1); + }); + + it('It calculates the Net Proceeds amounts', () => { + const netProceedsBeforeNull = calculateNetProceedsBeforeSppAmount(null); + const netProceedsAfterSPPNull = calculateNetProceedsAfterSppAmount(null); + + expect(netProceedsBeforeNull).toBe(0); + expect(netProceedsAfterSPPNull).toBe(0); + + const netProceedsBeforeSPPAmount = calculateNetProceedsBeforeSppAmount(mockDispositionFileApi); + const netProceedsAfterSPPAmount = calculateNetProceedsAfterSppAmount(mockDispositionFileApi); + + expect(netProceedsBeforeSPPAmount).toBe(2369000); + expect(netProceedsAfterSPPAmount).toBe(2365000); + }); + + it('It calculates the Net Proceeds amounts', () => { + const defaultValuesModel = new DispositionSaleFormModel(null, 1, null); + defaultValuesModel.saleFiscalYear = '2023'; + + const apiModel = defaultValuesModel.toApi(); + + expect(apiModel.id).toBeNull(); + expect(apiModel.dispositionFileId).toBe(1); + expect(apiModel.saleFiscalYear).toBe('2023'); + expect(apiModel.rowVersion).toBe(0); + }); +}); diff --git a/source/frontend/src/features/mapSideBar/disposition/models/DispositionSaleFormModel.ts b/source/frontend/src/features/mapSideBar/disposition/models/DispositionSaleFormModel.ts index f7c03e9c40..3717a095b2 100644 --- a/source/frontend/src/features/mapSideBar/disposition/models/DispositionSaleFormModel.ts +++ b/source/frontend/src/features/mapSideBar/disposition/models/DispositionSaleFormModel.ts @@ -1,13 +1,16 @@ -import { Api_DispositionFileSale } from '@/models/api/DispositionFile'; -import { emptyStringtoNullable } from '@/utils/formUtils'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; +import { ApiGen_Concepts_DispositionSalePurchaser } from '@/models/api/generated/ApiGen_Concepts_DispositionSalePurchaser'; +import { emptyStringtoNullable, stringToBoolean } from '@/utils/formUtils'; -export class DispositionSaleFormModel { +import { DispositionSaleContactModel, WithSalePurchasers } from './DispositionSaleContactModel'; + +export class DispositionSaleFormModel implements WithSalePurchasers { finalConditionRemovalDate: string | null = null; saleCompletionDate: string | null = null; saleFiscalYear: string | null = null; finalSaleAmount: number | null = null; realtorCommissionAmount: number | null = null; - isGstRequired: boolean | null = null; + isGstRequired: boolean = false; gstCollectedAmount: number | null = null; netBookAmount: number | null = null; totalCostAmount: number | null = null; @@ -15,21 +18,34 @@ export class DispositionSaleFormModel { sppAmount: number | null = null; netProceedsAfterSppAmount: number | null = null; remediationAmount: number | null = null; + dispositionPurchasers: DispositionSaleContactModel[] = []; + dispositionPurchaserAgent: DispositionSaleContactModel | null = new DispositionSaleContactModel(); + dispositionPurchaserSolicitor: DispositionSaleContactModel | null = + new DispositionSaleContactModel(); - constructor(readonly id: number | null = null, readonly dispositionFileId: number) { + constructor( + readonly id: number | null = null, + readonly dispositionFileId: number, + readonly rowVersion: number | null = null, + ) { this.id = id; this.dispositionFileId = dispositionFileId; + this.rowVersion = rowVersion; } - static fromApi(entity: Api_DispositionFileSale) { - const model = new DispositionSaleFormModel(entity.id, entity.dispositionFileId); + static fromApi(entity: ApiGen_Concepts_DispositionFileSale): DispositionSaleFormModel { + const model = new DispositionSaleFormModel( + entity.id, + entity.dispositionFileId, + entity.rowVersion, + ); model.finalConditionRemovalDate = entity.finalConditionRemovalDate; model.saleCompletionDate = entity.saleCompletionDate; model.saleFiscalYear = entity.saleFiscalYear; model.finalSaleAmount = entity.finalSaleAmount; model.realtorCommissionAmount = entity.realtorCommissionAmount; - model.isGstRequired = entity.isGstRequired; + model.isGstRequired = entity.isGstRequired ?? false; model.gstCollectedAmount = entity.gstCollectedAmount; model.netBookAmount = entity.netBookAmount; model.totalCostAmount = entity.totalCostAmount; @@ -38,31 +54,56 @@ export class DispositionSaleFormModel { model.netProceedsBeforeSppAmount = calculateNetProceedsBeforeSppAmount(entity); model.netProceedsAfterSppAmount = calculateNetProceedsAfterSppAmount(entity); + + model.dispositionPurchasers = + entity.dispositionPurchasers?.map(x => DispositionSaleContactModel.fromApi(x)) || []; + + model.dispositionPurchaserAgent = entity.dispositionPurchaserAgent + ? DispositionSaleContactModel.fromApi(entity.dispositionPurchaserAgent) + : new DispositionSaleContactModel(null, entity.id); + + model.dispositionPurchaserSolicitor = entity.dispositionPurchaserSolicitor + ? DispositionSaleContactModel.fromApi(entity.dispositionPurchaserSolicitor) + : new DispositionSaleContactModel(null, entity.id); + + return model; } - toApi(): Api_DispositionFileSale { + toApi(): ApiGen_Concepts_DispositionFileSale { return { id: this.id, dispositionFileId: this.dispositionFileId, finalConditionRemovalDate: emptyStringtoNullable(this.finalConditionRemovalDate), saleCompletionDate: emptyStringtoNullable(this.saleCompletionDate), saleFiscalYear: emptyStringtoNullable(this.saleFiscalYear), - finalSaleAmount: this.finalSaleAmount, - realtorCommissionAmount: this.realtorCommissionAmount, - isGstRequired: this.isGstRequired, - gstCollectedAmount: this.gstCollectedAmount, - netBookAmount: this.netBookAmount, - totalCostAmount: this.totalCostAmount, - sppAmount: this.sppAmount, - remediationAmount: this.remediationAmount, - dispositionPurchasers: [], - dispositionPurchaserAgents: [], - dispositionPurchaserSolicitors: [], + finalSaleAmount: this.finalSaleAmount ? parseFloat(this.finalSaleAmount.toString()) : null, + realtorCommissionAmount: this.realtorCommissionAmount + ? parseFloat(this.realtorCommissionAmount.toString()) + : null, + isGstRequired: stringToBoolean(this.isGstRequired), + gstCollectedAmount: this.gstCollectedAmount + ? parseFloat(this.gstCollectedAmount.toString()) + : null, + netBookAmount: this.netBookAmount ? parseFloat(this.netBookAmount.toString()) : null, + totalCostAmount: this.totalCostAmount ? parseFloat(this.totalCostAmount.toString()) : null, + sppAmount: this.sppAmount ? parseFloat(this.sppAmount.toString()) : null, + remediationAmount: this.remediationAmount + ? parseFloat(this.remediationAmount.toString()) + : null, + dispositionPurchasers: this.dispositionPurchasers + .filter(x => !!x.contact) + .map(x => x.toApi()) + .filter((x): x is ApiGen_Concepts_DispositionSalePurchaser => x !== null), + dispositionPurchaserAgent: this.dispositionPurchaserAgent?.toApi() ?? null, + dispositionPurchaserSolicitor: this.dispositionPurchaserSolicitor?.toApi() ?? null, + rowVersion: this.rowVersion ?? 0, }; } } -export const calculateNetProceedsBeforeSppAmount = (apiModel: Api_DispositionFileSale | null) => { +export const calculateNetProceedsBeforeSppAmount = ( + apiModel: ApiGen_Concepts_DispositionFileSale | null, +): number | null => { return apiModel == null ? 0 : (apiModel.finalSaleAmount ?? 0) - @@ -72,7 +113,9 @@ export const calculateNetProceedsBeforeSppAmount = (apiModel: Api_DispositionFil (apiModel.netBookAmount ?? 0)); }; -export const calculateNetProceedsAfterSppAmount = (apiModel: Api_DispositionFileSale | null) => { +export const calculateNetProceedsAfterSppAmount = ( + apiModel: ApiGen_Concepts_DispositionFileSale | null, +): number | null => { return apiModel == null ? 0 : (apiModel.finalSaleAmount ?? 0) - diff --git a/source/frontend/src/features/mapSideBar/disposition/models/DispositionSalePurchaserModel.ts b/source/frontend/src/features/mapSideBar/disposition/models/DispositionSalePurchaserModel.ts deleted file mode 100644 index 3396652491..0000000000 --- a/source/frontend/src/features/mapSideBar/disposition/models/DispositionSalePurchaserModel.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IContactSearchResult } from '@/interfaces/IContactSearchResult'; - -export class DispositionSalePurchaserModel { - contact: IContactSearchResult | null = null; - primaryContactId: string = ''; - - constructor( - readonly id: number | null = null, - readonly rowVersion: number | null = null, - contact: IContactSearchResult | null = null, - ) { - this.id = id; - this.contact = contact; - this.rowVersion = rowVersion; - } -} diff --git a/source/frontend/src/features/mapSideBar/disposition/router/DispositionRouter.tsx b/source/frontend/src/features/mapSideBar/disposition/router/DispositionRouter.tsx index bb91f8402f..c9991118f0 100644 --- a/source/frontend/src/features/mapSideBar/disposition/router/DispositionRouter.tsx +++ b/source/frontend/src/features/mapSideBar/disposition/router/DispositionRouter.tsx @@ -19,6 +19,9 @@ import UpdateDispositionAppraisalContainer from '../tabs/offersAndSale/dispositi import AddDispositionOfferContainer from '../tabs/offersAndSale/dispositionOffer/add/AddDispositionOfferContainer'; import DispositionOfferForm from '../tabs/offersAndSale/dispositionOffer/form/DispositionOfferForm'; import UpdateDispositionOfferContainer from '../tabs/offersAndSale/dispositionOffer/update/UpdateDispositionOfferContainer'; +import UpdateDispositionSaleContainer from '../tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleContainer'; +import UpdateDispositionSaleView from '../tabs/offersAndSale/dispositionSale/update/UpdateDispostionSaleView'; + export interface IDispositionRouterProps { formikRef: React.Ref>; dispositionFile?: Api_DispositionFile; @@ -114,6 +117,19 @@ export const DispositionRouter: React.FC = props => { key={'disposition'} title={'Update Disposition Offer'} /> + ( + + )} + claim={Claims.DISPOSITION_EDIT} + key={'disposition'} + title={'Add Disposition Offer'} + /> ([]); - const [dispositionSale, setDispositionSale] = useState(null); + const [dispositionSale, setDispositionSale] = + useState(null); const [dispositionAppraisal, setdispositionAppraisal] = useState(null); diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/OffersAndSaleContainerView.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/OffersAndSaleContainerView.tsx index 9a452b2c35..be62a824d1 100644 --- a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/OffersAndSaleContainerView.tsx +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/OffersAndSaleContainerView.tsx @@ -14,8 +14,8 @@ import { Api_DispositionFile, Api_DispositionFileAppraisal, Api_DispositionFileOffer, - Api_DispositionFileSale, } from '@/models/api/DispositionFile'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; import { prettyFormatDate } from '@/utils/dateUtils'; import { formatMoney } from '@/utils/numberFormatUtils'; @@ -30,7 +30,7 @@ export interface IOffersAndSaleContainerViewProps { loading: boolean; dispositionFile: Api_DispositionFile; dispositionOffers: Api_DispositionFileOffer[]; - dispositionSale: Api_DispositionFileSale | null; + dispositionSale: ApiGen_Concepts_DispositionFileSale | null; dispositionAppraisal: Api_DispositionFileAppraisal | null; onDispositionOfferDeleted: (offerId: number) => void; } @@ -47,8 +47,8 @@ const OffersAndSaleContainerView: React.FunctionComponent @@ -130,7 +130,7 @@ const OffersAndSaleContainerView: React.FunctionComponent {dispositionOffers.map((offer, index) => ( -
+
+ + {keycloak.hasClaim(Claims.DISPOSITION_EDIT) && ( + { + history.push(`${match.url}/sale/update`); + }} + /> + )} + + } + > {(dispositionSale && ( <> - {dispositionSale.dispositionPurchasers.map((purchaser, index) => ( - - - {index !== dispositionSale.dispositionPurchasers.length - 1 && ( - - )} - - ))} + {dispositionSale.dispositionPurchasers && + dispositionSale.dispositionPurchasers.map((purchaser, index) => ( + + + {dispositionSale.dispositionPurchasers && + index !== dispositionSale.dispositionPurchasers?.length - 1 && ( + + )} + + ))} {dispositionSale?.isGstRequired ? 'Yes' : 'No'} - - {formatMoney(dispositionSale.gstCollectedAmount)} - + {dispositionSale?.isGstRequired && ( + + {formatMoney(dispositionSale.gstCollectedAmount)} + + )} + @@ -231,7 +261,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = `
- Sales Details +
+ +
@@ -245,17 +281,17 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
Jan 30, 2022 @@ -497,13 +533,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
Jan 30, 2024 @@ -516,13 +552,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
2023 @@ -535,13 +571,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
$746,325.23 @@ -554,13 +590,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
$12,500.27 @@ -573,13 +609,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
Yes @@ -592,7 +628,7 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
$36,489.36 @@ -635,13 +671,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
$246.20 @@ -654,7 +690,7 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
$856,320.36 @@ -697,7 +733,7 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
-$159,230.96 @@ -740,7 +776,7 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
$1,000.00 @@ -783,7 +819,7 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
-$160,230.96 @@ -826,13 +862,13 @@ exports[`Disposition Offer Detail View component renders as expected 1`] = ` class="pr-0 text-left col-6" >
$1.00 diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionOffer/dispositionSaleContactDetails/DispositionSaleContactDetails.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionOffer/dispositionSaleContactDetails/DispositionSaleContactDetails.tsx index f3a677c41f..617eba4fe8 100644 --- a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionOffer/dispositionSaleContactDetails/DispositionSaleContactDetails.tsx +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionOffer/dispositionSaleContactDetails/DispositionSaleContactDetails.tsx @@ -3,25 +3,22 @@ import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { StyledLink } from '@/components/maps/leaflet/LayerPopup/styles'; -import { - Api_DispositionSalePurchaser, - Api_DispositionSalePurchaserAgent, - Api_DispositionSalePurchaserSolicitor, -} from '@/models/api/DispositionFile'; +import { ApiGen_Concepts_DispositionSalePurchaser } from '@/models/api/generated/ApiGen_Concepts_DispositionSalePurchaser'; +import { ApiGen_Concepts_DispositionSalePurchaserAgent } from '@/models/api/generated/ApiGen_Concepts_DispositionSalePurchaserAgent'; +import { ApiGen_Concepts_DispositionSalePurchaserSolicitor } from '@/models/api/generated/ApiGen_Concepts_DispositionSalePurchaserSolicitor'; import { formatApiPersonNames } from '@/utils/personUtils'; -export interface IDispositionSaleContactDetails { +export interface IDispositionSaleContactDetailsProps { contactInformation: - | Api_DispositionSalePurchaser - | Api_DispositionSalePurchaserAgent - | Api_DispositionSalePurchaserSolicitor; + | ApiGen_Concepts_DispositionSalePurchaser + | ApiGen_Concepts_DispositionSalePurchaserSolicitor + | ApiGen_Concepts_DispositionSalePurchaserAgent; primaryContactLabel?: string | null; } -const DispositionSaleContactDetails: React.FunctionComponent = ({ - contactInformation, - primaryContactLabel, -}) => { +const DispositionSaleContactDetails: React.FunctionComponent< + IDispositionSaleContactDetailsProps +> = ({ contactInformation, primaryContactLabel }) => { const labelValue = primaryContactLabel ? primaryContactLabel : 'Primary contact'; const primaryContact = contactInformation.primaryContact ?? null; diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleForm.test.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleForm.test.tsx new file mode 100644 index 0000000000..aefc829c99 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleForm.test.tsx @@ -0,0 +1,429 @@ +import { Formik, FormikProps } from 'formik'; +import { createMemoryHistory } from 'history'; +import { noop } from 'lodash'; +import React from 'react'; + +import Claims from '@/constants/claims'; +import { DispositionSaleFormModel } from '@/features/mapSideBar/disposition/models/DispositionSaleFormModel'; +import { mockLookups } from '@/mocks/lookups.mock'; +import { lookupCodesSlice } from '@/store/slices/lookupCodes/lookupCodesSlice'; +import { systemConstantsSlice } from '@/store/slices/systemConstants'; +import { + act, + fillInput, + fireEvent, + renderAsync, + RenderOptions, + userEvent, + waitFor, + waitForEffects, +} from '@/utils/test-utils'; + +import DispositionSaleForm, { IDispositionSaleFormProps } from './DispositionSaleForm'; +import { DispositionSaleFormYupSchema } from './DispositionSaleFormYupSchema'; + +const history = createMemoryHistory(); + +const defaultInitialValues = new DispositionSaleFormModel(null, 1, 0); + +describe('DispositionSaleForm component', () => { + const setup = async ( + renderOptions: RenderOptions & { props?: Partial }, + ) => { + // render component under + const ref = React.createRef>(); + const utils = await renderAsync( + + enableReinitialize + onSubmit={noop} + initialValues={defaultInitialValues} + validationSchema={DispositionSaleFormYupSchema} + innerRef={ref} + > + {formikProps => } + , + { + ...renderOptions, + useMockAuthentication: true, + claims: renderOptions?.claims ?? [Claims.DISPOSITION_EDIT], + history: history, + store: { + [lookupCodesSlice.name]: { lookupCodes: mockLookups }, + [systemConstantsSlice.name]: { systemConstants: [{ name: 'GST', value: '5.0' }] }, + }, + }, + ); + + return { + ...utils, + // Finding elements + getFinalSaleAmountTextbox: () => + utils.container.querySelector(`input[name="finalSaleAmount"]`) as HTMLInputElement, + getGSTCollectedAmountTextbox: () => + utils.container.querySelector(`input[name="gstCollectedAmount"]`) as HTMLInputElement, + getRealtorCommissionAmountTextbox: () => + utils.container.querySelector(`input[name="realtorCommissionAmount"]`) as HTMLInputElement, + getTotalCostSaleAmountTextbox: () => + utils.container.querySelector(`input[name="totalCostAmount"]`) as HTMLInputElement, + getNetBookAmountTextbox: () => + utils.container.querySelector(`input[name="netBookAmount"]`) as HTMLInputElement, + getNetProceedsBeforeSPPAmountTextbox: () => + utils.container.querySelector( + `input[name="netProceedsBeforeSppAmount"]`, + ) as HTMLInputElement, + getSPPAmountTextbox: () => + utils.container.querySelector(`input[name="sppAmount"]`) as HTMLInputElement, + getNetProceedsAfterSPPAmountTextbox: () => + utils.container.querySelector( + `input[name="netProceedsAfterSppAmount"]`, + ) as HTMLInputElement, + }; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders as expected', async () => { + const { asFragment } = await setup({ + props: { + dispostionSaleId: null, + }, + }); + + const fragment = await waitFor(() => asFragment()); + expect(fragment).toMatchSnapshot(); + }); + + it(`renders 'Add Purchaser' link`, async () => { + const { getByTestId } = await setup({}); + expect(getByTestId('add-purchaser-button')).toBeVisible(); + }); + + it(`renders 'Remove team member' link`, async () => { + const { getByTestId } = await setup({ props: { dispostionSaleId: null } }); + const addRow = getByTestId('add-purchaser-button'); + + await act(async () => userEvent.click(addRow)); + expect(getByTestId('dispositionPurchasers.0.remove-button')).toBeVisible(); + }); + + it(`displays a confirmation popup before purchaser is removed`, async () => { + const { getByTestId, getByText } = await setup({ + props: { dispostionSaleId: null }, + }); + const addRow = getByTestId('add-purchaser-button'); + + await act(async () => userEvent.click(addRow)); + await act(async () => userEvent.click(getByTestId('dispositionPurchasers.0.remove-button'))); + + expect(getByText(/Do you wish to remove this purchaser/i)).toBeVisible(); + }); + + it(`removes the purchaser upon user confirmation`, async () => { + const { getByTestId, getByText, getByTitle, queryByTestId } = await setup({ + props: { dispostionSaleId: null }, + }); + + const addRow = getByTestId('add-purchaser-button'); + await act(async () => userEvent.click(addRow)); + await act(async () => userEvent.click(getByTestId('dispositionPurchasers.0.remove-button'))); + + expect(getByText(/Do you wish to remove this purchaser/i)).toBeVisible(); + + await act(async () => userEvent.click(getByTitle('ok-modal'))); + expect(queryByTestId('purchaserRow[0]')).toBeNull(); + }); + + it(`does not remove the owner when confirmation popup is cancelled`, async () => { + const { getByTestId, getByText, getByTitle } = await setup({ + props: { dispostionSaleId: null }, + }); + + const addRow = getByTestId('add-purchaser-button'); + + await act(async () => userEvent.click(addRow)); + await act(async () => userEvent.click(getByTestId('dispositionPurchasers.0.remove-button'))); + + expect(getByText(/Do you wish to remove this purchaser/i)).toBeVisible(); + + await act(async () => userEvent.click(getByTitle('cancel-modal'))); + expect(getByTestId('purchaserRow[0]')).toBeInTheDocument(); + }); + + it('Calculates the GST collected amount over the "Final Sale Amount" when the GST is required is set to "Yes"', async () => { + const { container, getFinalSaleAmountTextbox, getGSTCollectedAmountTextbox } = await setup({ + props: { dispostionSaleId: null }, + }); + + expect(getFinalSaleAmountTextbox()).toBeVisible(); + expect(getFinalSaleAmountTextbox()).toHaveValue(''); + expect(getGSTCollectedAmountTextbox()).toBeNull(); + + await act(async () => { + fireEvent.change(getFinalSaleAmountTextbox(), { target: { value: '$1,000,000.00' } }); + }); + waitForEffects(); + + act(() => { + fillInput(container, 'isGstRequired', 'true', 'select'); + }); + waitForEffects(); + + expect(getGSTCollectedAmountTextbox()).toBeVisible(); + expect(getGSTCollectedAmountTextbox()).toHaveValue('$50,000.00'); + }); + + it('Calculates the GST and displays warning when the GST is required is set to "NO"', async () => { + const { + container, + getFinalSaleAmountTextbox, + getGSTCollectedAmountTextbox, + getByTitle, + findByText, + } = await setup({ + props: { dispostionSaleId: null }, + }); + + expect(getFinalSaleAmountTextbox()).toBeVisible(); + expect(getFinalSaleAmountTextbox()).toHaveValue(''); + expect(getGSTCollectedAmountTextbox()).toBeNull(); + + await act(async () => { + fireEvent.change(getFinalSaleAmountTextbox(), { target: { value: '$1,000,000.00' } }); + }); + waitForEffects(); + + act(() => { + fillInput(container, 'isGstRequired', 'true', 'select'); + }); + waitForEffects(); + + expect(getGSTCollectedAmountTextbox()).toBeVisible(); + expect(getGSTCollectedAmountTextbox()).toHaveValue('$50,000.00'); + + act(() => { + fillInput(container, 'isGstRequired', 'false', 'select'); + }); + waitForEffects(); + + expect( + await findByText(/The GST, if provided, will be cleared. Do you wish to proceed/i), + ).toBeVisible(); + + await act(async () => userEvent.click(getByTitle('ok-modal'))); + expect(getGSTCollectedAmountTextbox()).toBeNull(); + }); + + it('Calculates the Net Proceeds without GST', async () => { + const { + getFinalSaleAmountTextbox, + getRealtorCommissionAmountTextbox, + getTotalCostSaleAmountTextbox, + getNetBookAmountTextbox, + getGSTCollectedAmountTextbox, + getNetProceedsBeforeSPPAmountTextbox, + getSPPAmountTextbox, + getNetProceedsAfterSPPAmountTextbox, + } = await setup({ + props: { dispostionSaleId: null }, + }); + + expect(getFinalSaleAmountTextbox()).toBeVisible(); + expect(getFinalSaleAmountTextbox()).toHaveValue(''); + expect(getGSTCollectedAmountTextbox()).toBeNull(); + + await act(async () => { + fireEvent.change(getFinalSaleAmountTextbox(), { target: { value: '$10,000.00' } }); + }); + fireEvent.blur(getFinalSaleAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getRealtorCommissionAmountTextbox(), { target: { value: '$100.00' } }); + }); + fireEvent.blur(getRealtorCommissionAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getTotalCostSaleAmountTextbox(), { target: { value: '$100.00' } }); + }); + fireEvent.blur(getTotalCostSaleAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getNetBookAmountTextbox(), { target: { value: '$300.00' } }); + }); + fireEvent.blur(getNetBookAmountTextbox()); + await waitForEffects(); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,500.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$9,500.00'); + + await act(async () => { + fireEvent.change(getSPPAmountTextbox(), { target: { value: '$500.00' } }); + }); + fireEvent.blur(getSPPAmountTextbox()); + await waitForEffects(); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,500.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$9,000.00'); + }); + + it('Calculates the Net Proceeds WITH GST', async () => { + const { + container, + getFinalSaleAmountTextbox, + getRealtorCommissionAmountTextbox, + getTotalCostSaleAmountTextbox, + getNetBookAmountTextbox, + getGSTCollectedAmountTextbox, + getNetProceedsBeforeSPPAmountTextbox, + getSPPAmountTextbox, + getNetProceedsAfterSPPAmountTextbox, + } = await setup({ + props: { dispostionSaleId: null }, + }); + + expect(getFinalSaleAmountTextbox()).toBeVisible(); + expect(getFinalSaleAmountTextbox()).toHaveValue(''); + expect(getGSTCollectedAmountTextbox()).toBeNull(); + + await act(async () => { + fireEvent.change(getFinalSaleAmountTextbox(), { target: { value: '$10,000.00' } }); + }); + fireEvent.blur(getFinalSaleAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getRealtorCommissionAmountTextbox(), { target: { value: '$100.00' } }); + }); + fireEvent.blur(getRealtorCommissionAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getTotalCostSaleAmountTextbox(), { target: { value: '$100.00' } }); + }); + fireEvent.blur(getTotalCostSaleAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getNetBookAmountTextbox(), { target: { value: '$300.00' } }); + }); + fireEvent.blur(getNetBookAmountTextbox()); + await waitForEffects(); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,500.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$9,500.00'); + + await act(async () => { + fireEvent.change(getSPPAmountTextbox(), { target: { value: '$500.00' } }); + }); + fireEvent.blur(getSPPAmountTextbox()); + await waitForEffects(); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,500.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$9,000.00'); + + act(() => { + fillInput(container, 'isGstRequired', 'true', 'select'); + }); + fireEvent.blur(getGSTCollectedAmountTextbox()); + await waitForEffects(); + + expect(getGSTCollectedAmountTextbox()).toBeVisible(); + expect(getGSTCollectedAmountTextbox()).toHaveValue('$500.00'); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,000.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$8,500.00'); + }); + + it('Calculates the Net Proceeds WITH Negative Values', async () => { + const { + getFinalSaleAmountTextbox, + getRealtorCommissionAmountTextbox, + getGSTCollectedAmountTextbox, + getNetProceedsBeforeSPPAmountTextbox, + getNetProceedsAfterSPPAmountTextbox, + } = await setup({ + props: { dispostionSaleId: null }, + }); + + expect(getFinalSaleAmountTextbox()).toBeVisible(); + expect(getFinalSaleAmountTextbox()).toHaveValue(''); + expect(getGSTCollectedAmountTextbox()).toBeNull(); + + await act(async () => { + fireEvent.change(getFinalSaleAmountTextbox(), { target: { value: '$1,000.00' } }); + }); + fireEvent.blur(getFinalSaleAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getRealtorCommissionAmountTextbox(), { target: { value: '$1,500.00' } }); + }); + fireEvent.blur(getRealtorCommissionAmountTextbox()); + await waitForEffects(); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('-$500.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('-$500.00'); + }); + + it('Displays Warning when removing the GST is required', async () => { + const { + container, + getFinalSaleAmountTextbox, + getRealtorCommissionAmountTextbox, + getTotalCostSaleAmountTextbox, + getNetBookAmountTextbox, + getGSTCollectedAmountTextbox, + getNetProceedsBeforeSPPAmountTextbox, + getSPPAmountTextbox, + getNetProceedsAfterSPPAmountTextbox, + } = await setup({ + props: { dispostionSaleId: null }, + }); + + expect(getFinalSaleAmountTextbox()).toBeVisible(); + expect(getFinalSaleAmountTextbox()).toHaveValue(''); + expect(getGSTCollectedAmountTextbox()).toBeNull(); + + await act(async () => { + fireEvent.change(getFinalSaleAmountTextbox(), { target: { value: '$10,000.00' } }); + fireEvent.change(getRealtorCommissionAmountTextbox(), { target: { value: '$100.00' } }); + fireEvent.change(getTotalCostSaleAmountTextbox(), { target: { value: '$100.00' } }); + }); + fireEvent.blur(getFinalSaleAmountTextbox()); + await waitForEffects(); + + await act(async () => { + fireEvent.change(getNetBookAmountTextbox(), { target: { value: '$300.00' } }); + }); + fireEvent.blur(getNetBookAmountTextbox()); + await waitForEffects(); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,500.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$9,500.00'); + + await act(async () => { + fireEvent.change(getSPPAmountTextbox(), { target: { value: '$500.00' } }); + }); + fireEvent.blur(getSPPAmountTextbox()); + await waitForEffects(); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,500.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$9,000.00'); + + act(() => { + fillInput(container, 'isGstRequired', 'true', 'select'); + }); + fireEvent.blur(getGSTCollectedAmountTextbox()); + await waitForEffects(); + + expect(getGSTCollectedAmountTextbox()).toBeVisible(); + expect(getGSTCollectedAmountTextbox()).toHaveValue('$500.00'); + + expect(getNetProceedsBeforeSPPAmountTextbox()).toHaveValue('$9,000.00'); + expect(getNetProceedsAfterSPPAmountTextbox()).toHaveValue('$8,500.00'); + }); +}); diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleForm.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleForm.tsx new file mode 100644 index 0000000000..24a506c1d8 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleForm.tsx @@ -0,0 +1,224 @@ +import { getIn, useFormikContext } from 'formik'; + +import { FastCurrencyInput, FastDatePicker } from '@/components/common/form'; +import { ContactInputContainer } from '@/components/common/form/ContactInput/ContactInputContainer'; +import ContactInputView from '@/components/common/form/ContactInput/ContactInputView'; +import { FastDateYearPicker } from '@/components/common/form/FastDateYearPicker'; +import { PrimaryContactSelector } from '@/components/common/form/PrimaryContactSelector/PrimaryContactSelector'; +import { YesNoSelect } from '@/components/common/form/YesNoSelect'; +import { Section } from '@/components/common/Section/Section'; +import { SectionField } from '@/components/common/Section/SectionField'; +import { DispositionSaleFormModel } from '@/features/mapSideBar/disposition/models/DispositionSaleFormModel'; +import { useModalContext } from '@/hooks/useModalContext'; +import { SystemConstants, useSystemConstants } from '@/store/slices/systemConstants'; +import { getCurrencyCleanValue, stringToBoolean } from '@/utils/formUtils'; + +import { useCalculateNetProceeds } from '../hooks/useCalculateNetProceeds'; +import DispositionSalePurchaserSubForm from './DispositionSalePurchasersSubForm'; + +export interface IDispositionSaleFormProps { + dispostionSaleId: number | null; +} + +const DispositionSaleForm: React.FunctionComponent< + React.PropsWithChildren +> = ({ dispostionSaleId }) => { + const formikProps = useFormikContext(); + const { setModalContent, setDisplayModal } = useModalContext(); + + const isGstRequired = getIn(formikProps.values, 'isGstRequired'); + const dispositionPurchaserAgent = getIn(formikProps.values, 'dispositionPurchaserAgent'); + const dispositionPurchaserSolicitor = getIn(formikProps.values, 'dispositionPurchaserSolicitor'); + + const { getSystemConstant } = useSystemConstants(); + const gstConstant = getSystemConstant(SystemConstants.GST); + const gstDecimal = gstConstant !== undefined ? parseFloat(gstConstant.value) / 100 : undefined; + + useCalculateNetProceeds(isGstRequired); + + // Functions + const onFinalSaleAmountUpdated = (newValue: string): void => { + const isGstRequired = getIn(formikProps.values, 'isGstRequired'); + const cleanValue = getCurrencyCleanValue(newValue); + + setGSTDerivedAmountFields(isGstRequired, cleanValue); + }; + + const onUpdateGstApplicable = (gstOption: string): void => { + const isGstRequired = stringToBoolean(gstOption); + const taxCollectedAmountValue = getIn(formikProps.values, 'gstCollectedAmount'); + + if (!isGstRequired) { + setModalContent({ + variant: 'warning', + title: 'Confirm Change', + message: 'The GST, if provided, will be cleared. Do you wish to proceed?', + okButtonText: 'Yes', + cancelButtonText: 'No', + handleOk: () => { + formikProps.setFieldValue(`isGstRequired`, false); + setGSTDerivedAmountFields(isGstRequired, formikProps.values.finalSaleAmount ?? 0); + setDisplayModal(false); + }, + handleCancel: () => { + formikProps.setFieldValue(`isGstRequired`, true); + formikProps.setFieldValue(`gstCollectedAmount`, taxCollectedAmountValue); + setDisplayModal(false); + }, + }); + setDisplayModal(true); + } else { + formikProps.setFieldValue(`isGstRequired`, gstOption); + setGSTDerivedAmountFields(isGstRequired, formikProps.values.finalSaleAmount ?? 0); + } + }; + + const setGSTDerivedAmountFields = (gstRequired: boolean, salesAmount: number): void => { + if (gstRequired && gstDecimal) { + const taxAmount = salesAmount * gstDecimal; + formikProps.setFieldValue(`gstCollectedAmount`, taxAmount); + } else { + formikProps.setFieldValue(`gstCollectedAmount`, ''); + } + }; + + return ( +
+ + + + + + + + {dispositionPurchaserAgent.contact?.organizationId && + !dispositionPurchaserAgent.contact?.personId && ( + + + + )} + + + + + {dispositionPurchaserSolicitor.contact?.organizationId && + !dispositionPurchaserSolicitor.contact?.personId && ( + + + + )} + + + + + + + + + + + + ) => { + onFinalSaleAmountUpdated(e.target.value); + }} + /> + + + + + + + ) => { + const selectedValue = [].slice + .call(e.target.selectedOptions) + .map((option: HTMLOptionElement & number) => option.value)[0]; + onUpdateGstApplicable(selectedValue); + }} + > + + {isGstRequired && ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default DispositionSaleForm; diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleFormYupSchema.ts b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleFormYupSchema.ts new file mode 100644 index 0000000000..3e5a68dc00 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSaleFormYupSchema.ts @@ -0,0 +1,18 @@ +/* eslint-disable no-template-curly-in-string */ +import * as yup from 'yup'; + +export const DispositionSaleFormYupSchema = yup.object().shape({ + finalConditionRemovalDate: yup.string().nullable(), + saleCompletionDate: yup.string().nullable(), + saleFiscalYear: yup.string().max(4, 'Fiscal year must be at most ${max} characters').nullable(), + finalSaleAmount: yup.number().nullable(), + realtorCommissionAmount: yup.number().nullable(), + isGstRequired: yup.string(), + gstCollectedAmount: yup.number().nullable(), + netBookAmount: yup.number().nullable(), + totalCostAmount: yup.number().nullable(), + netProceedsBeforeSppAmount: yup.number().nullable(), + sppAmount: yup.number().nullable(), + netProceedsAfterSppAmount: yup.number().nullable(), + remediationAmount: yup.number().nullable(), +}); diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSalePurchasersSubForm.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSalePurchasersSubForm.tsx new file mode 100644 index 0000000000..63794b1e56 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/DispositionSalePurchasersSubForm.tsx @@ -0,0 +1,103 @@ +import { FieldArray, useFormikContext } from 'formik'; +import React from 'react'; +import { Col, Row } from 'react-bootstrap'; +import { FaTrash } from 'react-icons/fa'; + +import { LinkButton, StyledRemoveLinkButton } from '@/components/common/buttons'; +import { ContactInputContainer } from '@/components/common/form/ContactInput/ContactInputContainer'; +import ContactInputView from '@/components/common/form/ContactInput/ContactInputView'; +import { PrimaryContactSelector } from '@/components/common/form/PrimaryContactSelector/PrimaryContactSelector'; +import { SectionField } from '@/components/common/Section/SectionField'; +import { + DispositionSaleContactModel, + WithSalePurchasers, +} from '@/features/mapSideBar/disposition/models/DispositionSaleContactModel'; +import { getDeleteModalProps, useModalContext } from '@/hooks/useModalContext'; + +export interface IDispositionSalePurchasersSubFormProps { + dispositionSaleId: number | null; +} + +const DispositionSalePurchaserSubForm: React.FunctionComponent< + React.PropsWithChildren +> = ({ dispositionSaleId }) => { + const { values } = useFormikContext(); + const { setModalContent, setDisplayModal } = useModalContext(); + + return ( + ( + <> + {values.dispositionPurchasers.map( + (purchaser: DispositionSaleContactModel, index: number) => ( + + + + + + + { + setModalContent({ + ...getDeleteModalProps(), + title: 'Remove Purchaser', + message: 'Do you wish to remove this purchaser?', + okButtonText: 'Yes', + cancelButtonText: 'No', + handleOk: async () => { + arrayHelpers.remove(index); + setDisplayModal(false); + }, + handleCancel: () => { + setDisplayModal(false); + }, + }); + setDisplayModal(true); + }} + > + + + + + + {purchaser.contact?.organizationId && !purchaser.contact?.personId && ( + + + + + + + + )} + + ), + )} + + { + const purchaserContact = new DispositionSaleContactModel(null, dispositionSaleId); + arrayHelpers.push(purchaserContact); + }} + > + + Add another purchaser + + + )} + /> + ); +}; + +export default DispositionSalePurchaserSubForm; diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/__snapshots__/DispositionSaleForm.test.tsx.snap b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/__snapshots__/DispositionSaleForm.test.tsx.snap new file mode 100644 index 0000000000..6025d37f58 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/form/__snapshots__/DispositionSaleForm.test.tsx.snap @@ -0,0 +1,1056 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DispositionSaleForm component renders as expected 1`] = ` + +
+
+ .c4.btn { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + padding: 0.4rem 1.2rem; + min-height: 3rem; + border: 0.2rem solid transparent; + border-radius: 0.4rem; + text-align: center; + -webkit-text-decoration: none; + text-decoration: none; + font-size: 1.8rem; + font-family: 'BCSans','Noto Sans',Verdana,Arial,sans-serif; + font-weight: 700; + -webkit-letter-spacing: 0.1rem; + -moz-letter-spacing: 0.1rem; + -ms-letter-spacing: 0.1rem; + letter-spacing: 0.1rem; + cursor: pointer; +} + +.c4.btn:hover { + -webkit-text-decoration: underline; + text-decoration: underline; + opacity: 0.8; +} + +.c4.btn:focus { + outline-width: 0.4rem; + outline-style: solid; + outline-offset: 1px; + box-shadow: none; +} + +.c4.btn.btn-primary { + border: none; +} + +.c4.btn.btn-secondary { + background: none; +} + +.c4.btn.btn-info { + border: none; + background: none; + padding-left: 0.6rem; + padding-right: 0.6rem; +} + +.c4.btn.btn-info:hover, +.c4.btn.btn-info:active, +.c4.btn.btn-info:focus { + background: none; +} + +.c4.btn.btn-light { + border: none; +} + +.c4.btn.btn-dark { + border: none; +} + +.c4.btn.btn-link { + font-size: 1.6rem; + font-weight: 400; + background: none; + border: none; + -webkit-text-decoration: none; + text-decoration: none; + min-height: 2.5rem; + line-height: 3rem; + -webkit-box-pack: left; + -webkit-justify-content: left; + -ms-flex-pack: left; + justify-content: left; + -webkit-letter-spacing: unset; + -moz-letter-spacing: unset; + -ms-letter-spacing: unset; + letter-spacing: unset; + text-align: left; + padding: 0; +} + +.c4.btn.btn-link:hover, +.c4.btn.btn-link:active, +.c4.btn.btn-link:focus { + -webkit-text-decoration: underline; + text-decoration: underline; + border: none; + background: none; + box-shadow: none; + outline: none; +} + +.c4.btn.btn-link:disabled, +.c4.btn.btn-link.disabled { + background: none; + pointer-events: none; +} + +.c4.btn:disabled, +.c4.btn:disabled:hover { + box-shadow: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + cursor: not-allowed; + opacity: 0.65; +} + +.c4.Button .Button__icon { + margin-right: 1.6rem; +} + +.c4.Button--icon-only:focus { + outline: none; +} + +.c4.Button--icon-only .Button__icon { + margin-right: 0; +} + +.c7 .react-datepicker__calendar-icon { + width: 3rem; + height: 3rem; + margin-top: 0.5rem; + right: 0; + pointer-events: none; +} + +.c7 .react-datepicker__view-calendar-icon input { + padding: 0.6rem 1rem 0.5rem 0.6rem; +} + +.c7 .react-datepicker-wrapper { + max-width: 16rem; +} + +.c8.c8.form-control.is-valid { + background-image: none; +} + +.c8.c8.form-control.is-invalid { + border-color: #d8292f !important; +} + +.c5 { + background: none; + position: relative; + border-radius: 0.3rem; + padding: 0.6rem; + padding-right: 2.1rem; + border: solid 0.1rem; +} + +.c5.is-invalid { + border: solid 0.1rem; +} + +.c6.c6.btn { + position: absolute; + top: calc(50% - 1.4rem); + right: 0.4rem; + -webkit-text-decoration: none; + text-decoration: none; + line-height: unset; +} + +.c6.c6.btn .text { + display: none; +} + +.c6.c6.btn:hover, +.c6.c6.btn:active, +.c6.c6.btn:focus { + -webkit-text-decoration: none; + text-decoration: none; + opacity: unset; +} + +.c9 .react-datepicker__calendar-icon { + width: 3rem; + height: 3rem; + margin-top: 0.5rem; + right: 0; + pointer-events: none; +} + +.c9 .react-datepicker__view-calendar-icon input { + padding: 0.6rem 1rem 0.5rem 0.6rem; +} + +.c9 .react-datepicker-wrapper { + max-width: 16rem; +} + +.c9 .react-datepicker-year-header { + font-size: 1.6rem; + padding: 0.55rem 0; +} + +.c9 .react-datepicker__navigation { + top: 0.39rem; +} + +.c10.c10.form-control.is-valid { + background-image: none; +} + +.c10.c10.form-control.is-invalid { + border-color: #d8292f !important; +} + +.c1 { + font-weight: bold; + border-bottom: 0.2rem solid; + margin-bottom: 2rem; +} + +.c0 { + margin: 1.5rem; + padding: 1.5rem; + background-color: white; + text-align: left; + border-radius: 0.5rem; +} + +.c3.required::before { + content: '*'; + position: absolute; + top: 0.75rem; + left: 0rem; +} + +.c2 { + font-weight: bold; +} + +
+

+
+
+ Sales Details +
+
+

+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+
+
+ Select from contacts + +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Select from contacts + +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +`; diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/hooks/useCalculateNetProceeds.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/hooks/useCalculateNetProceeds.tsx new file mode 100644 index 0000000000..cc06839574 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/hooks/useCalculateNetProceeds.tsx @@ -0,0 +1,61 @@ +import { getIn, useFormikContext } from 'formik'; +import { useEffect } from 'react'; + +import { DispositionSaleFormModel } from '@/features/mapSideBar/disposition/models/DispositionSaleFormModel'; +import { SystemConstants, useSystemConstants } from '@/store/slices/systemConstants'; + +export const useCalculateNetProceeds = (isGstEligible: boolean) => { + const { values, touched, setFieldValue, isSubmitting } = + useFormikContext(); + const { getSystemConstant } = useSystemConstants(); + const gstConstant = getSystemConstant(SystemConstants.GST); + const gstDecimal = gstConstant !== undefined ? parseFloat(gstConstant.value) / 100 : undefined; + + const gstTouched = getIn(touched, 'gstCollectedAmount'); + + const finalSaleAmount = getIn(values, 'finalSaleAmount'); + const gstAmount = getIn(values, 'gstCollectedAmount'); + const realtorCommissionAmount = getIn(values, 'realtorCommissionAmount'); + const totalCostOfSale = getIn(values, 'totalCostAmount'); + const netBookAmount = getIn(values, 'netBookAmount'); + const sppAmount = getIn(values, 'sppAmount'); + + useEffect(() => { + if (!isSubmitting) { + if (isGstEligible) { + let calculatedGst; + if (gstTouched) { + calculatedGst = gstAmount; + } else { + calculatedGst = gstDecimal ? finalSaleAmount * gstDecimal : 0; + } + + const saleCosts = calculatedGst + realtorCommissionAmount + totalCostOfSale + netBookAmount; + const proceedsBeforeSPP = finalSaleAmount - saleCosts; + const proceedsAfterSPP = proceedsBeforeSPP - sppAmount; + + setFieldValue('netProceedsBeforeSppAmount', proceedsBeforeSPP); + setFieldValue('netProceedsAfterSppAmount', proceedsAfterSPP); + } else { + const saleCosts = realtorCommissionAmount + totalCostOfSale + netBookAmount; + const proceedsBeforeSPP = finalSaleAmount - saleCosts; + const proceedsAfterSPP = proceedsBeforeSPP - sppAmount; + + setFieldValue('netProceedsBeforeSppAmount', proceedsBeforeSPP); + setFieldValue('netProceedsAfterSppAmount', proceedsAfterSPP); + } + } + }, [ + gstDecimal, + setFieldValue, + isGstEligible, + isSubmitting, + finalSaleAmount, + realtorCommissionAmount, + totalCostOfSale, + netBookAmount, + gstTouched, + sppAmount, + gstAmount, + ]); +}; diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleContainer.test.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleContainer.test.tsx new file mode 100644 index 0000000000..fa6fedc7e3 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleContainer.test.tsx @@ -0,0 +1,160 @@ +import { createMemoryHistory } from 'history'; + +import { Claims } from '@/constants/claims'; +import { DispositionSaleFormModel } from '@/features/mapSideBar/disposition/models/DispositionSaleFormModel'; +import { mockDispositionSaleApi } from '@/mocks/dispositionFiles.mock'; +import { mockLookups } from '@/mocks/lookups.mock'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; +import { lookupCodesSlice } from '@/store/slices/lookupCodes'; +import { act, render, RenderOptions, waitForEffects } from '@/utils/test-utils'; + +import UpdateDispositionSaleContainer, { + IUpdateDispositionSaleContainerProps, +} from './UpdateDispositionSaleContainer'; +import { IUpdateDispositionSaleViewProps } from './UpdateDispostionSaleView'; + +const history = createMemoryHistory(); +const mockDispositionSale = mockDispositionSaleApi(1, 1); + +const mockGetSaleApi = { + error: undefined, + response: undefined, + execute: jest.fn().mockResolvedValue(mockDispositionSale), + loading: false, +}; + +const mockPostSaleApi = { + error: undefined, + response: undefined, + execute: jest.fn(), + loading: false, +}; + +const mockPutSaleApi = { + error: undefined, + response: undefined, + execute: jest.fn(), + loading: false, +}; + +jest.mock('@/hooks/repositories/useDispositionProvider', () => ({ + useDispositionProvider: () => { + return { + getDispositionFileSale: mockGetSaleApi, + postDispositionFileSale: mockPostSaleApi, + putDispositionFileSale: mockPutSaleApi, + }; + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +let viewProps: IUpdateDispositionSaleViewProps | undefined; +const TestView: React.FC = props => { + viewProps = props; + return Content Rendered; +}; + +describe('Update Disposition Appraisal Container component', () => { + const setup = async ( + renderOptions: RenderOptions & { + props?: Partial; + } = {}, + ) => { + const component = render( + , + { + history, + store: { + [lookupCodesSlice.name]: { lookupCodes: mockLookups }, + }, + useMockAuthentication: true, + claims: renderOptions?.claims ?? [Claims.DISPOSITION_VIEW, Claims.DISPOSITION_EDIT], + ...renderOptions, + }, + ); + + return { + ...component, + }; + }; + + beforeEach(() => { + viewProps = undefined; + jest.resetAllMocks(); + }); + + it('Renders the underlying form', async () => { + const { getByText } = await setup(); + expect(getByText(/Content Rendered/)).toBeVisible(); + expect(mockGetSaleApi.execute).toHaveBeenCalled(); + }); + + it('Loads props with the initial values when Sale has values', async () => { + mockGetSaleApi.execute.mockResolvedValue(mockDispositionSale); + await setup(); + await waitForEffects(); + + expect(mockGetSaleApi.execute).toHaveBeenCalled(); + const formModel = DispositionSaleFormModel.fromApi(mockDispositionSale); + + expect(viewProps?.initialValues).toStrictEqual(formModel); + }); + + it('Loads props with the initial values with default values when no sale exists', async () => { + mockGetSaleApi.execute.mockResolvedValue(null); + await setup(); + await waitForEffects(); + + expect(mockGetSaleApi.execute).toHaveBeenCalled(); + const formModel = new DispositionSaleFormModel(null, 1, null); + + expect(viewProps?.initialValues).toStrictEqual(formModel); + }); + + it('makes POST request to create a NEW Sale and returns the response', async () => { + mockGetSaleApi.execute.mockResolvedValue(null); + mockPostSaleApi.execute.mockResolvedValue(mockDispositionSale); + + await setup(); + await waitForEffects(); + + let createdSale: ApiGen_Concepts_DispositionFileSale | undefined; + await act(async () => { + createdSale = await viewProps?.onSave({ + id: null, + } as ApiGen_Concepts_DispositionFileSale); + }); + + expect(mockPostSaleApi.execute).toHaveBeenCalled(); + expect(createdSale).toStrictEqual({ ...mockDispositionSale }); + expect(history.location.pathname).toBe('/'); + }); + + it('makes PUT request to update Appraisal and returns the response', async () => { + mockGetSaleApi.execute.mockResolvedValue(mockDispositionSale); + mockPutSaleApi.execute.mockResolvedValue(mockDispositionSale); + + await setup({ props: { dispositionFileId: 1 } }); + await waitForEffects(); + + let updatedAppraisal: ApiGen_Concepts_DispositionFileSale | undefined; + await act(async () => { + updatedAppraisal = await viewProps?.onSave({ id: 1 } as ApiGen_Concepts_DispositionFileSale); + }); + + expect(mockPutSaleApi.execute).toHaveBeenCalled(); + expect(updatedAppraisal).toStrictEqual({ ...mockDispositionSale }); + expect(history.location.pathname).toBe('/'); + }); + + it('navigates back to Offers and Sale tab when form is cancelled', async () => { + await setup(); + act(() => { + viewProps?.onCancel(); + }); + + expect(history.location.pathname).toBe('/'); + expect(mockPutSaleApi.execute).not.toHaveBeenCalled(); + expect(mockPostSaleApi.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleContainer.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleContainer.tsx new file mode 100644 index 0000000000..a3d6ea0407 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleContainer.tsx @@ -0,0 +1,79 @@ +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useState } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { toast } from 'react-toastify'; + +import { DispositionSaleFormModel } from '@/features/mapSideBar/disposition/models/DispositionSaleFormModel'; +import { useDispositionProvider } from '@/hooks/repositories/useDispositionProvider'; +import { IApiError } from '@/interfaces/IApiError'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; + +import { IUpdateDispositionSaleViewProps } from './UpdateDispostionSaleView'; + +export interface IUpdateDispositionSaleContainerProps { + dispositionFileId: number; + View: React.FC; +} + +const UpdateDispositionSaleContainer: React.FunctionComponent< + React.PropsWithChildren +> = ({ dispositionFileId, View }) => { + const history = useHistory(); + const location = useLocation(); + const backUrl = location.pathname.split(`/sale/update`)[0]; + + const initialValues = new DispositionSaleFormModel(null, dispositionFileId, null); + const [dispositionSale, setDispositionSale] = useState(initialValues); + + const { + getDispositionFileSale: { execute: getDispositionSale, loading: loadingSale }, + postDispositionFileSale: { execute: postDispositionSale, loading: creatingSale }, + putDispositionFileSale: { execute: putDispositionSale, loading: updatingSale }, + } = useDispositionProvider(); + + // generic error handler. + const onError = (e: AxiosError) => { + if (e?.response?.status === 400) { + toast.error(e?.response.data.error); + } else { + toast.error('Unable to save. Please try again.'); + } + }; + + const fetchSaleInformation = useCallback(async () => { + const response = await getDispositionSale(dispositionFileId); + + if (response && response.id) { + const saleModel = DispositionSaleFormModel.fromApi(response); + setDispositionSale(saleModel); + } + }, [dispositionFileId, getDispositionSale]); + + const handleSucces = async () => { + history.push(backUrl); + }; + + const handleSave = async (dispositionSale: ApiGen_Concepts_DispositionFileSale) => { + if (dispositionSale.id) { + return putDispositionSale(dispositionFileId, dispositionSale.id, dispositionSale); + } + return postDispositionSale(dispositionFileId, dispositionSale); + }; + + useEffect(() => { + fetchSaleInformation(); + }, [fetchSaleInformation]); + + return ( + history.push(backUrl)} + onError={onError} + > + ); +}; + +export default UpdateDispositionSaleContainer; diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleView.test.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleView.test.tsx new file mode 100644 index 0000000000..ec75638565 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispositionSaleView.test.tsx @@ -0,0 +1,71 @@ +import { createMemoryHistory } from 'history'; + +import Claims from '@/constants/claims'; +import { DispositionSaleFormModel } from '@/features/mapSideBar/disposition/models/DispositionSaleFormModel'; +import { mockLookups } from '@/mocks/lookups.mock'; +import { lookupCodesSlice } from '@/store/slices/lookupCodes/lookupCodesSlice'; +import { systemConstantsSlice } from '@/store/slices/systemConstants'; +import { render, RenderOptions, waitFor } from '@/utils/test-utils'; + +import UpdateDispositionSaleView, { + IUpdateDispositionSaleViewProps, +} from './UpdateDispostionSaleView'; + +const defaultInitialValues = new DispositionSaleFormModel(null, 1, 0); + +const history = createMemoryHistory(); +jest.mock('@react-keycloak/web'); + +const onSave = jest.fn(); +const onCancel = jest.fn(); +const onSuccess = jest.fn(); +const onError = jest.fn(); + +describe('Update Disposition Sale View', () => { + const setup = async ( + renderOptions: RenderOptions & { props?: Partial } = {}, + ) => { + // const ref = createRef>(); + const utils = render( + , + { + ...renderOptions, + useMockAuthentication: true, + claims: renderOptions?.claims ?? [Claims.DISPOSITION_EDIT], + history: history, + store: { + [lookupCodesSlice.name]: { lookupCodes: mockLookups }, + [systemConstantsSlice.name]: { systemConstants: [{ name: 'GST', value: '5.0' }] }, + }, + }, + ); + + return { + ...utils, + getCancelButton: () => utils.getByText(/Cancel/i), + getFinalSaleAmountTextbox: () => + utils.container.querySelector(`input[name="finalSaleAmount"]`) as HTMLInputElement, + getGSTCollectedAmountTextbox: () => + utils.container.querySelector(`input[name="gstCollectedAmount"]`) as HTMLInputElement, + }; + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders as expected', async () => { + const { asFragment } = await setup(); + + const fragment = await waitFor(() => asFragment()); + expect(fragment).toMatchSnapshot(); + }); +}); diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispostionSaleView.tsx b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispostionSaleView.tsx new file mode 100644 index 0000000000..99e4131d1b --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/UpdateDispostionSaleView.tsx @@ -0,0 +1,123 @@ +import axios, { AxiosError } from 'axios'; +import { Formik } from 'formik'; +import styled from 'styled-components'; + +import LoadingBackdrop from '@/components/common/LoadingBackdrop'; +import { DispositionSaleFormModel } from '@/features/mapSideBar/disposition/models/DispositionSaleFormModel'; +import SidebarFooter from '@/features/mapSideBar/shared/SidebarFooter'; +import { getCancelModalProps, useModalContext } from '@/hooks/useModalContext'; +import { IApiError } from '@/interfaces/IApiError'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; + +import DispositionSaleForm from '../form/DispositionSaleForm'; +import { DispositionSaleFormYupSchema } from '../form/DispositionSaleFormYupSchema'; + +export interface IUpdateDispositionSaleViewProps { + initialValues: DispositionSaleFormModel; + loading: boolean; + onSave: ( + sale: ApiGen_Concepts_DispositionFileSale, + ) => Promise; + onCancel: () => void; + onSuccess: () => void; + onError: (e: AxiosError) => void; +} + +const UpdateDispositionSaleView: React.FC = ({ + initialValues, + loading, + onSave, + onCancel, + onSuccess, + onError, +}) => { + const { setModalContent, setDisplayModal } = useModalContext(); + + const cancelFunc = (resetForm: () => void, dirty: boolean) => { + if (!dirty) { + resetForm(); + onCancel(); + } else { + setModalContent({ + ...getCancelModalProps(), + handleOk: () => { + resetForm(); + setDisplayModal(false); + onCancel(); + }, + }); + setDisplayModal(true); + } + }; + + return ( + + + enableReinitialize + validationSchema={DispositionSaleFormYupSchema} + initialValues={initialValues} + onSubmit={async (values: DispositionSaleFormModel, formikHelpers) => { + try { + const sale = await onSave(values.toApi()); + if (sale) { + onSuccess(); + } + } catch (e) { + if (axios.isAxiosError(e)) { + const axiosError = e as AxiosError; + onError && onError(axiosError); + } + } finally { + formikHelpers.setSubmitting(false); + } + }} + > + {formikProps => { + return ( + <> + + + + + + formikProps.submitForm()} + isOkDisabled={formikProps.isSubmitting || !formikProps.dirty} + onCancel={() => cancelFunc(formikProps.resetForm, formikProps.dirty)} + displayRequiredFieldError={ + formikProps.isValid === false && !!formikProps.submitCount + } + /> + + + ); + }} + + + ); +}; + +export default UpdateDispositionSaleView; + +const StyledFormWrapper = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + text-align: left; + height: 100%; + overflow-y: auto; + padding-bottom: 1rem; +`; + +const StyledContent = styled.div` + background-color: ${props => props.theme.css.filterBackgroundColor}; +`; + +const StyledFooter = styled.div` + margin-right: 1rem; + padding-bottom: 1rem; + z-index: 0; +`; diff --git a/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/__snapshots__/UpdateDispositionSaleView.test.tsx.snap b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/__snapshots__/UpdateDispositionSaleView.test.tsx.snap new file mode 100644 index 0000000000..1751da0461 --- /dev/null +++ b/source/frontend/src/features/mapSideBar/disposition/tabs/offersAndSale/dispositionSale/update/__snapshots__/UpdateDispositionSaleView.test.tsx.snap @@ -0,0 +1,1136 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Update Disposition Sale View renders as expected 1`] = ` + +
+
+ .c5.btn { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + padding: 0.4rem 1.2rem; + min-height: 3rem; + border: 0.2rem solid transparent; + border-radius: 0.4rem; + text-align: center; + -webkit-text-decoration: none; + text-decoration: none; + font-size: 1.8rem; + font-family: 'BCSans','Noto Sans',Verdana,Arial,sans-serif; + font-weight: 700; + -webkit-letter-spacing: 0.1rem; + -moz-letter-spacing: 0.1rem; + -ms-letter-spacing: 0.1rem; + letter-spacing: 0.1rem; + cursor: pointer; +} + +.c5.btn:hover { + -webkit-text-decoration: underline; + text-decoration: underline; + opacity: 0.8; +} + +.c5.btn:focus { + outline-width: 0.4rem; + outline-style: solid; + outline-offset: 1px; + box-shadow: none; +} + +.c5.btn.btn-primary { + border: none; +} + +.c5.btn.btn-secondary { + background: none; +} + +.c5.btn.btn-info { + border: none; + background: none; + padding-left: 0.6rem; + padding-right: 0.6rem; +} + +.c5.btn.btn-info:hover, +.c5.btn.btn-info:active, +.c5.btn.btn-info:focus { + background: none; +} + +.c5.btn.btn-light { + border: none; +} + +.c5.btn.btn-dark { + border: none; +} + +.c5.btn.btn-link { + font-size: 1.6rem; + font-weight: 400; + background: none; + border: none; + -webkit-text-decoration: none; + text-decoration: none; + min-height: 2.5rem; + line-height: 3rem; + -webkit-box-pack: left; + -webkit-justify-content: left; + -ms-flex-pack: left; + justify-content: left; + -webkit-letter-spacing: unset; + -moz-letter-spacing: unset; + -ms-letter-spacing: unset; + letter-spacing: unset; + text-align: left; + padding: 0; +} + +.c5.btn.btn-link:hover, +.c5.btn.btn-link:active, +.c5.btn.btn-link:focus { + -webkit-text-decoration: underline; + text-decoration: underline; + border: none; + background: none; + box-shadow: none; + outline: none; +} + +.c5.btn.btn-link:disabled, +.c5.btn.btn-link.disabled { + background: none; + pointer-events: none; +} + +.c5.btn:disabled, +.c5.btn:disabled:hover { + box-shadow: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + cursor: not-allowed; + opacity: 0.65; +} + +.c5.Button .Button__icon { + margin-right: 1.6rem; +} + +.c5.Button--icon-only:focus { + outline: none; +} + +.c5.Button--icon-only .Button__icon { + margin-right: 0; +} + +.c8 .react-datepicker__calendar-icon { + width: 3rem; + height: 3rem; + margin-top: 0.5rem; + right: 0; + pointer-events: none; +} + +.c8 .react-datepicker__view-calendar-icon input { + padding: 0.6rem 1rem 0.5rem 0.6rem; +} + +.c8 .react-datepicker-wrapper { + max-width: 16rem; +} + +.c9.c9.form-control.is-valid { + background-image: none; +} + +.c9.c9.form-control.is-invalid { + border-color: #d8292f !important; +} + +.c13 { + position: -webkit-sticky; + position: sticky; + padding-top: 2rem; + bottom: 0; + background: white; + z-index: 10; +} + +.c6 { + background: none; + position: relative; + border-radius: 0.3rem; + padding: 0.6rem; + padding-right: 2.1rem; + border: solid 0.1rem; +} + +.c6.is-invalid { + border: solid 0.1rem; +} + +.c7.c7.btn { + position: absolute; + top: calc(50% - 1.4rem); + right: 0.4rem; + -webkit-text-decoration: none; + text-decoration: none; + line-height: unset; +} + +.c7.c7.btn .text { + display: none; +} + +.c7.c7.btn:hover, +.c7.c7.btn:active, +.c7.c7.btn:focus { + -webkit-text-decoration: none; + text-decoration: none; + opacity: unset; +} + +.c10 .react-datepicker__calendar-icon { + width: 3rem; + height: 3rem; + margin-top: 0.5rem; + right: 0; + pointer-events: none; +} + +.c10 .react-datepicker__view-calendar-icon input { + padding: 0.6rem 1rem 0.5rem 0.6rem; +} + +.c10 .react-datepicker-wrapper { + max-width: 16rem; +} + +.c10 .react-datepicker-year-header { + font-size: 1.6rem; + padding: 0.55rem 0; +} + +.c10 .react-datepicker__navigation { + top: 0.39rem; +} + +.c11.c11.form-control.is-valid { + background-image: none; +} + +.c11.c11.form-control.is-invalid { + border-color: #d8292f !important; +} + +.c2 { + font-weight: bold; + border-bottom: 0.2rem solid; + margin-bottom: 2rem; +} + +.c1 { + margin: 1.5rem; + padding: 1.5rem; + background-color: white; + text-align: left; + border-radius: 0.5rem; +} + +.c4.required::before { + content: '*'; + position: absolute; + top: 0.75rem; + left: 0rem; +} + +.c3 { + font-weight: bold; +} + +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + text-align: left; + height: 100%; + overflow-y: auto; + padding-bottom: 1rem; +} + +.c12 { + margin-right: 1rem; + padding-bottom: 1rem; + z-index: 0; +} + +
+
+
+

+
+
+ Sales Details +
+
+

+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+
+
+ Select from contacts + +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ Select from contacts + +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + +
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+ +`; diff --git a/source/frontend/src/hooks/pims-api/useApiDispositionFile.ts b/source/frontend/src/hooks/pims-api/useApiDispositionFile.ts index 198d93a608..2b42a6a626 100644 --- a/source/frontend/src/hooks/pims-api/useApiDispositionFile.ts +++ b/source/frontend/src/hooks/pims-api/useApiDispositionFile.ts @@ -7,11 +7,11 @@ import { Api_DispositionFileAppraisal, Api_DispositionFileOffer, Api_DispositionFileProperty, - Api_DispositionFileSale, Api_DispositionFileTeam, } from '@/models/api/DispositionFile'; import { Api_DispositionFilter } from '@/models/api/DispositionFilter'; import { Api_FileWithChecklist, Api_LastUpdatedBy } from '@/models/api/File'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { IPaginateRequest } from './interfaces/IPaginateRequest'; @@ -87,7 +87,24 @@ export const useApiDispositionFile = () => { postDispositionFileOffer: (dispositionFileId: number, offer: Api_DispositionFileOffer) => api.post(`/dispositionfiles/${dispositionFileId}/offers`, offer), getDispositionFileSale: (dispositionFileId: number) => - api.get(`/dispositionfiles/${dispositionFileId}/sale`), + api.get(`/dispositionfiles/${dispositionFileId}/sale`), + postDispositionFileSale: ( + dispositionFileId: number, + sale: ApiGen_Concepts_DispositionFileSale, + ) => + api.post( + `/dispositionfiles/${dispositionFileId}/sale`, + sale, + ), + putDispositionFileSale: ( + dispositionFileId: number, + saleId: number, + sale: ApiGen_Concepts_DispositionFileSale, + ) => + api.put( + `/dispositionfiles/${dispositionFileId}/sale/${saleId}`, + sale, + ), getDispositionFileOffer: (dispositionFileId: number, offferId: number) => api.get( `/dispositionfiles/${dispositionFileId}/offers/${offferId}`, diff --git a/source/frontend/src/hooks/repositories/useDispositionProvider.ts b/source/frontend/src/hooks/repositories/useDispositionProvider.ts index 6c29bd6f49..cc65a9a81f 100644 --- a/source/frontend/src/hooks/repositories/useDispositionProvider.ts +++ b/source/frontend/src/hooks/repositories/useDispositionProvider.ts @@ -8,10 +8,10 @@ import { Api_DispositionFileAppraisal, Api_DispositionFileOffer, Api_DispositionFileProperty, - Api_DispositionFileSale, Api_DispositionFileTeam, } from '@/models/api/DispositionFile'; import { Api_FileChecklistItem, Api_FileWithChecklist, Api_LastUpdatedBy } from '@/models/api/File'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; import { UserOverrideCode } from '@/models/api/UserOverrideCode'; import { useAxiosErrorHandler, @@ -40,6 +40,8 @@ export const useDispositionProvider = () => { getDispositionFileOffers, postDispositionFileOffer, getDispositionFileSale, + postDispositionFileSale, + putDispositionFileSale, getDispositionFileOffer, putDispositionFileOffer, deleteDispositionFileOffer, @@ -225,7 +227,7 @@ export const useDispositionProvider = () => { }); const getDispositionFileSaleApi = useApiRequestWrapper< - (dispositionFileId: number) => Promise> + (dispositionFileId: number) => Promise> >({ requestFunction: useCallback( async (dispositionFileId: number) => await getDispositionFileSale(dispositionFileId), @@ -235,6 +237,41 @@ export const useDispositionProvider = () => { onError: useAxiosErrorHandler('Failed to retrieve Disposition File Sale'), }); + const postDispositionSaleApi = useApiRequestWrapper< + ( + dispositionFileId: number, + dispositionSale: ApiGen_Concepts_DispositionFileSale, + ) => Promise> + >({ + requestFunction: useCallback( + async (dispositionFileId: number, dispositionSale: ApiGen_Concepts_DispositionFileSale) => + await postDispositionFileSale(dispositionFileId, dispositionSale), + [postDispositionFileSale], + ), + requestName: 'PostDispositionSale', + skipErrorLogCodes: ignoreErrorCodes, + throwError: true, + }); + + const putDispositionSaleApi = useApiRequestWrapper< + ( + dispositionFileId: number, + saleId: number, + dispositionSale: ApiGen_Concepts_DispositionFileSale, + ) => Promise> + >({ + requestFunction: useCallback( + async ( + dispositionFileId: number, + saleId: number, + dispositionSale: ApiGen_Concepts_DispositionFileSale, + ) => await putDispositionFileSale(dispositionFileId, saleId, dispositionSale), + [putDispositionFileSale], + ), + requestName: 'PutDispositionSale', + onError: useAxiosErrorHandler('Failed to udpate Disposition File Sale'), + }); + const getDispositionOfferApi = useApiRequestWrapper< ( dispositionFileId: number, @@ -298,6 +335,8 @@ export const useDispositionProvider = () => { getDispositionFileOffers: getAllDispositionOffersApi, postDispositionFileOffer: postDispositionOfferApi, getDispositionFileSale: getDispositionFileSaleApi, + postDispositionFileSale: postDispositionSaleApi, + putDispositionFileSale: putDispositionSaleApi, getDispositionOffer: getDispositionOfferApi, putDispositionOffer: putDispositionOfferApi, deleteDispositionOffer: deleteDispositionOfferApi, @@ -317,6 +356,8 @@ export const useDispositionProvider = () => { getAllDispositionOffersApi, postDispositionOfferApi, getDispositionFileSaleApi, + postDispositionSaleApi, + putDispositionSaleApi, getDispositionOfferApi, putDispositionOfferApi, deleteDispositionOfferApi, diff --git a/source/frontend/src/interfaces/IContactSearchResult.ts b/source/frontend/src/interfaces/IContactSearchResult.ts index 934a951f2a..5feda5e541 100644 --- a/source/frontend/src/interfaces/IContactSearchResult.ts +++ b/source/frontend/src/interfaces/IContactSearchResult.ts @@ -1,4 +1,6 @@ import { Api_Contact } from '@/models/api/Contact'; +import { ApiGen_Concepts_Organization } from '@/models/api/generated/ApiGen_Concepts_Organization'; +import { ApiGen_Concepts_Person } from '@/models/api/generated/ApiGen_Concepts_Person'; import { Api_Organization } from '@/models/api/Organization'; import { Api_Person } from '@/models/api/Person'; import { formatApiPersonNames } from '@/utils/personUtils'; @@ -12,10 +14,10 @@ export interface IContactSearchResult { leaseTenantId?: number; isDisabled?: boolean; summary?: string; - surname?: string; - firstName?: string; - middleNames?: string; - organizationName?: string; + surname?: string | null; + firstName?: string | null; + middleNames?: string | null; + organizationName?: string | null; email?: string; mailingAddress?: string; municipalityName?: string; @@ -49,12 +51,14 @@ export function fromContact(baseModel: Api_Contact): IContactSearchResult { }; } -export function fromApiPerson(baseModel: Api_Person): IContactSearchResult { +export function fromApiPerson( + baseModel: ApiGen_Concepts_Person | Api_Person, +): IContactSearchResult { var personOrganizations = baseModel?.personOrganizations !== undefined ? baseModel.personOrganizations : undefined; var organization = - personOrganizations !== undefined && personOrganizations.length > 0 + personOrganizations && personOrganizations?.length > 0 ? personOrganizations[0].organization : undefined; @@ -76,7 +80,9 @@ export function fromApiPerson(baseModel: Api_Person): IContactSearchResult { }; } -export function fromApiOrganization(baseModel: Api_Organization): IContactSearchResult { +export function fromApiOrganization( + baseModel: ApiGen_Concepts_Organization | Api_Organization, +): IContactSearchResult { return { id: 'O' + baseModel.id, organizationId: baseModel.id, diff --git a/source/frontend/src/mocks/dispositionFiles.mock.ts b/source/frontend/src/mocks/dispositionFiles.mock.ts index f25af65bc7..bacff83bba 100644 --- a/source/frontend/src/mocks/dispositionFiles.mock.ts +++ b/source/frontend/src/mocks/dispositionFiles.mock.ts @@ -2,8 +2,8 @@ import { Api_DispositionFile, Api_DispositionFileAppraisal, Api_DispositionFileOffer, - Api_DispositionFileSale, } from '@/models/api/DispositionFile'; +import { ApiGen_Concepts_DispositionFileSale } from '@/models/api/generated/ApiGen_Concepts_DispositionFileSale'; export const mockDispositionFileResponse = ( id = 1, @@ -268,7 +268,7 @@ export const mockDispositionFileOfferApi = ( export const mockDispositionFileSaleApi = ( id: number = 0, dispositionFileId: number = 1, -): Api_DispositionFileSale => ({ +): ApiGen_Concepts_DispositionFileSale => ({ id: id, dispositionFileId: dispositionFileId, finalConditionRemovalDate: '2022-01-30T00:00:00', @@ -282,31 +282,29 @@ export const mockDispositionFileSaleApi = ( totalCostAmount: 856320.36, sppAmount: 1000.0, remediationAmount: 1.0, - dispositionPurchaserAgents: [ - { - id: 100, - dispositionSaleId: 1, - personId: 1000, - person: { - id: 1000, - isDisabled: false, - surname: 'DOE', - firstName: 'JOHN', - middleNames: '', - preferredName: 'Johny Boy', - personOrganizations: [], - personAddresses: [], - contactMethods: [], - comment: '', - rowVersion: 1, - }, - organizationId: null, - organization: null, - primaryContactId: null, - primaryContact: null, + dispositionPurchaserAgent: { + id: 100, + dispositionSaleId: 1, + personId: 1000, + person: { + id: 1000, + isDisabled: false, + surname: 'DOE', + firstName: 'JOHN', + middleNames: '', + preferredName: 'Johny Boy', + personOrganizations: [], + personAddresses: [], + contactMethods: [], + comment: '', rowVersion: 1, }, - ], + organizationId: null, + organization: null, + primaryContactId: null, + primaryContact: null, + rowVersion: 1, + }, dispositionPurchasers: [ { id: 2, @@ -340,7 +338,7 @@ export const mockDispositionFileSaleApi = ( isDisabled: false, surname: 'Sanchez', firstName: 'Alejandro', - middleNames: undefined, + middleNames: null, preferredName: '', personOrganizations: [], personAddresses: [], @@ -389,31 +387,30 @@ export const mockDispositionFileSaleApi = ( rowVersion: 1, }, ], - dispositionPurchaserSolicitors: [ - { - id: 101, - dispositionSaleId: 1, - personId: 1001, - person: { - id: 1001, - isDisabled: false, - surname: 'DOE', - firstName: 'JANE', - middleNames: '', - preferredName: 'JANEY', - personOrganizations: [], - personAddresses: [], - contactMethods: [], - comment: '', - rowVersion: 1, - }, - organizationId: null, - organization: null, - primaryContactId: null, - primaryContact: null, + dispositionPurchaserSolicitor: { + id: 101, + dispositionSaleId: 1, + personId: 1001, + person: { + id: 1001, + isDisabled: false, + surname: 'DOE', + firstName: 'JANE', + middleNames: '', + preferredName: 'JANEY', + personOrganizations: [], + personAddresses: [], + contactMethods: [], + comment: '', rowVersion: 1, }, - ], + organizationId: null, + organization: null, + primaryContactId: null, + primaryContact: null, + rowVersion: 1, + }, + rowVersion: 1, }); export const mockDispositionAppraisalApi = ( @@ -429,3 +426,184 @@ export const mockDispositionAppraisalApi = ( listPriceAmount: 500000.0, rowVersion: 1, }); + +export const mockDispositionSaleApi = ( + id: number = 10, + dispositionFileId: number = 1, +): ApiGen_Concepts_DispositionFileSale => ({ + id: id, + dispositionFileId: dispositionFileId, + finalConditionRemovalDate: '2024-01-26', + saleCompletionDate: '2024-01-27', + saleFiscalYear: '2023', + finalSaleAmount: 2500000.0, + realtorCommissionAmount: 1000.0, + isGstRequired: true, + gstCollectedAmount: 125000.0, + netBookAmount: 2000.0, + totalCostAmount: 3000.0, + sppAmount: 4000.0, + remediationAmount: 5000.0, + dispositionPurchasers: [ + { + id: 200, + dispositionSaleId: id, + personId: null, + person: null, + organizationId: 2, + organization: { + id: 2, + isDisabled: false, + incorporationNumber: '123456', + name: 'French Mouse Property Management', + alias: 'FMPM', + organizationPersons: [], + organizationAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + primaryContactId: 2, + primaryContact: { + id: 2, + isDisabled: false, + surname: 'Wilson', + firstName: 'Volley', + middleNames: 'Ball', + preferredName: 'WILLY', + personOrganizations: [], + personAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + rowVersion: 5, + }, + { + id: 201, + dispositionSaleId: id, + personId: null, + person: null, + organizationId: 3, + organization: { + id: 3, + isDisabled: false, + incorporationNumber: '123456', + name: 'Dairy Queen Forever! Property Management', + alias: 'DQ', + organizationPersons: [], + organizationAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + primaryContactId: 3, + primaryContact: { + id: 3, + isDisabled: false, + surname: 'Cheese', + firstName: 'Stinky', + middleNames: '', + preferredName: 'Roquefort', + personOrganizations: [], + personAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + rowVersion: 4, + }, + { + id: 202, + dispositionSaleId: id, + personId: 15, + person: { + id: 15, + isDisabled: false, + surname: 'Sanchez', + middleNames: null, + firstName: 'Alejandro', + preferredName: 'Alex', + personOrganizations: [], + personAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + organizationId: null, + organization: null, + primaryContactId: null, + primaryContact: null, + rowVersion: 4, + }, + ], + dispositionPurchaserAgent: { + id: 300, + dispositionSaleId: id, + personId: null, + person: null, + organizationId: 3, + organization: { + id: 3, + isDisabled: false, + incorporationNumber: '123456', + name: 'Dairy Queen Forever! Property Management', + alias: 'DQ', + organizationPersons: [], + organizationAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + primaryContactId: 3, + primaryContact: { + id: 3, + isDisabled: false, + surname: 'Cheese', + firstName: 'Stinky', + preferredName: 'Roquerfort', + middleNames: '', + personOrganizations: [], + personAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + rowVersion: 1, + }, + dispositionPurchaserSolicitor: { + id: 21, + dispositionSaleId: 27, + personId: null, + person: null, + organizationId: 2, + organization: { + id: 2, + isDisabled: false, + incorporationNumber: '5678', + name: 'French Mouse Property Management', + alias: 'FMPM', + organizationPersons: [], + organizationAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + primaryContactId: 2, + primaryContact: { + id: 2, + isDisabled: false, + surname: 'Wilson', + firstName: 'Volley', + middleNames: 'Ball', + preferredName: 'Round', + personOrganizations: [], + personAddresses: [], + contactMethods: [], + comment: null, + rowVersion: 1, + }, + rowVersion: 2, + }, + rowVersion: 1, +}); diff --git a/source/frontend/src/models/api/DispositionFile.ts b/source/frontend/src/models/api/DispositionFile.ts index 5a1d586c9a..16862b2aa8 100644 --- a/source/frontend/src/models/api/DispositionFile.ts +++ b/source/frontend/src/models/api/DispositionFile.ts @@ -3,6 +3,7 @@ import Api_TypeCode from '@/models/api/TypeCode'; import { Api_AuditFields } from './AuditFields'; import { Api_ConcurrentVersion } from './ConcurrentVersion'; +import { ApiGen_Concepts_DispositionFileSale } from './generated/ApiGen_Concepts_DispositionFileSale'; import { Api_Organization } from './Organization'; import { Api_Person } from './Person'; import { Api_Product, Api_Project } from './Project'; @@ -37,7 +38,7 @@ export interface Api_DispositionFile // Offers dispositionOffers: Api_DispositionFileOffer[]; // Sale - dispositionSale: Api_DispositionFileSale | null; + dispositionSale: ApiGen_Concepts_DispositionFileSale | null; dispositionAppraisal: Api_DispositionFileAppraisal | null; } @@ -71,25 +72,6 @@ export interface Api_DispositionFileOffer extends Api_ConcurrentVersion { offerNote: string | null; } -export interface Api_DispositionFileSale { - id: number | null; - dispositionFileId: number; - finalConditionRemovalDate: string | null; - saleCompletionDate: string | null; - saleFiscalYear: string | null; - finalSaleAmount: number | null; - realtorCommissionAmount: number | null; - isGstRequired: boolean | null; - gstCollectedAmount: number | null; - netBookAmount: number | null; - totalCostAmount: number | null; - sppAmount: number | null; - remediationAmount: number | null; - dispositionPurchasers: Api_DispositionSalePurchaser[]; - dispositionPurchaserAgents: Api_DispositionSalePurchaserAgent[]; - dispositionPurchaserSolicitors: Api_DispositionSalePurchaserSolicitor[]; -} - export interface Api_DispositionFileAppraisal extends Api_ConcurrentVersion { id: number | null; dispositionFileId: number; @@ -108,27 +90,3 @@ export interface ContactInformation { primaryContactId: number | null; primaryContact: Api_Person | null; } - -export interface Api_DispositionSalePurchaser - extends ContactInformation, - Api_ConcurrentVersion, - Api_AuditFields { - id?: number; - dispositionSaleId: number; -} - -export interface Api_DispositionSalePurchaserAgent - extends ContactInformation, - Api_ConcurrentVersion, - Api_AuditFields { - id?: number; - dispositionSaleId: number; -} - -export interface Api_DispositionSalePurchaserSolicitor - extends ContactInformation, - Api_ConcurrentVersion, - Api_AuditFields { - id?: number; - dispositionSaleId: number; -} diff --git a/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileAppraisal.ts b/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileAppraisal.ts index f6d00a8a93..5142c3a1ea 100644 --- a/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileAppraisal.ts +++ b/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileAppraisal.ts @@ -2,10 +2,12 @@ * File autogenerated by TsGenerator. * Do not manually modify, changes made to this file will be lost when this file is regenerated. */ -import { ApiGen_Concepts_File } from './ApiGen_Concepts_File'; +import { ApiGen_Base_BaseConcurrent } from './ApiGen_Base_BaseConcurrent'; // LINK: @backend/apimodels/Models/Concepts/DispositionFile/DispositionFileAppraisalModel.cs -export interface ApiGen_Concepts_DispositionFileAppraisal extends ApiGen_Concepts_File { +export interface ApiGen_Concepts_DispositionFileAppraisal extends ApiGen_Base_BaseConcurrent { + id: number | null; + dispositionFileId: number; appraisedAmount: number | null; appraisalDate: string | null; bcaValueAmount: number | null; diff --git a/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileSale.ts b/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileSale.ts index 68c45c5e81..7926c0c30a 100644 --- a/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileSale.ts +++ b/source/frontend/src/models/api/generated/ApiGen_Concepts_DispositionFileSale.ts @@ -20,11 +20,9 @@ export interface ApiGen_Concepts_DispositionFileSale extends ApiGen_Base_BaseCon gstCollectedAmount: number | null; netBookAmount: number | null; totalCostAmount: number | null; - netProceedsBeforeSppAmount: number | null; - netProceedsAfterSppAmount: number | null; sppAmount: number | null; remediationAmount: number | null; dispositionPurchasers: ApiGen_Concepts_DispositionSalePurchaser[] | null; - dispositionPurchaserAgents: ApiGen_Concepts_DispositionSalePurchaserAgent[] | null; - dispositionPurchaserSolicitors: ApiGen_Concepts_DispositionSalePurchaserSolicitor[] | null; + dispositionPurchaserAgent: ApiGen_Concepts_DispositionSalePurchaserAgent | null; + dispositionPurchaserSolicitor: ApiGen_Concepts_DispositionSalePurchaserSolicitor | null; } diff --git a/source/frontend/src/models/api/generated/ApiGen_Concepts_PropertyActivity.ts b/source/frontend/src/models/api/generated/ApiGen_Concepts_PropertyActivity.ts index fd3b2f58fa..43de5df2b1 100644 --- a/source/frontend/src/models/api/generated/ApiGen_Concepts_PropertyActivity.ts +++ b/source/frontend/src/models/api/generated/ApiGen_Concepts_PropertyActivity.ts @@ -17,8 +17,8 @@ export interface ApiGen_Concepts_PropertyActivity extends ApiGen_Base_BaseAudit activityTypeCode: ApiGen_Base_CodeType | null; activitySubtypeCode: ApiGen_Base_CodeType | null; activityStatusTypeCode: ApiGen_Base_CodeType | null; - requestAddedDateTime: string; - completionDateTime: string | null; + requestAddedDateOnly: string; + completionDateOnly: string | null; description: string | null; requestSource: string | null; pretaxAmt: number | null; diff --git a/source/frontend/src/utils/personUtils.ts b/source/frontend/src/utils/personUtils.ts index 96a764e8bd..a059319fd8 100644 --- a/source/frontend/src/utils/personUtils.ts +++ b/source/frontend/src/utils/personUtils.ts @@ -1,4 +1,5 @@ import { IEditablePerson } from '@/interfaces/editable-contact'; +import { ApiGen_Concepts_Person } from '@/models/api/generated/ApiGen_Concepts_Person'; import { Api_Person } from '@/models/api/Person'; export function formatFullName(person?: Partial): string { @@ -8,7 +9,7 @@ export function formatFullName(person?: Partial): string { return formatNames([person.firstName, person.middleNames, person.surname]); } -export function formatApiPersonNames(person?: Api_Person | null): string { +export function formatApiPersonNames(person?: Api_Person | ApiGen_Concepts_Person | null): string { return formatNames([person?.firstName, person?.middleNames, person?.surname]); }