diff --git a/source/backend/api/Helpers/Extensions/AcquisitionFileExtensions.cs b/source/backend/api/Helpers/Extensions/AcquisitionFileExtensions.cs index a3592a3cde..6c944f1f8a 100644 --- a/source/backend/api/Helpers/Extensions/AcquisitionFileExtensions.cs +++ b/source/backend/api/Helpers/Extensions/AcquisitionFileExtensions.cs @@ -11,7 +11,7 @@ namespace Pims.Api.Helpers.Extensions { public static class AcquisitionFileExtensions { - public static void ThrowMissingContractorInTeam(this PimsAcquisitionFile acquisitionFile, ClaimsPrincipal principal, IUserRepository userRepository) + public static void ThrowMissingContractorInTeam(this PimsAcquisitionFile acquisitionFile, ClaimsPrincipal principal, IUserRepository userRepository, IProjectRepository projectRepository) { ArgumentNullException.ThrowIfNull(acquisitionFile); @@ -19,13 +19,19 @@ public static void ThrowMissingContractorInTeam(this PimsAcquisitionFile acquisi var pimsUser = userRepository.GetUserInfoByKeycloakUserId(principal.GetUserKey()); - if (pimsUser?.IsContractor == true && !acquisitionFile.PimsAcquisitionFileTeams.Any(x => x.PersonId == pimsUser.PersonId)) + PimsProject project = null; + if (acquisitionFile.ProjectId.HasValue) + { + project = projectRepository.TryGet(acquisitionFile.ProjectId.Value); + } + + if (pimsUser?.IsContractor == true && !acquisitionFile.PimsAcquisitionFileTeams.Any(x => x.PersonId == pimsUser.PersonId) && (project == null || !project.PimsProjectPeople.Any(x => x.PersonId == pimsUser.PersonId))) { throw new ContractorNotInTeamException("As a Contractor your user contact information should be assigned to the Acquisition File's team"); } } - public static void ThrowContractorRemovedFromTeam(this PimsAcquisitionFile acquisitionFile, ClaimsPrincipal principal, IUserRepository userRepository) + public static void ThrowContractorRemovedFromTeam(this PimsAcquisitionFile acquisitionFile, ClaimsPrincipal principal, IUserRepository userRepository, IProjectRepository projectRepository) { ArgumentNullException.ThrowIfNull(acquisitionFile); @@ -33,7 +39,13 @@ public static void ThrowContractorRemovedFromTeam(this PimsAcquisitionFile acqui var pimsUser = userRepository.GetUserInfoByKeycloakUserId(principal.GetUserKey()); - if (pimsUser?.IsContractor == true && !acquisitionFile.PimsAcquisitionFileTeams.Any(x => x.PersonId == pimsUser.PersonId)) + PimsProject project = null; + if (acquisitionFile.ProjectId.HasValue) + { + project = projectRepository.TryGet(acquisitionFile.ProjectId.Value); + } + + if (pimsUser?.IsContractor == true && !acquisitionFile.PimsAcquisitionFileTeams.Any(x => x.PersonId == pimsUser.PersonId) && (project == null || !project.PimsProjectPeople.Any(x => x.PersonId == pimsUser.PersonId))) { throw new UserOverrideException(UserOverrideCode.ContractorSelfRemoved, "Contractors cannot remove themselves from a file. Please contact the admin at pims@gov.bc.ca"); } diff --git a/source/backend/api/Helpers/Extensions/PrincipalExtensions.cs b/source/backend/api/Helpers/Extensions/PrincipalExtensions.cs index b4cfe5ff00..80290a4a0c 100644 --- a/source/backend/api/Helpers/Extensions/PrincipalExtensions.cs +++ b/source/backend/api/Helpers/Extensions/PrincipalExtensions.cs @@ -17,7 +17,7 @@ public static void ThrowInvalidAccessToAcquisitionFile(this ClaimsPrincipal prin var pimsUser = userRepository.GetUserInfoByKeycloakUserId(principal.GetUserKey()); PimsAcquisitionFile acquisitionFile = acquisitionFileRepository.GetById(acquisitionFileId); - if (pimsUser?.IsContractor == true && !acquisitionFile.PimsAcquisitionFileTeams.Any(x => x.PersonId == pimsUser.PersonId)) + if (pimsUser?.IsContractor == true && !acquisitionFile.PimsAcquisitionFileTeams.Any(x => x.PersonId == pimsUser.PersonId) && (acquisitionFile.Project == null || !acquisitionFile.Project.PimsProjectPeople.Any(x => x.PersonId == pimsUser.PersonId))) { throw new NotAuthorizedException("Contractor is not assigned to the Acquisition File's team"); } diff --git a/source/backend/api/Services/AcquisitionFileService.cs b/source/backend/api/Services/AcquisitionFileService.cs index acd7df194a..aa059732d6 100644 --- a/source/backend/api/Services/AcquisitionFileService.cs +++ b/source/backend/api/Services/AcquisitionFileService.cs @@ -38,6 +38,7 @@ public class AcquisitionFileService : IAcquisitionFileService private readonly ITakeRepository _takeRepository; private readonly IAcquisitionStatusSolver _statusSolver; private readonly IPropertyService _propertyService; + private readonly IProjectRepository _projectRepository; public AcquisitionFileService( ClaimsPrincipal user, @@ -55,6 +56,7 @@ public AcquisitionFileService( ICompReqFinancialService compReqFinancialService, IExpropriationPaymentRepository expropriationPaymentRepository, ITakeRepository takeRepository, + IProjectRepository projectRepository, IAcquisitionStatusSolver statusSolver, IPropertyService propertyService) { @@ -75,6 +77,7 @@ public AcquisitionFileService( _takeRepository = takeRepository; _statusSolver = statusSolver; _propertyService = propertyService; + _projectRepository = projectRepository; } public Paged GetPage(AcquisitionFilter filter) @@ -206,7 +209,8 @@ public PimsAcquisitionFile Add(PimsAcquisitionFile acquisitionFile, IEnumerable< _logger.LogInformation("Adding acquisition file with id {id}", acquisitionFile.Internal_Id); _user.ThrowIfNotAuthorized(Permissions.AcquisitionFileAdd); - acquisitionFile.ThrowMissingContractorInTeam(_user, _userRepository); + + acquisitionFile.ThrowMissingContractorInTeam(_user, _userRepository, _projectRepository); // validate the new acq region var cannotDetermineRegion = _lookupRepository.GetAllRegions().FirstOrDefault(x => x.RegionName == "Cannot determine"); @@ -274,7 +278,7 @@ public PimsAcquisitionFile Update(PimsAcquisitionFile acquisitionFile, IEnumerab ValidateStaff(acquisitionFile); ValidateOrganizationStaff(acquisitionFile); - acquisitionFile.ThrowContractorRemovedFromTeam(_user, _userRepository); + acquisitionFile.ThrowContractorRemovedFromTeam(_user, _userRepository, _projectRepository); ValidatePayeeDependency(acquisitionFile); diff --git a/source/backend/apimodels/Models/Concepts/Project/ProjectMap.cs b/source/backend/apimodels/Models/Concepts/Project/ProjectMap.cs index 0ed5a6ae60..aa213643bf 100644 --- a/source/backend/apimodels/Models/Concepts/Project/ProjectMap.cs +++ b/source/backend/apimodels/Models/Concepts/Project/ProjectMap.cs @@ -19,6 +19,7 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.Description, src => src.Description) .Map(dest => dest.Note, src => src.Note) .Map(dest => dest.ProjectProducts, src => src.PimsProjectProducts) + .Map(dest => dest.ProjectPersons, src => src.PimsProjectPeople) .Map(dest => dest.AppLastUpdateUserid, src => src.AppLastUpdateUserid) .Map(dest => dest.AppLastUpdateTimestamp, src => src.AppLastUpdateTimestamp) .Inherits(); @@ -34,6 +35,7 @@ public void Register(TypeAdapterConfig config) .Map(dest => dest.Description, src => src.Description) .Map(dest => dest.Note, src => src.Note) .Map(dest => dest.PimsProjectProducts, src => src.ProjectProducts) + .Map(dest => dest.PimsProjectPeople, src => src.ProjectPersons) .Inherits(); } } diff --git a/source/backend/apimodels/Models/Concepts/Project/ProjectModel.cs b/source/backend/apimodels/Models/Concepts/Project/ProjectModel.cs index ae34f57772..3fd63ac591 100644 --- a/source/backend/apimodels/Models/Concepts/Project/ProjectModel.cs +++ b/source/backend/apimodels/Models/Concepts/Project/ProjectModel.cs @@ -61,6 +61,11 @@ public class ProjectModel : BaseAuditModel /// get/set - Project products. /// public List ProjectProducts { get; set; } + + /// + /// get/set - Project persons. + /// + public List ProjectPersons { get; set; } #endregion } } diff --git a/source/backend/apimodels/Models/Concepts/Project/ProjectPersonMap.cs b/source/backend/apimodels/Models/Concepts/Project/ProjectPersonMap.cs new file mode 100644 index 0000000000..cbe29185d2 --- /dev/null +++ b/source/backend/apimodels/Models/Concepts/Project/ProjectPersonMap.cs @@ -0,0 +1,26 @@ +using Mapster; +using Pims.Api.Models.Base; +using Entity = Pims.Dal.Entities; + +namespace Pims.Api.Models.Concepts.Project +{ + public class ProjectPersonMap : IRegister + { + public void Register(TypeAdapterConfig config) + { + config.NewConfig() + .Map(dest => dest.Id, src => src.ProjectPersonId) + .Map(dest => dest.ProjectId, src => src.ProjectId) + .Map(dest => dest.Person, src => src.Person) + .Map(dest => dest.PersonId, src => src.PersonId) + .Map(dest => dest.Project, src => src.Project) + .Inherits(); + + config.NewConfig() + .Map(dest => dest.ProjectPersonId, src => src.Id) + .Map(dest => dest.ProjectId, src => src.ProjectId) + .Map(dest => dest.PersonId, src => src.PersonId) + .Inherits(); + } + } +} diff --git a/source/backend/apimodels/Models/Concepts/Project/ProjectPersonModel.cs b/source/backend/apimodels/Models/Concepts/Project/ProjectPersonModel.cs new file mode 100644 index 0000000000..759292b317 --- /dev/null +++ b/source/backend/apimodels/Models/Concepts/Project/ProjectPersonModel.cs @@ -0,0 +1,22 @@ +using Pims.Api.Models.Base; +using Pims.Api.Models.Concepts.Person; + +namespace Pims.Api.Models.Concepts.Project +{ + public class ProjectPersonModel : BaseAuditModel + { + #region Properties + + public long? Id { get; set; } + + public long? ProjectId { get; set; } + + public ProjectModel Project { get; set; } + + public long PersonId { get; set; } + + public PersonModel Person { get; set; } + + #endregion + } +} diff --git a/source/backend/dal/Repositories/AcquisitionFileRepository.cs b/source/backend/dal/Repositories/AcquisitionFileRepository.cs index 48aef5136e..002759a5f6 100644 --- a/source/backend/dal/Repositories/AcquisitionFileRepository.cs +++ b/source/backend/dal/Repositories/AcquisitionFileRepository.cs @@ -105,6 +105,8 @@ public PimsAcquisitionFile GetById(long id) .ThenInclude(x => x.CostTypeCode) .Include(r => r.Project) .ThenInclude(x => x.BusinessFunctionCode) + .Include(r => r.Project) + .ThenInclude(x => x.PimsProjectPeople) .Include(r => r.Product) .Include(r => r.PimsPropertyAcquisitionFiles) .Include(r => r.PimsAcquisitionFileTeams) @@ -887,7 +889,7 @@ private IQueryable GetCommonAcquisitionFileQueryDeep(Acquis if (contractorPersonId is not null) { - predicate = predicate.And(acq => acq.PimsAcquisitionFileTeams.Any(x => x.PersonId == contractorPersonId)); + predicate = predicate.And(acq => acq.PimsAcquisitionFileTeams.Any(x => x.PersonId == contractorPersonId) || (acq.Project != null && acq.Project.PimsProjectPeople.Any(x => x.PersonId == contractorPersonId))); } if (!string.IsNullOrWhiteSpace(filter.AcquisitionTeamMemberPersonId)) @@ -903,6 +905,7 @@ private IQueryable GetCommonAcquisitionFileQueryDeep(Acquis var query = Context.PimsAcquisitionFiles.AsNoTracking() .Include(r => r.RegionCodeNavigation) .Include(p => p.Project) + .ThenInclude(p => p.PimsProjectPeople) .Include(s => s.AcquisitionFileStatusTypeCodeNavigation) .Include(f => f.AcquisitionFundingTypeCodeNavigation) .Include(ph => ph.AcqPhysFileStatusTypeCodeNavigation) diff --git a/source/backend/dal/Repositories/ProjectRepository.cs b/source/backend/dal/Repositories/ProjectRepository.cs index 223ceee44f..e6ecdb9bf8 100644 --- a/source/backend/dal/Repositories/ProjectRepository.cs +++ b/source/backend/dal/Repositories/ProjectRepository.cs @@ -76,6 +76,8 @@ public PimsProject TryGet(long id) .AsNoTracking() .Include(x => x.PimsProjectProducts) .ThenInclude(x => x.Product) + .Include(x => x.PimsProjectPeople) + .ThenInclude(x => x.Person) .Include(x => x.ProjectStatusTypeCodeNavigation) .Include(x => x.RegionCodeNavigation) .Include(x => x.CostTypeCode) @@ -146,6 +148,7 @@ public PimsProject Update(PimsProject project) Func canDeleteGrandchild = (context, pa) => !context.PimsProducts.Any(o => o.Id == pa.ProductId); this.Context.UpdateGrandchild(p => p.PimsProjectProducts, pp => pp.Product, project.Id, project.PimsProjectProducts.ToArray(), canDeleteGrandchild); + this.Context.UpdateGrandchild(p => p.PimsProjectPeople, pp => pp.Person, project.Id, project.PimsProjectPeople.ToArray(), true); Context.Entry(existingProject).CurrentValues.SetValues(project); diff --git a/source/backend/entities/Partials/ProjectPerson.cs b/source/backend/entities/Partials/ProjectPerson.cs new file mode 100644 index 0000000000..bfaeda5e6e --- /dev/null +++ b/source/backend/entities/Partials/ProjectPerson.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Pims.Dal.Entities +{ + /// + /// PimsProjectPerson class, provides an entity for the datamodel to manage project persons. + /// + public partial class PimsProjectPerson : StandardIdentityBaseAppEntity, IBaseAppEntity + { + [NotMapped] + public override long Internal_Id { get => this.ProjectPersonId; set => this.ProjectPersonId = value; } + } +} diff --git a/source/frontend/src/components/common/form/ContactInput/ContactInputContainer.tsx b/source/frontend/src/components/common/form/ContactInput/ContactInputContainer.tsx index 92e0e90baa..8b817f357e 100644 --- a/source/frontend/src/components/common/form/ContactInput/ContactInputContainer.tsx +++ b/source/frontend/src/components/common/form/ContactInput/ContactInputContainer.tsx @@ -12,6 +12,7 @@ export type IContactInputContainerProps = { restrictContactType?: RestrictContactType; displayErrorAsTooltip?: boolean; onContactSelected?: (contact: IContactSearchResult) => void; + placeholder?: string; }; export const ContactInputContainer: React.FC< @@ -27,6 +28,7 @@ export const ContactInputContainer: React.FC< restrictContactType, displayErrorAsTooltip = true, onContactSelected, + placeholder, }) => { const [showContactManager, setShowContactManager] = useState(false); const [selectedContacts, setSelectedContacts] = useState([]); @@ -67,6 +69,7 @@ export const ContactInputContainer: React.FC< showActiveSelector: true, restrictContactType: restrictContactType, }} + placeholder={placeholder} /> ); }; diff --git a/source/frontend/src/components/common/form/ContactInput/ContactInputView.tsx b/source/frontend/src/components/common/form/ContactInput/ContactInputView.tsx index 3215da7892..ca6b83eb42 100644 --- a/source/frontend/src/components/common/form/ContactInput/ContactInputView.tsx +++ b/source/frontend/src/components/common/form/ContactInput/ContactInputView.tsx @@ -28,6 +28,7 @@ export type OptionalAttributes = { label?: string; required?: boolean; displayErrorTooltips?: boolean; + placeholder?: string; }; export type IContactInputViewProps = FormControlProps & OptionalAttributes & RequiredAttributes; @@ -39,6 +40,7 @@ const ContactInputView: React.FunctionComponent = ({ onClear, setShowContactManager, contactManagerProps, + placeholder, }) => { const { errors, touched, values } = useFormikContext(); const error = getIn(errors, field); @@ -46,10 +48,10 @@ const ContactInputView: React.FunctionComponent = ({ const contactInfo: IContactSearchResult | undefined = getIn(values, field); const errorTooltip = error && touch && displayErrorTooltips ? error : undefined; - let text = 'Select from contacts'; + let text = placeholder ?? 'Select from contacts'; if (contactInfo !== undefined) { - text = formatContactSearchResult(contactInfo, 'Select from contacts'); + text = formatContactSearchResult(contactInfo, placeholder ?? 'Select from contacts'); } return ( diff --git a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx index 5d047c2ce1..35a54f73fd 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/AcquisitionContainer.tsx @@ -15,6 +15,7 @@ import LoadingBackdrop from '@/components/common/LoadingBackdrop'; import { useMapStateMachine } from '@/components/common/mapFSM/MapStateMachineContext'; import { InventoryTabNames } from '@/features/mapSideBar/property/InventoryTabs'; import { useAcquisitionProvider } from '@/hooks/repositories/useAcquisitionProvider'; +import { useProjectProvider } from '@/hooks/repositories/useProjectProvider'; import { usePropertyAssociations } from '@/hooks/repositories/usePropertyAssociations'; import { useQuery } from '@/hooks/use-query'; import useApiUserOverride from '@/hooks/useApiUserOverride'; @@ -84,6 +85,9 @@ export const AcquisitionContainer: React.FunctionComponent { const retrieved = await retrieveAcquisitionFile(acquisitionFileId); if (exists(retrieved)) { + if (isValidId(retrieved.projectId)) { + retrieved.project = await getProjectFunction(retrieved.projectId); + } // retrieve related entities (ie properties, checklist items) in parallel const acquisitionPropertiesTask = retrieveAcquisitionFileProperties(acquisitionFileId); const acquisitionChecklistTask = retrieveAcquisitionFileChecklist(acquisitionFileId); @@ -152,6 +159,7 @@ export const AcquisitionContainer: React.FunctionComponent { diff --git a/source/frontend/src/features/mapSideBar/acquisition/add/models.ts b/source/frontend/src/features/mapSideBar/acquisition/add/models.ts index 172c0b7b32..410d94603e 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/add/models.ts +++ b/source/frontend/src/features/mapSideBar/acquisition/add/models.ts @@ -75,7 +75,7 @@ export class AcquisitionForm implements WithAcquisitionTeam, WithAcquisitionOwne acquisitionTypeCode: toTypeCodeNullable(this.acquisitionType), regionCode: toTypeCodeNullable(Number(this.region)), projectId: isValidId(this.project?.id) ? this.project!.id : null, - productId: this.product !== '' ? Number(this.product) : null, + productId: isValidId(Number(this.product)) ? Number(this.product) : null, fundingTypeCode: toTypeCodeNullable(this.fundingTypeCode), fundingOther: this.fundingTypeOtherDescription, subfileInterestTypeCode: toTypeCodeNullable(this.subfileInterestTypeCode), diff --git a/source/frontend/src/features/mapSideBar/acquisition/tabs/fileDetails/detail/AcquisitionSummaryView.tsx b/source/frontend/src/features/mapSideBar/acquisition/tabs/fileDetails/detail/AcquisitionSummaryView.tsx index b1360d0fd9..41eb5940f1 100644 --- a/source/frontend/src/features/mapSideBar/acquisition/tabs/fileDetails/detail/AcquisitionSummaryView.tsx +++ b/source/frontend/src/features/mapSideBar/acquisition/tabs/fileDetails/detail/AcquisitionSummaryView.tsx @@ -91,6 +91,20 @@ const AcquisitionSummaryView: React.FC = ({ {acquisitionFile?.fundingTypeCode?.id === 'OTHER' && ( {acquisitionFile.fundingOther} )} + {acquisitionFile?.project?.projectPersons?.map((teamMember, index) => ( + + + + {formatApiPersonNames(teamMember?.person)} + + + + + ))}
diff --git a/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx b/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx index c1acc3aec9..653f233814 100644 --- a/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx +++ b/source/frontend/src/features/mapSideBar/project/add/AddProjectForm.tsx @@ -11,6 +11,7 @@ import { Section } from '@/components/common/Section/Section'; import { SectionField } from '@/components/common/Section/SectionField'; import { ProjectForm } from '../models'; +import AddProjectTeamSubForm from './AddProjectTeamSubForm'; import ProductsArrayForm from './ProductsArrayForm'; export interface IAddProjectFormProps { @@ -99,6 +100,9 @@ const AddProjectForm = React.forwardRef, IAddProjectFor
+
+ +
)} diff --git a/source/frontend/src/features/mapSideBar/project/add/AddProjectTeamSubForm.tsx b/source/frontend/src/features/mapSideBar/project/add/AddProjectTeamSubForm.tsx new file mode 100644 index 0000000000..50dcf5266b --- /dev/null +++ b/source/frontend/src/features/mapSideBar/project/add/AddProjectTeamSubForm.tsx @@ -0,0 +1,74 @@ +import { FieldArray, useFormikContext } from 'formik'; +import * as React from 'react'; +import { Col, Row } from 'react-bootstrap'; + +import { LinkButton, RemoveButton } from '@/components/common/buttons'; +import { ContactInputContainer } from '@/components/common/form/ContactInput/ContactInputContainer'; +import ContactInputView from '@/components/common/form/ContactInput/ContactInputView'; +import { ModalSize } from '@/components/common/GenericModal'; +import { RestrictContactType } from '@/components/contact/ContactManagerView/ContactFilterComponent/ContactFilterComponent'; +import { useModalContext } from '@/hooks/useModalContext'; + +import { ProjectForm, ProjectTeamForm } from '../models'; + +const AddProjectTeamSubForm: React.FunctionComponent = () => { + const { values } = useFormikContext(); + const { setModalContent, setDisplayModal } = useModalContext(); + + return ( + ( + <> + {values.projectTeam.map((teamMember, index) => ( + + + + + + + { + setModalContent({ + modalSize: ModalSize.LARGE, + variant: 'info', + message: 'Are you sure you want to remove this row?', + title: 'Remove Team Member', + handleOk: () => { + arrayHelpers.remove(index); + setDisplayModal(false); + }, + handleCancel: () => { + setDisplayModal(false); + }, + }); + setDisplayModal(true); + }} + /> + + + + ))} + { + const teamMember = new ProjectTeamForm(values.id); + arrayHelpers.push(teamMember); + }} + > + + Add another management team member + + + )} + /> + ); +}; + +export default AddProjectTeamSubForm; diff --git a/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectContainer.test.tsx.snap b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectContainer.test.tsx.snap index 4ba46e871a..ba3db735cd 100644 --- a/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectContainer.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectContainer.test.tsx.snap @@ -927,6 +927,38 @@ exports[`AddProjectContainer component > renders as expected 1`] = `
+
+

+
+
+ Project Management Team +
+
+

+
+ +
+
diff --git a/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap index a0637de356..b94c1fa514 100644 --- a/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/project/add/__snapshots__/AddProjectForm.test.tsx.snap @@ -701,6 +701,38 @@ exports[`AddProjectForm component > renders as expected 1`] = `
+
+

+
+
+ Project Management Team +
+
+

+
+ +
+
@@ -2138,6 +2170,38 @@ exports[`AddProjectForm component > renders as expected with existing data 1`] =
+
+

+
+
+ Project Management Team +
+
+

+
+ +
+
diff --git a/source/frontend/src/features/mapSideBar/project/models.ts b/source/frontend/src/features/mapSideBar/project/models.ts index 111e153ebe..2f885a33c1 100644 --- a/source/frontend/src/features/mapSideBar/project/models.ts +++ b/source/frontend/src/features/mapSideBar/project/models.ts @@ -1,9 +1,11 @@ import { isNumber } from 'lodash'; import { SelectOption } from '@/components/common/form'; +import { fromApiPerson, IContactSearchResult } from '@/interfaces/IContactSearchResult'; import { ApiGen_Concepts_FinancialCodeTypes } from '@/models/api/generated/ApiGen_Concepts_FinancialCodeTypes'; import { ApiGen_Concepts_Product } from '@/models/api/generated/ApiGen_Concepts_Product'; import { ApiGen_Concepts_Project } from '@/models/api/generated/ApiGen_Concepts_Project'; +import { ApiGen_Concepts_ProjectPerson } from '@/models/api/generated/ApiGen_Concepts_ProjectPerson'; import { ApiGen_Concepts_ProjectProduct } from '@/models/api/generated/ApiGen_Concepts_ProjectProduct'; import { getEmptyBaseAudit } from '@/models/defaultInitializers'; import { NumberFieldValue } from '@/typings/NumberFieldValue'; @@ -66,6 +68,7 @@ export class ProjectForm { summary: string | '' = ''; rowVersion: number | null = null; products: ProductForm[] = []; + projectTeam: ProjectTeamForm[] = []; toApi(): ApiGen_Concepts_Project { return { @@ -85,6 +88,7 @@ export class ProjectForm { ...getEmptyBaseAudit(0), }; }), + projectPersons: this.projectTeam?.map(team => team.toApi()), businessFunctionCode: !!this.businessFunctionCode?.value && isNumber(this.businessFunctionCode.value) ? toFinancialCode( @@ -129,6 +133,9 @@ export class ProjectForm { ?.map(x => x.product) ?.filter(exists) .map(x => ProductForm.fromApi(x)) || []; + newForm.projectTeam = + model.projectPersons?.filter(exists).map(x => ProjectTeamForm.fromApi(x)) || + []; newForm.businessFunctionCode = !!model.businessFunctionCode?.id && isNumber(model.businessFunctionCode?.id) ? businessFunctionOptions.find(c => +c.value === model.businessFunctionCode?.id) ?? null @@ -145,3 +152,33 @@ export class ProjectForm { return newForm; } } + +export class ProjectTeamForm { + id: number | null; + projectId: number | null; + contact: IContactSearchResult; + + constructor(projectId?: number) { + this.projectId = projectId; + } + + toApi(): ApiGen_Concepts_ProjectPerson { + return { + id: this.id, + projectId: this.projectId, + personId: this.contact?.personId, + project: null, + person: null, + ...getEmptyBaseAudit(), + }; + } + + static fromApi(apiModel: ApiGen_Concepts_ProjectPerson): ProjectTeamForm { + const newForm = new ProjectTeamForm(); + newForm.projectId = apiModel.projectId; + newForm.contact = fromApiPerson(apiModel.person); + newForm.id = apiModel.id; + + return newForm; + } +} diff --git a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.tsx b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.tsx index 69f42fda41..83320691de 100644 --- a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.tsx +++ b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/ProjectSummaryView.tsx @@ -1,10 +1,15 @@ +import React from 'react'; +import { FaExternalLinkAlt } from 'react-icons/fa'; + import EditButton from '@/components/common/buttons/EditButton'; import { Section } from '@/components/common/Section/Section'; import { SectionField } from '@/components/common/Section/SectionField'; import { StyledEditWrapper, StyledSummarySection } from '@/components/common/Section/SectionStyles'; +import { StyledLink } from '@/components/maps/leaflet/LayerPopup/styles'; import Claims from '@/constants/claims'; import useKeycloakWrapper from '@/hooks/useKeycloakWrapper'; import { ApiGen_Concepts_Project } from '@/models/api/generated/ApiGen_Concepts_Project'; +import { formatApiPersonNames } from '@/utils/personUtils'; import ProjectProductView from './ProjectProductView'; @@ -42,6 +47,22 @@ const ProjectSummaryView: React.FunctionComponent< +
+ {project?.projectPersons?.map((teamMember, index) => ( + + + + {formatApiPersonNames(teamMember?.person)} + + + + + ))} +
); }; diff --git a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/__snapshots__/ProjectSummaryView.test.tsx.snap b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/__snapshots__/ProjectSummaryView.test.tsx.snap index 212bc956f2..b56eb3fbc0 100644 --- a/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/__snapshots__/ProjectSummaryView.test.tsx.snap +++ b/source/frontend/src/features/mapSideBar/project/tabs/projectDetails/detail/__snapshots__/ProjectSummaryView.test.tsx.snap @@ -432,6 +432,26 @@ exports[`ProjectSummaryView component > matches snapshot 1`] = `
+
+

+
+
+ Project Management Team +
+
+

+
+
`; diff --git a/source/frontend/src/features/mapSideBar/property/tabs/propertyAssociations/LeaseAssociationContent.tsx b/source/frontend/src/features/mapSideBar/property/tabs/propertyAssociations/LeaseAssociationContent.tsx index e867e35746..4bb8eb593a 100644 --- a/source/frontend/src/features/mapSideBar/property/tabs/propertyAssociations/LeaseAssociationContent.tsx +++ b/source/frontend/src/features/mapSideBar/property/tabs/propertyAssociations/LeaseAssociationContent.tsx @@ -55,7 +55,7 @@ const getFormattedTenants = (stakeholders: ApiGen_Concepts_LeaseStakeholder[]) = const stakeholderTypeCode = sortedStakeholders[0]?.stakeholderTypeCode?.id; return sortedStakeholders - .filter(t => t.stakeholderTypeCode.id === stakeholderTypeCode) + .filter(t => t.stakeholderTypeCode?.id === stakeholderTypeCode) .map(t => (t.lessorType?.id === 'PER' ? formatApiPersonNames(t.person) : t.organization?.name)) .join(', '); }; @@ -69,7 +69,7 @@ export const LeaseAssociationContent: React.FunctionComponent< } const tableData = orderBy( props.associations.map(x => { - const lease = find(props.leases, lease => x.id === lease.id); + const lease = find(props.leases, lease => x?.id === lease?.id); const leaseRenewals = props.renewals?.filter(renewal => x.id === renewal?.leaseId); const calculatedExpiry = getCalculatedExpiry(lease, leaseRenewals ?? []); return { diff --git a/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx b/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx index 640ae4135a..66746aca0d 100644 --- a/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx +++ b/source/frontend/src/features/mapSideBar/shared/detail/PropertyFileContainer.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { Claims } from '@/constants'; import { usePropertyDetails } from '@/features/mapSideBar/hooks/usePropertyDetails'; import { IInventoryTabsProps, @@ -16,6 +17,7 @@ import TakesDetailView from '@/features/mapSideBar/property/tabs/takes/detail/Ta import { PROPERTY_TYPES, useComposedProperties } from '@/hooks/repositories/useComposedProperties'; import { useLeaseRepository } from '@/hooks/repositories/useLeaseRepository'; import { useLeaseStakeholderRepository } from '@/hooks/repositories/useLeaseStakeholderRepository'; +import useKeycloakWrapper from '@/hooks/useKeycloakWrapper'; import { ApiGen_CodeTypes_FileTypes } from '@/models/api/generated/ApiGen_CodeTypes_FileTypes'; import { ApiGen_Concepts_FileProperty } from '@/models/api/generated/ApiGen_Concepts_FileProperty'; import { ApiGen_Concepts_ResearchFileProperty } from '@/models/api/generated/ApiGen_Concepts_ResearchFileProperty'; @@ -41,6 +43,7 @@ export const PropertyFileContainer: React.FunctionComponent< const id = props.fileProperty?.property?.id ?? undefined; const location = props.fileProperty?.property?.location ?? undefined; const latLng = useMemo(() => getLatLng(location) ?? undefined, [location]); + const { hasClaim } = useKeycloakWrapper(); const composedProperties = useComposedProperties({ pid, @@ -69,14 +72,22 @@ export const PropertyFileContainer: React.FunctionComponent< composedProperties?.propertyAssociationWrapper?.response?.leaseAssociations; useMemo( () => - getLeaseInfo( - leaseAssociations, - getLease.execute, - getLeaseStakeholders.execute, - getLeaseRenewals.execute, - setLeaseAssociationInfo, - ), - [leaseAssociations, getLease.execute, getLeaseStakeholders.execute, getLeaseRenewals.execute], + hasClaim(Claims.LEASE_VIEW) + ? getLeaseInfo( + leaseAssociations, + getLease.execute, + getLeaseStakeholders.execute, + getLeaseRenewals.execute, + setLeaseAssociationInfo, + ) + : null, + [ + leaseAssociations, + getLease.execute, + getLeaseStakeholders.execute, + getLeaseRenewals.execute, + hasClaim, + ], ); // After API property object has been received, we query relevant map layers to find diff --git a/source/frontend/src/mocks/acquisitionFiles.mock.ts b/source/frontend/src/mocks/acquisitionFiles.mock.ts index 8cd2a38a04..fddbd819da 100644 --- a/source/frontend/src/mocks/acquisitionFiles.mock.ts +++ b/source/frontend/src/mocks/acquisitionFiles.mock.ts @@ -80,6 +80,7 @@ export const mockAcquisitionFileResponse = ( }, regionCode: null, note: null, + projectPersons: [], projectProducts: [], ...getEmptyBaseAudit(), }, diff --git a/source/frontend/src/mocks/dispositionFiles.mock.ts b/source/frontend/src/mocks/dispositionFiles.mock.ts index 6101fba1c2..9cc989ba08 100644 --- a/source/frontend/src/mocks/dispositionFiles.mock.ts +++ b/source/frontend/src/mocks/dispositionFiles.mock.ts @@ -27,6 +27,7 @@ export const mockDispositionFileResponse = ( code: '00048', description: 'CLAIMS', note: null, + projectPersons: [], projectProducts: [], appCreateTimestamp: '2024-02-06T20:56:46.47', appLastUpdateTimestamp: '2024-02-06T20:56:46.47', diff --git a/source/frontend/src/mocks/lease.mock.ts b/source/frontend/src/mocks/lease.mock.ts index c4583a9016..20e91d24e8 100644 --- a/source/frontend/src/mocks/lease.mock.ts +++ b/source/frontend/src/mocks/lease.mock.ts @@ -442,6 +442,7 @@ export const getMockApiLease: () => ApiGen_Concepts_Lease = () => ({ code: '00048', description: 'CLAIMS', note: null, + projectPersons: [], projectProducts: [], appCreateTimestamp: '2024-09-04T17:09:56.207', appLastUpdateTimestamp: '2024-09-04T17:09:56.207', diff --git a/source/frontend/src/mocks/projects.mock.ts b/source/frontend/src/mocks/projects.mock.ts index 342b8c2c84..6b6acac439 100644 --- a/source/frontend/src/mocks/projects.mock.ts +++ b/source/frontend/src/mocks/projects.mock.ts @@ -31,6 +31,7 @@ export const mockProjects = (): ApiGen_Concepts_Project[] => [ description: 'test DESCRIPTION 1', note: 'test NOTE 1', projectStatusTypeCode: null, + projectPersons: [], projectProducts: [], ...getEmptyBaseAudit(1), }, @@ -49,6 +50,7 @@ export const mockProjects = (): ApiGen_Concepts_Project[] => [ description: 'test DESCRIPTION 2', note: 'test NOTE 2', projectStatusTypeCode: null, + projectPersons: [], projectProducts: [], ...getEmptyBaseAudit(1), }, @@ -82,6 +84,7 @@ export const mockProjectPostResponse = ( businessFunctionCode: null, workActivityCode: null, costTypeCode: null, + projectPersons: [], projectProducts: [], note: summary, appCreateTimestamp: '2022-05-28T00:57:37.42', @@ -109,6 +112,7 @@ export const mockProjectGetResponse = (): ApiGen_Concepts_Project => ({ code: '771', description: 'Project Cool A', note: 'Summary of the Project Cool A', + projectPersons: [], projectProducts: [ { id: 1, diff --git a/source/frontend/src/mocks/researchFile.mock.ts b/source/frontend/src/mocks/researchFile.mock.ts index 494cacc20c..297a1c2893 100644 --- a/source/frontend/src/mocks/researchFile.mock.ts +++ b/source/frontend/src/mocks/researchFile.mock.ts @@ -114,6 +114,7 @@ export const getMockResearchFile = (): ApiGen_Concepts_ResearchFile => ({ rowVersion: 1, appCreateUserGuid: null, appLastUpdateUserGuid: null, + projectPersons: [], }, projectId: 1, fileId: 109, diff --git a/source/frontend/src/models/api/generated/ApiGen_Concepts_Project.ts b/source/frontend/src/models/api/generated/ApiGen_Concepts_Project.ts index d5fecbf7c2..e6b08f7cc5 100644 --- a/source/frontend/src/models/api/generated/ApiGen_Concepts_Project.ts +++ b/source/frontend/src/models/api/generated/ApiGen_Concepts_Project.ts @@ -5,6 +5,7 @@ import { ApiGen_Base_BaseAudit } from './ApiGen_Base_BaseAudit'; import { ApiGen_Base_CodeType } from './ApiGen_Base_CodeType'; import { ApiGen_Concepts_FinancialCode } from './ApiGen_Concepts_FinancialCode'; +import { ApiGen_Concepts_ProjectPerson } from './ApiGen_Concepts_ProjectPerson'; import { ApiGen_Concepts_ProjectProduct } from './ApiGen_Concepts_ProjectProduct'; // LINK: @backend/apimodels/Models/Concepts/Project/ProjectModel.cs @@ -19,4 +20,5 @@ export interface ApiGen_Concepts_Project extends ApiGen_Base_BaseAudit { description: string | null; note: string | null; projectProducts: ApiGen_Concepts_ProjectProduct[] | null; + projectPersons: ApiGen_Concepts_ProjectPerson[] | null; } diff --git a/source/frontend/src/models/api/generated/ApiGen_Concepts_ProjectPerson.ts b/source/frontend/src/models/api/generated/ApiGen_Concepts_ProjectPerson.ts new file mode 100644 index 0000000000..8dd1d37965 --- /dev/null +++ b/source/frontend/src/models/api/generated/ApiGen_Concepts_ProjectPerson.ts @@ -0,0 +1,16 @@ +/** + * File autogenerated by TsGenerator. + * Do not manually modify, changes made to this file will be lost when this file is regenerated. + */ +import { ApiGen_Base_BaseAudit } from './ApiGen_Base_BaseAudit'; +import { ApiGen_Concepts_Person } from './ApiGen_Concepts_Person'; +import { ApiGen_Concepts_Project } from './ApiGen_Concepts_Project'; + +// LINK: @backend/apimodels/Models/Concepts/Project/ProjectPersonModel.cs +export interface ApiGen_Concepts_ProjectPerson extends ApiGen_Base_BaseAudit { + id: number | null; + projectId: number | null; + project: ApiGen_Concepts_Project | null; + personId: number; + person: ApiGen_Concepts_Person | null; +}