diff --git a/backend/Api/Agreements/AgreementController.cs b/backend/Api/Agreements/AgreementController.cs index b1a6338a..2c78c4e0 100644 --- a/backend/Api/Agreements/AgreementController.cs +++ b/backend/Api/Agreements/AgreementController.cs @@ -20,7 +20,7 @@ public class AgreementController( IAgreementsRepository agreementsRepository) : ControllerBase { private const string SelectedOrganizationNotFound = "Selected organization not found"; - + [HttpGet] [Route("{agreementId:int}")] diff --git a/backend/Api/Common/StorageService.cs b/backend/Api/Common/StorageService.cs index de775e13..b689bfe2 100644 --- a/backend/Api/Common/StorageService.cs +++ b/backend/Api/Common/StorageService.cs @@ -199,7 +199,20 @@ public async Task CreateConsultant(Organization org, ConsultantWrite return consultant; } - public Customer UpdateOrCreateCustomer(Organization org, string customerName, string orgUrlKey) + public Customer DeactivateOrActivateCustomer(int customerId, Organization org, bool active, string orgUrlKey) + { + var customer = GetCustomerFromId(orgUrlKey, customerId); + if (customer is null) return null; + + customer.IsActive = active; + _dbContext.Customer.Update(customer); + _dbContext.SaveChanges(); + ClearConsultantCache(orgUrlKey); + + return customer; + } + + public Customer FindOrCreateCustomer(Organization org, string customerName, string orgUrlKey) { var customer = context.Customer.Where(c => c.OrganizationId == org.Id) .SingleOrDefault(c => c.Name == customerName); @@ -211,14 +224,15 @@ public Customer UpdateOrCreateCustomer(Organization org, string customerName, st Name = customerName, Organization = org, OrganizationId = org.Id, - Projects = [] + Projects = [], + IsActive = true }; - context.Customer.Add(customer); + _dbContext.Customer.Add(customer); + _dbContext.SaveChanges(); + ClearConsultantCache(orgUrlKey); } - context.SaveChanges(); - ClearConsultantCache(orgUrlKey); return customer; } diff --git a/backend/Api/Projects/ProjectController.cs b/backend/Api/Projects/ProjectController.cs index 4c7d6e9f..6e7a930d 100644 --- a/backend/Api/Projects/ProjectController.cs +++ b/backend/Api/Projects/ProjectController.cs @@ -1,10 +1,14 @@ using Api.Common; using Api.StaffingController; +using Core.Consultants; +using Core.Customers; +using Core.DomainModels; using Core.Engagements; using Core.Organizations; using Core.Weeks; using Infrastructure.DatabaseContext; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -45,10 +49,9 @@ public async Task>> Get( var selectedOrgId = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, cancellationToken); if (selectedOrgId is null) return BadRequest(); - var absenceReadModels = new EngagementPerCustomerReadModel(-1, AbsenceCustomerName, - await context.Absence.Where(a => a.Organization.UrlKey == orgUrlKey).Select(absence => - new EngagementReadModel(absence.Id, absence.Name, EngagementState.Absence, false)) - .ToListAsync(cancellationToken)); + var absenceReadModels = new EngagementPerCustomerReadModel(-1, AbsenceCustomerName, true, + context.Absence.Where(a => a.Organization.UrlKey == orgUrlKey).Select(absence => + new EngagementReadModel(absence.Id, absence.Name, EngagementState.Absence, false)).ToList()); var projectReadModels = await context.Project.Include(project => project.Customer) .Where(project => @@ -58,6 +61,7 @@ await context.Absence.Where(a => a.Organization.UrlKey == orgUrlKey).Select(abse new EngagementPerCustomerReadModel( a.Key.Id, a.Key.Name, + a.Key.IsActive, a.Select(e => new EngagementReadModel(e.Id, e.Name, e.State, e.IsBillable)).ToList())) .ToListAsync(cancellationToken); @@ -94,6 +98,7 @@ CancellationToken cancellationToken return new CustomersWithProjectsReadModel( customer.Id, customer.Name, + customer.IsActive, customer.Projects.Where(p => p.Staffings.Any(s => s.Week.CompareTo(thisWeek) >= 0) && p.State != EngagementState.Closed).Select(e => new EngagementReadModel(e.Id, e.Name, e.State, e.IsBillable)).ToList(), @@ -286,6 +291,64 @@ private Engagement MergeProjects(int id) .Single(p => p.Id == engagementToKeep.Id); } + [HttpPut] + [Route("customer/{customerId}/activate")] + public async Task>> Put([FromRoute] int customerId, [FromQuery] bool activate, string orgUrlKey, CancellationToken ct) + { + + var service = new StorageService(cache, context); + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + if (selectedOrg is null) return TypedResults.NotFound("Selected org not found"); + var customer = service.DeactivateOrActivateCustomer(customerId, selectedOrg, activate, orgUrlKey); + if (customer is null) return TypedResults.NotFound("Selected customer not found"); + return TypedResults.Ok(); + } + + + [HttpPut] + public async Task> Put([FromRoute] string orgUrlKey, + [FromBody] EngagementWriteModel body, CancellationToken ct) + { + var service = new StorageService(cache, context); + + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + if (selectedOrg is null) return BadRequest("Selected org not found"); + + if (body.CustomerName == AbsenceCustomerName) + return Ok(HandleAbsenceChange(body, orgUrlKey)); + + var customer = service.FindOrCreateCustomer(selectedOrg, body.CustomerName, orgUrlKey); + + var project = context.Project + .Include(p => p.Customer) + .SingleOrDefault(p => p.Customer.Id == customer.Id + && p.Name == body.ProjectName + ); + + if (project is null) + { + project = new Engagement + { + Customer = customer, + State = body.BookingType, + Staffings = new List(), + Consultants = new List(), + Name = body.ProjectName, + IsBillable = body.IsBillable + }; + + context.Project.Add(project); + } + + await context.SaveChangesAsync(ct); + service.ClearConsultantCache(orgUrlKey); + + var responseModel = + new ProjectWithCustomerModel(project.Name, customer.Name, project.State, project.IsBillable, project.Id); + + return Ok(responseModel); + } + private ProjectWithCustomerModel HandleAbsenceChange(EngagementWriteModel body, string orgUrlKey) { @@ -298,7 +361,7 @@ private CustomersWithProjectsReadModel HandleGetAbsenceWithAbsences(string orgUr { var vacation = new EngagementReadModel(-1, "Ferie", EngagementState.Absence, false); - var readModel = new CustomersWithProjectsReadModel(-1, AbsenceCustomerName + " og Ferie", + var readModel = new CustomersWithProjectsReadModel(-1, AbsenceCustomerName + " og Ferie", true, context.Absence.Where(a => a.Organization.UrlKey == orgUrlKey).Select(absence => new EngagementReadModel(absence.Id, absence.Name, EngagementState.Absence, false)).ToList(), new List()); diff --git a/backend/Api/Projects/ProjectModels.cs b/backend/Api/Projects/ProjectModels.cs index b1fefb91..5a574865 100644 --- a/backend/Api/Projects/ProjectModels.cs +++ b/backend/Api/Projects/ProjectModels.cs @@ -1,18 +1,23 @@ using System.ComponentModel.DataAnnotations; +using System.Runtime.InteropServices; +using Core.Customers; using Core.Engagements; namespace Api.Projects; public record EngagementPerCustomerReadModel( - [property: Required] int CustomerId, - [property: Required] string CustomerName, - [property: Required] List Engagements); + int CustomerId, + string CustomerName, + bool IsActive, + List Engagements); + + public record EngagementReadModel( - [property: Required] int EngagementId, - [property: Required] string EngagementName, - [property: Required] EngagementState BookingType, - [property: Required] bool IsBillable); + int EngagementId, + string EngagementName, + EngagementState BookingType, + bool IsBillable); public record EngagementWriteModel( EngagementState BookingType, @@ -43,7 +48,8 @@ public record UpdateProjectWriteModel( public record UpdateEngagementNameWriteModel(int EngagementId, string EngagementName); public record CustomersWithProjectsReadModel( - [property: Required] int CustomerId, - [property: Required] string CustomerName, - [property: Required] List ActiveEngagements, - [property: Required] List InactiveEngagements); \ No newline at end of file + int CustomerId, + string CustomerName, + bool IsActive, + List ActiveEngagements, + List InactiveEngagements); \ No newline at end of file diff --git a/backend/Core/Customers/Customer.cs b/backend/Core/Customers/Customer.cs index 307525ea..08d5bc62 100644 --- a/backend/Core/Customers/Customer.cs +++ b/backend/Core/Customers/Customer.cs @@ -1,3 +1,4 @@ +using System.ComponentModel; using System.ComponentModel.DataAnnotations.Schema; using Core.Agreements; using Core.Engagements; @@ -13,10 +14,9 @@ public class Customer [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int Id { get; init; } public required string Name { get; set; } + public required Organization Organization { get; set; } + public required List Projects { get; set; } - public ICollection Agreements { get; init; } = new List(); - public required List Projects { get; init; } + public bool IsActive { get; set; } = true; - public required string OrganizationId { get; init; } - public required Organization Organization { get; init; } } \ No newline at end of file diff --git a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs index c3eb259d..471d174e 100644 --- a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs +++ b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs @@ -56,7 +56,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(customer => customer.OrganizationId); modelBuilder.Entity() - .HasIndex(customer => new { customer.OrganizationId, customer.Name }) + .HasIndex(customer => new { customer.OrganizationId, customer.Name, customer.IsActive }) .IsUnique(); modelBuilder.Entity() diff --git a/backend/Infrastructure/Migrations/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.Designer.cs b/backend/Infrastructure/Migrations/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.Designer.cs new file mode 100644 index 00000000..7b7600a7 --- /dev/null +++ b/backend/Infrastructure/Migrations/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.Designer.cs @@ -0,0 +1,631 @@ +// +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("20241218133743_AddedIsActiveOnCustomerWithDefaultTrue")] + partial class AddedIsActiveOnCustomerWithDefaultTrue + { + /// + 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("IsActive") + .HasColumnType("bit"); + + 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("UploadedBy") + .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/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.cs b/backend/Infrastructure/Migrations/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.cs new file mode 100644 index 00000000..c3702566 --- /dev/null +++ b/backend/Infrastructure/Migrations/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddedIsActiveOnCustomerWithDefaultTrue : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsActive", + table: "Customer", + type: "bit", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsActive", + table: "Customer"); + } + } +} diff --git a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs index 0b56970d..d5f79f58 100644 --- a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs +++ b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs @@ -215,6 +215,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + b.Property("IsActive") + .HasColumnType("bit"); + b.Property("Name") .IsRequired() .HasColumnType("nvarchar(450)"); diff --git a/frontend/mockdata/mockData.ts b/frontend/mockdata/mockData.ts index b253eacb..51dc3aef 100644 --- a/frontend/mockdata/mockData.ts +++ b/frontend/mockdata/mockData.ts @@ -63,6 +63,7 @@ export const MockEngagements: EngagementPerCustomerReadModel[] = [ customerId: 1, customerName: "TestCustomer", engagements: [], + isActive: false, }, ]; diff --git a/frontend/src/api-types.ts b/frontend/src/api-types.ts index 0f02d598..968e9cb5 100644 --- a/frontend/src/api-types.ts +++ b/frontend/src/api-types.ts @@ -129,6 +129,7 @@ export interface EngagementPerCustomerReadModel { /** @minLength 1 */ customerName: string; engagements: EngagementReadModel[]; + isActive: boolean; } export interface EngagementReadModel { @@ -291,6 +292,7 @@ export interface WeeklyHours { export interface CustomersWithProjectsReadModel { customerId: number; customerName: string; + isActive: boolean; activeEngagements: EngagementReadModel[]; inactiveEngagements: EngagementReadModel[]; } diff --git a/frontend/src/app/[organisation]/kunder/[customer]/page.tsx b/frontend/src/app/[organisation]/kunder/[customer]/page.tsx index 7e2563b6..86043d3b 100644 --- a/frontend/src/app/[organisation]/kunder/[customer]/page.tsx +++ b/frontend/src/app/[organisation]/kunder/[customer]/page.tsx @@ -2,8 +2,8 @@ import { CustomersWithProjectsReadModel, DepartmentReadModel, } from "@/api-types"; -import CustomerSidebar from "@/components/CostumerTable/CustomerSidebar"; -import CustomerTable from "@/components/CostumerTable/CustomerTable"; +import CustomerSidebar from "@/components/CustomerTable/CustomerSidebar"; +import CustomerTable from "@/components/CustomerTable/CustomerTable"; import { fetchWithToken } from "@/data/apiCallsWithToken"; import { ConsultantFilterProvider } from "@/hooks/ConsultantFilterProvider"; import { fetchWorkHoursPerWeek } from "@/hooks/fetchWorkHoursPerDay"; diff --git a/frontend/src/app/[organisation]/kunder/api/route.ts b/frontend/src/app/[organisation]/kunder/api/route.ts new file mode 100644 index 00000000..7392a32a --- /dev/null +++ b/frontend/src/app/[organisation]/kunder/api/route.ts @@ -0,0 +1,21 @@ +import { putWithToken } from "@/data/apiCallsWithToken"; +import { NextRequest, NextResponse } from "next/server"; +export async function PUT( + request: NextRequest, + { + params, + }: { + params: { organisation: string }; + }, +) { + const customerId = request.nextUrl.searchParams.get("customerId"); + const activate = request.nextUrl.searchParams.get("activate"); + const endpointUrl = `${params.organisation}/projects/customer/${customerId}/activate?activate=${activate}`; + + const status = await putWithToken(endpointUrl); + if (status === undefined) { + return NextResponse.error(); + } + + return new NextResponse(); +} diff --git a/frontend/src/app/[organisation]/kunder/page.tsx b/frontend/src/app/[organisation]/kunder/page.tsx index 1473ff0b..a2342d03 100644 --- a/frontend/src/app/[organisation]/kunder/page.tsx +++ b/frontend/src/app/[organisation]/kunder/page.tsx @@ -1,6 +1,6 @@ import { EngagementPerCustomerReadModel, EngagementState } from "@/api-types"; import { fetchWithToken } from "@/data/apiCallsWithToken"; -import { CustomerFilterProvider } from "@/hooks/CustomerFilterProvider"; +import { CustomerFilterProvider } from "@/hooks/CustomerFilter/CustomerFilterProvider"; import { CustomerContent } from "@/pagecontent/CustomerContent"; import { Metadata } from "next"; @@ -28,7 +28,7 @@ export default async function Kunder({ return ( - + ); } diff --git a/frontend/src/components/Buttons/ActivateButton.tsx b/frontend/src/components/Buttons/ActivateButton.tsx new file mode 100644 index 00000000..497a98bd --- /dev/null +++ b/frontend/src/components/Buttons/ActivateButton.tsx @@ -0,0 +1,24 @@ +import BaseButton from "./BaseButton"; + +export default function ActivateButton({ + mode, + onClick, + children, +}: { + mode: "activate" | "deactivate"; + onClick?: () => any; + children?: React.ReactNode; +}) { + const styling = { + activate: " border-2 border-activate/50 hover:border-activate", + deactivate: " border-2 border-deactivate/50 hover:border-deactivate", + }[mode]; + return ( + onClick && onClick()} + className={` text-primary bg-white border py-2 w-20 rounded-md flex flex-row items-center gap-2 shadow-sm ${styling}`} + > + {children} + + ); +} diff --git a/frontend/src/components/CostumerTable/CustomerSidebarWithFilters.tsx b/frontend/src/components/CostumerTable/CustomerSidebarWithFilters.tsx deleted file mode 100644 index 3c3b63d1..00000000 --- a/frontend/src/components/CostumerTable/CustomerSidebarWithFilters.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { FilteredCustomerContext } from "@/hooks/CustomerFilterProvider"; -import SearchBarComponent from "../SearchBarComponent"; - -export default function CustomerSidebarWithFilters() { - return ( - <> -
-
-

Filter

-
- -
- - ); -} diff --git a/frontend/src/components/CostumerTable/ActiveCustomerFilters.tsx b/frontend/src/components/CustomerTable/ActiveCustomerFilters.tsx similarity index 68% rename from frontend/src/components/CostumerTable/ActiveCustomerFilters.tsx rename to frontend/src/components/CustomerTable/ActiveCustomerFilters.tsx index 4096c8a0..f9c32093 100644 --- a/frontend/src/components/CostumerTable/ActiveCustomerFilters.tsx +++ b/frontend/src/components/CustomerTable/ActiveCustomerFilters.tsx @@ -1,19 +1,24 @@ "use client"; import { useContext } from "react"; -import { FilteredCustomerContext } from "@/hooks/CustomerFilterProvider"; +import { FilteredCustomerContext } from "@/hooks/CustomerFilter/CustomerFilterProvider"; export default function ActiveCustomerFilters() { const filterTextComponents: string[] = []; const { activeFilters } = useContext(FilteredCustomerContext); - const { searchFilter, engagementIsBillableFilter, bookingTypeFilter } = - activeFilters; + const { + searchFilter, + engagementIsBillableFilter, + bookingTypeFilter, + isCustomerActiveFilter, + } = activeFilters; if (searchFilter != "") filterTextComponents.push(` "${searchFilter}"`); if (engagementIsBillableFilter != "") filterTextComponents.push(`engagement`); if (bookingTypeFilter != "") filterTextComponents.push(` "${bookingTypeFilter}"`); - + if (isCustomerActiveFilter != "") + filterTextComponents.push(` "${isCustomerActiveFilter}"`); const filterSummaryText = filterTextComponents.join(", "); return ( diff --git a/frontend/src/components/CostumerTable/CustomerRow.tsx b/frontend/src/components/CustomerTable/CustomerRow.tsx similarity index 71% rename from frontend/src/components/CostumerTable/CustomerRow.tsx rename to frontend/src/components/CustomerTable/CustomerRow.tsx index dc21a6f4..169600d7 100644 --- a/frontend/src/components/CostumerTable/CustomerRow.tsx +++ b/frontend/src/components/CustomerTable/CustomerRow.tsx @@ -1,7 +1,7 @@ "use client"; import { EngagementPerCustomerReadModel } from "@/api-types"; -import { useState } from "react"; +import { useContext, useState } from "react"; import { ArrowRight, ChevronDown } from "react-feather"; import { getColorByStaffingType, @@ -9,19 +9,55 @@ import { } from "../Staffing/helpers/utils"; import { getBookingTypeFromProjectState } from "../Staffing/EditEngagementHourModal/utils"; import Link from "next/link"; +import { useParams } from "next/navigation"; +import ActivateButton from "../Buttons/ActivateButton"; +import { FilteredCustomerContext } from "@/hooks/CustomerFilter/CustomerFilterProvider"; -export default function CostumerRow({ +export default function CustomerRow({ customer, }: { customer: EngagementPerCustomerReadModel; }) { + const { setCustomers } = useContext(FilteredCustomerContext); const [isListElementVisible, setIsListElementVisible] = useState(false); const [isRowHovered, setIsRowHovered] = useState(false); + const [isActive, setIsActive] = useState(customer.isActive); + const [isLoading, setIsLoading] = useState(false); + const { organisation } = useParams(); function toggleListElementVisibility() { setIsListElementVisible((old) => !old); } + async function onActivate(customerId: number, active: boolean) { + if (isLoading) return; + setIsLoading(true); + setIsActive(active); + + try { + const response = await fetch( + `/${organisation}/kunder/api?customerId=${customerId}&activate=${active}`, + { + method: "PUT", + }, + ); + if (response.status !== 200) { + setIsActive(!active); + return; + } + setCustomers((prevCustomers) => + prevCustomers.map((oldCustomer) => + oldCustomer.customerId === customerId + ? { ...oldCustomer, isActive: active } + : oldCustomer, + ), + ); + } catch { + setIsActive(!active); + } finally { + setIsLoading(false); + } + } return ( <> ))} + + onActivate(customer.customerId, !isActive)} + mode={isActive ? "deactivate" : "activate"} + > + {isActive ? "Deaktiver" : "Aktiver"} + + {isListElementVisible && customer.engagements && diff --git a/frontend/src/components/CostumerTable/CustomerSidebar.tsx b/frontend/src/components/CustomerTable/CustomerSidebar.tsx similarity index 100% rename from frontend/src/components/CostumerTable/CustomerSidebar.tsx rename to frontend/src/components/CustomerTable/CustomerSidebar.tsx diff --git a/frontend/src/components/CustomerTable/CustomerSidebarWithFilters.tsx b/frontend/src/components/CustomerTable/CustomerSidebarWithFilters.tsx new file mode 100644 index 00000000..6b036059 --- /dev/null +++ b/frontend/src/components/CustomerTable/CustomerSidebarWithFilters.tsx @@ -0,0 +1,26 @@ +import { FilteredCustomerContext } from "@/hooks/CustomerFilter/CustomerFilterProvider"; +import SearchBarComponent from "../SearchBarComponent"; +import { useState } from "react"; +import { ToggleSwitch } from "./SwitchToggle"; +import { useIsCustomerActive } from "@/hooks/CustomerFilter/useIsCustomerActive"; + +export default function CustomerSidebarWithFilters() { + const { isCustomerActive, toggleActive } = useIsCustomerActive(); + return ( + <> +
+
+

Filter

+
+ +
+

Vis bare aktive kunder

+ +
+
+ + ); +} diff --git a/frontend/src/components/CostumerTable/CustomerTable.tsx b/frontend/src/components/CustomerTable/CustomerTable.tsx similarity index 75% rename from frontend/src/components/CostumerTable/CustomerTable.tsx rename to frontend/src/components/CustomerTable/CustomerTable.tsx index ac9978c3..08557ac1 100644 --- a/frontend/src/components/CostumerTable/CustomerTable.tsx +++ b/frontend/src/components/CustomerTable/CustomerTable.tsx @@ -9,6 +9,9 @@ import WeekSelector from "../WeekSelector"; import { useWeekSelectors } from "@/hooks/useWeekSelectors"; import { WeekSpanTableHead } from "../Staffing/WeekTableHead"; import { AgreementEdit } from "../Agreement/AgreementEdit"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import ActivateButton from "../Buttons/ActivateButton"; export default function CustomerTable({ customer, @@ -31,10 +34,41 @@ export default function CustomerTable({ } = useWeekSelectors(); const { filteredDepartments } = useDepartmentFilter(); + const [isActive, setIsActive] = useState(customer.isActive); + const [isLoading, setIsLoading] = useState(false); + const { organisation } = useParams(); + async function onActivate(customerId: number, active: boolean) { + if (isLoading) return; + setIsLoading(true); + setIsActive(active); + try { + const response = await fetch( + `/${organisation}/kunder/api?customerId=${customerId}&activate=${active}`, + { + method: "PUT", + }, + ); + if (response.status !== 200) { + setIsActive(!active); + } + } catch { + setIsActive(!active); + } finally { + setIsLoading(false); + } + } return (
-

{customer?.customerName}

+
+

{customer?.customerName}

+ onActivate(customer.customerId, !customer.isActive)} + mode={isActive ? "deactivate" : "activate"} + > + {isActive ? "Deaktiver" : "Aktiver"} + +
diff --git a/frontend/src/components/CostumerTable/EngagementRow.tsx b/frontend/src/components/CustomerTable/EngagementRow.tsx similarity index 100% rename from frontend/src/components/CostumerTable/EngagementRow.tsx rename to frontend/src/components/CustomerTable/EngagementRow.tsx diff --git a/frontend/src/components/CostumerTable/FilteredCustomersTable.tsx b/frontend/src/components/CustomerTable/FilteredCustomersTable.tsx similarity index 88% rename from frontend/src/components/CostumerTable/FilteredCustomersTable.tsx rename to frontend/src/components/CustomerTable/FilteredCustomersTable.tsx index 7c92d162..a77b9b4a 100644 --- a/frontend/src/components/CostumerTable/FilteredCustomersTable.tsx +++ b/frontend/src/components/CustomerTable/FilteredCustomersTable.tsx @@ -1,7 +1,7 @@ "use client"; import { EngagementPerCustomerReadModel } from "@/api-types"; -import CustomerRow from "@/components/CostumerTable/CustomerRow"; -import { useCustomerFilter } from "@/hooks/staffing/useCustomerFilter"; +import CustomerRow from "@/components/CustomerTable/CustomerRow"; +import { useCustomerFilter } from "@/hooks/CustomerFilter/useCustomerFilter"; function FilteredCustomerTable() { const filteredCustomers = useCustomerFilter(); diff --git a/frontend/src/components/CustomerTable/SwitchToggle.tsx b/frontend/src/components/CustomerTable/SwitchToggle.tsx new file mode 100644 index 00000000..3656d873 --- /dev/null +++ b/frontend/src/components/CustomerTable/SwitchToggle.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; + +// __ __ __ _ __ +// / /__ / /_/ /| | / /___ __ _____ +// __ / / _ \/ __/ __/ | /| / / __ `/ | / / _ \ +// / /_/ / __/ /_/ /_ | |/ |/ / /_/ /| |/ / __/ +// \____/\___/\__/\__/ |__/|__/\__,_/ |___/\___/ + +// https://jettwave.com + +export function ToggleSwitch({ + value, + onChange, +}: { + value: boolean | string; + onChange: () => any; +}) { + return ( +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/SearchBarComponent.tsx b/frontend/src/components/SearchBarComponent.tsx index 5a5eed4f..c37593ae 100644 --- a/frontend/src/components/SearchBarComponent.tsx +++ b/frontend/src/components/SearchBarComponent.tsx @@ -7,9 +7,11 @@ import { FilteredContext } from "@/hooks/ConsultantFilterProvider"; export default function SearchBarComponent({ hidden = false, context, + placeholder, }: { hidden?: boolean; context: Context; + placeholder: string; }) { const { setNameSearch, activeNameSearch } = useNameSearch(context); const inputRef = useRef(null); @@ -60,7 +62,7 @@ export default function SearchBarComponent({ setNameSearch(e.target.value)} diff --git a/frontend/src/components/Staffing/WeekCell.tsx b/frontend/src/components/Staffing/WeekCell.tsx index e3db5d5a..572f70e3 100644 --- a/frontend/src/components/Staffing/WeekCell.tsx +++ b/frontend/src/components/Staffing/WeekCell.tsx @@ -63,7 +63,7 @@ export function WeekCell(props: {
0 - ? `bg-black text-white` + ? `bg-overbooked text-white` : bookedHoursPerWeek.bookingModel.totalSellableTime > 0 ? `bg-available/50` : `bg-primary/[3%]` diff --git a/frontend/src/components/StaffingSidebar.tsx b/frontend/src/components/StaffingSidebar.tsx index f43c4f2f..2bd314c1 100644 --- a/frontend/src/components/StaffingSidebar.tsx +++ b/frontend/src/components/StaffingSidebar.tsx @@ -31,7 +31,10 @@ export default function StaffingSidebar({
- + {isStaffing ? : null} @@ -42,6 +45,7 @@ export default function StaffingSidebar({ {!isSidebarOpen && (