diff --git a/backend/Api/Projects/AgreementController.cs b/backend/Api/Projects/AgreementController.cs index a1e6927d..7390c3d3 100644 --- a/backend/Api/Projects/AgreementController.cs +++ b/backend/Api/Projects/AgreementController.cs @@ -1,4 +1,6 @@ using Core.Agreements; +using Core.Customers; +using Core.Engagements; using Core.Organizations; using Infrastructure.DatabaseContext; using Microsoft.AspNetCore.Authorization; @@ -30,8 +32,10 @@ public async Task> GetAgreement([FromRoute] str if (agreement is null) return NotFound(); var responseModel = new AgreementReadModel( + Name: agreement.Name, AgreementId: agreement.Id, EngagementId: agreement.EngagementId, + CustomerId: agreement.CustomerId, StartDate: agreement.StartDate, EndDate: agreement.EndDate, NextPriceAdjustmentDate: agreement.NextPriceAdjustmentDate, @@ -50,19 +54,21 @@ public async Task> GetAgreement([FromRoute] str [HttpGet] [Route("get/engagement/{engagementId}")] - public async Task> GetAgreementByEngagement([FromRoute] string orgUrlKey, + public async Task>> GetAgreementsByEngagement([FromRoute] string orgUrlKey, [FromRoute] int engagementId, CancellationToken ct) { var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); if (selectedOrg is null) return BadRequest("Selected org not found"); - var agreement = await agreementsRepository.GetAgreementByEngagementId(engagementId, ct); + var agreements = await agreementsRepository.GetAgreementsByEngagementId(engagementId, ct); - if (agreement is null) return NotFound(); + if (agreements is null || !agreements.Any()) return NotFound(); - var responseModel = new AgreementReadModel( + var responseModels = agreements.Select(agreement => new AgreementReadModel( AgreementId: agreement.Id, + Name: agreement.Name, EngagementId: agreement.EngagementId, + CustomerId: agreement.CustomerId, StartDate: agreement.StartDate, EndDate: agreement.EndDate, NextPriceAdjustmentDate: agreement.NextPriceAdjustmentDate, @@ -75,24 +81,86 @@ public async Task> GetAgreementByEngagement([Fr BlobName: f.BlobName, UploadedOn: f.UploadedOn )).ToList() - ); - return Ok(responseModel); + )).ToList(); + + return Ok(responseModels); + } + + [HttpGet] + [Route("get/customer/{customerId}")] + public async Task>> GetAgreementsByCustomer([FromRoute] string orgUrlKey, + [FromRoute] int customerId, CancellationToken ct) + { + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + if (selectedOrg is null) return BadRequest("Selected org not found"); + + var agreements = await agreementsRepository.GetAgreementsByCustomerId(customerId, ct); + + if (agreements is null || !agreements.Any()) return NotFound(); + + var responseModels = agreements.Select(agreement => new AgreementReadModel( + AgreementId: agreement.Id, + Name: agreement.Name, + EngagementId: agreement.EngagementId, + CustomerId: agreement.CustomerId, + StartDate: agreement.StartDate, + EndDate: agreement.EndDate, + NextPriceAdjustmentDate: agreement.NextPriceAdjustmentDate, + PriceAdjustmentIndex: agreement.PriceAdjustmentIndex, + Notes: agreement.Notes, + Options: agreement.Options, + PriceAdjustmentProcess: agreement.PriceAdjustmentProcess, + Files: agreement.Files.Select(f => new FileReferenceReadModel( + FileName: f.FileName, + BlobName: f.BlobName, + UploadedOn: f.UploadedOn + )).ToList() + )).ToList(); + + return Ok(responseModels); } [HttpPost] [Route("create")] - public async Task> Post([FromRoute] string orgUrlKey, + public async Task> Post([FromRoute] string orgUrlKey, [FromBody] AgreementWriteModel body, CancellationToken ct) { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (body.CustomerId is null && body.EngagementId is null) + { + ModelState.AddModelError("", "At least one of CustomerId or EngagementId must be provided."); + return BadRequest(ModelState); + } var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); - if (selectedOrg is null) return BadRequest("Selected org not found"); + if (selectedOrg is null) + return BadRequest("Selected organization not found"); + + Customer? customer = null; + if (body.CustomerId != null) + { + customer = await context.Customer.FindAsync(body.CustomerId.Value); + if (customer == null) + return BadRequest("Customer not found"); + } - var engagement = await context.Project.FindAsync(body.EngagementId); - if (engagement is null) return BadRequest("Engagement not found"); + Engagement? engagement = null; + if (body.EngagementId != null) + { + engagement = await context.Project.FindAsync(body.EngagementId.Value); + if (engagement is null) + return BadRequest("Engagement not found"); + } var agreement = new Agreement { + Name = body.Name, + CustomerId = body.CustomerId, + Customer = customer, EngagementId = body.EngagementId, Engagement = engagement, StartDate = body.StartDate, @@ -113,7 +181,9 @@ public async Task> Post([FromRoute] string org await agreementsRepository.AddAgreementAsync(agreement, ct); var responseModel = new AgreementReadModel( + Name: agreement.Name, AgreementId: agreement.Id, + CustomerId: agreement.CustomerId, EngagementId: agreement.EngagementId, StartDate: agreement.StartDate, EndDate: agreement.EndDate, @@ -137,13 +207,58 @@ public async Task> Post([FromRoute] string org public async Task> Put([FromRoute] string orgUrlKey, [FromRoute] int agreementId, [FromBody] AgreementWriteModel body, CancellationToken ct) { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (body.CustomerId is null && body.EngagementId is null) + { + ModelState.AddModelError("", "At least one of CustomerId or EngagementId must be provided."); + return BadRequest(ModelState); + } + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); - if (selectedOrg is null) return BadRequest("Selected org not found"); + if (selectedOrg is null) + return BadRequest("Selected organization not found"); var agreement = await agreementsRepository.GetAgreementById(agreementId, ct); - if (agreement is null) return NotFound(); + if (agreement is null) + return NotFound("Agreement not found"); + + Customer? customer = null; + if (body.CustomerId is not null) + { + customer = await context.Customer.FindAsync(body.CustomerId); + if (customer is null) + return BadRequest("Customer not found"); + + agreement.CustomerId = body.CustomerId; + agreement.Customer = customer; + } + else + { + agreement.CustomerId = null; + agreement.Customer = null; + } + + Engagement? engagement = null; + if (body.EngagementId is not null) + { + engagement = await context.Project.FindAsync(body.EngagementId); + if (engagement is null) + return BadRequest("Engagement not found"); + + agreement.EngagementId = body.EngagementId; + agreement.Engagement = engagement; + } + else + { + agreement.EngagementId = null; + agreement.Engagement = null; + } - agreement.EngagementId = body.EngagementId; + agreement.Name = body.Name; agreement.StartDate = body.StartDate; agreement.EndDate = body.EndDate; agreement.NextPriceAdjustmentDate = body.NextPriceAdjustmentDate; @@ -162,6 +277,8 @@ public async Task> Put([FromRoute] string orgUr var responseModel = new AgreementReadModel( AgreementId: agreement.Id, + Name: agreement.Name, + CustomerId: agreement.CustomerId, EngagementId: agreement.EngagementId, StartDate: agreement.StartDate, EndDate: agreement.EndDate, @@ -192,7 +309,7 @@ public async Task Delete([FromRoute] string orgUrlKey, [FromRoute] await agreementsRepository.DeleteAgreementAsync(agreementId, ct); - return Ok(); + return Ok("Deleted"); } [HttpGet] diff --git a/backend/Api/Projects/AgreementModels.cs b/backend/Api/Projects/AgreementModels.cs index cb9c3ca7..7370105a 100644 --- a/backend/Api/Projects/AgreementModels.cs +++ b/backend/Api/Projects/AgreementModels.cs @@ -4,7 +4,9 @@ public record AgreementReadModel( int AgreementId, - int EngagementId, + string? Name, + int? CustomerId, + int? EngagementId, DateTime? StartDate, DateTime EndDate, DateTime? NextPriceAdjustmentDate, @@ -14,7 +16,6 @@ public record AgreementReadModel( string? PriceAdjustmentProcess, List Files ); - public record FileReferenceReadModel( string FileName, string BlobName, @@ -22,7 +23,9 @@ DateTime UploadedOn ); public record AgreementWriteModel( - int EngagementId, + string? Name, + int? CustomerId, + int? EngagementId, DateTime? StartDate, DateTime EndDate, DateTime? NextPriceAdjustmentDate, @@ -31,7 +34,19 @@ public record AgreementWriteModel( string? Options, string? PriceAdjustmentProcess, List Files -); +): IValidatableObject +{ + public IEnumerable Validate(ValidationContext validationContext) + { + if (CustomerId == null && EngagementId == null) + { + yield return new ValidationResult( + "At least one of CustomerId or EngagementId must be provided.", + new[] { nameof(CustomerId), nameof(EngagementId) }); + } + } +} + public record FileReferenceWriteModel( string FileName, diff --git a/backend/Core/Agreements/Agreement.cs b/backend/Core/Agreements/Agreement.cs index 1e5ceb11..8d423c31 100644 --- a/backend/Core/Agreements/Agreement.cs +++ b/backend/Core/Agreements/Agreement.cs @@ -1,31 +1,33 @@ using System.ComponentModel.DataAnnotations.Schema; +using Core.Customers; using Core.Engagements; namespace Core.Agreements { public class Agreement - { - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; set; } - - public int EngagementId { get; set; } - - public required Engagement Engagement { get; set; } +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; set; } + public string? Name { get; set; } = string.Empty; + public int? CustomerId { get; set; } + public Customer? Customer { get; set; } - public ICollection Files { get; set; } = new List(); + public int? EngagementId { get; set; } + public Engagement? Engagement { get; set; } - public DateTime? StartDate { get; set; } + public ICollection Files { get; set; } = new List(); - public required DateTime EndDate { get; set; } + public DateTime? StartDate { get; set; } + public required DateTime EndDate { get; set; } - public DateTime? NextPriceAdjustmentDate { get; set; } + public DateTime? NextPriceAdjustmentDate { get; set; } - public string? PriceAdjustmentIndex { get; set; } + public string? PriceAdjustmentIndex { get; set; } + public string? Notes { get; set; } = string.Empty; + public string? Options { get; set; } = string.Empty; + public string? PriceAdjustmentProcess { get; set; } = string.Empty; +} - public string? Notes { get; set; } = string.Empty; - public string? Options { get; set; } = string.Empty; - public string? PriceAdjustmentProcess { get; set; } = string.Empty; - } public class FileReference { diff --git a/backend/Core/Agreements/IAgreementRepository.cs b/backend/Core/Agreements/IAgreementRepository.cs index 4f6c1aba..f0bc54ff 100644 --- a/backend/Core/Agreements/IAgreementRepository.cs +++ b/backend/Core/Agreements/IAgreementRepository.cs @@ -4,8 +4,8 @@ public interface IAgreementsRepository { public Task GetAgreementById(int id, CancellationToken cancellationToken); - public Task GetAgreementByEngagementId(int engagementId, CancellationToken cancellationToken); - + public Task> GetAgreementsByEngagementId(int engagementId, CancellationToken cancellationToken); + public Task> GetAgreementsByCustomerId(int customerId, CancellationToken cancellationToken); public Task AddAgreementAsync(Agreement agreement, CancellationToken cancellationToken); public Task UpdateAgreementAsync(Agreement agreement, CancellationToken cancellationToken); diff --git a/backend/Core/Customers/Customer.cs b/backend/Core/Customers/Customer.cs index 33f255d2..db7581e4 100644 --- a/backend/Core/Customers/Customer.cs +++ b/backend/Core/Customers/Customer.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using Core.Agreements; using Core.Engagements; using Core.Organizations; @@ -10,7 +11,7 @@ public class Customer public int Id { get; set; } public string? OrganizationId { get; set; } - + public ICollection Agreements { get; set; } = new List(); public required string Name { get; set; } public required Organization Organization { get; set; } public required List Projects { get; set; } diff --git a/backend/Core/Engagements/Engagement.cs b/backend/Core/Engagements/Engagement.cs index 4c5c8211..0de67da7 100644 --- a/backend/Core/Engagements/Engagement.cs +++ b/backend/Core/Engagements/Engagement.cs @@ -15,7 +15,7 @@ public class Engagement public required Customer Customer { get; set; } - public Agreement? Agreement { get; set; } + public ICollection Agreements { get; set; } = new List(); public required EngagementState State { get; set; } diff --git a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs index 485c1a02..c06bbb60 100644 --- a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs +++ b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs @@ -167,16 +167,22 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { - entity.OwnsMany(e => e.Files, a => - { - a.WithOwner().HasForeignKey("AgreementId"); - a.Property("Id"); - a.HasKey("Id"); - }); + entity.HasOne(a => a.Customer) + .WithMany(c => c.Agreements) + .HasForeignKey(a => a.CustomerId) + .OnDelete(DeleteBehavior.Restrict); entity.HasOne(a => a.Engagement) - .WithOne(e => e.Agreement) - .HasForeignKey(a => a.EngagementId); + .WithMany(e => e.Agreements) + .HasForeignKey(a => a.EngagementId) + .OnDelete(DeleteBehavior.Restrict); + + entity.OwnsMany(a => a.Files, fr => + { + fr.WithOwner().HasForeignKey("AgreementId"); + fr.Property("Id"); + fr.HasKey("Id"); + }); }); modelBuilder.Entity() diff --git a/backend/Infrastructure/Migrations/20241119081305_updateAgreementRelationship.Designer.cs b/backend/Infrastructure/Migrations/20241119081305_updateAgreementRelationship.Designer.cs new file mode 100644 index 00000000..454edaaf --- /dev/null +++ b/backend/Infrastructure/Migrations/20241119081305_updateAgreementRelationship.Designer.cs @@ -0,0 +1,625 @@ +// +using System; +using Infrastructure.DatabaseContext; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20241119081305_updateAgreementRelationship")] + partial class updateAgreementRelationship + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Core.Absences.Absence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExcludeFromBillRate") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Absence"); + }); + + modelBuilder.Entity("Core.Agreements.Agreement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EngagementId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("NextPriceAdjustmentDate") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Options") + .HasColumnType("nvarchar(max)"); + + b.Property("PriceAdjustmentIndex") + .HasColumnType("nvarchar(max)"); + + b.Property("PriceAdjustmentProcess") + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.HasIndex("EngagementId"); + + b.ToTable("Agreements"); + }); + + modelBuilder.Entity("Core.Consultants.Competence", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Competence"); + + b.HasData( + new + { + Id = "frontend", + Name = "Frontend" + }, + new + { + Id = "backend", + Name = "Backend" + }, + new + { + Id = "design", + Name = "Design" + }, + new + { + Id = "project-mgmt", + Name = "Project Management" + }, + new + { + Id = "development", + Name = "Utvikling" + }); + }); + + modelBuilder.Entity("Core.Consultants.CompetenceConsultant", b => + { + b.Property("ConsultantId") + .HasColumnType("int"); + + b.Property("CompetencesId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("ConsultantId", "CompetencesId"); + + b.HasIndex("CompetencesId"); + + b.ToTable("CompetenceConsultant"); + }); + + modelBuilder.Entity("Core.Consultants.Consultant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Degree") + .HasColumnType("nvarchar(max)"); + + b.Property("DepartmentId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("TransferredVacationDays") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.ToTable("Consultant"); + + b.HasData( + new + { + Id = 1, + Degree = "Master", + DepartmentId = "trondheim", + Email = "j@variant.no", + GraduationYear = 2019, + Name = "Jonas", + StartDate = new DateTime(2020, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified) + }); + }); + + modelBuilder.Entity("Core.Customers.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("OrganizationId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("Customer"); + }); + + modelBuilder.Entity("Core.Engagements.Engagement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("IsBillable") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId", "Name") + .IsUnique(); + + b.ToTable("Project"); + }); + + modelBuilder.Entity("Core.Organizations.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("nvarchar(450)"); + + b.Property("Hotkey") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Department"); + + b.HasData( + new + { + Id = "trondheim", + Name = "Trondheim", + OrganizationId = "variant-as" + }); + }); + + modelBuilder.Entity("Core.Organizations.Organization", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("Country") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HasVacationInChristmas") + .HasColumnType("bit"); + + b.Property("HoursPerWorkday") + .HasColumnType("float"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NumberOfVacationDaysInYear") + .HasColumnType("int"); + + b.Property("UrlKey") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Organization"); + + b.HasData( + new + { + Id = "variant-as", + Country = "norway", + HasVacationInChristmas = true, + HoursPerWorkday = 7.5, + Name = "Variant AS", + NumberOfVacationDaysInYear = 25, + UrlKey = "variant-as" + }); + }); + + modelBuilder.Entity("Core.PlannedAbsences.PlannedAbsence", b => + { + b.Property("AbsenceId") + .HasColumnType("int"); + + b.Property("ConsultantId") + .HasColumnType("int"); + + b.Property("Week") + .HasColumnType("int"); + + b.Property("Hours") + .HasColumnType("float"); + + b.HasKey("AbsenceId", "ConsultantId", "Week"); + + b.HasIndex("ConsultantId"); + + b.ToTable("PlannedAbsence"); + }); + + modelBuilder.Entity("Core.Staffings.Staffing", b => + { + b.Property("EngagementId") + .HasColumnType("int"); + + b.Property("ConsultantId") + .HasColumnType("int"); + + b.Property("Week") + .HasColumnType("int"); + + b.Property("Hours") + .HasColumnType("float"); + + b.HasKey("EngagementId", "ConsultantId", "Week"); + + b.HasIndex("ConsultantId"); + + b.ToTable("Staffing"); + }); + + modelBuilder.Entity("Core.Vacations.Vacation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConsultantId") + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("ConsultantId"); + + b.ToTable("Vacation"); + }); + + modelBuilder.Entity("Core.Absences.Absence", b => + { + b.HasOne("Core.Organizations.Organization", "Organization") + .WithMany("AbsenceTypes") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Core.Agreements.Agreement", b => + { + b.HasOne("Core.Customers.Customer", "Customer") + .WithMany("Agreements") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Engagements.Engagement", "Engagement") + .WithMany("Agreements") + .HasForeignKey("EngagementId") + .OnDelete(DeleteBehavior.Restrict); + + b.OwnsMany("Core.Agreements.FileReference", "Files", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b1.Property("Id")); + + b1.Property("AgreementId") + .HasColumnType("int"); + + b1.Property("BlobName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("FileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("UploadedOn") + .HasColumnType("datetime2"); + + b1.HasKey("Id"); + + b1.HasIndex("AgreementId"); + + b1.ToTable("FileReference"); + + b1.WithOwner() + .HasForeignKey("AgreementId"); + }); + + b.Navigation("Customer"); + + b.Navigation("Engagement"); + + b.Navigation("Files"); + }); + + modelBuilder.Entity("Core.Consultants.CompetenceConsultant", b => + { + b.HasOne("Core.Consultants.Competence", "Competence") + .WithMany("CompetenceConsultant") + .HasForeignKey("CompetencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Consultants.Consultant", "Consultant") + .WithMany("CompetenceConsultant") + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Competence"); + + b.Navigation("Consultant"); + }); + + modelBuilder.Entity("Core.Consultants.Consultant", b => + { + b.HasOne("Core.Organizations.Department", "Department") + .WithMany("Consultants") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Department"); + }); + + modelBuilder.Entity("Core.Customers.Customer", b => + { + b.HasOne("Core.Organizations.Organization", "Organization") + .WithMany("Customers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Core.Engagements.Engagement", b => + { + b.HasOne("Core.Customers.Customer", "Customer") + .WithMany("Projects") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Core.Organizations.Department", b => + { + b.HasOne("Core.Organizations.Organization", "Organization") + .WithMany("Departments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Core.PlannedAbsences.PlannedAbsence", b => + { + b.HasOne("Core.Absences.Absence", "Absence") + .WithMany() + .HasForeignKey("AbsenceId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("Core.Consultants.Consultant", "Consultant") + .WithMany("PlannedAbsences") + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Absence"); + + b.Navigation("Consultant"); + }); + + modelBuilder.Entity("Core.Staffings.Staffing", b => + { + b.HasOne("Core.Consultants.Consultant", "Consultant") + .WithMany("Staffings") + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("Core.Engagements.Engagement", "Engagement") + .WithMany("Staffings") + .HasForeignKey("EngagementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultant"); + + b.Navigation("Engagement"); + }); + + modelBuilder.Entity("Core.Vacations.Vacation", b => + { + b.HasOne("Core.Consultants.Consultant", "Consultant") + .WithMany("Vacations") + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultant"); + }); + + modelBuilder.Entity("Core.Consultants.Competence", b => + { + b.Navigation("CompetenceConsultant"); + }); + + modelBuilder.Entity("Core.Consultants.Consultant", b => + { + b.Navigation("CompetenceConsultant"); + + b.Navigation("PlannedAbsences"); + + b.Navigation("Staffings"); + + b.Navigation("Vacations"); + }); + + modelBuilder.Entity("Core.Customers.Customer", b => + { + b.Navigation("Agreements"); + + b.Navigation("Projects"); + }); + + modelBuilder.Entity("Core.Engagements.Engagement", b => + { + b.Navigation("Agreements"); + + b.Navigation("Staffings"); + }); + + modelBuilder.Entity("Core.Organizations.Department", b => + { + b.Navigation("Consultants"); + }); + + modelBuilder.Entity("Core.Organizations.Organization", b => + { + b.Navigation("AbsenceTypes"); + + b.Navigation("Customers"); + + b.Navigation("Departments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Infrastructure/Migrations/20241119081305_updateAgreementRelationship.cs b/backend/Infrastructure/Migrations/20241119081305_updateAgreementRelationship.cs new file mode 100644 index 00000000..6d154c97 --- /dev/null +++ b/backend/Infrastructure/Migrations/20241119081305_updateAgreementRelationship.cs @@ -0,0 +1,118 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class updateAgreementRelationship : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Agreements_Project_EngagementId", + table: "Agreements"); + + migrationBuilder.DropIndex( + name: "IX_Agreements_EngagementId", + table: "Agreements"); + + migrationBuilder.AlterColumn( + name: "EngagementId", + table: "Agreements", + type: "int", + nullable: true, + oldClrType: typeof(int), + oldType: "int"); + + migrationBuilder.AddColumn( + name: "CustomerId", + table: "Agreements", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "Name", + table: "Agreements", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Agreements_CustomerId", + table: "Agreements", + column: "CustomerId"); + + migrationBuilder.CreateIndex( + name: "IX_Agreements_EngagementId", + table: "Agreements", + column: "EngagementId"); + + migrationBuilder.AddForeignKey( + name: "FK_Agreements_Customer_CustomerId", + table: "Agreements", + column: "CustomerId", + principalTable: "Customer", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_Agreements_Project_EngagementId", + table: "Agreements", + column: "EngagementId", + principalTable: "Project", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Agreements_Customer_CustomerId", + table: "Agreements"); + + migrationBuilder.DropForeignKey( + name: "FK_Agreements_Project_EngagementId", + table: "Agreements"); + + migrationBuilder.DropIndex( + name: "IX_Agreements_CustomerId", + table: "Agreements"); + + migrationBuilder.DropIndex( + name: "IX_Agreements_EngagementId", + table: "Agreements"); + + migrationBuilder.DropColumn( + name: "CustomerId", + table: "Agreements"); + + migrationBuilder.DropColumn( + name: "Name", + table: "Agreements"); + + migrationBuilder.AlterColumn( + name: "EngagementId", + table: "Agreements", + type: "int", + nullable: false, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Agreements_EngagementId", + table: "Agreements", + column: "EngagementId"); + + migrationBuilder.AddForeignKey( + name: "FK_Agreements_Project_EngagementId", + table: "Agreements", + column: "EngagementId", + principalTable: "Project", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs index 394fc805..ade52f49 100644 --- a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs +++ b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs @@ -56,12 +56,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("CustomerId") + .HasColumnType("int"); + b.Property("EndDate") .HasColumnType("datetime2"); - b.Property("EngagementId") + b.Property("EngagementId") .HasColumnType("int"); + b.Property("Name") + .HasColumnType("nvarchar(max)"); + b.Property("NextPriceAdjustmentDate") .HasColumnType("datetime2"); @@ -82,8 +88,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.HasIndex("EngagementId") - .IsUnique(); + b.HasIndex("CustomerId"); + + b.HasIndex("EngagementId"); b.ToTable("Agreements"); }); @@ -405,11 +412,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Core.Agreements.Agreement", b => { + b.HasOne("Core.Customers.Customer", "Customer") + .WithMany("Agreements") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("Core.Engagements.Engagement", "Engagement") - .WithOne("Agreement") - .HasForeignKey("Core.Agreements.Agreement", "EngagementId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .WithMany("Agreements") + .HasForeignKey("EngagementId") + .OnDelete(DeleteBehavior.Restrict); b.OwnsMany("Core.Agreements.FileReference", "Files", b1 => { @@ -443,6 +454,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("AgreementId"); }); + b.Navigation("Customer"); + b.Navigation("Engagement"); b.Navigation("Files"); @@ -578,12 +591,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Core.Customers.Customer", b => { + b.Navigation("Agreements"); + b.Navigation("Projects"); }); modelBuilder.Entity("Core.Engagements.Engagement", b => { - b.Navigation("Agreement"); + b.Navigation("Agreements"); b.Navigation("Staffings"); }); diff --git a/backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs b/backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs index 427574fd..2a98df07 100644 --- a/backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs +++ b/backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs @@ -13,11 +13,20 @@ public class AgreementDbRepository(ApplicationContext context) : IAgreementsRepo .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); } - public Task GetAgreementByEngagementId(int engagementId, CancellationToken cancellationToken) + public Task> GetAgreementsByEngagementId(int engagementId, CancellationToken cancellationToken) { return context.Agreements .Include(p => p.Files) - .FirstOrDefaultAsync(p => p.EngagementId == engagementId, cancellationToken); + .Where(p => p.EngagementId == engagementId) + .ToListAsync(cancellationToken); + } + + public Task> GetAgreementsByCustomerId(int customerId, CancellationToken cancellationToken) + { + return context.Agreements + .Include(p => p.Files) + .Where(p => p.CustomerId == customerId) + .ToListAsync(cancellationToken); } public async Task AddAgreementAsync(Agreement agreement, CancellationToken cancellationToken) diff --git a/frontend/src/actions/agreementActions.ts b/frontend/src/actions/agreementActions.ts index 5b97c568..5d832d75 100644 --- a/frontend/src/actions/agreementActions.ts +++ b/frontend/src/actions/agreementActions.ts @@ -17,7 +17,13 @@ export async function saveChanges( try { const agreementId = formData.get("agreementId") as string; let agreementData: AgreementWriteModel = { - engagementId: Number(formData.get("engagementId") as string), + name: formData.get("name") as string, + engagementId: formData.get("engagementId") + ? Number(formData.get("engagementId") as string) + : undefined, + customerId: formData.get("customerId") + ? Number(formData.get("customerId") as string) + : undefined, startDate: (formData.get("startDate") as string) ? new Date(formData.get("startDate") as string) : undefined, @@ -40,7 +46,7 @@ export async function saveChanges( ); if (validFiles.length > 0) { - const fileRes = await uploadFiles(agreementData.engagementId, validFiles); + const fileRes = await uploadFiles(validFiles); agreementData.files = [...(agreementData.files ?? []), ...fileRes]; } @@ -56,7 +62,10 @@ export async function saveChanges( if (validFiles.length > 0) { await deleteFiles( validFiles.map( - (file) => `${agreementData.engagementId}${file.name}`, + (file) => + `${file.name}${agreementData.files?.find( + (f) => f.fileName === file.name, + )?.uploadedOn}`, ), ); } @@ -71,7 +80,10 @@ export async function saveChanges( if (validFiles.length > 0) { await deleteFiles( validFiles.map( - (file) => `${agreementData.engagementId}${file.name}`, + (file) => + `${file.name}${agreementData.files?.find( + (f) => f.fileName === file.name, + )?.uploadedOn}`, ), ); } @@ -100,12 +112,12 @@ export async function deleteFile( } } -export async function getAgreementForProject( +export async function getAgreementsForProject( projectId: number, orgUrlKey: string, ) { try { - const res = await fetchWithToken( + const res = await fetchWithToken( `${orgUrlKey}/agreements/get/engagement/${projectId}`, ); @@ -115,6 +127,21 @@ export async function getAgreementForProject( } } +export async function getAgreementsForCustomer( + customerId: number, + orgUrlKey: string, +) { + try { + const res = await fetchWithToken( + `${orgUrlKey}/agreements/get/customer/${customerId}`, + ); + + return await res; + } catch (e) { + console.error("Error fetching agreement for customer", e); + } +} + export async function updateAgreement(agreement: Agreement, orgUrlKey: string) { try { const res = await putWithToken( @@ -156,6 +183,19 @@ export async function deleteAgreement(agreementId: number, orgUrlKey: string) { } } +export async function deleteAgreementWithFiles( + agreement: Agreement, + org: string, +) { + try { + await deleteFiles(agreement.files?.map((file) => file.blobName) ?? []); + + return await deleteAgreement(agreement.agreementId, org); + } catch (e) { + console.error("Error deleting agreement with files", e); + } +} + export async function getPriceAdjustmentIndexes(orgUrlKey: string) { try { const res = await fetchWithToken( diff --git a/frontend/src/actions/blobActions.ts b/frontend/src/actions/blobActions.ts index 35160d2a..58e6bad3 100644 --- a/frontend/src/actions/blobActions.ts +++ b/frontend/src/actions/blobActions.ts @@ -8,7 +8,7 @@ import { StorageSharedKeyCredential, } from "@azure/storage-blob"; -export async function uploadFiles(engagementId: number, files: File[]) { +export async function uploadFiles(files: File[]) { const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; if (!connectionString) { throw new Error("Azure Storage connection string is not defined."); @@ -23,7 +23,7 @@ export async function uploadFiles(engagementId: number, files: File[]) { const uploadedFiles = await Promise.all( files.map(async (file) => { const uploaded = new Date(); - const blobName = `${engagementId}${file.name}`; + const blobName = `${file.name}${uploaded}`; const arrayBuffer = await file.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); diff --git a/frontend/src/components/Agreement/AgreementEdit.tsx b/frontend/src/components/Agreement/AgreementEdit.tsx index 84aa83f0..c396353c 100644 --- a/frontend/src/components/Agreement/AgreementEdit.tsx +++ b/frontend/src/components/Agreement/AgreementEdit.tsx @@ -1,51 +1,88 @@ "use client"; import { + deleteAgreementWithFiles, deleteFile, - getAgreementForProject, + getAgreementsForCustomer, + getAgreementsForProject, getPriceAdjustmentIndexes, saveChanges, } from "@/actions/agreementActions"; -import { ProjectWithCustomerModel } from "@/api-types"; +import { + CustomersWithProjectsReadModel, + ProjectWithCustomerModel, +} from "@/api-types"; import { useParams } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; -import { EditInput } from "./components/EditInput"; +import { useEffect, useState } from "react"; import { EditDateInput } from "./components/EditDateInput"; import { Agreement } from "@/types"; import { getDownloadUrl } from "@/actions/blobActions"; import { EditTextarea } from "./components/EditTextarea"; import { EditSelect } from "./components/EditSelect"; +import { EditInput } from "./components/EditInput"; export function AgreementEdit({ project, + customer, }: { - project: ProjectWithCustomerModel; + project?: ProjectWithCustomerModel; + customer?: CustomersWithProjectsReadModel; }) { const params = useParams(); const organisation = params.organisation as string; - const [agreement, setAgreement] = useState(); - const [inEdit, setInEdit] = useState(false); + const [agreements, setAgreements] = useState(null); + const [inEditIndex, setInEditIndex] = useState(null); const [priceAdjustmentIndexes, setPriceAdjustmentIndexes] = useState< { value: string; label: string }[] >([]); useEffect(() => { - async function getAgreement() { - if (organisation && project.projectId) { - const agree = await getAgreementForProject( - project.projectId, - organisation, - ); - await getPriceIndexes(); + async function getAgreements() { + if (organisation) { + if (project) { + const agree = await getAgreementsForProject( + project.projectId, + organisation, + ); - if (agree?.agreementId) { - setAgreement(agree); - } else { - setAgreement(null); + await getPriceIndexes(); + + if (agree && agree.length > 0) { + setAgreements( + agree.sort( + (a, b) => + new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ), + ); + setInEditIndex(null); + } else { + setAgreements([]); + setInEditIndex(null); + } + } else if (customer) { + const agree = await getAgreementsForCustomer( + customer.customerId, + organisation, + ); + + await getPriceIndexes(); + + if (agree && agree.length > 0) { + setAgreements( + agree.sort( + (a, b) => + new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ), + ); + setInEditIndex(null); + } else { + setAgreements([]); + setInEditIndex(null); + } } } } - getAgreement(); - }, [organisation, project.projectId]); + getAgreements(); + }, [organisation, project, customer]); async function getPriceIndexes() { if (organisation) { @@ -56,17 +93,24 @@ export function AgreementEdit({ } } - async function save(formData: FormData) { + async function save(formData: FormData, index: number) { try { const res = await saveChanges( formData, - agreement?.files ?? [], + agreements ? agreements[index]?.files ?? [] : [], organisation, ); if (res) { - setAgreement(res); - setInEdit(false); + let agreementsCopy = [...(agreements ? agreements : [])]; + agreementsCopy[index] = res; + setAgreements( + agreementsCopy.sort( + (a, b) => + new Date(b.endDate).getTime() - new Date(a.endDate).getTime(), + ), + ); + setInEditIndex(null); await getPriceIndexes(); } else { console.error("Failed to save agreement"); @@ -90,183 +134,237 @@ export function AgreementEdit({ } } + function handlePriceAdjustmentIndexChange(value: string, index: number) { + setAgreements((prevAgreements) => { + if (!Array.isArray(prevAgreements)) return prevAgreements; + const newAgreements = [...prevAgreements]; + newAgreements[index] = { + ...newAgreements[index], + priceAdjustmentIndex: value, + }; + return newAgreements; + }); + } + return (
-

Avtale

-
- {agreement && agreement !== null ? ( -
-
- {agreement.agreementId !== -1 && ( +

Avtaler

+ + {agreements && + agreements.map((agreement, i) => ( + save(form, i)} + key={agreement.agreementId} + className="border p-3 mt-2" + > +
+ +
+ +
+ {agreement.agreementId !== -1 && ( + + )} - )} - - - - - - setAgreement((prev) => - prev ? { ...prev, priceAdjustmentIndex: value } : prev, - ) - } - /> -
-
- {agreement.options || inEdit ? ( -
- -
- ) : null} - {agreement.priceAdjustmentProcess || inEdit ? ( -
- -
- ) : null} - {agreement.notes || inEdit ? ( -
- -
- ) : null} -
+ + + + + handlePriceAdjustmentIndexChange(value, i)} + /> +
+
+ {agreement.options || inEditIndex === i ? ( +
+ +
+ ) : null} + {agreement.priceAdjustmentProcess || inEditIndex === i ? ( +
+ +
+ ) : null} + {agreement.notes || inEditIndex === i ? ( +
+ +
+ ) : null} +
-
- {agreement.files && agreement.files?.length > 0 ? ( - - ) : null} - {inEdit ? ( - <> - - {agreement.files?.map((file, i) => ( -
+
+ {agreement.files && agreement.files?.length > 0 ? ( + + ) : null} + {inEditIndex === i ? ( + <> + + {agreement.files?.map((file, ind) => ( +
+ + {file.fileName} +
+ ))} + + ) : ( +
+ {agreement.files?.map((file, ind) => ( - {file.fileName} -
- ))} - + ))} +
+ )} +
+ + {inEditIndex === i ? ( + ) : ( -
- {agreement.files?.map((file, i) => ( - - ))} +
+ +
)} -
+ + ))} - {inEdit ? ( - - ) : ( - - )} - - ) : ( - agreement === null && ( - - ) + {inEditIndex === null && agreements !== null && ( + )}
); diff --git a/frontend/src/components/CostumerTable/CustomerTable.tsx b/frontend/src/components/CostumerTable/CustomerTable.tsx index 78b1dcd5..ac9978c3 100644 --- a/frontend/src/components/CostumerTable/CustomerTable.tsx +++ b/frontend/src/components/CostumerTable/CustomerTable.tsx @@ -8,6 +8,7 @@ import { useDepartmentFilter } from "@/hooks/staffing/useDepartmentFilter"; import WeekSelector from "../WeekSelector"; import { useWeekSelectors } from "@/hooks/useWeekSelectors"; import { WeekSpanTableHead } from "../Staffing/WeekTableHead"; +import { AgreementEdit } from "../Agreement/AgreementEdit"; export default function CustomerTable({ customer, @@ -95,6 +96,7 @@ export default function CustomerTable({ +
); diff --git a/frontend/src/data/apiCallsWithToken.ts b/frontend/src/data/apiCallsWithToken.ts index 3f17c1ba..49f359fd 100644 --- a/frontend/src/data/apiCallsWithToken.ts +++ b/frontend/src/data/apiCallsWithToken.ts @@ -67,13 +67,19 @@ export async function callApi( const response = await callApiNoParse(path, method, bodyInit); if (!response || response.status == 204 || !response.ok) { const responseText = await response?.text(); - console.error( - `Failed to fetch data from ${path}. Response text: ${responseText}`, - ); + console.error(`Failed from ${path}. Response text: ${responseText}`); return; } - const json = await response.json(); - return json as T; + const contentType = response.headers.get("Content-Type"); + let result: T; + + if (contentType && contentType.includes("application/json")) { + result = await response.json(); + } else { + result = (await response.text()) as unknown as T; + } + + return result as T; } catch (e) { throw new Error(`${method} ${path} failed`); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a9de0632..0c4d1675 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -46,8 +46,10 @@ export interface ConsultantWithWeekHours { } export interface Agreement { + name: string; agreementId: number; - engagementId: number; + customerId?: number; + engagementId?: number; startDate?: Date; endDate: Date; nextPriceAdjustmentDate?: Date; @@ -59,7 +61,9 @@ export interface Agreement { } export interface AgreementWriteModel { - engagementId: number; + name: string; + customerId?: number; + engagementId?: number; startDate?: Date; endDate: Date; nextPriceAdjustmentDate?: Date;