From 9688b3dc2ddd0bb6bb03ff658b06a02e9eb2c0f0 Mon Sep 17 00:00:00 2001 From: Truls Henrik <39614013+trulshj@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:29:17 +0100 Subject: [PATCH 1/2] chore: clean up AgreementController (#558) --- backend/Api/Agreements/AgreementController.cs | 104 +++++++----------- backend/Api/Agreements/AgreementModels.cs | 18 ++- .../DatabaseContext/ApplicationContext.cs | 35 +++--- .../ApplicationContextModelSnapshot.cs | 28 ++--- frontend/src/actions/agreementActions.ts | 10 +- 5 files changed, 85 insertions(+), 110 deletions(-) diff --git a/backend/Api/Agreements/AgreementController.cs b/backend/Api/Agreements/AgreementController.cs index 3b74dd9e..53a17766 100644 --- a/backend/Api/Agreements/AgreementController.cs +++ b/backend/Api/Agreements/AgreementController.cs @@ -5,9 +5,10 @@ using Infrastructure.DatabaseContext; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -namespace Api.Projects; +namespace Api.Agreements; [Authorize] [Route("/v0/{orgUrlKey}/agreements")] @@ -18,14 +19,16 @@ public class AgreementController( IOrganisationRepository organisationRepository, IAgreementsRepository agreementsRepository) : ControllerBase { + private const string SelectedOrganizationNotFound = "Selected organization not found"; + [HttpGet] - [Route("get/{agreementId}")] + [Route("{agreementId:int}")] 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"); + if (selectedOrg is null) return NotFound(SelectedOrganizationNotFound); var agreement = await agreementsRepository.GetAgreementById(agreementId, ct); @@ -43,28 +46,21 @@ public async Task> GetAgreement([FromRoute] str Notes: agreement.Notes, Options: agreement.Options, PriceAdjustmentProcess: agreement.PriceAdjustmentProcess, - Files: agreement.Files.Select(f => new FileReferenceReadModel( - FileName: f.FileName, - BlobName: f.BlobName, - UploadedOn: f.UploadedOn, - UploadedBy: f.UploadedBy ?? "Unknown" - )).ToList() + Files: agreement.Files.Select(f => new FileReferenceReadModel(f)).ToList() ); return Ok(responseModel); } [HttpGet] - [Route("get/engagement/{engagementId}")] + [Route("engagement/{engagementId:int}")] public async Task>> GetAgreementsByEngagement([FromRoute] string orgUrlKey, [FromRoute] int engagementId, CancellationToken ct) { var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); - if (selectedOrg is null) return BadRequest("Selected org not found"); + if (selectedOrg is null) return NotFound(SelectedOrganizationNotFound); var agreements = await agreementsRepository.GetAgreementsByEngagementId(engagementId, ct); - if (agreements is null || !agreements.Any()) return NotFound(); - var responseModels = agreements.Select(agreement => new AgreementReadModel( AgreementId: agreement.Id, Name: agreement.Name, @@ -77,29 +73,22 @@ public async Task>> GetAgreementsByEngagem Notes: agreement.Notes, Options: agreement.Options, PriceAdjustmentProcess: agreement.PriceAdjustmentProcess, - Files: agreement.Files.Select(f => new FileReferenceReadModel( - FileName: f.FileName, - BlobName: f.BlobName, - UploadedOn: f.UploadedOn, - UploadedBy: f.UploadedBy ?? "Unknown" - )).ToList() + Files: agreement.Files.Select(f => new FileReferenceReadModel(f)).ToList() )).ToList(); return Ok(responseModels); } [HttpGet] - [Route("get/customer/{customerId}")] + [Route("customer/{customerId:int}")] public async Task>> GetAgreementsByCustomer([FromRoute] string orgUrlKey, [FromRoute] int customerId, CancellationToken ct) { var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); - if (selectedOrg is null) return BadRequest("Selected org not found"); + if (selectedOrg is null) return NotFound(SelectedOrganizationNotFound); var agreements = await agreementsRepository.GetAgreementsByCustomerId(customerId, ct); - if (agreements is null || !agreements.Any()) return NotFound(); - var responseModels = agreements.Select(agreement => new AgreementReadModel( AgreementId: agreement.Id, Name: agreement.Name, @@ -112,21 +101,15 @@ public async Task>> GetAgreementsByCustome Notes: agreement.Notes, Options: agreement.Options, PriceAdjustmentProcess: agreement.PriceAdjustmentProcess, - Files: agreement.Files.Select(f => new FileReferenceReadModel( - FileName: f.FileName, - BlobName: f.BlobName, - UploadedOn: f.UploadedOn, - UploadedBy: f.UploadedBy ?? "Unknown" - )).ToList() + Files: agreement.Files.Select(f => new FileReferenceReadModel(f)).ToList() )).ToList(); return Ok(responseModels); } [HttpPost] - [Route("create")] public async Task> Post([FromRoute] string orgUrlKey, - [FromBody] AgreementWriteModel body, CancellationToken ct) + [FromBody] AgreementWriteModel body, CancellationToken cancellationToken) { if (!ModelState.IsValid) { @@ -139,24 +122,26 @@ public async Task> Post([FromRoute] string orgU return BadRequest(ModelState); } - var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, cancellationToken); if (selectedOrg is null) - return BadRequest("Selected organization not found"); + return NotFound(SelectedOrganizationNotFound); Customer? customer = null; if (body.CustomerId != null) { - customer = await context.Customer.FindAsync(body.CustomerId.Value); + customer = await context.Customer.FirstOrDefaultAsync(c => c.Id == body.CustomerId.Value, + cancellationToken); if (customer == null) - return BadRequest("Customer not found"); + return NotFound("Customer not found"); } Engagement? engagement = null; if (body.EngagementId != null) { - engagement = await context.Project.FindAsync(body.EngagementId.Value); + engagement = + await context.Project.FirstOrDefaultAsync(e => e.Id == body.EngagementId.Value, cancellationToken); if (engagement is null) - return BadRequest("Engagement not found"); + return NotFound("Engagement not found"); } var agreement = new Agreement @@ -182,7 +167,7 @@ public async Task> Post([FromRoute] string orgU }).ToList() }; - await agreementsRepository.AddAgreementAsync(agreement, ct); + await agreementsRepository.AddAgreementAsync(agreement, cancellationToken); var responseModel = new AgreementReadModel( Name: agreement.Name, @@ -196,12 +181,7 @@ public async Task> Post([FromRoute] string orgU Notes: agreement.Notes, Options: agreement.Options, PriceAdjustmentProcess: agreement.PriceAdjustmentProcess, - Files: agreement.Files.Select(f => new FileReferenceReadModel( - FileName: f.FileName, - BlobName: f.BlobName, - UploadedOn: f.UploadedOn, - UploadedBy: f.UploadedBy ?? "Unknown" - )).ToList() + Files: agreement.Files.Select(f => new FileReferenceReadModel(f)).ToList() ); cache.Remove($"consultantCacheKey/{orgUrlKey}"); @@ -209,9 +189,9 @@ public async Task> Post([FromRoute] string orgU } [HttpPut] - [Route("update/{agreementId}")] + [Route("{agreementId:int}")] public async Task> Put([FromRoute] string orgUrlKey, - [FromRoute] int agreementId, [FromBody] AgreementWriteModel body, CancellationToken ct) + [FromRoute] int agreementId, [FromBody] AgreementWriteModel body, CancellationToken cancellationToken) { if (!ModelState.IsValid) { @@ -224,20 +204,19 @@ public async Task> Put([FromRoute] string orgUr return BadRequest(ModelState); } - var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); + var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, cancellationToken); if (selectedOrg is null) - return BadRequest("Selected organization not found"); + return NotFound("Selected organization not found"); - var agreement = await agreementsRepository.GetAgreementById(agreementId, ct); + var agreement = await agreementsRepository.GetAgreementById(agreementId, cancellationToken); if (agreement is null) return NotFound("Agreement not found"); - Customer? customer = null; if (body.CustomerId is not null) { - customer = await context.Customer.FindAsync(body.CustomerId); + var customer = await context.Customer.FirstOrDefaultAsync(c => c.Id == body.CustomerId, cancellationToken); if (customer is null) - return BadRequest("Customer not found"); + return NotFound("Customer not found"); agreement.CustomerId = body.CustomerId; agreement.Customer = customer; @@ -248,12 +227,12 @@ public async Task> Put([FromRoute] string orgUr agreement.Customer = null; } - Engagement? engagement = null; if (body.EngagementId is not null) { - engagement = await context.Project.FindAsync(body.EngagementId); + var engagement = + await context.Project.FirstOrDefaultAsync(e => e.Id == body.EngagementId, cancellationToken); if (engagement is null) - return BadRequest("Engagement not found"); + return NotFound("Engagement not found"); agreement.EngagementId = body.EngagementId; agreement.Engagement = engagement; @@ -280,7 +259,7 @@ public async Task> Put([FromRoute] string orgUr UploadedBy = f.UploadedBy ?? "Unknown" }).ToList(); - await agreementsRepository.UpdateAgreementAsync(agreement, ct); + await agreementsRepository.UpdateAgreementAsync(agreement, cancellationToken); var responseModel = new AgreementReadModel( AgreementId: agreement.Id, @@ -294,12 +273,7 @@ public async Task> Put([FromRoute] string orgUr Notes: agreement.Notes, Options: agreement.Options, PriceAdjustmentProcess: agreement.PriceAdjustmentProcess, - Files: agreement.Files.Select(f => new FileReferenceReadModel( - FileName: f.FileName, - BlobName: f.BlobName, - UploadedOn: f.UploadedOn, - UploadedBy: f.UploadedBy ?? "Unknown" - )).ToList() + agreement.Files.Select(f => new FileReferenceReadModel(f)).ToList() ); cache.Remove($"consultantCacheKey/{orgUrlKey}"); @@ -308,11 +282,11 @@ public async Task> Put([FromRoute] string orgUr } [HttpDelete] - [Route("delete/{agreementId}")] + [Route("{agreementId:int}")] 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"); + if (selectedOrg is null) return NotFound(SelectedOrganizationNotFound); var agreement = await agreementsRepository.GetAgreementById(agreementId, ct); if (agreement is null) return NotFound(); @@ -328,7 +302,7 @@ public async Task Delete([FromRoute] string orgUrlKey, [FromRoute] public async Task>> GetPriceAdjustmentIndexes([FromRoute] string orgUrlKey, CancellationToken ct) { var selectedOrg = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); - if (selectedOrg is null) return BadRequest("Selected org not found"); + if (selectedOrg is null) return NotFound(SelectedOrganizationNotFound); var priceAdjustmentIndexes = await agreementsRepository.GetPriceAdjustmentIndexesAsync(ct); diff --git a/backend/Api/Agreements/AgreementModels.cs b/backend/Api/Agreements/AgreementModels.cs index 96cddf7e..e38fc16d 100644 --- a/backend/Api/Agreements/AgreementModels.cs +++ b/backend/Api/Agreements/AgreementModels.cs @@ -1,6 +1,8 @@ -using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Core.Agreements; +// ReSharper disable NotAccessedPositionalProperty.Global + +namespace Api.Agreements; public record AgreementReadModel( int AgreementId, @@ -16,12 +18,19 @@ public record AgreementReadModel( string? PriceAdjustmentProcess, List Files ); + public record FileReferenceReadModel( string FileName, string BlobName, DateTime UploadedOn, string? UploadedBy -); +) +{ + public FileReferenceReadModel(FileReference fileReference) : this(fileReference.FileName, fileReference.BlobName, + fileReference.UploadedOn, fileReference.UploadedBy ?? "Unknown") + { + } +} public record AgreementWriteModel( string? Name, @@ -49,9 +58,10 @@ public IEnumerable Validate(ValidationContext validationContex } +// ReSharper disable once ClassNeverInstantiated.Global public record FileReferenceWriteModel( string FileName, string BlobName, DateTime UploadedOn, string? UploadedBy -); +); \ No newline at end of file diff --git a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs index c06bbb60..25b2c1b9 100644 --- a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs +++ b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs @@ -13,25 +13,20 @@ namespace Infrastructure.DatabaseContext; -public class ApplicationContext : DbContext +public class ApplicationContext(DbContextOptions options) : DbContext(options) { - public ApplicationContext(DbContextOptions options) : base(options) - { - } - - public DbSet Consultant { get; set; } = null!; - public DbSet Competence { get; set; } = null!; - public DbSet CompetenceConsultant { get; set; } = null!; - public DbSet Department { get; set; } = null!; - public DbSet Organization { get; set; } = null!; - public DbSet Absence { get; set; } = null!; - - public DbSet PlannedAbsence { get; set; } = null!; - public DbSet Vacation { get; set; } = null!; - public DbSet Customer { get; set; } = null!; - public DbSet Project { get; set; } = null!; - public DbSet Staffing { get; set; } = null!; - public DbSet Agreements { get; set; } = null!; + public DbSet Consultant { get; init; } = null!; + public DbSet Competence { get; init; } = null!; + public DbSet CompetenceConsultant { get; init; } = null!; + public DbSet Department { get; init; } = null!; + public DbSet Organization { get; init; } = null!; + public DbSet Absence { get; init; } = null!; + public DbSet PlannedAbsence { get; init; } = null!; + public DbSet Vacation { get; init; } = null!; + public DbSet Customer { get; init; } = null!; + public DbSet Project { get; init; } = null!; + public DbSet Staffing { get; init; } = null!; + public DbSet Agreements { get; init; } = null!; protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) @@ -135,10 +130,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(v => v.EndDate) .HasConversion(); - /*modelBuilder.Entity() - .HasMany(v => v.CompetenceConsultant) - .WithMany();*/ - modelBuilder.Entity() .Property(c => c.TransferredVacationDays) .HasDefaultValue(0); diff --git a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs index b4fc0430..0b56970d 100644 --- a/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs +++ b/backend/Infrastructure/Migrations/ApplicationContextModelSnapshot.cs @@ -45,7 +45,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrganizationId"); - b.ToTable("Absence"); + b.ToTable("Absence", (string)null); }); modelBuilder.Entity("Core.Agreements.Agreement", b => @@ -92,7 +92,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("EngagementId"); - b.ToTable("Agreements"); + b.ToTable("Agreements", (string)null); }); modelBuilder.Entity("Core.Consultants.Competence", b => @@ -106,7 +106,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("Competence"); + b.ToTable("Competence", (string)null); b.HasData( new @@ -148,7 +148,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CompetencesId"); - b.ToTable("CompetenceConsultant"); + b.ToTable("CompetenceConsultant", (string)null); }); modelBuilder.Entity("Core.Consultants.Consultant", b => @@ -192,7 +192,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("DepartmentId"); - b.ToTable("Consultant"); + b.ToTable("Consultant", (string)null); b.HasData( new @@ -228,7 +228,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrganizationId", "Name") .IsUnique(); - b.ToTable("Customer"); + b.ToTable("Customer", (string)null); }); modelBuilder.Entity("Core.Engagements.Engagement", b => @@ -258,7 +258,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CustomerId", "Name") .IsUnique(); - b.ToTable("Project"); + b.ToTable("Project", (string)null); }); modelBuilder.Entity("Core.Organizations.Department", b => @@ -282,7 +282,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("OrganizationId"); - b.ToTable("Department"); + b.ToTable("Department", (string)null); b.HasData( new @@ -321,7 +321,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("Organization"); + b.ToTable("Organization", (string)null); b.HasData( new @@ -354,7 +354,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ConsultantId"); - b.ToTable("PlannedAbsence"); + b.ToTable("PlannedAbsence", (string)null); }); modelBuilder.Entity("Core.Staffings.Staffing", b => @@ -375,7 +375,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ConsultantId"); - b.ToTable("Staffing"); + b.ToTable("Staffing", (string)null); }); modelBuilder.Entity("Core.Vacations.Vacation", b => @@ -396,7 +396,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("ConsultantId"); - b.ToTable("Vacation"); + b.ToTable("Vacation", (string)null); }); modelBuilder.Entity("Core.Absences.Absence", b => @@ -422,7 +422,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("EngagementId") .OnDelete(DeleteBehavior.Restrict); - b.OwnsMany("Core.Agreements.FileReference", "Files", b1 => + b.OwnsMany("Core.Agreements.Agreement.Files#Core.Agreements.FileReference", "Files", b1 => { b1.Property("Id") .ValueGeneratedOnAdd() @@ -451,7 +451,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b1.HasIndex("AgreementId"); - b1.ToTable("FileReference"); + b1.ToTable("FileReference", (string)null); b1.WithOwner() .HasForeignKey("AgreementId"); diff --git a/frontend/src/actions/agreementActions.ts b/frontend/src/actions/agreementActions.ts index baf87687..330e9971 100644 --- a/frontend/src/actions/agreementActions.ts +++ b/frontend/src/actions/agreementActions.ts @@ -135,7 +135,7 @@ export async function getAgreementsForProject( ) { try { const res = await fetchWithToken( - `${orgUrlKey}/agreements/get/engagement/${projectId}`, + `${orgUrlKey}/agreements/engagement/${projectId}`, ); let agreementsWithDateTypes: Agreement[] = []; @@ -154,7 +154,7 @@ export async function getAgreementsForCustomer( ) { try { const res = await fetchWithToken( - `${orgUrlKey}/agreements/get/customer/${customerId}`, + `${orgUrlKey}/agreements/customer/${customerId}`, ); let agreementsWithDateTypes: Agreement[] = []; @@ -170,7 +170,7 @@ export async function getAgreementsForCustomer( export async function updateAgreement(agreement: Agreement, orgUrlKey: string) { try { const res = await putWithToken( - `${orgUrlKey}/agreements/update/${agreement.agreementId}`, + `${orgUrlKey}/agreements/${agreement.agreementId}`, agreement, ); @@ -190,7 +190,7 @@ export async function createAgreement( ) { try { const res = await postWithToken( - `${orgUrlKey}/agreements/create`, + `${orgUrlKey}/agreements`, agreement, ); let agreementWithDateTypes: Agreement | null = null; @@ -206,7 +206,7 @@ export async function createAgreement( export async function deleteAgreement(agreementId: number, orgUrlKey: string) { try { const res = await deleteWithToken( - `${orgUrlKey}/agreements/delete/${agreementId}`, + `${orgUrlKey}/agreements/${agreementId}`, ); return res; From 3443fb56ef11d9126ca0c7e4387d2163bedca072 Mon Sep 17 00:00:00 2001 From: Maria Katrin Bonde <78419778+MariaBonde@users.noreply.github.com> Date: Mon, 6 Jan 2025 09:34:51 +0100 Subject: [PATCH 2/2] Deactivate customer (#557) --- backend/Api/Agreements/AgreementController.cs | 2 +- backend/Api/Common/StorageService.cs | 22 +- backend/Api/Projects/ProjectController.cs | 23 +- backend/Api/Projects/ProjectModels.cs | 28 +- backend/Core/Customers/Customer.cs | 4 + .../DatabaseContext/ApplicationContext.cs | 4 +- ...ctiveOnCustomerWithDefaultTrue.Designer.cs | 631 ++++++++++++++++++ ..._AddedIsActiveOnCustomerWithDefaultTrue.cs | 29 + .../ApplicationContextModelSnapshot.cs | 3 + frontend/mockdata/mockData.ts | 1 + frontend/src/api-types.ts | 2 + .../[organisation]/kunder/[customer]/page.tsx | 4 +- .../app/[organisation]/kunder/api/route.ts | 21 + .../src/app/[organisation]/kunder/page.tsx | 4 +- .../src/components/Buttons/ActivateButton.tsx | 24 + .../CustomerSidebarWithFilters.tsx | 15 - .../ActiveCustomerFilters.tsx | 13 +- .../CustomerRow.tsx | 48 +- .../CustomerSidebar.tsx | 0 .../CustomerSidebarWithFilters.tsx | 26 + .../CustomerTable.tsx | 36 +- .../EngagementRow.tsx | 0 .../FilteredCustomersTable.tsx | 4 +- .../components/CustomerTable/SwitchToggle.tsx | 38 ++ .../src/components/SearchBarComponent.tsx | 4 +- frontend/src/components/Staffing/WeekCell.tsx | 2 +- frontend/src/components/StaffingSidebar.tsx | 6 +- .../CustomerFilterProvider.tsx | 11 +- .../useCustomerFilter.ts | 19 +- .../CustomerFilter/useIsCustomerActive.ts | 33 + frontend/src/pagecontent/CustomerContent.tsx | 8 +- frontend/tailwind.config.ts | 3 + 32 files changed, 1007 insertions(+), 61 deletions(-) create mode 100644 backend/Infrastructure/Migrations/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.Designer.cs create mode 100644 backend/Infrastructure/Migrations/20241218133743_AddedIsActiveOnCustomerWithDefaultTrue.cs create mode 100644 frontend/src/app/[organisation]/kunder/api/route.ts create mode 100644 frontend/src/components/Buttons/ActivateButton.tsx delete mode 100644 frontend/src/components/CostumerTable/CustomerSidebarWithFilters.tsx rename frontend/src/components/{CostumerTable => CustomerTable}/ActiveCustomerFilters.tsx (68%) rename frontend/src/components/{CostumerTable => CustomerTable}/CustomerRow.tsx (71%) rename frontend/src/components/{CostumerTable => CustomerTable}/CustomerSidebar.tsx (100%) create mode 100644 frontend/src/components/CustomerTable/CustomerSidebarWithFilters.tsx rename frontend/src/components/{CostumerTable => CustomerTable}/CustomerTable.tsx (75%) rename frontend/src/components/{CostumerTable => CustomerTable}/EngagementRow.tsx (100%) rename frontend/src/components/{CostumerTable => CustomerTable}/FilteredCustomersTable.tsx (88%) create mode 100644 frontend/src/components/CustomerTable/SwitchToggle.tsx rename frontend/src/hooks/{ => CustomerFilter}/CustomerFilterProvider.tsx (85%) rename frontend/src/hooks/{staffing => CustomerFilter}/useCustomerFilter.ts (75%) create mode 100644 frontend/src/hooks/CustomerFilter/useIsCustomerActive.ts diff --git a/backend/Api/Agreements/AgreementController.cs b/backend/Api/Agreements/AgreementController.cs index 53a17766..9520b8b5 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 5d9aa87f..c5bb83b5 100644 --- a/backend/Api/Common/StorageService.cs +++ b/backend/Api/Common/StorageService.cs @@ -195,7 +195,20 @@ private List LoadConsultantsFromDb(string orgUrlKey) 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 = _dbContext.Customer.Where(c => c.OrganizationId == org.Id) .SingleOrDefault(c => c.Name == customerName); @@ -206,14 +219,15 @@ public Customer UpdateOrCreateCustomer(Organization org, string customerName, st { Name = customerName, Organization = org, - Projects = [] + Projects = [], + IsActive = true }; _dbContext.Customer.Add(customer); + _dbContext.SaveChanges(); + ClearConsultantCache(orgUrlKey); } - _dbContext.SaveChanges(); - ClearConsultantCache(orgUrlKey); return customer; } diff --git a/backend/Api/Projects/ProjectController.cs b/backend/Api/Projects/ProjectController.cs index 92e9b369..0fa8daad 100644 --- a/backend/Api/Projects/ProjectController.cs +++ b/backend/Api/Projects/ProjectController.cs @@ -1,12 +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.Staffings; using Infrastructure.DatabaseContext; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -47,7 +49,7 @@ public async Task>> Get( var selectedOrgId = await organisationRepository.GetOrganizationByUrlKey(orgUrlKey, ct); if (selectedOrgId is null) return BadRequest(); - var absenceReadModels = new EngagementPerCustomerReadModel(-1, AbsenceCustomerName, + 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()); @@ -58,6 +60,7 @@ public async Task>> Get( 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())) .ToList(); @@ -94,6 +97,7 @@ CancellationToken ct 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(), @@ -244,6 +248,19 @@ 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, @@ -257,7 +274,7 @@ public async Task> Put([FromRoute] string if (body.CustomerName == AbsenceCustomerName) return Ok(HandleAbsenceChange(body, orgUrlKey)); - var customer = service.UpdateOrCreateCustomer(selectedOrg, body.CustomerName, orgUrlKey); + var customer = service.FindOrCreateCustomer(selectedOrg, body.CustomerName, orgUrlKey); var project = context.Project .Include(p => p.Customer) @@ -301,7 +318,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 db7581e4..8222369a 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; @@ -15,4 +16,7 @@ public class Customer public required string Name { get; set; } public required Organization Organization { get; set; } public required List Projects { get; set; } + + public bool IsActive { get; set; } = true; + } \ No newline at end of file diff --git a/backend/Infrastructure/DatabaseContext/ApplicationContext.cs b/backend/Infrastructure/DatabaseContext/ApplicationContext.cs index 25b2c1b9..8cf1069c 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() @@ -202,7 +202,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) GraduationYear = 2019 }); - + base.OnModelCreating(modelBuilder); } 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 && (