diff --git a/frontend/mockdata/mockData.ts b/frontend/mockdata/mockData.ts index 7552d76b..2dd461dc 100644 --- a/frontend/mockdata/mockData.ts +++ b/frontend/mockdata/mockData.ts @@ -1,13 +1,16 @@ import { + BookedHoursPerWeek, + BookingType, CompetenceReadModel, ConsultantReadModel, Degree, DepartmentReadModel, + DetailedBooking, EngagementPerCustomerReadModel, - Forecast, OrganisationReadModel, WeeklyBookingReadModel, } from "@/api-types"; +import { Forecast } from "@/types"; const MockWeeklyBookingReadModel: WeeklyBookingReadModel = { totalHolidayHours: 0, @@ -69,6 +72,369 @@ const mockForecast6: Forecast = { valueAddedManually: 10, }; +export const MockDepartments: DepartmentReadModel[] = [ + { + id: "myDepartment", + name: "My Department", + }, +]; + +export const MockOrganisations: OrganisationReadModel[] = [ + { + name: "My Organisation", + urlKey: "my-org", + }, +]; + +export const MockEngagements: EngagementPerCustomerReadModel[] = [ + { + customerId: 1, + customerName: "TestCustomer", + engagements: [], + isActive: false, + }, +]; + +export const MockCompetences: CompetenceReadModel[] = [ + { + id: "development", + name: "Utvikling", + }, +]; +const mockDetailedBookings: DetailedBooking[] = [ + { + bookingDetails: { + projectName: "Elbil", + type: BookingType.Booking, + customerName: "Bil", + projectId: 1001, + isBillable: true, + endDateAgreement: "2025-05-08T00:00:00", + }, + hours: [ + { week: 202502, hours: 37.5 }, + { week: 202503, hours: 37.5 }, + { week: 202504, hours: 37.5 }, + { week: 202505, hours: 37.5 }, + { week: 202506, hours: 30 }, + { week: 202507, hours: 22.5 }, + { week: 202508, hours: 37.5 }, + { week: 202509, hours: 37.5 }, + { week: 202510, hours: 37.5 }, + { week: 202511, hours: 30 }, + { week: 202512, hours: 30 }, + { week: 202513, hours: 30 }, + { week: 202514, hours: 37.5 }, + { week: 202515, hours: 37.5 }, + { week: 202516, hours: 22.5 }, + { week: 202517, hours: 30 }, + { week: 202518, hours: 30 }, + ], + }, + { + bookingDetails: { + projectName: "Nye nettsider", + type: BookingType.Offer, + customerName: "Åkerblå", + projectId: 208, + isBillable: false, + endDateAgreement: "2024-11-29T00:00:00", + }, + hours: [ + { week: 202502, hours: 0 }, + { week: 202503, hours: 0 }, + { week: 202504, hours: 0 }, + { week: 202505, hours: 0 }, + { week: 202506, hours: 0 }, + { week: 202507, hours: 0 }, + { week: 202508, hours: 0 }, + { week: 202509, hours: 0 }, + { week: 202510, hours: 0 }, + { week: 202511, hours: 7.5 }, + { week: 202512, hours: 7.5 }, + { week: 202513, hours: 7.5 }, + { week: 202514, hours: 0 }, + { week: 202515, hours: 0 }, + { week: 202516, hours: 0 }, + { week: 202517, hours: 0 }, + { week: 202518, hours: 0 }, + ], + }, +]; +const mockBookings: BookedHoursPerWeek[] = [ + { + year: 2025, + weekNumber: 2, + sortableWeek: 202502, + dateString: "06.01 - 10.01", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 3, + sortableWeek: 202503, + dateString: "13.01 - 17.01", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 4, + sortableWeek: 202504, + dateString: "20.01 - 24.01", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 5, + sortableWeek: 202505, + dateString: "27.01 - 31.01", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 6, + sortableWeek: 202506, + dateString: "03.02 - 07.02", + bookingModel: { + totalBillable: 30, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 7.5, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 7, + sortableWeek: 202507, + dateString: "10.02 - 14.02", + bookingModel: { + totalBillable: 22.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 15, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 8, + sortableWeek: 202508, + dateString: "17.02 - 21.02", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 9, + sortableWeek: 202509, + dateString: "24.02 - 28.02", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 10, + sortableWeek: 202510, + dateString: "03.03 - 07.03", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 11, + sortableWeek: 202511, + dateString: "10.03 - 14.03", + bookingModel: { + totalBillable: 30, + totalOffered: 7.5, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 7.5, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 12, + sortableWeek: 202512, + dateString: "17.03 - 21.03", + bookingModel: { + totalBillable: 30, + totalOffered: 7.5, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 7.5, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 13, + sortableWeek: 202513, + dateString: "24.03 - 28.03", + bookingModel: { + totalBillable: 30, + totalOffered: 7.5, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 7.5, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 14, + sortableWeek: 202514, + dateString: "31.03 - 04.04", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 15, + sortableWeek: 202515, + dateString: "07.04 - 11.04", + bookingModel: { + totalBillable: 37.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 16, + sortableWeek: 202516, + dateString: "14.04 - 18.04", + bookingModel: { + totalBillable: 22.5, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 15, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 17, + sortableWeek: 202517, + dateString: "21.04 - 25.04", + bookingModel: { + totalBillable: 30, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 0, + totalHolidayHours: 7.5, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, +]; export const MockConsultants: ConsultantReadModel[] = [ { id: 1, @@ -101,32 +467,55 @@ export const MockConsultants: ConsultantReadModel[] = [ }, ]; -export const MockDepartments: DepartmentReadModel[] = [ - { - id: "myDepartment", - name: "My Department", - }, -]; - -export const MockOrganisations: OrganisationReadModel[] = [ - { - name: "My Organisation", - urlKey: "my-org", - }, -]; - -export const MockEngagements: EngagementPerCustomerReadModel[] = [ +export const MockConsultantsForForecast: ConsultantReadModel[] = [ { - customerId: 1, - customerName: "TestCustomer", - engagements: [], - isActive: false, + id: 1, + name: "Test Consultant", + email: "test@company.io", + competences: [{ id: "development", name: "Utvikling" }], + department: { id: "mydepartment", name: "My Department" }, + bookings: [ + { + year: 2023, + weekNumber: 10, + dateString: "", + bookingModel: MockWeeklyBookingReadModel, + sortableWeek: 202310, + }, + ], + yearsOfExperience: 23, + detailedBooking: [], + isOccupied: true, + graduationYear: 2010, + degree: Degree.Bachelor, + forecasts: [ + mockForecast1, + mockForecast2, + mockForecast3, + mockForecast4, + mockForecast5, + mockForecast6, + ], }, -]; - -export const MockCompetences: CompetenceReadModel[] = [ { - id: "development", - name: "Utvikling", + id: 2, + name: "2test Consultant", + email: "test2@company.io", + competences: [{ id: "development", name: "Utvikling" }], + department: { id: "mydepartment", name: "My Department" }, + bookings: mockBookings, + yearsOfExperience: 23, + detailedBooking: mockDetailedBookings, + isOccupied: true, + graduationYear: 2010, + degree: Degree.Bachelor, + forecasts: [ + mockForecast1, + mockForecast2, + mockForecast3, + mockForecast4, + mockForecast5, + mockForecast6, + ], }, ]; diff --git a/frontend/src/api-types.ts b/frontend/src/api-types.ts index d4bfa062..747dc4b6 100644 --- a/frontend/src/api-types.ts +++ b/frontend/src/api-types.ts @@ -9,6 +9,8 @@ * --------------------------------------------------------------- */ +import { Forecast } from "./types"; + export interface BookedHoursPerWeek { /** @format int32 */ year: number; @@ -20,7 +22,6 @@ export interface BookedHoursPerWeek { dateString: string; bookingModel: WeeklyBookingReadModel; } - export interface BookingDetails { /** @minLength 1 */ projectName: string; @@ -66,15 +67,6 @@ export interface ConsultantReadModel { forecasts?: Forecast[]; } -export interface Forecast { - id: number; - month: number; - year: number; - forecastValue: number; - hasBeenChanged: boolean; - valueAddedManually: number; -} - export interface ConsultantWriteModel { /** @minLength 1 */ name: string; diff --git a/frontend/src/components/Forecast/ForecastRows.tsx b/frontend/src/components/Forecast/ForecastRows.tsx index 177321ff..c8e8b09c 100644 --- a/frontend/src/components/Forecast/ForecastRows.tsx +++ b/frontend/src/components/Forecast/ForecastRows.tsx @@ -1,9 +1,8 @@ "use client"; import { ConsultantReadModel, ProjectWithCustomerModel } from "@/api-types"; import React, { useContext, useEffect, useState } from "react"; -import { AlertCircle, CheckCircle, ChevronDown, Plus } from "react-feather"; +import { Plus } from "react-feather"; import { DetailedBookingRows } from "@/components/Staffing/DetailedBookingRows"; -import { WeekCell } from "@/components/Staffing/WeekCell"; import { useModal } from "@/hooks/useModal"; import { usePathname } from "next/navigation"; import { @@ -15,8 +14,11 @@ import { setDetailedBookingHours } from "@/components/Staffing/DetailedBookingRo import { FilteredContext } from "@/hooks/ConsultantFilterProvider"; import { DateTime } from "luxon"; import Image from "next/image"; -import { INTERNAL_CUSTOMER_NAME } from "../Staffing/helpers/utils"; import { MonthCell } from "./MonthCell"; +import { + bookingForMonth, + transformToMonthlyData, +} from "./TransformWeekDataToMonth"; export default function ForecastRows({ consultant, @@ -68,6 +70,7 @@ export default function ForecastRows({ } = useModal({ closeOnBackdropClick: false, }); + const bookingsPerMonth = transformToMonthlyData(consultant.bookings); const [selectedProjectId, setSelectedProjectId] = useState< number | undefined @@ -211,6 +214,11 @@ export default function ForecastRows({ {currentConsultant.forecasts?.map((b, index) => ( - {MockConsultants.map((consultant) => ( + {MockConsultantsForForecast.map((consultant) => ( + monthHours.month % 100 == hoveredRowMonth && monthHours.hours != 0, + ).length == 0 + ); +} +export function HoveredMonth(props: { + hoveredRowMonth: number; + consultant: ConsultantReadModel; + isLastCol: boolean; + isSecondLastCol: boolean; + columnCount: number; +}) { + const { + hoveredRowMonth: hoveredRowMonth, + consultant, + isLastCol, + isSecondLastCol, + columnCount, + } = props; + + const bookings = transformToMonthlyData(consultant.bookings); + const detailedBookings = transformDetailedBookingToMonthlyData( + consultant.detailedBooking, + ); + const nonZeroHoursDetailedBookings = detailedBookings.filter( + (d) => !isMonthBookingZeroHours(d, hoveredRowMonth), + ); + + const freeTime = bookings.find((b) => b.month == hoveredRowMonth) + ?.bookingModel.totalSellableTime; + if (freeTime && freeTime > 0) { + nonZeroHoursDetailedBookings.push({ + bookingDetails: { + type: BookingType.Available, + projectName: "", + customerName: "Ledig Tid", + projectId: 0, + isBillable: false, + }, + hours: [ + { + month: hoveredRowMonth, + hours: + bookings.find((b) => b.month == hoveredRowMonth)?.bookingModel + .totalSellableTime || 0, + }, + ], + }); + } + + return ( + <> +
= 26) + ? "right-0 " + : "left-1/2 -translate-x-1/2" + } ${nonZeroHoursDetailedBookings.length == 0 && "hidden"}`} + > + {nonZeroHoursDetailedBookings.map((detailedBooking, index) => ( +
+
+
+ {getIconByBookingType(detailedBooking.bookingDetails.type, 16)} +
+
+

+ {detailedBooking.bookingDetails.projectName} +

+

+ {detailedBooking.bookingDetails.customerName} +

+
+
+

+ { + detailedBooking.hours.find( + (hour: MonthlyHours) => hour.month % 100 == hoveredRowMonth, + )?.hours + } +

+
+ ))} +
+
+ + ); +} diff --git a/frontend/src/components/Forecast/MonthCell.tsx b/frontend/src/components/Forecast/MonthCell.tsx index 4b87de03..aaf6fab0 100644 --- a/frontend/src/components/Forecast/MonthCell.tsx +++ b/frontend/src/components/Forecast/MonthCell.tsx @@ -1,5 +1,5 @@ -import { BookedHoursPerWeek, ConsultantReadModel } from "@/api-types"; -import { HoveredWeek } from "@/components/Staffing/HoveredWeek"; +import { ConsultantReadModel } from "@/api-types"; +import { BookedHoursPerMonth } from "@/types"; import InfoPill from "@/components/Staffing/InfoPill"; import { AlertTriangle, @@ -12,8 +12,10 @@ import { import { getInfopillVariantByColumnCount } from "@/components/Staffing/helpers/utils"; import React from "react"; import { has } from "lodash"; +import { HoveredMonth } from "./HoveredMonth"; export function MonthCell(props: { + bookedHoursPerMonth?: BookedHoursPerMonth; forecastValue: number; hasBeenEdited: boolean; consultant: ConsultantReadModel; @@ -26,6 +28,7 @@ export function MonthCell(props: { numWorkHours: number; }) { const { + bookedHoursPerMonth: bookedHoursPerMonth, forecastValue, consultant, hasBeenEdited, @@ -37,24 +40,25 @@ export function MonthCell(props: { isSecondLastCol, numWorkHours, } = props; + let pillNumber = 0; - /* let pillNumber = 0; - - if (bookedHoursPerWeek.bookingModel.totalOffered > 0) { - pillNumber++; - } - if (bookedHoursPerWeek.bookingModel.totalOverbooking > 0) { - pillNumber++; - } - if (bookedHoursPerWeek.bookingModel.totalPlannedAbsences > 0) { - pillNumber++; - } - if (bookedHoursPerWeek.bookingModel.totalVacationHours > 0) { - pillNumber++; + if (bookedHoursPerMonth) { + if (bookedHoursPerMonth.bookingModel.totalOffered > 0) { + pillNumber++; + } + if (bookedHoursPerMonth.bookingModel.totalOverbooking > 0) { + pillNumber++; + } + if (bookedHoursPerMonth.bookingModel.totalPlannedAbsences > 0) { + pillNumber++; + } + if (bookedHoursPerMonth.bookingModel.totalVacationHours > 0) { + pillNumber++; + } + if (bookedHoursPerMonth.bookingModel.totalSellableTime > 0) { + pillNumber++; + } } - if (bookedHoursPerWeek.bookingModel.totalSellableTime > 0) { - pillNumber++; - } */ const uneditable = forecastValue === 100; return ( setHoveredRowWeek(-1)} > {hoveredRowWeek != -1 && hoveredRowWeek == month && ( - )} -
- {/* {bookedHoursPerWeek.bookingModel.totalOffered > 0 && ( - } - variant={getInfopillVariantByColumnCount(columnCount)} - /> - )} - {bookedHoursPerWeek.bookingModel.totalSellableTime > 0 && - getInfopillVariantByColumnCount(columnCount) !== "narrow" && ( + {bookedHoursPerMonth && ( +
+ {bookedHoursPerMonth.bookingModel.totalOffered > 0 && ( } + colors="bg-offer text-primary_darker border-primary_darker" + icon={} variant={getInfopillVariantByColumnCount(columnCount)} /> )} - {bookedHoursPerWeek.bookingModel.totalVacationHours > 0 && ( - 0 && + getInfopillVariantByColumnCount(columnCount) !== "narrow" && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> )} - colors="bg-vacation text-vacation_darker border-vacation_darker" - icon={} - variant={getInfopillVariantByColumnCount(columnCount)} - /> - )} - {bookedHoursPerWeek.bookingModel.totalPlannedAbsences > 0 && - getInfopillVariantByColumnCount(columnCount) !== "narrow" && ( + {bookedHoursPerMonth.bookingModel.totalVacationHours > 0 && ( } + colors="bg-vacation text-vacation_darker border-vacation_darker" + icon={} variant={getInfopillVariantByColumnCount(columnCount)} /> )} - {bookedHoursPerWeek.bookingModel.totalOverbooking > 0 && ( - 0 && + getInfopillVariantByColumnCount(columnCount) !== "narrow" && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> )} - colors="bg-overbooked_darker text-white border-white" - icon={} - variant={getInfopillVariantByColumnCount(columnCount)} - /> - )} - {bookedHoursPerWeek.bookingModel.totalNotStartedOrQuit > 0 && ( - } - variant={getInfopillVariantByColumnCount(columnCount)} - /> - )} */} -
+ {bookedHoursPerMonth.bookingModel.totalOverbooking > 0 && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} + {bookedHoursPerMonth.bookingModel.totalNotStartedOrQuit > 0 && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} +
+ )}

- {/* {checkIfNotStartedOrQuit(consultant, bookedHoursPerWeek, numWorkHours) + {/* {checkIfNotStartedOrQuit(consultant, bookedHoursPerMonth, numWorkHours) ? "-" - : bookedHoursPerWeek.bookingModel.totalBillable.toLocaleString( + : bookedHoursPerMonth.bookingModel.totalBillable.toLocaleString( "nb-No", )} */} {`${forecastValue}%`} @@ -182,14 +188,14 @@ export function MonthCell(props: { function checkIfNotStartedOrQuit( consultant: ConsultantReadModel, - bookedHoursPerWeek: BookedHoursPerWeek, + bookedHoursPerMonth: BookedHoursPerMonth, numWorkHours: number, ) { const notStartedOrQuitHours = - bookedHoursPerWeek.bookingModel.totalNotStartedOrQuit; + bookedHoursPerMonth.bookingModel.totalNotStartedOrQuit; return ( notStartedOrQuitHours == - numWorkHours - bookedHoursPerWeek.bookingModel.totalHolidayHours + numWorkHours - bookedHoursPerMonth.bookingModel.totalHolidayHours ); } diff --git a/frontend/src/components/Forecast/TransformWeekDataToMonth.ts b/frontend/src/components/Forecast/TransformWeekDataToMonth.ts new file mode 100644 index 00000000..ae92b7da --- /dev/null +++ b/frontend/src/components/Forecast/TransformWeekDataToMonth.ts @@ -0,0 +1,159 @@ +import { + BookedHoursPerWeek, + BookingDetails, + DetailedBooking, + WeeklyBookingReadModel, +} from "@/api-types"; +import { getMonthOfWeek, weekToWeekType } from "./WeekToMonthConverter"; +import { + BookedHoursPerMonth, + MonthlyDetailedBooking, + MonthlyHours, +} from "@/types"; + +function round2Decimals(num: number) { + return Math.round(num * 2) / 2; +} +function transformToMonthlyData(weeklyData: BookedHoursPerWeek[]) { + const monthlyData: { [key: string]: BookedHoursPerMonth } = {}; + + weeklyData.forEach((week) => { + const { year, weekNumber, bookingModel } = week; + const monthDistribution = getMonthOfWeek({ week: weekNumber, year: year }); + const primaryMonthKey = `${monthDistribution.year}-${String( + monthDistribution.month, + ).padStart(2, "0")}`; + const secondaryMonthKey = + monthDistribution.secondMonth !== undefined + ? `${monthDistribution.year}-${String( + monthDistribution.secondMonth, + ).padStart(2, "0")}` + : null; + + const primaryDistribution = monthDistribution.distribution / 100; + const secondaryDistribution = secondaryMonthKey + ? (100 - monthDistribution.distribution) / 100 + : 0; + + function distributeBookingModel( + distribution: number, + bookingModel: WeeklyBookingReadModel, + ): WeeklyBookingReadModel { + const distributedModel: WeeklyBookingReadModel = { ...bookingModel }; + for (const key of Object.keys( + bookingModel, + ) as (keyof WeeklyBookingReadModel)[]) { + distributedModel[key] = round2Decimals( + bookingModel[key] * distribution, + ); + } + return distributedModel; + } + + const primaryBookingModel: WeeklyBookingReadModel = distributeBookingModel( + primaryDistribution, + bookingModel, + ); + const secondaryBookingModel: WeeklyBookingReadModel | null = + secondaryMonthKey + ? distributeBookingModel(secondaryDistribution, bookingModel) + : null; + + function addToMonthlyData(monthKey: string, model: WeeklyBookingReadModel) { + if (!monthlyData[monthKey]) { + monthlyData[monthKey] = { + year: year, + month: parseInt(monthKey.split("-")[1]), + bookingModel: Object.keys(model).reduce((acc, key) => { + acc[key as keyof WeeklyBookingReadModel] = 0; + return acc; + }, {} as WeeklyBookingReadModel), + }; + } + for (const key of Object.keys( + model, + ) as (keyof WeeklyBookingReadModel)[]) { + monthlyData[monthKey].bookingModel[key] += model[key]; + } + } + + addToMonthlyData(primaryMonthKey, primaryBookingModel); + if (secondaryMonthKey) { + addToMonthlyData(secondaryMonthKey, secondaryBookingModel!); + } + }); + return Object.values(monthlyData); +} + +function bookingForMonth( + bookings: BookedHoursPerMonth[], + month: number, + year: number, +) { + return bookings.find( + (booking) => booking.month === month && booking.year === year, + ); +} + +function transformDetailedBookingToMonthlyData( + weeklyDetails: DetailedBooking[], +) { + const monthlyData: MonthlyDetailedBooking[] = []; + function addToMonthlyData( + hours: MonthlyHours[], + bookingDetails: BookingDetails, + ) { + monthlyData.push({ bookingDetails: bookingDetails, hours: hours }); + } + weeklyDetails.forEach((detailedBooking) => { + const { bookingDetails, hours } = detailedBooking; + const monthlyHours: { [key: string]: MonthlyHours } = {}; + function addToMonthlyHours( + monthLabel: string, + hours: number, + distribution: number, + ) { + if (!monthlyHours[monthLabel]) { + monthlyHours[monthLabel] = { + month: parseInt(monthLabel), + hours: 0, + }; + } + monthlyHours[monthLabel].hours += round2Decimals(hours * distribution); + } + hours.forEach((weekhour) => { + const { week, hours } = weekhour; + const weekType = weekToWeekType(week); + const monthDistribution = getMonthOfWeek(weekType); + + const primaryMonthKey = + "" + + monthDistribution.year + + `${monthDistribution.month < 10 ? "0" : ""}${monthDistribution.month}`; + const secondaryMonthKey = + monthDistribution.secondMonth !== undefined + ? "" + + monthDistribution.year + + `${monthDistribution.secondMonth < 10 ? "0" : ""}${ + monthDistribution.secondMonth + }` + : null; + const primaryDistribution = monthDistribution.distribution / 100; + const secondaryDistribution = secondaryMonthKey + ? (100 - monthDistribution.distribution) / 100 + : 0; + + addToMonthlyHours(primaryMonthKey, hours, primaryDistribution); + if (secondaryMonthKey) { + addToMonthlyHours(secondaryMonthKey, hours, secondaryDistribution); + } + }); + addToMonthlyData(Object.values(monthlyHours), bookingDetails); + }); + return monthlyData; +} +export { + transformToMonthlyData, + bookingForMonth, + transformDetailedBookingToMonthlyData, +}; diff --git a/frontend/src/components/Forecast/WeekToMonthConverter.ts b/frontend/src/components/Forecast/WeekToMonthConverter.ts new file mode 100644 index 00000000..0a0e40f4 --- /dev/null +++ b/frontend/src/components/Forecast/WeekToMonthConverter.ts @@ -0,0 +1,56 @@ +import { addDays, isMonday, previousMonday } from "date-fns"; + +type Week = { + week: number; + year: number; +}; + +export type MonthDistributionOfWeek = { + week: number; + year: number; + month: number; + secondMonth?: number; + distribution: number; +}; + +function weekToWeekType(weekInput: number) { + const weekInputStr = weekInput.toString(); + return { + week: Number(weekInputStr.slice(-2)), + year: Number(weekInputStr.slice(0, 4)), + } as Week; +} + +function getMonthOfWeek(week: Week) { + const weekNumber = week.week; + const year = week.year; + var daysFromStartOfYear = 1 + (weekNumber - 1) * 7; + + const dayOfSelectedWeek = new Date(year, 0, daysFromStartOfYear); + const mondayOfSelectedWeek = !isMonday(dayOfSelectedWeek) + ? previousMonday(dayOfSelectedWeek) + : dayOfSelectedWeek; + const month = mondayOfSelectedWeek.getMonth(); + + var distribution = 100; + var endDate = null; + for (let i = 1; i < 5; i++) { + const addedDayDate = addDays(mondayOfSelectedWeek, i); + if (addedDayDate.getMonth() != month) { + distribution = (distribution / 5) * i; + endDate = addedDayDate; + break; + } + } + const weekToMonthInstance = { + week: weekNumber, + year: year, + month: month, + secondMonth: endDate?.getMonth(), + distribution: distribution, + } as MonthDistributionOfWeek; + + return weekToMonthInstance; +} + +export { weekToWeekType, getMonthOfWeek }; diff --git a/frontend/src/components/Staffing/HoveredWeek.tsx b/frontend/src/components/Staffing/HoveredWeek.tsx index 87130c0f..f8892f6d 100644 --- a/frontend/src/components/Staffing/HoveredWeek.tsx +++ b/frontend/src/components/Staffing/HoveredWeek.tsx @@ -4,7 +4,6 @@ import { getIconByBookingType, isWeekBookingZeroHours, } from "@/components/Staffing/helpers/utils"; -import React from "react"; export function HoveredWeek(props: { hoveredRowWeek: number; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 392564d1..fed45a9e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,4 +1,10 @@ -import { BookingType, ConsultantReadModel, EngagementState } from "@/api-types"; +import { + BookingDetails, + BookingType, + ConsultantReadModel, + EngagementState, + WeeklyBookingReadModel, +} from "@/api-types"; export type YearRange = { label: string; @@ -21,6 +27,30 @@ export interface updateBookingHoursBody { endWeek?: number; } +export interface BookedHoursPerMonth { + month: number; + year: number; + bookingModel: WeeklyBookingReadModel; +} + +export interface Forecast { + id: number; + month: number; + year: number; + forecastValue: number; + hasBeenChanged: boolean; + valueAddedManually: number; +} + +export interface MonthlyDetailedBooking { + bookingDetails: BookingDetails; + hours: MonthlyHours[]; +} +export interface MonthlyHours { + month: number; + hours: number; +} + export interface updateProjectStateBody { engagementId: string; projectState: EngagementState;