diff --git a/backend/Api/Consultants/ConsultantController.cs b/backend/Api/Consultants/ConsultantController.cs index d0e2fa7c..5c69a6d0 100644 --- a/backend/Api/Consultants/ConsultantController.cs +++ b/backend/Api/Consultants/ConsultantController.cs @@ -3,7 +3,6 @@ using Core.Services; using Database.DatabaseContext; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; @@ -27,10 +26,15 @@ public ConsultantController(ApplicationContext context, IMemoryCache cache) [HttpGet] public ActionResult> Get( [FromRoute] string orgUrlKey, + [FromQuery(Name = "Year")] int? selectedYearParam = null, + [FromQuery(Name = "Week")] int? selectedWeekParam = null, [FromQuery(Name = "weeks")] int numberOfWeeks = 8, [FromQuery(Name = "includeOccupied")] bool includeOccupied = true) { - var consultants = GetConsultantsWithAvailability(orgUrlKey, numberOfWeeks) + var selectedYear = selectedYearParam ?? DateTime.Now.Year; + var selectedWeekNumber = selectedWeekParam ?? DateService.GetWeekNumber(DateTime.Now); + var selectedWeek = new Week(selectedYear, selectedWeekNumber); + var consultants = GetConsultantsWithAvailability(orgUrlKey, selectedWeek, numberOfWeeks) .Where(c => includeOccupied || c.IsOccupied @@ -41,53 +45,27 @@ public ActionResult> Get( } - [HttpPost] - public async Task, ProblemHttpResult, ValidationProblem>> AddBasicConsultant( - [FromBody] ConsultantWriteModel basicConsultant) - { - try - { - var selectedDepartment = await GetDepartmentByIdAsync(basicConsultant.DepartmentId); - if (selectedDepartment == null) return TypedResults.Problem("Department does not exist", statusCode: 400); - - var consultantList = await GetAllConsultantsAsync(_context); - var validationResults = ConsultantValidators.ValidateUniqueness(consultantList, basicConsultant); - - if (validationResults.Count > 0) return TypedResults.ValidationProblem(validationResults); - - var newConsultant = CreateConsultantFromModel(basicConsultant, selectedDepartment); - await AddConsultantToDatabaseAsync(_context, newConsultant); - ClearConsultantCache(selectedDepartment.Organization.UrlKey); - - return TypedResults.Created($"/consultant/{newConsultant.Id}", basicConsultant); - } - catch - { - // Adding exception handling later - return TypedResults.Problem("An error occurred while processing the request", statusCode: 500); - } - } - - private List GetConsultantsWithAvailability(string orgUrlKey, int numberOfWeeks) + private List GetConsultantsWithAvailability(string orgUrlKey, Week initialWeekNumber, + int numberOfWeeks) { if (numberOfWeeks == 8) { _cache.TryGetValue( - $"{orgUrlKey}/{CacheKeys.ConsultantAvailability8Weeks}", + $"{orgUrlKey}/{initialWeekNumber}/{CacheKeys.ConsultantAvailability8Weeks}", out List? cachedConsultants); if (cachedConsultants != null) return cachedConsultants; } - var consultants = LoadConsultantAvailability(orgUrlKey, numberOfWeeks) - .Select(c => c.MapConsultantToReadModel(numberOfWeeks)).ToList(); + var consultants = LoadConsultantAvailability(orgUrlKey, initialWeekNumber, numberOfWeeks) + .Select(c => c.MapConsultantToReadModel(initialWeekNumber, numberOfWeeks)).ToList(); - _cache.Set($"{orgUrlKey}/{CacheKeys.ConsultantAvailability8Weeks}", consultants); + _cache.Set($"{orgUrlKey}/{initialWeekNumber}/{CacheKeys.ConsultantAvailability8Weeks}", consultants); return consultants; } - private List LoadConsultantAvailability(string orgUrlKey, int numberOfWeeks) + private List LoadConsultantAvailability(string orgUrlKey, Week selectedWeek, int numberOfWeeks) { - var applicableWeeks = DateService.GetNextWeeks(numberOfWeeks); + var applicableWeeks = DateService.GetNextWeeks(selectedWeek, numberOfWeeks); var firstDayOfCurrentWeek = DateService.GetFirstDayOfWeekContainingDate(DateTime.Now); var firstWorkDayOutOfScope = DateService.GetFirstDayOfWeekContainingDate(DateTime.Now.AddDays(numberOfWeeks * 7)); @@ -96,17 +74,17 @@ private List LoadConsultantAvailability(string orgUrlKey, int number // From november, we will span two years. // Given a 5-week span, the set of weeks can look like this: (2022) 51, 52, 53, 1, 2 (2023) // Then we can filter as follows: Either the staffing has year 2022 and a week between 51 and 53, or year 2023 with weeks 1 and 2. - var minWeekNum = applicableWeeks.Select(w => w.week).Min(); + var minWeekNum = applicableWeeks.Select(w => w.WeekNumber).Min(); // Set A will be either the weeks in the next year (2023 in the above example), or have all the weeks in a mid-year case - var yearA = applicableWeeks.Select(w => w.year).Max(); - var weeksInA = applicableWeeks.Select(w => w.week).Where(w => w < minWeekNum + numberOfWeeks).ToList(); + var yearA = applicableWeeks.Select(w => w.Year).Max(); + var weeksInA = applicableWeeks.Select(w => w.WeekNumber).Where(w => w < minWeekNum + numberOfWeeks).ToList(); var minWeekA = weeksInA.Min(); var maxWeekA = weeksInA.Max(); // Set B will be either the weeks in the current year (2022 in the above example), or and empty set in a mid-year case. - var yearB = applicableWeeks.Select(w => w.year).Min(); - var weeksInB = applicableWeeks.Select(w => w.week).Where(w => w < minWeekNum + numberOfWeeks).ToList(); + var yearB = applicableWeeks.Select(w => w.Year).Min(); + var weeksInB = applicableWeeks.Select(w => w.WeekNumber).Where(w => w < minWeekNum + numberOfWeeks).ToList(); var minWeekB = weeksInB.Min(); var maxWeekB = weeksInB.Max(); @@ -119,52 +97,16 @@ private List LoadConsultantAvailability(string orgUrlKey, int number .Include(c => c.PlannedAbsences.Where(pa => (pa.Year <= yearA && minWeekA <= pa.WeekNumber && pa.WeekNumber <= maxWeekA) || (yearB <= pa.Year && minWeekB <= pa.WeekNumber && pa.WeekNumber <= maxWeekB))) - .ThenInclude(pa => pa.Absence) + .ThenInclude(pa => pa.Absence) .Include(c => c.Department) - .ThenInclude(d => d.Organization) + .ThenInclude(d => d.Organization) .Where(c => c.Department.Organization.UrlKey == orgUrlKey) .Include(c => c.Staffings.Where(s => (s.Year <= yearA && minWeekA <= s.Week && s.Week <= maxWeekA) || (yearB <= s.Year && minWeekB <= s.Week && s.Week <= maxWeekB))) - .ThenInclude(s => s.Project) - .ThenInclude(p => p.Customer) + .ThenInclude(s => s.Project) + .ThenInclude(p => p.Customer) .OrderBy(c => c.Name) .ToList(); } - - - private async Task GetDepartmentByIdAsync(string departmentId) - { - return await _context.Department.SingleOrDefaultAsync(d => d.Id == departmentId); - } - - private static async Task> GetAllConsultantsAsync(ApplicationContext db) - { - return await db.Consultant.ToListAsync(); - } - - private static Consultant CreateConsultantFromModel(ConsultantWriteModel basicConsultant, - Department selectedDepartment) - { - return new Consultant - { - Name = basicConsultant.Name, - Email = basicConsultant.Email, - Department = selectedDepartment - }; - } - - private static async Task AddConsultantToDatabaseAsync(ApplicationContext db, Consultant newConsultant) - { - await db.Consultant.AddAsync(newConsultant); - await db.SaveChangesAsync(); - } - - private void ClearConsultantCache(string orgUrlKey) - { - _cache.Remove($"{orgUrlKey}/{CacheKeys.ConsultantAvailability8Weeks}"); - } - - - public record ConsultantWriteModel(string Name, string Email, string DepartmentId); } \ No newline at end of file diff --git a/backend/Api/Consultants/ConsultantExtensions.cs b/backend/Api/Consultants/ConsultantExtensions.cs index 53716849..61777a72 100644 --- a/backend/Api/Consultants/ConsultantExtensions.cs +++ b/backend/Api/Consultants/ConsultantExtensions.cs @@ -6,14 +6,14 @@ namespace Api.Consultants; public static class ConsultantExtensions { - public static ConsultantReadModel MapConsultantToReadModel(this Consultant consultant, int weeks) + public static ConsultantReadModel MapConsultantToReadModel(this Consultant consultant, Week firstWeek, int weeks) { const double tolerance = 0.1; var currentYear = DateTime.Now.Year; var yearsOfExperience = currentYear - (consultant.GraduationYear is null or 0 ? currentYear : consultant.GraduationYear) ?? 0; - var bookedHours = GetBookedHoursForWeeks(consultant, weeks); + var bookedHours = GetBookedHoursForWeeks(consultant, firstWeek, weeks); var isOccupied = bookedHours.All(b => b.BookingModel.TotalSellableTime <= 0 + tolerance); @@ -77,23 +77,20 @@ public static WeeklyBookingReadModel GetBookingModelForWeek(this Consultant cons bookingList); } - private static List GetBookedHoursForWeeks(this Consultant consultant, int weeksAhead) + private static List GetBookedHoursForWeeks(this Consultant consultant, Week firstWeek, + int weeksAhead) { - return Enumerable.Range(0, weeksAhead) - .Select(offset => - { - var year = DateTime.Today.AddDays(7 * offset).Year; - var week = DateService.GetWeekAhead(offset); - var datestring = DateService.GetDatesInWorkWeek(year, week)[0].ToString("dd.MM") + "-" + DateService - .GetDatesInWorkWeek(year, week)[^1].ToString("dd.MM"); - - return new BookedHoursPerWeek( - year, - week, - datestring, - GetBookingModelForWeek(consultant, year, week) - ); - }) + var nextWeeks = DateService.GetNextWeeks(firstWeek, weeksAhead); + var datestring = DateService.GetDatesInWorkWeek(firstWeek.Year, firstWeek.WeekNumber)[0].ToString("dd.MM") + + "-" + DateService + .GetDatesInWorkWeek(firstWeek.Year, firstWeek.WeekNumber)[^1].ToString("dd.MM"); + + return nextWeeks + .Select(week => new BookedHoursPerWeek( + week.Year, + week.WeekNumber, + datestring, + GetBookingModelForWeek(consultant, week.Year, week.WeekNumber))) .ToList(); } } \ No newline at end of file diff --git a/backend/Api/Consultants/ConsultantValidators.cs b/backend/Api/Consultants/ConsultantValidators.cs deleted file mode 100644 index ff9d946b..00000000 --- a/backend/Api/Consultants/ConsultantValidators.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Net.Mail; -using Core.DomainModels; - -namespace Api.Consultants; - -public static class ConsultantValidators -{ - public static bool IsValidEmail(string email) - { - return MailAddress.TryCreate(email, out _); - } - - public static IDictionary ValidateUniqueness(List consultantList, - ConsultantController.ConsultantWriteModel body) - { - var results = new Dictionary(); - - var nameExits = consultantList.Any(item => item.Name == body.Name); - var emailExists = consultantList.Any(item => item.Email == body.Email); - var validEmail = IsValidEmail(body.Email); - if (nameExits) - results.Add("name", new[] { "already exist" }); - if (emailExists) - results.Add("email", new[] { "already exist" }); - if (!validEmail) - results.Add("email", new[] { "has a invalid email format" }); - - return results; - } -} \ No newline at end of file diff --git a/backend/Core/DomainModels/Week.cs b/backend/Core/DomainModels/Week.cs new file mode 100644 index 00000000..91cf1e99 --- /dev/null +++ b/backend/Core/DomainModels/Week.cs @@ -0,0 +1,3 @@ +namespace Core.DomainModels; + +public record Week(int Year, int WeekNumber); \ No newline at end of file diff --git a/backend/Core/Services/DateService.cs b/backend/Core/Services/DateService.cs index 9a907521..3952752e 100644 --- a/backend/Core/Services/DateService.cs +++ b/backend/Core/Services/DateService.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Core.DomainModels; namespace Core.Services; @@ -34,15 +35,16 @@ public static bool DateIsInWeek(DateOnly day, int year, int week) return day.Year == year && GetWeekNumber(day.ToDateTime(TimeOnly.MinValue)) == week; } - public static List<(int year, int week)> GetNextWeeks(int weeksAhead) + public static List GetNextWeeks(Week firstWeek, int weeksAhead) { + var a = FirstWorkDayOfWeek(firstWeek.Year, firstWeek.WeekNumber); return Enumerable.Range(0, weeksAhead) .Select(offset => { - var year = DateTime.Today.AddDays(7 * offset).Year; - var week = GetWeekAhead(offset); + var year = a.AddDays(7 * offset).Year; + var week = GetWeekNumber(a.AddDays(7 * offset)); - return (year, week); + return new Week(year, week); }).ToList(); } diff --git a/frontend/package.json b/frontend/package.json index e864ea21..a7e34b14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "autoprefixer": "^10.4.16", "eslint": "8.48.0", "eslint-config-next": "^13.5.2", + "luxon": "^3.4.3", "next": "^13.5.2", "next-auth": "^4.23.2", "nextjs-toploader": "^1.5.3", @@ -30,6 +31,7 @@ }, "devDependencies": { "@playwright/test": "^1.38.1", + "@types/luxon": "^3.3.3", "eslint-config-prettier": "^9.0.0", "prettier": "3.0.3" } diff --git a/frontend/src/app/[organisation]/bemanning/page.tsx b/frontend/src/app/[organisation]/bemanning/page.tsx index 200d0664..44bc8d66 100644 --- a/frontend/src/app/[organisation]/bemanning/page.tsx +++ b/frontend/src/app/[organisation]/bemanning/page.tsx @@ -3,15 +3,24 @@ import FilteredConsultantsList from "@/components/FilteredConsultantsList"; import { fetchWithToken } from "@/data/fetchWithToken"; import { Consultant, Department } from "@/types"; import { ConsultantFilterProvider } from "@/components/FilteredConsultantProvider"; +import { stringToWeek } from "@/data/urlUtils"; export default async function Bemanning({ params, + searchParams, }: { params: { organisation: string }; + searchParams: { selectedWeek?: string }; }) { + const selectedWeek = stringToWeek(searchParams.selectedWeek || undefined); + const consultants = (await fetchWithToken( - `${params.organisation}/consultants`, + `${params.organisation}/consultants${ + selectedWeek + ? `?Year=${selectedWeek.year}&Week=${selectedWeek.weekNumber}` + : "" + }`, )) ?? []; const departments = diff --git a/frontend/src/components/FilteredConsultantsList.tsx b/frontend/src/components/FilteredConsultantsList.tsx index 2a66c17f..53662c7c 100644 --- a/frontend/src/components/FilteredConsultantsList.tsx +++ b/frontend/src/components/FilteredConsultantsList.tsx @@ -2,6 +2,7 @@ import ConsultantRows from "./ConsultantRows"; import ActiveFilters from "./ActiveFilters"; import { useFilteredConsultants } from "@/hooks/useFilteredConsultants"; +import WeekSelection from "@/components/WeekSelection"; export default function FilteredConsultantList() { const { filteredConsultants: consultants } = useFilteredConsultants(); @@ -10,6 +11,7 @@ export default function FilteredConsultantList() {
+
diff --git a/frontend/src/components/WeekSelection.tsx b/frontend/src/components/WeekSelection.tsx new file mode 100644 index 00000000..f4f74cd1 --- /dev/null +++ b/frontend/src/components/WeekSelection.tsx @@ -0,0 +1,16 @@ +"use client"; +import { useFilteredConsultants } from "@/hooks/useFilteredConsultants"; +import SecondaryButton from "@/components/SecondaryButton"; + +export default function WeekSelection() { + const { incrementSelectedWeek, resetSelectedWeek, decrementSelectedWeek } = + useFilteredConsultants(); + + return ( +
+ + + "} onClick={incrementSelectedWeek} /> +
+ ); +} diff --git a/frontend/src/data/urlUtils.tsx b/frontend/src/data/urlUtils.tsx new file mode 100644 index 00000000..fdff3764 --- /dev/null +++ b/frontend/src/data/urlUtils.tsx @@ -0,0 +1,22 @@ +import { Week } from "@/types"; + +export function stringToWeek(urlString?: string): Week | undefined { + if (!urlString) return; + try { + const args = urlString.split("-"); + const year = Number.parseInt(args[0]); + const week = Number.parseInt(args[1]); + if (year && week) { + return { + year: year, + weekNumber: week, + }; + } + } catch { + return; + } +} + +export function weekToString(week: Week) { + return `${week.year}-${week.weekNumber}`; +} diff --git a/frontend/src/hooks/useFilteredConsultants.ts b/frontend/src/hooks/useFilteredConsultants.ts index e2037f71..da496cd1 100644 --- a/frontend/src/hooks/useFilteredConsultants.ts +++ b/frontend/src/hooks/useFilteredConsultants.ts @@ -1,15 +1,18 @@ "use client"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { Consultant, Department, YearRange } from "@/types"; +import { Consultant, Department, Week, YearRange } from "@/types"; import { useCallback, useContext, useEffect, useState } from "react"; import { FilteredContext } from "@/components/FilteredConsultantProvider"; import { yearRanges } from "@/components/ExperienceFilter"; +import { DateTime } from "luxon"; +import { stringToWeek, weekToString } from "@/data/urlUtils"; interface UpdateFilterParams { search?: string; departments?: string; years?: string; + week?: Week; } export function useFilteredConsultants() { @@ -21,6 +24,9 @@ export function useFilteredConsultants() { const searchFilter = searchParams.get("search") || ""; const departmentFilter = searchParams.get("depFilter") || ""; const yearFilter = searchParams.get("yearFilter") || ""; + const selectedWeek = stringToWeek( + searchParams.get("selectedWeek") || undefined, + ); const [activeNameSearch, setActiveNameSearch] = useState(searchFilter); @@ -33,14 +39,58 @@ export function useFilteredConsultants() { const { search = searchFilter } = updateParams; const { departments = departmentFilter } = updateParams; const { years = yearFilter } = updateParams; + const { week = selectedWeek } = updateParams; router.push( - `${pathname}?search=${search}&depFilter=${departments}&yearFilter=${years}`, + `${pathname}?search=${search}&depFilter=${departments}&yearFilter=${years}${ + week ? `&selectedWeek=${weekToString(week)}` : "" + }`, ); }, - [departmentFilter, pathname, router, searchFilter, yearFilter], + [ + departmentFilter, + pathname, + router, + searchFilter, + selectedWeek, + yearFilter, + ], ); + function incrementSelectedWeek() { + let date = selectedWeek + ? DateTime.now().set({ + weekYear: selectedWeek.year, + weekNumber: selectedWeek.weekNumber, + }) + : DateTime.now(); + + let newDate = date.plus({ week: 1 }); + updateRoute({ + week: { year: newDate.year, weekNumber: newDate.weekNumber }, + }); + } + + function decrementSelectedWeek() { + let date = selectedWeek + ? DateTime.now().set({ + weekYear: selectedWeek.year, + weekNumber: selectedWeek.weekNumber, + }) + : DateTime.now(); + + let newDate = date.plus({ week: -1 }); + updateRoute({ + week: { year: newDate.year, weekNumber: newDate.weekNumber }, + }); + } + + function resetSelectedWeek() { + router.push( + `${pathname}?search=${searchFilter}&depFilter=${departmentFilter}&yearFilter=${yearFilter}`, + ); + } + useEffect(() => { let nameSearchDebounceTimer = setTimeout(() => { if ( @@ -136,6 +186,10 @@ export function useFilteredConsultants() { setNameSearch, toggleDepartmentFilter, toggleYearFilter, + selectedWeek, + incrementSelectedWeek, + decrementSelectedWeek, + resetSelectedWeek, }; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ab3ce064..6068e30d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -54,3 +54,8 @@ export type YearRange = { start: number; end?: number; }; + +export type Week = { + year: number; + weekNumber: number; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 9a26e287..e6002879 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -216,6 +216,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/luxon@^3.3.3": + version "3.3.3" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.3.3.tgz#b2e20a9536f91ab3e6e7895c91883e1a7ad49a6e" + integrity sha512-/BJF3NT0pRMuxrenr42emRUF67sXwcZCd+S1ksG/Fcf9O7C3kKCY4uJSbKBE4KDUIYr3WMsvfmWD8hRjXExBJQ== + "@types/node@20.5.9": version "20.5.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.9.tgz#a70ec9d8fa0180a314c3ede0e20ea56ff71aed9a" @@ -1696,6 +1701,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +luxon@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.3.tgz#8ddf0358a9492267ffec6a13675fbaab5551315d" + integrity sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg== + match-sorter@^6.0.2: version "6.3.1" resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda"