diff --git a/frontend/mockdata/mockData.ts b/frontend/mockdata/mockData.ts index 7552d76b..367dd6e2 100644 --- a/frontend/mockdata/mockData.ts +++ b/frontend/mockdata/mockData.ts @@ -1,4 +1,5 @@ import { + BookingType, CompetenceReadModel, ConsultantReadModel, Degree, @@ -16,7 +17,7 @@ const MockWeeklyBookingReadModel: WeeklyBookingReadModel = { totalSellableTime: 0, totalBillable: 0, totalOffered: 0, - totalVacationHours: 0, + totalVacationHours: 10, totalExcludableAbsence: 0, totalNotStartedOrQuit: 0, }; @@ -78,11 +79,11 @@ export const MockConsultants: ConsultantReadModel[] = [ department: { id: "mydepartment", name: "My Department" }, bookings: [ { - year: 2023, - weekNumber: 10, + year: 2025, + weekNumber: 14, dateString: "", bookingModel: MockWeeklyBookingReadModel, - sortableWeek: 202310, + sortableWeek: 202514, }, ], yearsOfExperience: 23, @@ -99,6 +100,131 @@ export const MockConsultants: ConsultantReadModel[] = [ mockForecast6, ], }, + { + id: 2, + name: "2test Consultant", + email: "test2@company.io", + competences: [{ id: "development", name: "Utvikling" }], + department: { id: "mydepartment", name: "My Department" }, + bookings: [ + { + year: 2025, + weekNumber: 3, + sortableWeek: 202503, + dateString: "13.01 - 17.01", + bookingModel: { + totalBillable: 0, + totalOffered: 7.5, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 7.5, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + { + year: 2025, + weekNumber: 4, + sortableWeek: 202504, + dateString: "20.01 - 24.01", + bookingModel: { + totalBillable: 20, + 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: 20, + 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: 20, + totalOffered: 0, + totalPlannedAbsences: 0, + totalExcludableAbsence: 0, + totalSellableTime: 37.5, + totalHolidayHours: 0, + totalVacationHours: 0, + totalOverbooking: 0, + totalNotStartedOrQuit: 0, + }, + }, + ], + yearsOfExperience: 23, + detailedBooking: [ + { + bookingDetails: { + projectName: "Design", + type: BookingType.Booking, + customerName: "Aion", + projectId: 185, + isBillable: true, + endDateAgreement: null, + }, + hours: [ + { week: 202503, hours: 10 }, + { week: 202504, hours: 5 }, + { week: 202505, hours: 15 }, + { week: 202506, hours: 37 }, + ], + }, + { + bookingDetails: { + projectName: "Nye nettsider", + type: BookingType.Booking, + customerName: "Åkerblå", + projectId: 208, + isBillable: true, + endDateAgreement: "2024-11-29T00:00:00", + }, + hours: [ + { week: 202503, hours: 10 }, + { week: 202504, hours: 5 }, + { week: 202505, hours: 15 }, + { week: 202506, hours: 37 }, + { week: 202507, hours: 37 }, + { week: 202514, hours: 37 }, + ], + }, + ], + isOccupied: true, + graduationYear: 2010, + degree: Degree.Bachelor, + forecasts: [ + mockForecast1, + mockForecast2, + mockForecast3, + mockForecast4, + mockForecast5, + mockForecast6, + ], + }, ]; export const MockDepartments: DepartmentReadModel[] = [ diff --git a/frontend/src/api-types.ts b/frontend/src/api-types.ts index d4bfa062..b9011533 100644 --- a/frontend/src/api-types.ts +++ b/frontend/src/api-types.ts @@ -20,7 +20,11 @@ export interface BookedHoursPerWeek { dateString: string; bookingModel: WeeklyBookingReadModel; } - +export interface BookedHoursPerMonth { + month: number; + year: number; + bookingModel: WeeklyBookingReadModel; +} export interface BookingDetails { /** @minLength 1 */ projectName: string; @@ -133,6 +137,11 @@ export interface DetailedBooking { hours: WeeklyHours[]; } +export interface MonthlyDetailedBooking { + bookingDetails: BookingDetails; + hours: MonthlyHours[]; +} + export interface EngagementPerCustomerReadModel { /** @format int32 */ customerId: number; @@ -299,6 +308,11 @@ export interface WeeklyHours { hours: number; } +export interface MonthlyHours { + month: number; + hours: number; +} + export interface CustomersWithProjectsReadModel { customerId: number; customerName: string; diff --git a/frontend/src/components/Forecast/ForecastRows.tsx b/frontend/src/components/Forecast/ForecastRows.tsx index 177321ff..e990bfca 100644 --- a/frontend/src/components/Forecast/ForecastRows.tsx +++ b/frontend/src/components/Forecast/ForecastRows.tsx @@ -1,5 +1,10 @@ "use client"; -import { ConsultantReadModel, ProjectWithCustomerModel } from "@/api-types"; +import { + BookedHoursPerWeek, + ConsultantReadModel, + ProjectWithCustomerModel, + WeeklyBookingReadModel, +} from "@/api-types"; import React, { useContext, useEffect, useState } from "react"; import { AlertCircle, CheckCircle, ChevronDown, Plus } from "react-feather"; import { DetailedBookingRows } from "@/components/Staffing/DetailedBookingRows"; @@ -17,6 +22,16 @@ import { DateTime } from "luxon"; import Image from "next/image"; import { INTERNAL_CUSTOMER_NAME } from "../Staffing/helpers/utils"; import { MonthCell } from "./MonthCell"; +import { + getMonthOfWeek, + MonthDistributionOfWeek, + weekToWeekType, +} from "./WeekToMonthConverter"; +import { Month } from "date-fns"; +import { + bookingForMonth, + transformToMonthlyData, +} from "./TransformWeekDataToMonth"; export default function ForecastRows({ consultant, @@ -68,6 +83,7 @@ export default function ForecastRows({ } = useModal({ closeOnBackdropClick: false, }); + const bookingsPerMonth = transformToMonthlyData(consultant.bookings); const [selectedProjectId, setSelectedProjectId] = useState< number | undefined @@ -211,6 +227,11 @@ export default function ForecastRows({ {currentConsultant.forecasts?.map((b, index) => ( + 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: + consultant.bookings.find((b) => b.weekNumber == 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) => hour.month % 100 == hoveredRowMonth, + )?.hours + } +

+
+ ))} +
+
+ + ); +} diff --git a/frontend/src/components/Forecast/MonthCell.tsx b/frontend/src/components/Forecast/MonthCell.tsx index 4b87de03..a7c9340c 100644 --- a/frontend/src/components/Forecast/MonthCell.tsx +++ b/frontend/src/components/Forecast/MonthCell.tsx @@ -1,4 +1,4 @@ -import { BookedHoursPerWeek, ConsultantReadModel } from "@/api-types"; +import { BookedHoursPerMonth, ConsultantReadModel } from "@/api-types"; import { HoveredWeek } from "@/components/Staffing/HoveredWeek"; import InfoPill from "@/components/Staffing/InfoPill"; import { @@ -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..52f057f8 --- /dev/null +++ b/frontend/src/components/Forecast/TransformWeekDataToMonth.ts @@ -0,0 +1,152 @@ +import { + BookedHoursPerMonth, + BookedHoursPerWeek, + BookingDetails, + DetailedBooking, + MonthlyDetailedBooking, + MonthlyHours, + WeeklyBookingReadModel, +} from "@/api-types"; +import { getMonthOfWeek, weekToWeekType } from "./WeekToMonthConverter"; +import { add } from "lodash"; + +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 + 1, + ).padStart(2, "0")}`; + const secondaryMonthKey = + monthDistribution.secondMonth !== undefined + ? `${monthDistribution.year}-${String( + monthDistribution.secondMonth + 1, + ).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] = 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 += 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/helpers/utils.tsx b/frontend/src/components/Staffing/helpers/utils.tsx index 3cfb376b..c3bf870f 100644 --- a/frontend/src/components/Staffing/helpers/utils.tsx +++ b/frontend/src/components/Staffing/helpers/utils.tsx @@ -3,6 +3,7 @@ import { ConsultantReadModel, DetailedBooking, EngagementState, + MonthlyDetailedBooking, } from "@/api-types"; import React, { ReactElement } from "react"; import {