diff --git a/backend/Api/Common/StorageService.cs b/backend/Api/Common/StorageService.cs index 4b8f6592..0d1f9b26 100644 --- a/backend/Api/Common/StorageService.cs +++ b/backend/Api/Common/StorageService.cs @@ -18,6 +18,18 @@ public StorageService(IMemoryCache cache, ApplicationContext context) _dbContext = context; } + public Consultant? GetConsultantByEmail(string orgUrlKey, string email) + { + var consultant = _dbContext.Consultant.Include(c=>c.Department).ThenInclude(d=> d.Organization).SingleOrDefault(c => c.Email == email); + if (consultant is null || consultant.Department.Organization.UrlKey != orgUrlKey) + { + return null; + } + + return consultant; + + } + public void ClearConsultantCache(string orgUrlKey) { _cache.Remove($"{ConsultantCacheKey}/{orgUrlKey}"); @@ -358,4 +370,38 @@ public Engagement GetProjectWithOrganisationById(int id) .ThenInclude(c => c.Organization) .Single(p => p.Id == id); } + + public List LoadConsultantVacation(int consultantId) + { + return _dbContext.Vacation.Where(v => v.ConsultantId == consultantId).ToList(); + } + + public List? LoadPublicHolidays(string orgUrlKey) + { + var org = _dbContext.Organization.SingleOrDefault(org => org.UrlKey == orgUrlKey); + if (org is null) return null; + return org.GetPublicHolidays(DateOnly.FromDateTime(DateTime.Now).Year); + } + + public void RemoveVacationDay(int consultantId, DateOnly date) + { + var vacation = _dbContext.Vacation.Single(v => v.ConsultantId == consultantId && v.Date.Equals(date)); + + _dbContext.Vacation.Remove(vacation); + _dbContext.SaveChanges(); + } + + public void AddVacationDay(int consultantId, DateOnly date) + { + var consultant = _dbContext.Consultant.Single(c => c.Id == consultantId); + var vacation = new Vacation + { + ConsultantId = consultantId, + Consultant = consultant, + Date = date + }; + _dbContext.Add(vacation); + _dbContext.SaveChanges(); + } + } \ No newline at end of file diff --git a/backend/Api/Consultants/ConsultantController.cs b/backend/Api/Consultants/ConsultantController.cs new file mode 100644 index 00000000..b7e7f76a --- /dev/null +++ b/backend/Api/Consultants/ConsultantController.cs @@ -0,0 +1,43 @@ +using Api.Common; +using Database.DatabaseContext; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Api.Consultants; + +[Authorize] +[Route("/v0/{orgUrlKey}/consultants")] +[ApiController] +public class ConsultantController : ControllerBase +{ + + private readonly IMemoryCache _cache; + private readonly ApplicationContext _context; + + public ConsultantController(ApplicationContext context, IMemoryCache cache) + { + _context = context; + _cache = cache; + } + + + [HttpGet] + public ActionResult Get([FromRoute] string orgUrlKey, + [FromQuery(Name = "Email")] string? email = "") + { + var service = new StorageService(_cache, _context); + + var consultant = service.GetConsultantByEmail(orgUrlKey, email ?? ""); + + + if (consultant is null) + { + return NotFound(); + } + + return Ok( new SingleConsultantReadModel(consultant)); + } + + +} diff --git a/backend/Api/Consultants/ConsultantReadModel.cs b/backend/Api/Consultants/ConsultantReadModel.cs new file mode 100644 index 00000000..af04e6c6 --- /dev/null +++ b/backend/Api/Consultants/ConsultantReadModel.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using Core.DomainModels; + +namespace Api.Consultants; + +public record SingleConsultantReadModel([property: Required] int Id, + [property: Required] string Name, + [property: Required] string Email, + [property: Required] List Competences, + [property: Required] string Department, + [property: Required] int YearsOfExperience, + [property: Required] Degree Degree) + +{ + public SingleConsultantReadModel(Consultant consultant) + : this( + consultant.Id, + consultant.Name, + consultant.Email, + consultant.Competences.Select(c => c.Name).ToList(), + consultant.Department.Name, + consultant.YearsOfExperience, + consultant.Degree ?? Degree.Master + ) + { + } +} \ No newline at end of file diff --git a/backend/Api/Organisation/OrganisationHolidayExtensions.cs b/backend/Api/Organisation/OrganisationHolidayExtensions.cs index e3a91313..a25a4b60 100644 --- a/backend/Api/Organisation/OrganisationHolidayExtensions.cs +++ b/backend/Api/Organisation/OrganisationHolidayExtensions.cs @@ -50,4 +50,20 @@ private static bool IsChristmasHoliday(this Organization organization, DateOnly return date >= startDate && date <= endDate; } + + public static List GetPublicHolidays(this Organization organization, int year) + { + var publicHoliday = organization.GetPublicHoliday(); + var publicHolidays = publicHoliday.PublicHolidays(year).Select(DateOnly.FromDateTime).ToList(); + if (organization.HasVacationInChristmas) + { + var startDate = new DateTime(year, 12, 24); + var endDate = new DateTime(year, 12, 31); + var list = Enumerable.Range(0, 1 + endDate.Subtract(startDate).Days) + .Select(offset => DateOnly.FromDateTime(startDate.AddDays(offset))) + .ToList(); + publicHolidays = publicHolidays.Concat(list).Distinct().ToList(); + } + return publicHolidays; + } } \ No newline at end of file diff --git a/backend/Api/Projects/ProjectController.cs b/backend/Api/Projects/ProjectController.cs index f3211511..b7cbaf63 100644 --- a/backend/Api/Projects/ProjectController.cs +++ b/backend/Api/Projects/ProjectController.cs @@ -70,7 +70,7 @@ public ActionResult Delete(int id) [HttpPut] [Route("updateState")] - public ActionResult> Put([FromRoute] string orgUrlKey, + public ActionResult> Put([FromRoute] string orgUrlKey, [FromBody] UpdateProjectWriteModel projectWriteModel) { // Merk: Service kommer snart via Dependency Injection, da slipper å lage ny hele tiden diff --git a/backend/Api/StaffingController/ReadModelFactory.cs b/backend/Api/StaffingController/ReadModelFactory.cs index 7ac06ba4..b5ba2af3 100644 --- a/backend/Api/StaffingController/ReadModelFactory.cs +++ b/backend/Api/StaffingController/ReadModelFactory.cs @@ -13,7 +13,7 @@ public ReadModelFactory(StorageService storageService) _storageService = storageService; } - public List GetConsultantReadModelsForWeeks(string orgUrlKey, List weeks) + public List GetConsultantReadModelsForWeeks(string orgUrlKey, List weeks) { var firstDayInScope = weeks.First().FirstDayOfWorkWeek(); var firstWorkDayOutOfScope = weeks.Last().LastWorkDayOfWeek(); @@ -26,40 +26,40 @@ public List GetConsultantReadModelsForWeeks(string orgUrlKe } - public ConsultantReadModel GetConsultantReadModelForWeek(int consultantId, Week week) + public StaffingReadModel GetConsultantReadModelForWeek(int consultantId, Week week) { var consultant = _storageService.LoadConsultantForSingleWeek(consultantId, week); var readModel = MapToReadModelList(consultant, new List { week }); - return new ConsultantReadModel(consultant, new List { readModel.Bookings.First() }, + return new StaffingReadModel(consultant, new List { readModel.Bookings.First() }, readModel.DetailedBooking.ToList(), readModel.IsOccupied); } - public ConsultantReadModel GetConsultantReadModelForWeeks(int consultantId, List weeks) + public StaffingReadModel GetConsultantReadModelForWeeks(int consultantId, List weeks) { var consultant = _storageService.LoadConsultantForWeekSet(consultantId, weeks); var readModel = MapToReadModelList(consultant, weeks); - return new ConsultantReadModel(consultant, readModel.Bookings, + return new StaffingReadModel(consultant, readModel.Bookings, readModel.DetailedBooking, readModel.IsOccupied); } - public List GetConsultantReadModelForWeeks(List consultantIds, List weeks) + public List GetConsultantReadModelForWeeks(List consultantIds, List weeks) { - var consultants = new List(); + var consultants = new List(); foreach (var i in consultantIds) { var consultant = _storageService.LoadConsultantForWeekSet(i, weeks); var readModel = MapToReadModelList(consultant, weeks); - consultants.Add(new ConsultantReadModel(consultant, readModel.Bookings, + consultants.Add(new StaffingReadModel(consultant, readModel.Bookings, readModel.DetailedBooking, readModel.IsOccupied)); } return consultants; } - public static ConsultantReadModel MapToReadModelList( + public static StaffingReadModel MapToReadModelList( Consultant consultant, List weekSet) { @@ -80,7 +80,7 @@ public static ConsultantReadModel MapToReadModelList( b.BookingModel.TotalBillable + b.BookingModel.TotalPlannedAbsences + b.BookingModel.TotalVacationHours + b.BookingModel.TotalHolidayHours >= hoursPrWeek); - return new ConsultantReadModel( + return new StaffingReadModel( consultant, bookingSummary, detailedBookings.ToList(), diff --git a/backend/Api/StaffingController/ConsultantController.cs b/backend/Api/StaffingController/StaffingController.cs similarity index 92% rename from backend/Api/StaffingController/ConsultantController.cs rename to backend/Api/StaffingController/StaffingController.cs index 14557ff0..42b3baa3 100644 --- a/backend/Api/StaffingController/ConsultantController.cs +++ b/backend/Api/StaffingController/StaffingController.cs @@ -6,23 +6,22 @@ using Microsoft.Extensions.Caching.Memory; namespace Api.StaffingController; - [Authorize] -[Route("/v0/{orgUrlKey}/consultants")] +[Route("/v0/{orgUrlKey}/staffings")] [ApiController] -public class ConsultantController : ControllerBase +public class StaffingController : ControllerBase { private readonly IMemoryCache _cache; private readonly ApplicationContext _context; - public ConsultantController(ApplicationContext context, IMemoryCache cache) + public StaffingController(ApplicationContext context, IMemoryCache cache) { _context = context; _cache = cache; } [HttpGet] - public ActionResult> Get( + public ActionResult> Get( [FromRoute] string orgUrlKey, [FromQuery(Name = "Year")] int? selectedYearParam = null, [FromQuery(Name = "Week")] int? selectedWeekParam = null, @@ -39,10 +38,11 @@ public ActionResult> Get( var readModels = new ReadModelFactory(service).GetConsultantReadModelsForWeeks(orgUrlKey, weekSet); return Ok(readModels); } + [HttpPut] - [Route("staffing/update")] - public ActionResult Put( + [Route("update")] + public ActionResult Put( [FromRoute] string orgUrlKey, [FromBody] StaffingWriteModel staffingWriteModel ) @@ -86,8 +86,8 @@ [FromBody] StaffingWriteModel staffingWriteModel } [HttpPut] - [Route("staffing/update/several")] - public ActionResult Put( + [Route("update/several")] + public ActionResult Put( [FromRoute] string orgUrlKey, [FromBody] SeveralStaffingWriteModel severalStaffingWriteModel ) diff --git a/backend/Api/StaffingController/ConsultantReadModel.cs b/backend/Api/StaffingController/StaffingReadModel.cs similarity index 95% rename from backend/Api/StaffingController/ConsultantReadModel.cs rename to backend/Api/StaffingController/StaffingReadModel.cs index 79a1ed36..a9e31619 100644 --- a/backend/Api/StaffingController/ConsultantReadModel.cs +++ b/backend/Api/StaffingController/StaffingReadModel.cs @@ -5,7 +5,7 @@ namespace Api.StaffingController; -public record ConsultantReadModel( +public record StaffingReadModel( [property: Required] int Id, [property: Required] string Name, [property: Required] string Email, @@ -17,7 +17,7 @@ public record ConsultantReadModel( [property: Required] List DetailedBooking, [property: Required] bool IsOccupied) { - public ConsultantReadModel(Consultant consultant, List bookings, + public StaffingReadModel(Consultant consultant, List bookings, List detailedBookings, bool IsOccupied) : this( consultant.Id, @@ -35,6 +35,7 @@ public ConsultantReadModel(Consultant consultant, List booki } } + public record BookedHoursPerWeek( [property: Required] int Year, [property: Required] int WeekNumber, diff --git a/backend/Api/VacationsController/VacationsController.cs b/backend/Api/VacationsController/VacationsController.cs new file mode 100644 index 00000000..181192d2 --- /dev/null +++ b/backend/Api/VacationsController/VacationsController.cs @@ -0,0 +1,151 @@ +using System.Globalization; +using Api.Common; +using Core.DomainModels; +using Database.DatabaseContext; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace Api.VacationsController; + +[Authorize] +[Route("/v0/{orgUrlKey}/vacations")] +[ApiController] +public class VacationsController : ControllerBase +{ + private readonly IMemoryCache _cache; + private readonly ApplicationContext _context; + + public VacationsController(ApplicationContext context, IMemoryCache cache) + { + _context = context; + _cache = cache; + } + + [HttpGet] + [Route("publicHolidays")] + public ActionResult> GetPublicHolidays([FromRoute] string orgUrlKey) + { + //TODO: Validate that consultant is in organisation + //TODO: Year? + var selectedOrg = _context.Organization.SingleOrDefault(org => org.UrlKey == orgUrlKey); + if (selectedOrg is null) return BadRequest(); + + var service = new StorageService(_cache, _context); + var publicHolidays = service.LoadPublicHolidays(orgUrlKey); + if (publicHolidays is null) return BadRequest("Something went wrong fetching public holidays"); + + return publicHolidays; + } + + [HttpGet] + [Route("{consultantId}/get")] + public ActionResult GetVacations([FromRoute] string orgUrlKey, + [FromRoute] int consultantId) + { + //TODO: Make year optional search param + + var selectedOrg = _context.Organization.SingleOrDefault(org => org.UrlKey == orgUrlKey); + if (selectedOrg is null) return BadRequest(); + + var service = new StorageService(_cache, _context); + + if (!VacationsValidator.ValidateVacation(consultantId, service, orgUrlKey)) + return BadRequest(); + + var vacationDays = service.LoadConsultantVacation(consultantId); + var consultant = service.GetBaseConsultantById(consultantId); + if (consultant is null) return BadRequest(); + var vacationMetaData = new VacationMetaData(consultant, DateOnly.FromDateTime(DateTime.Now)); + return new VacationReadModel(consultantId, vacationDays, vacationMetaData); + } + + [HttpDelete] + [Route("{consultantId}/{date}/delete")] + public ActionResult DeleteVacation([FromRoute] string orgUrlKey, + [FromRoute] int consultantId, + [FromRoute] string date) + { + //TODO: Make year optional search param + + var selectedOrg = _context.Organization.SingleOrDefault(org => org.UrlKey == orgUrlKey); + if (selectedOrg is null) return BadRequest(); + + var service = new StorageService(_cache, _context); + + if (!VacationsValidator.ValidateVacation(consultantId, service, orgUrlKey)) + return BadRequest(); + + try + { + var dateObject = DateOnly.FromDateTime(DateTime.Parse(date, CultureInfo.InvariantCulture)); + service.RemoveVacationDay(consultantId, dateObject); + } + catch (Exception e) + { + Console.WriteLine(e); + return BadRequest("Something went wrong"); + } + + var vacationDays = service.LoadConsultantVacation(consultantId); + var consultant = service.GetBaseConsultantById(consultantId); + if (consultant is null) return BadRequest(); + var vacationMetaData = new VacationMetaData(consultant, DateOnly.FromDateTime(DateTime.Now)); + return new VacationReadModel(consultantId, vacationDays, vacationMetaData); + } + + [HttpPut] + [Route("{consultantId}/{date}/update")] + public ActionResult UpdateVacation([FromRoute] string orgUrlKey, + [FromRoute] int consultantId, + [FromRoute] string date) + { + //TODO: Make year optional search param + + var selectedOrg = _context.Organization.SingleOrDefault(org => org.UrlKey == orgUrlKey); + if (selectedOrg is null) return BadRequest(); + + var service = new StorageService(_cache, _context); + + if (!VacationsValidator.ValidateVacation(consultantId, service, orgUrlKey)) + return BadRequest(); + + try + { + var dateObject = DateOnly.FromDateTime(DateTime.Parse(date)); + service.AddVacationDay(consultantId, dateObject); + } + catch (Exception e) + { + Console.WriteLine(e); + return BadRequest("Something went wrong"); + } + + var vacationDays = service.LoadConsultantVacation(consultantId); + var consultant = service.GetBaseConsultantById(consultantId); + if (consultant is null) return BadRequest(); + var vacationMetaData = new VacationMetaData(consultant, DateOnly.FromDateTime(DateTime.Now)); + return new VacationReadModel(consultantId, vacationDays, vacationMetaData); + } +} + +public record VacationMetaData(int DaysTotal, int TransferredDays, int Planned, int Used, int LeftToPlan) +{ + public VacationMetaData(Consultant consultant, DateOnly day) : this( + consultant.TotalAvailableVacationDays, + consultant.TransferredVacationDays, + consultant.GetPlannedVacationDays(day), + consultant.GetUsedVacationDays(day), + consultant.GetRemainingVacationDays(day) + ) + { + } +} + +public record VacationReadModel(int ConsultantId, List VacationDays, VacationMetaData VacationMetaData) +{ + public VacationReadModel(int consultantId, List vacations, VacationMetaData vacationMetaData) : this( + consultantId, vacations.Select(v => v.Date).ToList(), vacationMetaData) + { + } +} \ No newline at end of file diff --git a/backend/Api/VacationsController/VacationsValidator.cs b/backend/Api/VacationsController/VacationsValidator.cs new file mode 100644 index 00000000..5485f35c --- /dev/null +++ b/backend/Api/VacationsController/VacationsValidator.cs @@ -0,0 +1,13 @@ +using Api.Common; + +namespace Api.VacationsController; + +public static class VacationsValidator +{ + public static bool ValidateVacation(int consultantId, StorageService storageService, + string orgUrlKey) + { + return storageService.GetBaseConsultantById(consultantId)?.Department.Organization.UrlKey == + orgUrlKey; + } +} \ No newline at end of file diff --git a/backend/Core/DomainModels/Consultant.cs b/backend/Core/DomainModels/Consultant.cs index 7d5854da..868f0530 100644 --- a/backend/Core/DomainModels/Consultant.cs +++ b/backend/Core/DomainModels/Consultant.cs @@ -17,6 +17,7 @@ public class Consultant public Degree? Degree { get; set; } public int? GraduationYear { get; set; } + public int TransferredVacationDays { get; set; } public List Competences { get; set; } = new(); @@ -38,6 +39,24 @@ public int YearsOfExperience return currentAcademicYear - GraduationYear ?? currentAcademicYear; } } + + public int TotalAvailableVacationDays => Department.Organization.NumberOfVacationDaysInYear; + + public int GetUsedVacationDays(DateOnly day) + { + return Vacations.Where(v => v.Date.Year.Equals(day.Year)).Count(v => v.Date < day); + } + + public int GetPlannedVacationDays(DateOnly day) + { + return Vacations.Where(v => v.Date.Year.Equals(day.Year)).Count(v => v.Date > day); + } + + public int GetRemainingVacationDays(DateOnly day) + { + return TotalAvailableVacationDays + TransferredVacationDays - GetUsedVacationDays(day) - + GetPlannedVacationDays(day); + } } public class Competence diff --git a/backend/Database/DatabaseContext/ApplicationContext.cs b/backend/Database/DatabaseContext/ApplicationContext.cs index 795ef8db..6f53054b 100644 --- a/backend/Database/DatabaseContext/ApplicationContext.cs +++ b/backend/Database/DatabaseContext/ApplicationContext.cs @@ -118,6 +118,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity() .HasMany(v => v.Competences) .WithMany(); + + modelBuilder.Entity() + .Property(c => c.TransferredVacationDays) + .HasDefaultValue(0); modelBuilder.Entity().HasData(new List { diff --git a/backend/Database/Migrations/20231212143200_TransferredVacationDays.Designer.cs b/backend/Database/Migrations/20231212143200_TransferredVacationDays.Designer.cs new file mode 100644 index 00000000..798143c5 --- /dev/null +++ b/backend/Database/Migrations/20231212143200_TransferredVacationDays.Designer.cs @@ -0,0 +1,506 @@ +// +using System; +using Database.DatabaseContext; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Database.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20231212143200_TransferredVacationDays")] + partial class TransferredVacationDays + { + /// + 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("CompetenceConsultant", b => + { + b.Property("CompetencesId") + .HasColumnType("nvarchar(450)"); + + b.Property("ConsultantId") + .HasColumnType("int"); + + b.HasKey("CompetencesId", "ConsultantId"); + + b.HasIndex("ConsultantId"); + + b.ToTable("CompetenceConsultant"); + }); + + modelBuilder.Entity("Core.DomainModels.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.DomainModels.Competence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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" + }); + }); + + modelBuilder.Entity("Core.DomainModels.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.DomainModels.Customer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("OrganizationId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Customer"); + }); + + modelBuilder.Entity("Core.DomainModels.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.DomainModels.Engagement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CustomerId") + .HasColumnType("int"); + + b.Property("IsBillable") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CustomerId"); + + b.ToTable("Project"); + }); + + modelBuilder.Entity("Core.DomainModels.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.DomainModels.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.DomainModels.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.DomainModels.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("CompetenceConsultant", b => + { + b.HasOne("Core.DomainModels.Competence", null) + .WithMany() + .HasForeignKey("CompetencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.DomainModels.Consultant", null) + .WithMany() + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.DomainModels.Absence", b => + { + b.HasOne("Core.DomainModels.Organization", "Organization") + .WithMany("AbsenceTypes") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Core.DomainModels.Consultant", b => + { + b.HasOne("Core.DomainModels.Department", "Department") + .WithMany("Consultants") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Department"); + }); + + modelBuilder.Entity("Core.DomainModels.Customer", b => + { + b.HasOne("Core.DomainModels.Organization", "Organization") + .WithMany("Customers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Core.DomainModels.Department", b => + { + b.HasOne("Core.DomainModels.Organization", "Organization") + .WithMany("Departments") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Core.DomainModels.Engagement", b => + { + b.HasOne("Core.DomainModels.Customer", "Customer") + .WithMany("Projects") + .HasForeignKey("CustomerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Customer"); + }); + + modelBuilder.Entity("Core.DomainModels.PlannedAbsence", b => + { + b.HasOne("Core.DomainModels.Absence", "Absence") + .WithMany() + .HasForeignKey("AbsenceId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("Core.DomainModels.Consultant", "Consultant") + .WithMany("PlannedAbsences") + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Absence"); + + b.Navigation("Consultant"); + }); + + modelBuilder.Entity("Core.DomainModels.Staffing", b => + { + b.HasOne("Core.DomainModels.Consultant", "Consultant") + .WithMany("Staffings") + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("Core.DomainModels.Engagement", "Engagement") + .WithMany("Staffings") + .HasForeignKey("EngagementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultant"); + + b.Navigation("Engagement"); + }); + + modelBuilder.Entity("Core.DomainModels.Vacation", b => + { + b.HasOne("Core.DomainModels.Consultant", "Consultant") + .WithMany("Vacations") + .HasForeignKey("ConsultantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultant"); + }); + + modelBuilder.Entity("Core.DomainModels.Consultant", b => + { + b.Navigation("PlannedAbsences"); + + b.Navigation("Staffings"); + + b.Navigation("Vacations"); + }); + + modelBuilder.Entity("Core.DomainModels.Customer", b => + { + b.Navigation("Projects"); + }); + + modelBuilder.Entity("Core.DomainModels.Department", b => + { + b.Navigation("Consultants"); + }); + + modelBuilder.Entity("Core.DomainModels.Engagement", b => + { + b.Navigation("Staffings"); + }); + + modelBuilder.Entity("Core.DomainModels.Organization", b => + { + b.Navigation("AbsenceTypes"); + + b.Navigation("Customers"); + + b.Navigation("Departments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Database/Migrations/20231212143200_TransferredVacationDays.cs b/backend/Database/Migrations/20231212143200_TransferredVacationDays.cs new file mode 100644 index 00000000..850a2c8f --- /dev/null +++ b/backend/Database/Migrations/20231212143200_TransferredVacationDays.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Database.Migrations +{ + /// + public partial class TransferredVacationDays : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TransferredVacationDays", + table: "Consultant", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TransferredVacationDays", + table: "Consultant"); + } + } +} diff --git a/backend/Database/Migrations/ApplicationContextModelSnapshot.cs b/backend/Database/Migrations/ApplicationContextModelSnapshot.cs index c1caf5a3..3f61ca56 100644 --- a/backend/Database/Migrations/ApplicationContextModelSnapshot.cs +++ b/backend/Database/Migrations/ApplicationContextModelSnapshot.cs @@ -132,6 +132,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("StartDate") .HasColumnType("datetime2"); + b.Property("TransferredVacationDays") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + b.HasKey("Id"); b.HasIndex("DepartmentId"); diff --git a/frontend/package.json b/frontend/package.json index c15e2ec3..e7aa115f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-feather": "^2.0.10", + "react-multi-date-picker": "^4.4.1", "react-query": "^3.39.3", "react-select": "^5.8.0", "tailwindcss": "^3.3.3", diff --git a/frontend/src/api-types.ts b/frontend/src/api-types.ts index 0f273acd..e8db449c 100644 --- a/frontend/src/api-types.ts +++ b/frontend/src/api-types.ts @@ -146,6 +146,21 @@ export interface SeveralStaffingWriteModel { hours?: number; } +export interface SingleConsultantReadModel { + /** @format int32 */ + id: number; + /** @minLength 1 */ + name: string; + /** @minLength 1 */ + email: string; + competences: string[]; + /** @minLength 1 */ + department: string; + /** @format int32 */ + yearsOfExperience: number; + degree: Degree; +} + export interface StaffingWriteModel { type?: BookingType; /** @format int32 */ @@ -172,6 +187,26 @@ export interface UpdateProjectWriteModel { weekSpan?: number; } +export interface VacationMetaData { + /** @format int32 */ + daysTotal?: number; + /** @format int32 */ + transferredDays?: number; + /** @format int32 */ + planned?: number; + /** @format int32 */ + used?: number; + /** @format int32 */ + leftToPlan?: number; +} + +export interface VacationReadModel { + /** @format int32 */ + consultantId?: number; + vacationDays?: string[]; + vacationMetaData?: VacationMetaData; +} + export interface WeeklyBookingReadModel { /** @format double */ totalBillable: number; diff --git a/frontend/src/app/[organisation]/bemanning/api/updateHours/route.ts b/frontend/src/app/[organisation]/bemanning/api/updateHours/route.ts index 7c65f9bc..e51f7cf2 100644 --- a/frontend/src/app/[organisation]/bemanning/api/updateHours/route.ts +++ b/frontend/src/app/[organisation]/bemanning/api/updateHours/route.ts @@ -31,8 +31,8 @@ export async function PUT( }; const url = endWeek - ? `${orgUrlKey}/consultants/staffing/update/several` - : `${orgUrlKey}/consultants/staffing/update`; + ? `${orgUrlKey}/staffings/update/several` + : `${orgUrlKey}/staffings/update`; const staffing = (await putWithToken(url, body)) ?? []; diff --git a/frontend/src/app/[organisation]/bemanning/page.tsx b/frontend/src/app/[organisation]/bemanning/page.tsx index 0054d661..778bd534 100644 --- a/frontend/src/app/[organisation]/bemanning/page.tsx +++ b/frontend/src/app/[organisation]/bemanning/page.tsx @@ -23,7 +23,7 @@ export default async function Bemanning({ const consultants = (await fetchWithToken( - `${params.organisation}/consultants${ + `${params.organisation}/staffings${ selectedWeek ? `?Year=${selectedWeek.year}&Week=${selectedWeek.weekNumber}` : "" diff --git a/frontend/src/app/[organisation]/ferie/api/route.tsx b/frontend/src/app/[organisation]/ferie/api/route.tsx new file mode 100644 index 00000000..e2d7ba98 --- /dev/null +++ b/frontend/src/app/[organisation]/ferie/api/route.tsx @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { deleteWithToken, putWithToken } from "@/data/apiCallsWithToken"; + +interface vacationHoursBody { + consultantId: number; + vacationDay: string; +} + +export async function PUT( + request: Request, + { params }: { params: { organisation: string } }, +) { + const orgUrlKey = params.organisation; + const requestBody = (await request.json()) as vacationHoursBody; + + const url = `${orgUrlKey}/vacations/${requestBody.consultantId}/${requestBody.vacationDay}/update`; + + const vacationDays = + (await putWithToken(url, undefined)) ?? []; + + return NextResponse.json(vacationDays); +} + +export async function DELETE( + request: Request, + { params }: { params: { organisation: string } }, +) { + const orgUrlKey = params.organisation; + const requestBody = (await request.json()) as vacationHoursBody; + + const url = `${orgUrlKey}/vacations/${requestBody.consultantId}/${requestBody.vacationDay}/delete`; + + const vacationDays = + (await deleteWithToken(url, undefined)) ?? []; + + return NextResponse.json(vacationDays); +} diff --git a/frontend/src/app/[organisation]/ferie/layout.tsx b/frontend/src/app/[organisation]/ferie/layout.tsx new file mode 100644 index 00000000..e38da3a0 --- /dev/null +++ b/frontend/src/app/[organisation]/ferie/layout.tsx @@ -0,0 +1,7 @@ +export default function FerieLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/frontend/src/app/[organisation]/ferie/page.tsx b/frontend/src/app/[organisation]/ferie/page.tsx new file mode 100644 index 00000000..c97752a1 --- /dev/null +++ b/frontend/src/app/[organisation]/ferie/page.tsx @@ -0,0 +1,48 @@ +import { ConsultantReadModel, VacationReadModel } from "@/api-types"; +import { + authOptions, + getCustomServerSession, +} from "@/app/api/auth/[...nextauth]/route"; +import VacationCalendar from "@/components/VacationCalendar"; +import { fetchWithToken } from "@/data/apiCallsWithToken"; + +export default async function Ferie({ + params, +}: { + params: { organisation: string }; +}) { + const session = + !process.env.NEXT_PUBLIC_NO_AUTH && + (await getCustomServerSession(authOptions)); + + const userEmail = + session && session.user && session.user.email ? session.user.email : ""; + + const consultant = + (await fetchWithToken( + `${params.organisation}/consultants?Email=${userEmail}`, + )) ?? undefined; + + const vacationDays = + (await fetchWithToken( + `${params.organisation}/vacations/${consultant?.id}/get`, + )) ?? undefined; + + const publicHolidays = + (await fetchWithToken( + `${params.organisation}/vacations/publicHolidays`, + )) ?? undefined; + + return ( + consultant && + vacationDays && + publicHolidays && ( + + ) + ); +} diff --git a/frontend/src/components/NavBar/NavBarDropdown.tsx b/frontend/src/components/NavBar/NavBarDropdown.tsx index b5338bf7..007f450b 100644 --- a/frontend/src/components/NavBar/NavBarDropdown.tsx +++ b/frontend/src/components/NavBar/NavBarDropdown.tsx @@ -1,11 +1,15 @@ "use client"; import React, { useRef, useState } from "react"; import { signOut } from "next-auth/react"; -import { LogOut } from "react-feather"; +import { Sun, LogOut } from "react-feather"; import { useOutsideClick } from "@/hooks/useOutsideClick"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; export default function NavBarDropdown(props: { initial: string }) { const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); + const orgUrl = pathname.split("/")[1] || ""; const menuRef = useRef(null); @@ -31,6 +35,15 @@ export default function NavBarDropdown(props: { initial: string }) { !isOpen && "hidden" }`} > + + +

+ Ferie +

+