diff --git a/frontend/src/app/[organisation]/prosjekt/[project]/Sidebar.tsx b/frontend/src/app/[organisation]/prosjekt/[project]/Sidebar.tsx new file mode 100644 index 00000000..885f240a --- /dev/null +++ b/frontend/src/app/[organisation]/prosjekt/[project]/Sidebar.tsx @@ -0,0 +1,102 @@ +"use client"; +import { ConsultantReadModel, ProjectWithCustomerModel } from "@/api-types"; +import InfoBox from "@/components/InfoBox"; +import { weekToString } from "@/data/urlUtils"; +import { setWeeklyTotalBillableForProject } from "@/hooks/staffing/useConsultantsFilter"; +import { useWeekSelectors } from "@/hooks/useWeekSelectors"; +import { Week } from "@/types"; +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +// TODO: Call funtion and set a state when adding or removing consultants and hours +function Sidebar({ project }: { project: ProjectWithCustomerModel }) { + const { selectedWeek, selectedWeekSpan } = useWeekSelectors(); + + const [selectedConsultants, setSelectedConsultants] = useState< + ConsultantReadModel[] + >([]); + + const organisationUrl = usePathname().split("/")[1]; + + useEffect(() => { + if (project != undefined) { + fetchConsultantsFromProject( + project, + organisationUrl, + selectedWeek, + selectedWeekSpan, + ).then((res) => { + setSelectedConsultants([ + // Use spread to make a new list, forcing a re-render + ...res, + ]); + }); + } + }, [project, organisationUrl, selectedWeek, selectedWeekSpan]); + + function calculateTotalHours() { + const weeklyTotalBillableAndOffered = setWeeklyTotalBillableForProject( + selectedConsultants, + project, + ); + var sum = 0; + weeklyTotalBillableAndOffered.forEach((element) => { + sum += element; + }); + return sum.toString(); + } + + return ( +
+
+
+

Info

+
+

Om

+ +
+
+

Bemanning

