From fc154fc002827a5022ccc467916c07c974bfac52 Mon Sep 17 00:00:00 2001 From: Magnus Dahl <55053871+Dahly96@users.noreply.github.com> Date: Thu, 14 Nov 2024 13:19:15 +0100 Subject: [PATCH] added agreeemnts for projects with file upload (#535) Co-authored-by: md --- backend/Api/Projects/AgreementController.cs | 193 ++++++ backend/Api/Projects/AgreementModels.cs | 37 ++ backend/Core/Agreements/Agreement.cs | 34 + .../Core/Agreements/IAgreementRepository.cs | 15 + backend/Core/Engagements/Engagement.cs | 3 + .../DatabaseContext/ApplicationContext.cs | 18 + ...20241114074100_agreementEntity.Designer.cs | 604 ++++++++++++++++++ .../20241114074100_agreementEntity.cs | 82 +++ .../ApplicationContextModelSnapshot.cs | 231 ++++--- .../Agreement/AgreementDbRepository.cs | 44 ++ .../Repositories/RepositoryExtensions.cs | 2 + frontend/package.json | 1 + frontend/src/actions/agreementActions.ts | 155 +++++ frontend/src/actions/blobActions.ts | 120 ++++ .../prosjekt/[project]/page.tsx | 3 + .../components/Agreement/AgreementEdit.tsx | 236 +++++++ .../Agreement/components/EditDateInput.tsx | 51 ++ .../Agreement/components/EditInput.tsx | 44 ++ .../Agreement/components/EditTextarea.tsx | 48 ++ .../consultants/AddNewConsultantModal.tsx | 2 - frontend/src/data/apiCallsWithToken.ts | 7 +- frontend/src/types.ts | 27 + frontend/yarn.lock | 172 ++++- 23 files changed, 2049 insertions(+), 80 deletions(-) create mode 100644 backend/Api/Projects/AgreementController.cs create mode 100644 backend/Api/Projects/AgreementModels.cs create mode 100644 backend/Core/Agreements/Agreement.cs create mode 100644 backend/Core/Agreements/IAgreementRepository.cs create mode 100644 backend/Infrastructure/Migrations/20241114074100_agreementEntity.Designer.cs create mode 100644 backend/Infrastructure/Migrations/20241114074100_agreementEntity.cs create mode 100644 backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs create mode 100644 frontend/src/actions/agreementActions.ts create mode 100644 frontend/src/actions/blobActions.ts create mode 100644 frontend/src/components/Agreement/AgreementEdit.tsx create mode 100644 frontend/src/components/Agreement/components/EditDateInput.tsx create mode 100644 frontend/src/components/Agreement/components/EditInput.tsx create mode 100644 frontend/src/components/Agreement/components/EditTextarea.tsx diff --git a/backend/Api/Projects/AgreementController.cs b/backend/Api/Projects/AgreementController.cs new file mode 100644 index 00000000..6a67ed9f --- /dev/null +++ b/backend/Api/Projects/AgreementController.cs @@ -0,0 +1,193 @@ +using Api.Common; +using Api.StaffingController; +using Core.Agreements; +using Core.Consultants; +using Core.DomainModels; +using Core.Organizations; +using Core.Staffings; +using Infrastructure.DatabaseContext; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace Api.Projects; + +[Authorize] +[Route("/v0/{orgUrlKey}/agreements")] +[ApiController] +public class AgreementController( + ApplicationContext context, + IMemoryCache cache, + IOrganisationRepository organisationRepository, + IAgreementsRepository agreementsRepository) : ControllerBase +{ + + [HttpGet] + [Route("get/{agreementId}")] + public async Task> GetAgreement([FromRoute] string orgUrlKey, + [FromRoute] int agreementId, CancellationToken ct) + { + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + if (selectedOrg is null) return BadRequest("Selected org not found"); + + var agreement = await agreementsRepository.GetAgreementById(agreementId, ct); + + if (agreement is null) return NotFound(); + + var responseModel = new AgreementReadModel( + AgreementId: agreement.Id, + EngagementId: agreement.EngagementId, + StartDate: agreement.StartDate, + EndDate: agreement.EndDate, + NextPriceAdjustmentDate: agreement.NextPriceAdjustmentDate, + PriceAdjustmentIndex: agreement.PriceAdjustmentIndex, + Notes: agreement.Notes, + Files: agreement.Files.Select(f => new FileReferenceReadModel( + FileName: f.FileName, + BlobName: f.BlobName, + UploadedOn: f.UploadedOn + )).ToList() + ); + return Ok(responseModel); + } + + [HttpGet] + [Route("get/engagement/{engagementId}")] + public async Task> GetAgreementByEngagement([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); + + if (agreement is null) return NotFound(); + + var responseModel = new AgreementReadModel( + AgreementId: agreement.Id, + EngagementId: agreement.EngagementId, + StartDate: agreement.StartDate, + EndDate: agreement.EndDate, + NextPriceAdjustmentDate: agreement.NextPriceAdjustmentDate, + PriceAdjustmentIndex: agreement.PriceAdjustmentIndex, + Notes: agreement.Notes, + Files: agreement.Files.Select(f => new FileReferenceReadModel( + FileName: f.FileName, + BlobName: f.BlobName, + UploadedOn: f.UploadedOn + )).ToList() + ); + return Ok(responseModel); + } + + [HttpPost] + [Route("create")] + public async Task> Post([FromRoute] string orgUrlKey, + [FromBody] AgreementWriteModel body, CancellationToken ct) + { + + Console.WriteLine(body); + + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + if (selectedOrg is null) return BadRequest("Selected org not found"); + + var engagement = await context.Project.FindAsync(body.EngagementId); + if (engagement is null) return BadRequest("Engagement not found"); + + var agreement = new Agreement + { + EngagementId = body.EngagementId, + Engagement = engagement, + StartDate = body.StartDate, + EndDate = body.EndDate, + NextPriceAdjustmentDate = body.NextPriceAdjustmentDate, + PriceAdjustmentIndex = body.PriceAdjustmentIndex, + Notes = body.Notes, + Files = body.Files.Select(f => new FileReference + { + FileName = f.FileName, + BlobName = f.BlobName, + UploadedOn = f.UploadedOn + }).ToList() + }; + + await agreementsRepository.AddAgreementAsync(agreement, ct); + + var responseModel = new AgreementReadModel( + AgreementId: agreement.Id, + EngagementId: agreement.EngagementId, + StartDate: agreement.StartDate, + EndDate: agreement.EndDate, + NextPriceAdjustmentDate: agreement.NextPriceAdjustmentDate, + PriceAdjustmentIndex: agreement.PriceAdjustmentIndex, + Notes: agreement.Notes, + Files: agreement.Files.Select(f => new FileReferenceReadModel( + FileName: f.FileName, + BlobName: f.BlobName, + UploadedOn: f.UploadedOn + )).ToList() + ); + + return Ok(responseModel); + } + + [HttpPut] + [Route("update/{agreementId}")] + public async Task> Put([FromRoute] string orgUrlKey, + [FromRoute] int agreementId, [FromBody] AgreementWriteModel body, CancellationToken ct) + { + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + if (selectedOrg is null) return BadRequest("Selected org not found"); + + var agreement = await agreementsRepository.GetAgreementById(agreementId, ct); + if (agreement is null) return NotFound(); + + agreement.EngagementId = body.EngagementId; + agreement.StartDate = body.StartDate; + agreement.EndDate = body.EndDate; + agreement.NextPriceAdjustmentDate = body.NextPriceAdjustmentDate; + agreement.PriceAdjustmentIndex = body.PriceAdjustmentIndex; + agreement.Notes = body.Notes; + agreement.Files = body.Files.Select(f => new FileReference + { + FileName = f.FileName, + BlobName = f.BlobName, + UploadedOn = f.UploadedOn + }).ToList(); + + await agreementsRepository.UpdateAgreementAsync(agreement, ct); + + var responseModel = new AgreementReadModel( + AgreementId: agreement.Id, + EngagementId: agreement.EngagementId, + StartDate: agreement.StartDate, + EndDate: agreement.EndDate, + NextPriceAdjustmentDate: agreement.NextPriceAdjustmentDate, + PriceAdjustmentIndex: agreement.PriceAdjustmentIndex, + Notes: agreement.Notes, + Files: agreement.Files.Select(f => new FileReferenceReadModel( + FileName: f.FileName, + BlobName: f.BlobName, + UploadedOn: f.UploadedOn + )).ToList() + ); + + return Ok(responseModel); + } + + [HttpDelete] + [Route("delete/{agreementId}")] + public async Task Delete([FromRoute] string orgUrlKey, [FromRoute] int agreementId, CancellationToken ct) + { + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + if (selectedOrg is null) return BadRequest("Selected org not found"); + + var agreement = await agreementsRepository.GetAgreementById(agreementId, ct); + if (agreement is null) return NotFound(); + + await agreementsRepository.DeleteAgreementAsync(agreementId, ct); + + return Ok(); + } +} \ No newline at end of file diff --git a/backend/Api/Projects/AgreementModels.cs b/backend/Api/Projects/AgreementModels.cs new file mode 100644 index 00000000..6a8f087e --- /dev/null +++ b/backend/Api/Projects/AgreementModels.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +public record AgreementReadModel( + int AgreementId, + int EngagementId, + DateTime? StartDate, + DateTime EndDate, + DateTime? NextPriceAdjustmentDate, + string? PriceAdjustmentIndex, + string? Notes, + List Files +); + +public record FileReferenceReadModel( + string FileName, + string BlobName, + DateTime UploadedOn +); + +public record AgreementWriteModel( + int EngagementId, + DateTime? StartDate, + DateTime EndDate, + DateTime? NextPriceAdjustmentDate, + string? PriceAdjustmentIndex, + string? Notes, + List Files +); + +public record FileReferenceWriteModel( + string FileName, + string BlobName, + DateTime UploadedOn +); + \ No newline at end of file diff --git a/backend/Core/Agreements/Agreement.cs b/backend/Core/Agreements/Agreement.cs new file mode 100644 index 00000000..d62c90d5 --- /dev/null +++ b/backend/Core/Agreements/Agreement.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations.Schema; +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; } + + public ICollection Files { get; set; } = new List(); + + public DateTime? StartDate { get; set; } + + public required DateTime EndDate { get; set; } + + public DateTime? NextPriceAdjustmentDate { get; set; } + + public string? PriceAdjustmentIndex { get; set; } + + public string? Notes { get; set; } = string.Empty; + } + + public class FileReference + { + public string FileName { get; set; } = string.Empty; + public string BlobName { get; set; } = string.Empty; // URI to the blob storage + public DateTime UploadedOn { get; set; } + } +} \ No newline at end of file diff --git a/backend/Core/Agreements/IAgreementRepository.cs b/backend/Core/Agreements/IAgreementRepository.cs new file mode 100644 index 00000000..b185fd87 --- /dev/null +++ b/backend/Core/Agreements/IAgreementRepository.cs @@ -0,0 +1,15 @@ +namespace Core.Agreements; + +public interface IAgreementsRepository +{ + public Task GetAgreementById(int id, CancellationToken cancellationToken); + + public Task GetAgreementByEngagementId(int engagementId, CancellationToken cancellationToken); + + public Task AddAgreementAsync(Agreement agreement, CancellationToken cancellationToken); + + public Task UpdateAgreementAsync(Agreement agreement, CancellationToken cancellationToken); + + public Task DeleteAgreementAsync(int id, CancellationToken cancellationToken); + +} \ No newline at end of file diff --git a/backend/Core/Engagements/Engagement.cs b/backend/Core/Engagements/Engagement.cs index a1b811dc..4c5c8211 100644 --- a/backend/Core/Engagements/Engagement.cs +++ b/backend/Core/Engagements/Engagement.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations.Schema; +using Core.Agreements; using Core.Consultants; using Core.Customers; using Core.Staffings; @@ -14,6 +15,8 @@ public class Engagement public required Customer Customer { get; set; } + public Agreement? Agreement { get; set; } + public required EngagementState State { get; set; } public List Consultants { get; set; } = new(); diff --git a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs index ca2a7913..485c1a02 100644 --- a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs +++ b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs @@ -1,4 +1,5 @@ using Core.Absences; +using Core.Agreements; using Core.Consultants; using Core.Customers; using Core.DomainModels; @@ -30,6 +31,7 @@ public ApplicationContext(DbContextOptions options) : base(options) public DbSet Customer { get; set; } = null!; public DbSet Project { get; set; } = null!; public DbSet Staffing { get; set; } = null!; + public DbSet Agreements { get; set; } = null!; protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) @@ -163,6 +165,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) new() { Id = "development", Name = "Utvikling" } }); + modelBuilder.Entity(entity => + { + entity.OwnsMany(e => e.Files, a => + { + a.WithOwner().HasForeignKey("AgreementId"); + a.Property("Id"); + a.HasKey("Id"); + }); + + entity.HasOne(a => a.Engagement) + .WithOne(e => e.Agreement) + .HasForeignKey(a => a.EngagementId); + }); + modelBuilder.Entity() .HasData(new { @@ -189,6 +205,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) GraduationYear = 2019 }); + + base.OnModelCreating(modelBuilder); } } \ No newline at end of file diff --git a/backend/Infrastructure/Migrations/20241114074100_agreementEntity.Designer.cs b/backend/Infrastructure/Migrations/20241114074100_agreementEntity.Designer.cs new file mode 100644 index 00000000..09701912 --- /dev/null +++ b/backend/Infrastructure/Migrations/20241114074100_agreementEntity.Designer.cs @@ -0,0 +1,604 @@ +// +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("20241114074100_agreementEntity")] + partial class agreementEntity + { + /// + 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("EndDate") + .HasColumnType("datetime2"); + + b.Property("EngagementId") + .HasColumnType("int"); + + b.Property("NextPriceAdjustmentDate") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PriceAdjustmentIndex") + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EngagementId") + .IsUnique(); + + 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.Engagements.Engagement", "Engagement") + .WithOne("Agreement") + .HasForeignKey("Core.Agreements.Agreement", "EngagementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("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("Projects"); + }); + + modelBuilder.Entity("Core.Engagements.Engagement", b => + { + b.Navigation("Agreement"); + + 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/20241114074100_agreementEntity.cs b/backend/Infrastructure/Migrations/20241114074100_agreementEntity.cs new file mode 100644 index 00000000..c529d08b --- /dev/null +++ b/backend/Infrastructure/Migrations/20241114074100_agreementEntity.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class agreementEntity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Agreements", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EngagementId = table.Column(type: "int", nullable: false), + StartDate = table.Column(type: "datetime2", nullable: true), + EndDate = table.Column(type: "datetime2", nullable: false), + NextPriceAdjustmentDate = table.Column(type: "datetime2", nullable: true), + PriceAdjustmentIndex = table.Column(type: "nvarchar(max)", nullable: true), + Notes = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Agreements", x => x.Id); + table.ForeignKey( + name: "FK_Agreements_Project_EngagementId", + column: x => x.EngagementId, + principalTable: "Project", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FileReference", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + FileName = table.Column(type: "nvarchar(max)", nullable: false), + BlobName = table.Column(type: "nvarchar(max)", nullable: false), + UploadedOn = table.Column(type: "datetime2", nullable: false), + AgreementId = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FileReference", x => x.Id); + table.ForeignKey( + name: "FK_FileReference_Agreements_AgreementId", + column: x => x.AgreementId, + principalTable: "Agreements", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Agreements_EngagementId", + table: "Agreements", + column: "EngagementId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_FileReference_AgreementId", + table: "FileReference", + column: "AgreementId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FileReference"); + + migrationBuilder.DropTable( + name: "Agreements"); + } + } +} diff --git a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs index 912ee0bc..dd556e10 100644 --- a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs +++ b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs @@ -22,7 +22,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Core.DomainModels.Absence", b => + modelBuilder.Entity("Core.Absences.Absence", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -48,7 +48,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Absence"); }); - modelBuilder.Entity("Core.DomainModels.Competence", b => + modelBuilder.Entity("Core.Agreements.Agreement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EngagementId") + .HasColumnType("int"); + + b.Property("NextPriceAdjustmentDate") + .HasColumnType("datetime2"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("PriceAdjustmentIndex") + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EngagementId") + .IsUnique(); + + b.ToTable("Agreements"); + }); + + modelBuilder.Entity("Core.Consultants.Competence", b => { b.Property("Id") .HasColumnType("nvarchar(450)"); @@ -89,7 +123,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("Core.DomainModels.CompetenceConsultant", b => + modelBuilder.Entity("Core.Consultants.CompetenceConsultant", b => { b.Property("ConsultantId") .HasColumnType("int"); @@ -104,7 +138,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CompetenceConsultant"); }); - modelBuilder.Entity("Core.DomainModels.Consultant", b => + modelBuilder.Entity("Core.Consultants.Consultant", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -160,7 +194,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("Core.DomainModels.Customer", b => + modelBuilder.Entity("Core.Customers.Customer", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -184,7 +218,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Customer"); }); - modelBuilder.Entity("Core.DomainModels.Department", b => + 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() @@ -216,37 +280,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("Core.DomainModels.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.DomainModels.Organization", b => + modelBuilder.Entity("Core.Organizations.Organization", b => { b.Property("Id") .HasColumnType("nvarchar(450)"); @@ -289,7 +323,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("Core.DomainModels.PlannedAbsence", b => + modelBuilder.Entity("Core.PlannedAbsences.PlannedAbsence", b => { b.Property("AbsenceId") .HasColumnType("int"); @@ -310,7 +344,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("PlannedAbsence"); }); - modelBuilder.Entity("Core.DomainModels.Staffing", b => + modelBuilder.Entity("Core.Staffings.Staffing", b => { b.Property("EngagementId") .HasColumnType("int"); @@ -331,7 +365,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Staffing"); }); - modelBuilder.Entity("Core.DomainModels.Vacation", b => + modelBuilder.Entity("Core.Vacations.Vacation", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -352,9 +386,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Vacation"); }); - modelBuilder.Entity("Core.DomainModels.Absence", b => + modelBuilder.Entity("Core.Absences.Absence", b => { - b.HasOne("Core.DomainModels.Organization", "Organization") + b.HasOne("Core.Organizations.Organization", "Organization") .WithMany("AbsenceTypes") .HasForeignKey("OrganizationId") .OnDelete(DeleteBehavior.Cascade) @@ -363,15 +397,60 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); - modelBuilder.Entity("Core.DomainModels.CompetenceConsultant", b => + modelBuilder.Entity("Core.Agreements.Agreement", b => { - b.HasOne("Core.DomainModels.Competence", "Competence") + b.HasOne("Core.Engagements.Engagement", "Engagement") + .WithOne("Agreement") + .HasForeignKey("Core.Agreements.Agreement", "EngagementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("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.DomainModels.Consultant", "Consultant") + b.HasOne("Core.Consultants.Consultant", "Consultant") .WithMany("CompetenceConsultant") .HasForeignKey("ConsultantId") .OnDelete(DeleteBehavior.Cascade) @@ -382,9 +461,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Consultant"); }); - modelBuilder.Entity("Core.DomainModels.Consultant", b => + modelBuilder.Entity("Core.Consultants.Consultant", b => { - b.HasOne("Core.DomainModels.Department", "Department") + b.HasOne("Core.Organizations.Department", "Department") .WithMany("Consultants") .HasForeignKey("DepartmentId") .OnDelete(DeleteBehavior.Cascade) @@ -393,9 +472,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Department"); }); - modelBuilder.Entity("Core.DomainModels.Customer", b => + modelBuilder.Entity("Core.Customers.Customer", b => { - b.HasOne("Core.DomainModels.Organization", "Organization") + b.HasOne("Core.Organizations.Organization", "Organization") .WithMany("Customers") .HasForeignKey("OrganizationId") .OnDelete(DeleteBehavior.Cascade) @@ -404,37 +483,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Organization"); }); - modelBuilder.Entity("Core.DomainModels.Department", b => + modelBuilder.Entity("Core.Engagements.Engagement", b => { - b.HasOne("Core.DomainModels.Organization", "Organization") - .WithMany("Departments") - .HasForeignKey("OrganizationId") + b.HasOne("Core.Customers.Customer", "Customer") + .WithMany("Projects") + .HasForeignKey("CustomerId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Organization"); + b.Navigation("Customer"); }); - modelBuilder.Entity("Core.DomainModels.Engagement", b => + modelBuilder.Entity("Core.Organizations.Department", b => { - b.HasOne("Core.DomainModels.Customer", "Customer") - .WithMany("Projects") - .HasForeignKey("CustomerId") + b.HasOne("Core.Organizations.Organization", "Organization") + .WithMany("Departments") + .HasForeignKey("OrganizationId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Customer"); + b.Navigation("Organization"); }); - modelBuilder.Entity("Core.DomainModels.PlannedAbsence", b => + modelBuilder.Entity("Core.PlannedAbsences.PlannedAbsence", b => { - b.HasOne("Core.DomainModels.Absence", "Absence") + b.HasOne("Core.Absences.Absence", "Absence") .WithMany() .HasForeignKey("AbsenceId") .OnDelete(DeleteBehavior.ClientCascade) .IsRequired(); - b.HasOne("Core.DomainModels.Consultant", "Consultant") + b.HasOne("Core.Consultants.Consultant", "Consultant") .WithMany("PlannedAbsences") .HasForeignKey("ConsultantId") .OnDelete(DeleteBehavior.Cascade) @@ -445,15 +524,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Consultant"); }); - modelBuilder.Entity("Core.DomainModels.Staffing", b => + modelBuilder.Entity("Core.Staffings.Staffing", b => { - b.HasOne("Core.DomainModels.Consultant", "Consultant") + b.HasOne("Core.Consultants.Consultant", "Consultant") .WithMany("Staffings") .HasForeignKey("ConsultantId") .OnDelete(DeleteBehavior.ClientCascade) .IsRequired(); - b.HasOne("Core.DomainModels.Engagement", "Engagement") + b.HasOne("Core.Engagements.Engagement", "Engagement") .WithMany("Staffings") .HasForeignKey("EngagementId") .OnDelete(DeleteBehavior.Cascade) @@ -464,9 +543,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Engagement"); }); - modelBuilder.Entity("Core.DomainModels.Vacation", b => + modelBuilder.Entity("Core.Vacations.Vacation", b => { - b.HasOne("Core.DomainModels.Consultant", "Consultant") + b.HasOne("Core.Consultants.Consultant", "Consultant") .WithMany("Vacations") .HasForeignKey("ConsultantId") .OnDelete(DeleteBehavior.Cascade) @@ -475,12 +554,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Consultant"); }); - modelBuilder.Entity("Core.DomainModels.Competence", b => + modelBuilder.Entity("Core.Consultants.Competence", b => { b.Navigation("CompetenceConsultant"); }); - modelBuilder.Entity("Core.DomainModels.Consultant", b => + modelBuilder.Entity("Core.Consultants.Consultant", b => { b.Navigation("CompetenceConsultant"); @@ -491,22 +570,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Vacations"); }); - modelBuilder.Entity("Core.DomainModels.Customer", b => + modelBuilder.Entity("Core.Customers.Customer", b => { b.Navigation("Projects"); }); - modelBuilder.Entity("Core.DomainModels.Department", b => + modelBuilder.Entity("Core.Engagements.Engagement", b => { - b.Navigation("Consultants"); + b.Navigation("Agreement"); + + b.Navigation("Staffings"); }); - modelBuilder.Entity("Core.DomainModels.Engagement", b => + modelBuilder.Entity("Core.Organizations.Department", b => { - b.Navigation("Staffings"); + b.Navigation("Consultants"); }); - modelBuilder.Entity("Core.DomainModels.Organization", b => + modelBuilder.Entity("Core.Organizations.Organization", b => { b.Navigation("AbsenceTypes"); diff --git a/backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs b/backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs new file mode 100644 index 00000000..0c02763d --- /dev/null +++ b/backend/Infrastructure/Repositories/Agreement/AgreementDbRepository.cs @@ -0,0 +1,44 @@ +using Core.Agreements; +using Infrastructure.DatabaseContext; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Repositories; + +public class AgreementDbRepository(ApplicationContext context) : IAgreementsRepository +{ + public Task GetAgreementById(int id, CancellationToken cancellationToken) + { + return context.Agreements + .Include(p => p.Files) + .FirstOrDefaultAsync(p => p.Id == id, cancellationToken); + } + + public Task GetAgreementByEngagementId(int engagementId, CancellationToken cancellationToken) + { + return context.Agreements + .Include(p => p.Files) + .FirstOrDefaultAsync(p => p.EngagementId == engagementId, cancellationToken); + } + + public async Task AddAgreementAsync(Agreement agreement, CancellationToken cancellationToken) + { + context.Agreements.Add(agreement); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateAgreementAsync(Agreement agreement, CancellationToken cancellationToken) + { + context.Agreements.Update(agreement); + await context.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteAgreementAsync(int id, CancellationToken cancellationToken) + { + var agreement = await context.Agreements.FindAsync(id); + if (agreement != null) + { + context.Agreements.Remove(agreement); + await context.SaveChangesAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/backend/Infrastructure/Repositories/RepositoryExtensions.cs b/backend/Infrastructure/Repositories/RepositoryExtensions.cs index c41fd4ff..72589cdd 100644 --- a/backend/Infrastructure/Repositories/RepositoryExtensions.cs +++ b/backend/Infrastructure/Repositories/RepositoryExtensions.cs @@ -1,3 +1,4 @@ +using Core.Agreements; using Core.Consultants; using Core.Engagements; using Core.Organizations; @@ -29,5 +30,6 @@ public static void AddRepositories(this WebApplicationBuilder builder) builder.Services.Decorate(); builder.Services.AddScoped(); + builder.Services.AddScoped(); } } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 55ed338f..54179c48 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "generate-types": "npx swagger-typescript-api -p http://localhost:7172/swagger/v0/swagger.json -o ./src -n api-types.ts --no-client" }, "dependencies": { + "@azure/storage-blob": "^12.25.0", "@headlessui/react": "^1.7.17", "@types/node": "20.5.9", "@types/react": "18.2.21", diff --git a/frontend/src/actions/agreementActions.ts b/frontend/src/actions/agreementActions.ts new file mode 100644 index 00000000..62e785df --- /dev/null +++ b/frontend/src/actions/agreementActions.ts @@ -0,0 +1,155 @@ +"use server"; + +import { + deleteWithToken, + fetchWithToken, + postWithToken, + putWithToken, +} from "@/data/apiCallsWithToken"; +import { Agreement, AgreementWriteModel, FileReference } from "@/types"; +import { deleteFiles, uploadFiles } from "./blobActions"; + +export async function saveChanges( + formData: FormData, + oldFiles: FileReference[], + org: string, +) { + try { + const agreementId = formData.get("agreementId") as string; + let agreementData: AgreementWriteModel = { + engagementId: Number(formData.get("engagementId") as string), + startDate: (formData.get("startDate") as string) + ? new Date(formData.get("startDate") as string) + : undefined, + endDate: new Date(formData.get("endDate") as string), + nextPriceAdjustmentDate: (formData.get( + "nextPriceAdjustmentDate", + ) as string) + ? new Date(formData.get("nextPriceAdjustmentDate") as string) + : undefined, + priceAdjustmentIndex: formData.get("priceAdjustmentIndex") as string, + notes: formData.get("notes") as string, + files: oldFiles, + }; + + const files = formData.getAll("files") as File[]; + const validFiles = files.filter( + (file) => file.size > 0 && file.name !== "undefined", + ); + + if (validFiles.length > 0) { + const fileRes = await uploadFiles(agreementData.engagementId, validFiles); + agreementData.files = [...(agreementData.files ?? []), ...fileRes]; + } + + if (agreementId !== null) { + const res = await updateAgreement( + { ...agreementData, agreementId: Number(agreementId) }, + org, + ); + + if (res) { + return res; + } else { + if (validFiles.length > 0) { + await deleteFiles( + validFiles.map( + (file) => `${agreementData.engagementId}${file.name}`, + ), + ); + } + console.error("Failed to update agreement"); + return; + } + } else { + const res = await createAgreement(agreementData, org); + if (res) { + return res; + } else { + if (validFiles.length > 0) { + await deleteFiles( + validFiles.map( + (file) => `${agreementData.engagementId}${file.name}`, + ), + ); + } + console.error("Failed to create agreement"); + return; + } + } + } catch (error) { + console.error("Failed to save changes", error); + throw new Error(`Failed to save changes: ${error}`); + } +} + +export async function deleteFile( + blobName: string, + agreement: Agreement, + org: string, +) { + try { + await updateAgreement(agreement, org); + + await deleteFiles([blobName]); + } catch (error) { + console.error("Failed to delete file", error); + throw new Error(`Failed to delete file: ${error}`); + } +} + +export async function getAgreementForProject( + projectId: number, + orgUrlKey: string, +) { + try { + const res = await fetchWithToken( + `${orgUrlKey}/agreements/get/engagement/${projectId}`, + ); + + return await res; + } catch (e) { + console.error("Error fetching agreement for project", e); + } +} + +export async function updateAgreement(agreement: Agreement, orgUrlKey: string) { + try { + const res = await putWithToken( + `${orgUrlKey}/agreements/update/${agreement.agreementId}`, + agreement, + ); + + return await res; + } catch (e) { + console.error("Error updating agreement", e); + } +} + +export async function createAgreement( + agreement: AgreementWriteModel, + orgUrlKey: string, +) { + try { + const res = await postWithToken( + `${orgUrlKey}/agreements/create`, + agreement, + ); + + return res; + } catch (e) { + console.error("Error creating agreement", e); + } +} + +export async function deleteAgreement(agreementId: number, orgUrlKey: string) { + try { + const res = await deleteWithToken( + `${orgUrlKey}/agreements/delete/${agreementId}`, + ); + + return res; + } catch (e) { + console.error("Error deleting agreement", e); + } +} diff --git a/frontend/src/actions/blobActions.ts b/frontend/src/actions/blobActions.ts new file mode 100644 index 00000000..35160d2a --- /dev/null +++ b/frontend/src/actions/blobActions.ts @@ -0,0 +1,120 @@ +"use server"; +import { FileReference } from "@/types"; +import { + BlobSASPermissions, + BlobSASSignatureValues, + BlobServiceClient, + generateBlobSASQueryParameters, + StorageSharedKeyCredential, +} from "@azure/storage-blob"; + +export async function uploadFiles(engagementId: number, files: File[]) { + const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; + if (!connectionString) { + throw new Error("Azure Storage connection string is not defined."); + } + + const blobServiceClient: BlobServiceClient = + BlobServiceClient.fromConnectionString(connectionString); + + const containerClient = blobServiceClient.getContainerClient("files"); + + try { + const uploadedFiles = await Promise.all( + files.map(async (file) => { + const uploaded = new Date(); + const blobName = `${engagementId}${file.name}`; + + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + const blockBlobClient = containerClient.getBlockBlobClient(blobName); + await blockBlobClient.uploadData(buffer, { + blobHTTPHeaders: { blobContentType: file.type }, + }); + + return { + fileName: file.name, + blobName: blobName, + uploadedOn: uploaded, + } as FileReference; + }), + ); + + return uploadedFiles; + } catch (error) { + console.error("Error uploading files to Azure Blob Storage:", error); + throw new Error("Failed to upload files."); + } +} + +export async function deleteFiles(blobNames: string[]) { + const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; + if (!connectionString) { + throw new Error("Azure Storage connection string is not defined."); + } + const blobServiceClient: BlobServiceClient = + BlobServiceClient.fromConnectionString(connectionString); + + const containerClient = blobServiceClient.getContainerClient("files"); + + try { + await Promise.all( + blobNames.map(async (blobName) => { + const blobClient = containerClient.getBlobClient(blobName); + await blobClient.delete(); + }), + ); + } catch (error) { + console.error("Error deleting files from Azure Blob Storage:", error); + throw new Error("Failed to delete files."); + } +} + +export async function getDownloadUrl(blobName: string, fileName: string) { + // Optionally, implement authentication and authorization checks here + + try { + const containerName = "files"; + + const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; + if (!connectionString) { + throw new Error("Azure Storage connection string is not defined."); + } + + const blobServiceClient: BlobServiceClient = + BlobServiceClient.fromConnectionString(connectionString); + + const containerClient = blobServiceClient.getContainerClient(containerName); + const blobClient = containerClient.getBlobClient(blobName); + + // Check if the blob exists + const exists = await blobClient.exists(); + if (!exists) { + throw new Error("File not found."); + } + + // Generate SAS token + const sasOptions: BlobSASSignatureValues = { + containerName, + blobName, + permissions: BlobSASPermissions.parse("r"), // Read permission + startsOn: new Date(), + expiresOn: new Date(new Date().valueOf() + 5 * 60 * 1000), // Expires in 5 minutes + contentDisposition: `attachment; filename="${encodeURIComponent( + fileName, + )}"`, + }; + + const sasToken = generateBlobSASQueryParameters( + sasOptions, + blobServiceClient.credential as StorageSharedKeyCredential, + ).toString(); + const sasUrl = `${blobClient.url}?${sasToken}`; + + return sasUrl; + } catch (error) { + console.error("Error generating download URL:", error); + throw new Error("Error generating download URL."); + } +} diff --git a/frontend/src/app/[organisation]/prosjekt/[project]/page.tsx b/frontend/src/app/[organisation]/prosjekt/[project]/page.tsx index f3d5a823..a15638c6 100644 --- a/frontend/src/app/[organisation]/prosjekt/[project]/page.tsx +++ b/frontend/src/app/[organisation]/prosjekt/[project]/page.tsx @@ -9,6 +9,7 @@ import { ConsultantFilterProvider } from "@/hooks/ConsultantFilterProvider"; import { parseYearWeekFromUrlString } from "@/data/urlUtils"; import { fetchWorkHoursPerWeek } from "@/hooks/fetchWorkHoursPerDay"; import EditEngagementName from "@/components/EditEngagementName"; +import { AgreementEdit } from "@/components/Agreement/AgreementEdit"; export default async function Project({ params, @@ -66,6 +67,8 @@ export default async function Project({ + + {project ? : null} ); diff --git a/frontend/src/components/Agreement/AgreementEdit.tsx b/frontend/src/components/Agreement/AgreementEdit.tsx new file mode 100644 index 00000000..80672111 --- /dev/null +++ b/frontend/src/components/Agreement/AgreementEdit.tsx @@ -0,0 +1,236 @@ +"use client"; +import { + deleteFile, + getAgreementForProject, + saveChanges, +} from "@/actions/agreementActions"; +import { ProjectWithCustomerModel } from "@/api-types"; +import { useParams } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; +import { EditInput } from "./components/EditInput"; +import { EditDateInput } from "./components/EditDateInput"; +import { Agreement } from "@/types"; +import { getDownloadUrl } from "@/actions/blobActions"; +import { EditTextarea } from "./components/EditTextarea"; + +export function AgreementEdit({ + project, +}: { + project: ProjectWithCustomerModel; +}) { + const params = useParams(); + const organisation = params.organisation as string; + const [agreement, setAgreement] = useState(); + const [inEdit, setInEdit] = useState(false); + + useEffect(() => { + async function getAgreement() { + if (organisation && project.projectId) { + const agree = await getAgreementForProject( + project.projectId, + organisation, + ); + if (agree?.agreementId) { + setAgreement(agree); + } else { + setAgreement(null); + } + } + } + getAgreement(); + }, [organisation, project.projectId]); + + async function save(formData: FormData) { + try { + const res = await saveChanges( + formData, + agreement?.files ?? [], + organisation, + ); + + if (res) { + setAgreement(res); + setInEdit(false); + } else { + console.error("Failed to save agreement"); + } + } catch (error) { + console.error(`Failed to save agreement: ${error}`); + } + } + + async function download(blobName: string, filename: string) { + try { + const sasUrl = await getDownloadUrl(blobName, filename); + const link = document.createElement("a"); + link.href = sasUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + console.error("Error downloading file:", error); + } + } + + return ( +
+

Avtale

+
+ {agreement && agreement !== null ? ( +
+
+ {agreement.agreementId !== -1 && ( + + )} + + + + + +
+
+
+ +
+ +
+ {agreement.files && agreement.files?.length > 0 ? ( + + ) : null} + {inEdit ? ( + <> + + {agreement.files?.map((file) => ( +
+ + {file.fileName} +
+ ))} + + ) : ( +
+ {agreement.files?.map((file) => ( +
+ +
{file.fileName}
+
+ ))} +
+ )} +
+
+ {inEdit ? ( + + ) : ( + + )} +
+ ) : ( + agreement === null && ( + + ) + )} +
+ ); +} diff --git a/frontend/src/components/Agreement/components/EditDateInput.tsx b/frontend/src/components/Agreement/components/EditDateInput.tsx new file mode 100644 index 00000000..eaf38055 --- /dev/null +++ b/frontend/src/components/Agreement/components/EditDateInput.tsx @@ -0,0 +1,51 @@ +export function EditDateInput({ + value, + name, + label, + inEdit, + required = false, +}: { + value: Date | null; + name: string; + label: string; + inEdit: boolean; + required?: boolean; +}) { + return ( +
+ {inEdit ? ( + <> + + + + ) : ( + <> + +

+ {value ? new Date(value).toLocaleDateString() : ""} +

+ + )} +
+ ); +} diff --git a/frontend/src/components/Agreement/components/EditInput.tsx b/frontend/src/components/Agreement/components/EditInput.tsx new file mode 100644 index 00000000..b0140992 --- /dev/null +++ b/frontend/src/components/Agreement/components/EditInput.tsx @@ -0,0 +1,44 @@ +export function EditInput({ + value, + name, + label, + inEdit, +}: { + value?: string; + name: string; + label: string; + inEdit: boolean; +}) { + return ( +
+ {inEdit ? ( + <> + + + + ) : ( + <> + +

{value}

+ + )} +
+ ); +} diff --git a/frontend/src/components/Agreement/components/EditTextarea.tsx b/frontend/src/components/Agreement/components/EditTextarea.tsx new file mode 100644 index 00000000..8d3598ac --- /dev/null +++ b/frontend/src/components/Agreement/components/EditTextarea.tsx @@ -0,0 +1,48 @@ +export function EditTextarea({ + value, + name, + label, + inEdit, +}: { + value?: string; + name: string; + label: string; + inEdit: boolean; +}) { + return ( +
+ {inEdit ? ( + <> + +