diff --git a/frontend/mockdata/mockData.ts b/frontend/mockdata/mockData.ts index bac4ca8c..7552d76b 100644 --- a/frontend/mockdata/mockData.ts +++ b/frontend/mockdata/mockData.ts @@ -4,6 +4,7 @@ import { Degree, DepartmentReadModel, EngagementPerCustomerReadModel, + Forecast, OrganisationReadModel, WeeklyBookingReadModel, } from "@/api-types"; @@ -19,6 +20,54 @@ const MockWeeklyBookingReadModel: WeeklyBookingReadModel = { totalExcludableAbsence: 0, totalNotStartedOrQuit: 0, }; +const mockForecast1: Forecast = { + id: 1, + month: 0, + year: 2025, + forecastValue: 80, + hasBeenChanged: false, + valueAddedManually: 0, +}; +const mockForecast2: Forecast = { + id: 2, + month: 1, + year: 2025, + forecastValue: 80, + hasBeenChanged: false, + valueAddedManually: 0, +}; +const mockForecast3: Forecast = { + id: 3, + month: 2, + year: 2025, + forecastValue: 50, + hasBeenChanged: false, + valueAddedManually: 0, +}; +const mockForecast4: Forecast = { + id: 4, + month: 3, + year: 2025, + forecastValue: 100, + hasBeenChanged: false, + valueAddedManually: 0, +}; +const mockForecast5: Forecast = { + id: 5, + month: 4, + year: 2025, + forecastValue: 0, + hasBeenChanged: true, + valueAddedManually: 50, +}; +const mockForecast6: Forecast = { + id: 6, + month: 5, + year: 2025, + forecastValue: 70, + hasBeenChanged: true, + valueAddedManually: 10, +}; export const MockConsultants: ConsultantReadModel[] = [ { @@ -41,6 +90,14 @@ export const MockConsultants: ConsultantReadModel[] = [ 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 b66d8d92..d4bfa062 100644 --- a/frontend/src/api-types.ts +++ b/frontend/src/api-types.ts @@ -63,6 +63,16 @@ export interface ConsultantReadModel { isOccupied: boolean; imageUrl?: string; imageThumbUrl?: string; + forecasts?: Forecast[]; +} + +export interface Forecast { + id: number; + month: number; + year: number; + forecastValue: number; + hasBeenChanged: boolean; + valueAddedManually: number; } export interface ConsultantWriteModel { diff --git a/frontend/src/components/Forecast/ForecastRows.tsx b/frontend/src/components/Forecast/ForecastRows.tsx new file mode 100644 index 00000000..177321ff --- /dev/null +++ b/frontend/src/components/Forecast/ForecastRows.tsx @@ -0,0 +1,291 @@ +"use client"; +import { ConsultantReadModel, ProjectWithCustomerModel } from "@/api-types"; +import React, { useContext, useEffect, useState } from "react"; +import { AlertCircle, CheckCircle, ChevronDown, 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 { + dayToWeek, + getBookingTypeFromProjectState, +} from "../Staffing/EditEngagementHourModal/utils"; +import { useWeekSelectors } from "@/hooks/useWeekSelectors"; +import { setDetailedBookingHours } from "@/components/Staffing/DetailedBookingRows"; +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"; + +export default function ForecastRows({ + consultant, + numWorkHours, +}: { + consultant: ConsultantReadModel; + numWorkHours: number; +}) { + const [currentConsultant, setCurrentConsultant] = + useState(consultant); + const [isListElementVisible, setIsListElementVisible] = useState(false); + const [isRowHovered, setIsRowHovered] = useState(false); + const [hoveredRowWeek, setHoveredRowWeek] = useState(-1); + const [isAddStaffingHovered, setIsAddStaffingHovered] = useState(false); + const [addNewRow, setAddNewRow] = useState(false); + const { weekList, setSelectedWeekSpan } = useWeekSelectors(); + const { setIsDisabledHotkeys } = useContext(FilteredContext); + const { selectedWeekFilter, weekSpan } = + useContext(FilteredContext).activeFilters; + + const [newWeekList, setNewWeekList] = useState(weekList); + + useEffect(() => { + setSelectedWeekSpan(consultant.bookings.length); + setCurrentConsultant(consultant); + if (selectedWeekFilter) { + setNewWeekList([]); + Array.from({ length: weekSpan }).map((_, index) => { + setNewWeekList((old) => [ + ...old, + DateTime.fromISO( + `${selectedWeekFilter?.year}-W${selectedWeekFilter?.weekNumber}`, + ).plus({ weeks: index }), + ]); + }); + } + }, [consultant.bookings.length, consultant]); + + const columnCount = currentConsultant.bookings.length ?? 0; + + function toggleListElementVisibility() { + setIsListElementVisible((old) => !old); + } + + const { + closeModal: closeChangeEngagementModal, + openModal: openChangeEngagementModal, + modalRef: changeEngagementModalRef, + } = useModal({ + closeOnBackdropClick: false, + }); + + const [selectedProjectId, setSelectedProjectId] = useState< + number | undefined + >(undefined); + const [selectedProject, setSelectedProject] = useState< + ProjectWithCustomerModel | undefined + >(undefined); + + const organisationUrl = usePathname().split("/")[1]; + + useEffect(() => { + async function fetchProject() { + const url = `/${organisationUrl}/bemanning/api/projects?projectId=${selectedProjectId}`; + + try { + const data = await fetch(url, { + method: "get", + }); + setSelectedProject((await data.json()) as ProjectWithCustomerModel); + } catch (e) { + console.error("Error updating staffing", e); + } + } + selectedProjectId && fetchProject(); + }, [organisationUrl, selectedProjectId]); + + function openEngagementAndSetID(id: number) { + setSelectedProjectId(id); + openChangeEngagementModal(); + } + + function onCloseEngagementModal() { + setSelectedProject(undefined); + setSelectedProjectId(undefined); + } + + function handleNewEngagementCancelled() { + setAddNewRow(false); + setIsDisabledHotkeys(false); + } + + async function handleNewEngagement(project: ProjectWithCustomerModel) { + setAddNewRow(false); + setIsDisabledHotkeys(false); + setSelectedProject(project); + + if ( + project !== undefined && + !currentConsultant.detailedBooking.some( + (e) => + e.bookingDetails.projectId === project.projectId && + e.bookingDetails.type === + getBookingTypeFromProjectState(project?.bookingType), + ) + ) { + try { + const body = { + hours: 0, + bookingType: getBookingTypeFromProjectState(project?.bookingType), + organisationUrl: organisationUrl, + consultantId: currentConsultant.id, + bookingId: `${project?.projectId}`, + startWeek: dayToWeek(newWeekList[0]), + }; + const res = await setDetailedBookingHours(body); + + if (res) { + const tempCurrentConsultant = { ...currentConsultant }; + + const newDetailedBooking = res.detailedBooking.find( + (e) => e.bookingDetails.projectId === project.projectId, + ); + + if (newDetailedBooking) { + newDetailedBooking.hours = newWeekList.map((e) => { + return { week: dayToWeek(e), hours: 0 }; + }); + tempCurrentConsultant.detailedBooking.push(newDetailedBooking); + setCurrentConsultant(tempCurrentConsultant); + } + } + } catch (e) { + console.error("Error updating staffing", e); + } + } + } + + return ( + <> + setIsRowHovered(true)} + onMouseLeave={() => setIsRowHovered(false)} + > + + {/* //utkommentert foreløpig, kan slettes om vi ikke skal ha mulighet til å utvide raden */} + + +
+
+ {consultant.imageThumbUrl ? ( + {consultant.name} + ) : ( +
+ )} +
+
+

+ {currentConsultant.name} +

+

+ {`${currentConsultant.yearsOfExperience} års erfaring`} +

+
+
+ + {currentConsultant.forecasts?.map((b, index) => ( + + ))} + + {isListElementVisible && + currentConsultant.detailedBooking && + currentConsultant.detailedBooking.map((db, index) => ( + + ))} + {isListElementVisible && addNewRow && ( + + + + )} + + {isListElementVisible && ( + + + + {!addNewRow && ( + + )} + + + )} + + ); +} diff --git a/frontend/src/components/Forecast/ForecastTable.tsx b/frontend/src/components/Forecast/ForecastTable.tsx new file mode 100644 index 00000000..3ecc15f3 --- /dev/null +++ b/frontend/src/components/Forecast/ForecastTable.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { isCurrentWeek } from "@/hooks/staffing/dateTools"; +import { useConsultantsFilter } from "@/hooks/staffing/useConsultantsFilter"; +import InfoPill from "../Staffing/InfoPill"; +import { Calendar } from "react-feather"; +import React, { useContext } from "react"; +import { FilteredContext } from "@/hooks/ConsultantFilterProvider"; +import ForecastRows from "./ForecastRows"; +import { MockConsultants } from "../../../mockdata/mockData"; + +const months = [ + "Januar", + "Februar", + "Mars", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Desember", +]; +const monthsShort = [ + "Jan", + "Feb", + "Mar", + "Apr", + "Mai", + "Jun", + "Jul", + "Aug", + "Sep", + "Okt", + "Nov", + "Des", +]; +function mapMonthToNumber(month: string) { + return monthsShort.indexOf(month); +} +function isCurrentMonth(month: number) { + return month === 0; +} +export default function ForecastTable() { + const { + numWorkHours, + filteredConsultants, + weeklyTotalBillable, + weeklyTotalBillableAndOffered, + weeklyInvoiceRates, + } = useConsultantsFilter(); + + const { weekSpan } = useContext(FilteredContext).activeFilters; + + return ( + + + + + {filteredConsultants + .at(0) + ?.bookings.map((_, index) => )} + + + + + {monthsShort.map((month) => ( + + ))} + + + + {MockConsultants.map((consultant) => ( + + ))} + +
+
+

Konsulenter

+

+ {filteredConsultants?.length} +

+
+
+
+ {isCurrentMonth(mapMonthToNumber(month)) ? ( +
+ {/* {booking.bookingModel.totalHolidayHours > 0 && ( + } + colors={"bg-holiday text-holiday_darker w-fit"} + variant={weekSpan < 24 ? "wide" : "medium"} + /> + )} */} +
+ +

{month}

+
+ ) : ( +
= 26 + ? "min-h-[30px] flex-col mb-2 gap-[1px] items-end" + : "flex-row gap-2" + }`} + > + {/* {booking.bookingModel.totalHolidayHours > 0 && ( + } + colors={"bg-holiday text-holiday_darker w-fit"} + variant={weekSpan < 24 ? "wide" : "medium"} + /> + )} */} +

{month}

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Forecast/MonthCell.tsx b/frontend/src/components/Forecast/MonthCell.tsx new file mode 100644 index 00000000..4b87de03 --- /dev/null +++ b/frontend/src/components/Forecast/MonthCell.tsx @@ -0,0 +1,195 @@ +import { BookedHoursPerWeek, ConsultantReadModel } from "@/api-types"; +import { HoveredWeek } from "@/components/Staffing/HoveredWeek"; +import InfoPill from "@/components/Staffing/InfoPill"; +import { + AlertTriangle, + Calendar, + Coffee, + FileText, + Moon, + Sun, +} from "react-feather"; +import { getInfopillVariantByColumnCount } from "@/components/Staffing/helpers/utils"; +import React from "react"; +import { has } from "lodash"; + +export function MonthCell(props: { + forecastValue: number; + hasBeenEdited: boolean; + consultant: ConsultantReadModel; + setHoveredRowWeek: (number: number) => void; + hoveredRowWeek: number; + month: number; + columnCount: number; + isLastCol: boolean; + isSecondLastCol: boolean; + numWorkHours: number; +}) { + const { + forecastValue, + consultant, + hasBeenEdited, + setHoveredRowWeek, + hoveredRowWeek, + month, + columnCount, + isLastCol, + isSecondLastCol, + numWorkHours, + } = props; + + /* 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 (bookedHoursPerWeek.bookingModel.totalSellableTime > 0) { + pillNumber++; + } */ + const uneditable = forecastValue === 100; + return ( + +
setHoveredRowWeek(month)} + onMouseLeave={() => setHoveredRowWeek(-1)} + > + {hoveredRowWeek != -1 && hoveredRowWeek == month && ( + + )} +
+ {/* {bookedHoursPerWeek.bookingModel.totalOffered > 0 && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} + {bookedHoursPerWeek.bookingModel.totalSellableTime > 0 && + getInfopillVariantByColumnCount(columnCount) !== "narrow" && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} + {bookedHoursPerWeek.bookingModel.totalVacationHours > 0 && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} + {bookedHoursPerWeek.bookingModel.totalPlannedAbsences > 0 && + getInfopillVariantByColumnCount(columnCount) !== "narrow" && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} + {bookedHoursPerWeek.bookingModel.totalOverbooking > 0 && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} + {bookedHoursPerWeek.bookingModel.totalNotStartedOrQuit > 0 && ( + } + variant={getInfopillVariantByColumnCount(columnCount)} + /> + )} */} +
+

+ {/* {checkIfNotStartedOrQuit(consultant, bookedHoursPerWeek, numWorkHours) + ? "-" + : bookedHoursPerWeek.bookingModel.totalBillable.toLocaleString( + "nb-No", + )} */} + {`${forecastValue}%`} +

+
+ + ); +} + +function checkIfNotStartedOrQuit( + consultant: ConsultantReadModel, + bookedHoursPerWeek: BookedHoursPerWeek, + numWorkHours: number, +) { + const notStartedOrQuitHours = + bookedHoursPerWeek.bookingModel.totalNotStartedOrQuit; + + return ( + notStartedOrQuitHours == + numWorkHours - bookedHoursPerWeek.bookingModel.totalHolidayHours + ); +} diff --git a/frontend/src/pagecontent/ForecastContent.tsx b/frontend/src/pagecontent/ForecastContent.tsx index 3859a601..65365864 100644 --- a/frontend/src/pagecontent/ForecastContent.tsx +++ b/frontend/src/pagecontent/ForecastContent.tsx @@ -8,6 +8,7 @@ import IconActionButton from "@/components/Buttons/IconActionButton"; import { Filter } from "react-feather"; import ActiveFilters from "@/components/ActiveFilters"; import WeekSelection from "@/components/WeekSelection"; +import ForecastTable from "@/components/Forecast/ForecastTable"; export function ForecastContent() { const [isSideBarOpen, setIsSidebarOpen] = useState(false); @@ -34,6 +35,7 @@ export function ForecastContent() { +