+ + +
+
+
+
+ ); +} + +async function fetchConsultantsFromProject( + project: ProjectWithCustomerModel, + organisationUrl: string, + selectedWeek: Week, + selectedWeekSpan: number, +) { + const url = `/${organisationUrl}/bemanning/api/projects/staffings?projectId=${ + project.projectId + }&selectedWeek=${weekToString( + selectedWeek, + )}&selectedWeekSpan=${selectedWeekSpan}`; + + try { + const data = await fetch(url, { + method: "get", + }); + return (await data.json()) as ConsultantReadModel[]; + } catch (e) { + console.error("Error updating staffing", e); + } + + return []; +} + +export default Sidebar; diff --git a/frontend/src/app/[organisation]/prosjekt/[project]/layout.tsx b/frontend/src/app/[organisation]/prosjekt/[project]/layout.tsx new file mode 100644 index 00000000..ecebf567 --- /dev/null +++ b/frontend/src/app/[organisation]/prosjekt/[project]/layout.tsx @@ -0,0 +1,7 @@ +export default function BemanningLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/frontend/src/app/[organisation]/prosjekt/[project]/page.tsx b/frontend/src/app/[organisation]/prosjekt/[project]/page.tsx new file mode 100644 index 00000000..9ab21266 --- /dev/null +++ b/frontend/src/app/[organisation]/prosjekt/[project]/page.tsx @@ -0,0 +1,59 @@ +import { EditEngagementHour } from "@/components/Staffing/EditEngagementHourModal/EditEngagementHour"; +import { + fetchEmployeesWithImageAndToken, + fetchWithToken, +} from "@/data/apiCallsWithToken"; +import { ProjectWithCustomerModel } from "@/api-types"; +import Sidebar from "./Sidebar"; +import { ConsultantFilterProvider } from "@/hooks/ConsultantFilterProvider"; +import { parseYearWeekFromUrlString } from "@/data/urlUtils"; + +export default async function Project({ + params, + searchParams, +}: { + params: { organisation: string; project: string }; + searchParams: { selectedWeek?: string; weekSpan?: string }; +}) { + const project = + (await fetchWithToken( + `${params.organisation}/projects/get/${params.project}`, + )) ?? undefined; + + const selectedWeek = parseYearWeekFromUrlString( + searchParams.selectedWeek || undefined, + ); + const weekSpan = searchParams.weekSpan || undefined; + + const consultants = + (await fetchEmployeesWithImageAndToken( + `${params.organisation}/staffings${ + selectedWeek + ? `?Year=${selectedWeek.year}&Week=${selectedWeek.weekNumber}` + : "" + }${weekSpan ? `${selectedWeek ? "&" : "?"}WeekSpan=${weekSpan}` : ""}`, + )) ?? []; + + if (project) { + return ( + + +
+
+

{project.projectName}

+

{project.customerName}

+
+ + +
+
+ ); + } else { + return

Fant ikke prosjektet

; + } +} diff --git a/frontend/src/components/Buttons/FilterButton.tsx b/frontend/src/components/Buttons/FilterButton.tsx index 055dc3cf..00220d2d 100644 --- a/frontend/src/components/Buttons/FilterButton.tsx +++ b/frontend/src/components/Buttons/FilterButton.tsx @@ -1,38 +1,31 @@ export default function FilterButton({ label, - onClick, - checked, - enabled = true, + rounded, + ...inputProps }: { label: string; - onClick: () => void; - checked: boolean; - enabled?: boolean; -}) { - function handleToggle() { - if (enabled) { - onClick(); - } - } - + rounded?: boolean; +} & React.InputHTMLAttributes) { return ( -
+
-
diff --git a/frontend/src/components/ChangeEngagementState.tsx b/frontend/src/components/ChangeEngagementState.tsx new file mode 100644 index 00000000..8f3419f5 --- /dev/null +++ b/frontend/src/components/ChangeEngagementState.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + EngagementState, + ProjectWithCustomerModel, + UpdateProjectWriteModel, +} from "@/api-types"; +import { useState } from "react"; +import FilterButton from "./Buttons/FilterButton"; +import { usePathname, useRouter } from "next/navigation"; + +export default function ChangeEngagementState({ + project, +}: { + project: ProjectWithCustomerModel; +}) { + const organisationName = usePathname().split("/")[1]; + + const router = useRouter(); + + const [engagementState, setEngagementState] = useState( + project.bookingType, + ); + + async function handleChange(newState: EngagementState) { + setEngagementState(newState); + + const currentDate = new Date(); + const startYear = currentDate.getFullYear(); + const startWeek = Math.ceil( + (currentDate.getTime() - new Date(startYear, 0, 1).getTime()) / + (7 * 24 * 60 * 60 * 1000), + ); + + const body: UpdateProjectWriteModel = { + engagementId: project.projectId, + projectState: newState, + startYear: startYear, + startWeek: startWeek, + weekSpan: 26, + }; + + await submitAddEngagementForm(body); + router.refresh(); + } + + async function submitAddEngagementForm(body: UpdateProjectWriteModel) { + const url = `/${organisationName}/bemanning/api/projects/updateState`; + try { + const data = await fetch(url, { + method: "PUT", + body: JSON.stringify({ + ...body, + }), + }); + return (await data.json()) as ProjectWithCustomerModel; + } catch (e) { + console.error("Error updating engagement state", e); + } + } + + return ( +
+ handleChange(e.target.value as EngagementState)} + /> + handleChange(e.target.value as EngagementState)} + /> + + handleChange(e.target.value as EngagementState)} + /> + + ); +} diff --git a/frontend/src/components/CostumerTable/CustomerRow.tsx b/frontend/src/components/CostumerTable/CustomerRow.tsx index 310c7e70..cdf6799f 100644 --- a/frontend/src/components/CostumerTable/CustomerRow.tsx +++ b/frontend/src/components/CostumerTable/CustomerRow.tsx @@ -76,7 +76,7 @@ export default function CostumerRow({ {isListElementVisible && customer.engagements && - customer.engagements.map((engagement, index) => ( + customer.engagements.map((engagement) => (
-
+

{engagement.isBillable ? "Fakturerbart" : "Ikke-fakturerbart"}

{engagement.engagementName}

-
+ ))} diff --git a/frontend/src/components/Staffing/AddEngagementForm.tsx b/frontend/src/components/Staffing/AddEngagementForm.tsx index 6adbe798..faf24ecb 100644 --- a/frontend/src/components/Staffing/AddEngagementForm.tsx +++ b/frontend/src/components/Staffing/AddEngagementForm.tsx @@ -165,6 +165,7 @@ export function AddEngagementForm({ async function submitAddEngagementForm(body: EngagementWriteModel) { const url = `/${organisationName}/bemanning/api/projects`; + setIsSubmitting(true); try { const data = await fetch(url, { @@ -298,7 +299,7 @@ export function AddEngagementForm({ label="Fakturerbart" onClick={handleBillableToggled} checked={isBillable} - enabled={!isInternalProject} + disabled={isInternalProject} /> )} diff --git a/frontend/src/components/Staffing/DetailedBookingRows.tsx b/frontend/src/components/Staffing/DetailedBookingRows.tsx index 18392954..a18b577e 100644 --- a/frontend/src/components/Staffing/DetailedBookingRows.tsx +++ b/frontend/src/components/Staffing/DetailedBookingRows.tsx @@ -18,6 +18,7 @@ import { Edit3, Minus, Plus, ThumbsDown, ThumbsUp } from "react-feather"; import { updateBookingHoursBody, updateProjectStateBody } from "@/types"; import { useOutsideClick } from "@/hooks/useOutsideClick"; import { parseYearWeekFromString } from "@/data/urlUtils"; +import Link from "next/link"; async function updateProjectState( organisationName: string, @@ -92,19 +93,9 @@ export function DetailedBookingRows(props: { > - + {/* For offer dropdown open*/}
>(new Map()); + + const [selectedConsultants, setSelectedConsultants] = useState< + ConsultantReadModel[] + >([]); + + const [selectedNewConsultants, setSelectedNewConsultants] = useState< + ConsultantWithWeekHours[] + >([]); + + const remainingConsultants = consultants.filter( + (c) => + !selectedNewConsultants.find((c2) => c2.consultant.id == c.id) && + !selectedConsultants.find((c2) => c2.id == c.id), + ); + + useEffect(() => setProject(project), [project]); + + useEffect(() => { + //check if selectedConsultants contains any of the selectedNewConsultants + const newConsultants = selectedNewConsultants.map((c) => c.consultant.id); + const consultants = selectedConsultants.map((c) => c.id); + const intersection = newConsultants.filter((c) => consultants.includes(c)); + // remove the consultants that are in both lists from selectedNewConsultants + if (intersection.length > 0) { + setSelectedNewConsultants( + selectedNewConsultants.filter( + (c) => !intersection.includes(c.consultant.id), + ), + ); + } + if (chosenProject) { + const weeklyTotalBillableAndOffered = setWeeklyTotalBillableForProject( + selectedConsultants, + chosenProject, + ); + setweeklyTotalBillableAndOfferedState(weeklyTotalBillableAndOffered); + } + }, [selectedConsultants]); + + function handleAddConsultant(option: SelectOption) { + const consultant = remainingConsultants.find((c) => c.id == option.value); + if (consultant) { + setSelectedNewConsultants([ + ...addNewConsultatWHours( + selectedNewConsultants, + consultant, + chosenProject?.projectId || 0, + chosenProject?.bookingType || EngagementState.Order, + ), + ]); + } + } + + const organisationUrl = usePathname().split("/")[1]; + + useEffect(() => { + if (chosenProject != undefined) { + fetchConsultantsFromProject( + chosenProject, + organisationUrl, + selectedWeek, + selectedWeekSpan, + ).then((res) => { + setSelectedConsultants([ + // Use spread to make a new list, forcing a re-render + ...res, + ]); + }); + } + }, [chosenProject, organisationUrl, selectedWeek, selectedWeekSpan]); + + return ( +
+
+ {project && } + +
+ 23 + ? "min-w-[1400px]" + : selectedWeekSpan > 11 + ? "min-w-[850px]" + : "min-w-[700px]" + } table-fixed`} + > + + + + {selectedConsultants + .at(0) + ?.bookings.map((_, index) => )} + + + + {selectedConsultants?.map((consultant) => ( + db.bookingDetails.projectId == project?.projectId, + )[0] + } + consultants={selectedConsultants} + setConsultants={setSelectedConsultants} + /> + ))} + + {selectedNewConsultants?.length > 0 && ( + + + + )} + + {selectedNewConsultants?.map((consultant) => ( + + ))} + + + + + +
+ Nylig lagt til: +
+
+ ); +} + +async function fetchConsultantsFromProject( + project: ProjectWithCustomerModel, + organisationUrl: string, + selectedWeek: Week, + selectedWeekSpan: number, +) { + const url = `/${organisationUrl}/bemanning/api/projects/staffings?projectId=${ + project.projectId + }&selectedWeek=${weekToString( + selectedWeek, + )}&selectedWeekSpan=${selectedWeekSpan}`; + + try { + const data = await fetch(url, { + method: "get", + }); + return (await data.json()) as ConsultantReadModel[]; + } catch (e) { + console.error("Error updating staffing", e); + } + + return []; +} diff --git a/frontend/src/components/StaffingSums.tsx b/frontend/src/components/StaffingSums.tsx index 9f277e6f..3f5456c3 100644 --- a/frontend/src/components/StaffingSums.tsx +++ b/frontend/src/components/StaffingSums.tsx @@ -1,7 +1,9 @@ +import { useEffect, useState } from "react"; + interface StaffingSumsProps { - weeklyTotalBillable: Map; + weeklyTotalBillable?: Map; weeklyTotalBillableAndOffered: Map; - weeklyInvoiceRates: Map; + weeklyInvoiceRates?: Map; } export default function StaffingSums({ @@ -9,29 +11,41 @@ export default function StaffingSums({ weeklyTotalBillableAndOffered, weeklyInvoiceRates, }: StaffingSumsProps) { - const totalBillableHours = Array.from(weeklyTotalBillable.values()); + const [totalBillableHours, setTotalBillableHours] = useState(); const totalBillableAndOfferedHours = Array.from( weeklyTotalBillableAndOffered.values(), ); - const weeklyInvoiceRatesArray = Array.from(weeklyInvoiceRates.values()); + const [weeklyInvoiceRatesArray, setWeeklyInvoiceRatesArray] = + useState(); + + useEffect(() => { + if (weeklyTotalBillable) { + setTotalBillableHours(Array.from(weeklyTotalBillable.values())); + } + if (weeklyInvoiceRates) { + setWeeklyInvoiceRatesArray(Array.from(weeklyInvoiceRates.values())); + } + }, [weeklyTotalBillable, weeklyInvoiceRates]); return ( - - -

Sum bemanning

- - {totalBillableHours.map((totalBillableHour, index) => ( - -

- {totalBillableHour.toLocaleString("nb-No", { - maximumFractionDigits: 1, - minimumFractionDigits: 1, - })} -

+ {weeklyTotalBillable && ( + + +

Sum bemanning

- ))} - + {totalBillableHours?.map((totalBillableHour, index) => ( + +

+ {totalBillableHour.toLocaleString("nb-No", { + maximumFractionDigits: 1, + minimumFractionDigits: 1, + })} +

+ + ))} + + )}

Sum bemanning og tilbud

@@ -49,18 +63,20 @@ export default function StaffingSums({ ), )} - - -

Fakureringsgrad

- - {weeklyInvoiceRatesArray.map((indexRates, index) => ( - -

- {Math.round(indexRates * 100)}% -

+ {weeklyInvoiceRatesArray && ( + + +

Fakureringsgrad

- ))} - + {weeklyInvoiceRatesArray.map((indexRates, index) => ( + +

+ {Math.round(indexRates * 100)}% +

+ + ))} + + )} ); } diff --git a/frontend/src/hooks/staffing/useConsultantsFilter.ts b/frontend/src/hooks/staffing/useConsultantsFilter.ts index 0cae1188..adad2689 100644 --- a/frontend/src/hooks/staffing/useConsultantsFilter.ts +++ b/frontend/src/hooks/staffing/useConsultantsFilter.ts @@ -4,7 +4,7 @@ import { useContext, useEffect, useState } from "react"; import { useYearsXpFilter } from "./useYearsXpFilter"; import { useAvailabilityFilter } from "./useAvailabilityFilter"; import { usePathname } from "next/navigation"; -import { ConsultantReadModel } from "@/api-types"; +import { ConsultantReadModel, ProjectWithCustomerModel } from "@/api-types"; async function getNumWorkHours( setNumWorkHours: Function, @@ -161,7 +161,7 @@ interface WeeklyTotal { weeklyTotalBillableAndOffered: Map; } -function setWeeklyTotalBillable( +export function setWeeklyTotalBillable( filteredConsultants: ConsultantReadModel[], ): WeeklyTotal { const weeklyTotalBillable = new Map(); @@ -201,6 +201,30 @@ function setWeeklyTotalBillable( }; } +export function setWeeklyTotalBillableForProject( + selectedConsultants: ConsultantReadModel[], + project: ProjectWithCustomerModel, +) { + const weeklyTotalBillableAndOffered = new Map(); + + selectedConsultants.map((consultant) => { + const bookingDetail = consultant.detailedBooking.find( + (booking) => booking.bookingDetails.projectId === project.projectId, + ); + + if (bookingDetail) { + bookingDetail.hours.map((weeklyHours) => { + weeklyTotalBillableAndOffered.set( + weeklyHours.week, + (weeklyTotalBillableAndOffered.get(weeklyHours.week) || 0) + + weeklyHours.hours, + ); + }); + } + }); + return weeklyTotalBillableAndOffered; +} + function setWeeklyInvoiceRate( filteredConsultants: ConsultantReadModel[], weeklyTotalBillable: Map, diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index ef34829f..ca2779d3 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -26,6 +26,9 @@ export default { background_light_purple_hover: "#423D891A", text_light_black: "#333333BF", }, + fontSize: { + h1: ["1.625rem", "2.5rem"], + }, extend: { minWidth: { "8": "2rem",