Przeglądaj źródła

Save staff allocation in project creation

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 rok temu
rodzic
commit
ec1e1b4e69
7 zmienionych plików z 84 dodań i 119 usunięć
  1. +2
    -1
      src/app/(main)/projects/create/page.tsx
  2. +1
    -10
      src/app/api/projects/actions.ts
  3. +4
    -7
      src/app/api/staff/index.ts
  4. +3
    -1
      src/components/CreateProject/CreateProject.tsx
  5. +4
    -1
      src/components/CreateProject/CreateProjectWrapper.tsx
  6. +13
    -0
      src/components/CreateProject/ProjectClientDetails.tsx
  7. +57
    -99
      src/components/CreateProject/StaffAllocation.tsx

+ 2
- 1
src/app/(main)/projects/create/page.tsx Wyświetl plik

@@ -7,7 +7,7 @@ import {
fetchProjectServiceTypes, fetchProjectServiceTypes,
fetchProjectWorkNatures, fetchProjectWorkNatures,
} from "@/app/api/projects"; } from "@/app/api/projects";
import { preloadStaff } from "@/app/api/staff";
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff";
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks";
import CreateProject from "@/components/CreateProject"; import CreateProject from "@/components/CreateProject";
import { I18nProvider, getServerI18n } from "@/i18n"; import { I18nProvider, getServerI18n } from "@/i18n";
@@ -31,6 +31,7 @@ const Projects: React.FC = async () => {
fetchProjectServiceTypes(); fetchProjectServiceTypes();
fetchProjectBuildingTypes(); fetchProjectBuildingTypes();
fetchProjectWorkNatures(); fetchProjectWorkNatures();
preloadTeamLeads();
preloadStaff(); preloadStaff();


return ( return (


+ 1
- 10
src/app/api/projects/actions.ts Wyświetl plik

@@ -26,24 +26,15 @@ export interface CreateProjectInputs {
clientContactId: number; clientContactId: number;
clientSubsidiaryId?: number; clientSubsidiaryId?: number;


// Tasks
tasks: {
[taskId: Task["id"]]: {
manhourAllocation: ManhourAllocation;
};
};

// Allocation
totalManhour: number; totalManhour: number;
manhourPercentageByGrade: ManhourAllocation; manhourPercentageByGrade: ManhourAllocation;

taskGroups: { taskGroups: {
[taskGroup: TaskGroup["id"]]: { [taskGroup: TaskGroup["id"]]: {
taskIds: Task["id"][]; taskIds: Task["id"][];
percentAllocation: number; percentAllocation: number;
}; };
}; };

// Staff
allocatedStaffIds: number[]; allocatedStaffIds: number[];


// Milestones // Milestones


+ 4
- 7
src/app/api/staff/index.ts Wyświetl plik

@@ -15,7 +15,7 @@ export interface StaffResult {
grade: string; grade: string;
joinPosition: string; joinPosition: string;
currentPosition: string; currentPosition: string;
data: data
data: data;
} }
export interface searchInput { export interface searchInput {
staffId: string; staffId: string;
@@ -40,10 +40,7 @@ export const preloadStaff = () => {
}; };


export const fetchStaff = cache(async () => { export const fetchStaff = cache(async () => {
return serverFetchJson<StaffResult[]>(`${BASE_API_URL}/staffs`, {
next: { tags: ["staffs"] },
});
return serverFetchJson<StaffResult[]>(`${BASE_API_URL}/staffs`, {
next: { tags: ["staffs"] },
});
}); });




+ 3
- 1
src/components/CreateProject/CreateProject.tsx Wyświetl plik

@@ -49,6 +49,7 @@ export interface Props {
locationTypes: LocationType[]; locationTypes: LocationType[];
buildingTypes: BuildingType[]; buildingTypes: BuildingType[];
workNatures: WorkNature[]; workNatures: WorkNature[];
allStaffs: StaffResult[];


// Mocked // Mocked
grades: Grade[]; grades: Grade[];
@@ -79,6 +80,7 @@ const CreateProject: React.FC<Props> = ({
serviceTypes, serviceTypes,
buildingTypes, buildingTypes,
workNatures, workNatures,
allStaffs,
}) => { }) => {
const [serverError, setServerError] = useState(""); const [serverError, setServerError] = useState("");
const [tabIndex, setTabIndex] = useState(0); const [tabIndex, setTabIndex] = useState(0);
@@ -122,7 +124,6 @@ const CreateProject: React.FC<Props> = ({


const formProps = useForm<CreateProjectInputs>({ const formProps = useForm<CreateProjectInputs>({
defaultValues: { defaultValues: {
tasks: {},
taskGroups: {}, taskGroups: {},
allocatedStaffIds: [], allocatedStaffIds: [],
milestones: {}, milestones: {},
@@ -182,6 +183,7 @@ const CreateProject: React.FC<Props> = ({
isActive={tabIndex === 2} isActive={tabIndex === 2}
allTasks={allTasks} allTasks={allTasks}
grades={grades} grades={grades}
allStaffs={allStaffs}
/> />
} }
{<Milestone allTasks={allTasks} isActive={tabIndex === 3} />} {<Milestone allTasks={allTasks} isActive={tabIndex === 3} />}


+ 4
- 1
src/components/CreateProject/CreateProjectWrapper.tsx Wyświetl plik

@@ -9,7 +9,7 @@ import {
fetchProjectServiceTypes, fetchProjectServiceTypes,
fetchProjectWorkNatures, fetchProjectWorkNatures,
} from "@/app/api/projects"; } from "@/app/api/projects";
import { fetchTeamLeads } from "@/app/api/staff";
import { fetchStaff, fetchTeamLeads } from "@/app/api/staff";
import { fetchAllCustomers } from "@/app/api/customer"; import { fetchAllCustomers } from "@/app/api/customer";


const CreateProjectWrapper: React.FC = async () => { const CreateProjectWrapper: React.FC = async () => {
@@ -25,6 +25,7 @@ const CreateProjectWrapper: React.FC = async () => {
serviceTypes, serviceTypes,
buildingTypes, buildingTypes,
workNatures, workNatures,
allStaffs,
] = await Promise.all([ ] = await Promise.all([
fetchAllTasks(), fetchAllTasks(),
fetchTaskTemplates(), fetchTaskTemplates(),
@@ -37,6 +38,7 @@ const CreateProjectWrapper: React.FC = async () => {
fetchProjectServiceTypes(), fetchProjectServiceTypes(),
fetchProjectBuildingTypes(), fetchProjectBuildingTypes(),
fetchProjectWorkNatures(), fetchProjectWorkNatures(),
fetchStaff(),
]); ]);


return ( return (
@@ -52,6 +54,7 @@ const CreateProjectWrapper: React.FC = async () => {
serviceTypes={serviceTypes} serviceTypes={serviceTypes}
buildingTypes={buildingTypes} buildingTypes={buildingTypes}
workNatures={workNatures} workNatures={workNatures}
allStaffs={allStaffs}
// Mocks // Mocks
grades={[ grades={[
{ name: "Grade 1", id: 1, code: "1" }, { name: "Grade 1", id: 1, code: "1" },


+ 13
- 0
src/components/CreateProject/ProjectClientDetails.tsx Wyświetl plik

@@ -32,6 +32,7 @@ import Link from "next/link";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { fetchCustomer } from "@/app/api/customer/actions"; import { fetchCustomer } from "@/app/api/customer/actions";
import { Checkbox, ListItemText } from "@mui/material"; import { Checkbox, ListItemText } from "@mui/material";
import uniq from "lodash/uniq";


interface Props { interface Props {
isActive: boolean; isActive: boolean;
@@ -64,6 +65,8 @@ const ProjectClientDetails: React.FC<Props> = ({
formState: { errors }, formState: { errors },
watch, watch,
control, control,
setValue,
getValues,
} = useFormContext<CreateProjectInputs>(); } = useFormContext<CreateProjectInputs>();


const selectedCustomerId = watch("clientId"); const selectedCustomerId = watch("clientId");
@@ -95,6 +98,16 @@ const ProjectClientDetails: React.FC<Props> = ({
} }
}, [selectedCustomerId]); }, [selectedCustomerId]);


// Automatically add the team lead to the allocated staff list
const selectedTeamLeadId = watch("projectLeadId");
useEffect(() => {
if (selectedTeamLeadId !== undefined) {
const currentStaffIds = getValues("allocatedStaffIds");
const newList = uniq([...currentStaffIds, selectedTeamLeadId]);
setValue("allocatedStaffIds", newList);
}
}, [getValues, selectedTeamLeadId, setValue]);

const buildingTypeIdNameMap = buildingTypes.reduce<{ [id: number]: string }>( const buildingTypeIdNameMap = buildingTypes.reduce<{ [id: number]: string }>(
(acc, building) => ({ ...acc, [building.id]: building.name }), (acc, building) => ({ ...acc, [building.id]: building.name }),
{}, {},


+ 57
- 99
src/components/CreateProject/StaffAllocation.tsx Wyświetl plik

@@ -1,7 +1,7 @@
"use client"; "use client";


import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import React, { useEffect } from "react";
import React, { useEffect, useMemo } from "react";
import RestartAlt from "@mui/icons-material/RestartAlt"; import RestartAlt from "@mui/icons-material/RestartAlt";
import SearchResults, { Column } from "../SearchResults"; import SearchResults, { Column } from "../SearchResults";
import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material"; import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material";
@@ -26,7 +26,8 @@ import {
Tabs, Tabs,
SelectChangeEvent, SelectChangeEvent,
} from "@mui/material"; } from "@mui/material";
import differenceBy from "lodash/differenceBy";
import differenceWith from "lodash/differenceWith";
import intersectionWith from "lodash/intersectionWith";
import uniq from "lodash/uniq"; import uniq from "lodash/uniq";
import ResourceCapacity from "./ResourceCapacity"; import ResourceCapacity from "./ResourceCapacity";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
@@ -34,65 +35,7 @@ import { CreateProjectInputs } from "@/app/api/projects/actions";
import ResourceAllocation from "./ResourceAllocation"; import ResourceAllocation from "./ResourceAllocation";
import { Task } from "@/app/api/tasks"; import { Task } from "@/app/api/tasks";
import { Grade } from "@/app/api/grades"; import { Grade } from "@/app/api/grades";

interface StaffResult {
id: number;
name: string;
team: string;
grade: string;
title: string;
}

const mockStaffs: StaffResult[] = [
{
name: "Albert",
grade: "1",
id: 1,
team: "ABC",
title: "Associate Quantity Surveyor",
},
{
name: "Bernard",
grade: "2",
id: 2,
team: "ABC",
title: "Quantity Surveyor",
},
{
name: "Carl",
grade: "3",
id: 3,
team: "XYZ",
title: "Senior Quantity Surveyor",
},
{ name: "Denis", grade: "4", id: 4, team: "ABC", title: "Manager" },
{ name: "Edward", grade: "5", id: 5, team: "ABC", title: "Director" },
{ name: "Fred", grade: "1", id: 6, team: "XYZ", title: "General Laborer" },
{ name: "Gordon", grade: "2", id: 7, team: "ABC", title: "Inspector" },
{
name: "Heather",
grade: "3",
id: 8,
team: "XYZ",
title: "Field Engineer",
},
{ name: "Ivan", grade: "4", id: 9, team: "ABC", title: "Senior Manager" },
{
name: "Jackson",
grade: "5",
id: 10,
team: "XYZ",
title: "Senior Director",
},
{
name: "Kurt",
grade: "1",
id: 11,
team: "XYZ",
title: "Construction Assistant",
},
{ name: "Lawrence", grade: "2", id: 12, team: "ABC", title: "Operator" },
];
import { StaffResult } from "@/app/api/staff";


const staffComparator = (a: StaffResult, b: StaffResult) => { const staffComparator = (a: StaffResult, b: StaffResult) => {
return ( return (
@@ -103,7 +46,7 @@ const staffComparator = (a: StaffResult, b: StaffResult) => {
}; };


export interface Props { export interface Props {
allStaff?: StaffResult[];
allStaffs: StaffResult[];
isActive: boolean; isActive: boolean;
defaultManhourBreakdownByGrade?: { [gradeId: number]: number }; defaultManhourBreakdownByGrade?: { [gradeId: number]: number };
allTasks: Task[]; allTasks: Task[];
@@ -111,43 +54,50 @@ export interface Props {
} }


const StaffAllocation: React.FC<Props> = ({ const StaffAllocation: React.FC<Props> = ({
allStaff = mockStaffs,
allStaffs: dataStaffs,
allTasks, allTasks,
isActive, isActive,
defaultManhourBreakdownByGrade, defaultManhourBreakdownByGrade,
grades, grades,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setValue, getValues } = useFormContext<CreateProjectInputs>();
const { setValue, getValues, watch } = useFormContext<CreateProjectInputs>();

// TODO: remove this when grade and positions are done
const allStaffs = useMemo<StaffResult[]>(() => {
return dataStaffs.map((staff, index) => ({
...staff,
grade: grades[index % grades.length].name,
currentPosition: `Mock Postion ${index}`,
}));
}, [dataStaffs, grades]);


const [filteredStaff, setFilteredStaff] = React.useState( const [filteredStaff, setFilteredStaff] = React.useState(
allStaff.sort(staffComparator),
);
const [selectedStaff, setSelectedStaff] = React.useState<
typeof filteredStaff
>(
allStaff.filter((staff) =>
getValues("allocatedStaffIds").includes(staff.id),
),
allStaffs.sort(staffComparator),
); );
const selectedStaffIds = watch("allocatedStaffIds");


// Adding / Removing staff // Adding / Removing staff
const addStaff = React.useCallback((staff: StaffResult) => {
setSelectedStaff((staffs) => [...staffs, staff]);
}, []);
const removeStaff = React.useCallback((staff: StaffResult) => {
setSelectedStaff((staffs) => staffs.filter((s) => s.id !== staff.id));
}, []);
const addStaff = React.useCallback(
(staff: StaffResult) => {
const currentStaffIds = getValues("allocatedStaffIds");
setValue("allocatedStaffIds", [...currentStaffIds, staff.id]);
},
[getValues, setValue],
);
const removeStaff = React.useCallback(
(staff: StaffResult) => {
const currentStaffIds = getValues("allocatedStaffIds");
setValue(
"allocatedStaffIds",
currentStaffIds.filter((id) => id !== staff.id),
);
},
[getValues, setValue],
);
const clearStaff = React.useCallback(() => { const clearStaff = React.useCallback(() => {
setSelectedStaff([]);
}, []);
// Sync with form
useEffect(() => {
setValue(
"allocatedStaffIds",
selectedStaff.map((staff) => staff.id),
);
}, [selectedStaff, setValue]);
setValue("allocatedStaffIds", []);
}, [setValue]);


const staffPoolColumns = React.useMemo<Column<StaffResult>[]>( const staffPoolColumns = React.useMemo<Column<StaffResult>[]>(
() => [ () => [
@@ -159,9 +109,9 @@ const StaffAllocation: React.FC<Props> = ({
}, },
{ label: t("Team"), name: "team" }, { label: t("Team"), name: "team" },
{ label: t("Grade"), name: "grade" }, { label: t("Grade"), name: "grade" },
{ label: t("Staff ID"), name: "id" },
{ label: t("Staff ID"), name: "staffId" },
{ label: t("Staff Name"), name: "name" }, { label: t("Staff Name"), name: "name" },
{ label: t("Title"), name: "title" },
{ label: t("Title"), name: "currentPosition" },
], ],
[addStaff, t], [addStaff, t],
); );
@@ -178,7 +128,7 @@ const StaffAllocation: React.FC<Props> = ({
{ label: t("Grade"), name: "grade" }, { label: t("Grade"), name: "grade" },
{ label: t("Staff ID"), name: "id" }, { label: t("Staff ID"), name: "id" },
{ label: t("Staff Name"), name: "name" }, { label: t("Staff Name"), name: "name" },
{ label: t("Title"), name: "title" },
{ label: t("Title"), name: "currentPosition" },
], ],
[removeStaff, t], [removeStaff, t],
); );
@@ -202,12 +152,12 @@ const StaffAllocation: React.FC<Props> = ({
(acc, filter) => { (acc, filter) => {
return { return {
...acc, ...acc,
[filter]: uniq(allStaff.map((staff) => staff[filter])),
[filter]: uniq(allStaffs.map((staff) => staff[filter])),
}; };
}, },
{}, {},
); );
}, [columnFilters, allStaff]);
}, [columnFilters, allStaffs]);
const defaultFilterValues = React.useMemo(() => { const defaultFilterValues = React.useMemo(() => {
return columnFilters.reduce<{ [filter in keyof StaffResult]?: string }>( return columnFilters.reduce<{ [filter in keyof StaffResult]?: string }>(
(acc, filter) => { (acc, filter) => {
@@ -224,14 +174,14 @@ const StaffAllocation: React.FC<Props> = ({
[], [],
); );


React.useEffect(() => {
useEffect(() => {
setFilteredStaff( setFilteredStaff(
allStaff.filter((staff) => {
allStaffs.filter((staff) => {
const q = query.toLowerCase(); const q = query.toLowerCase();
return ( return (
(staff.name.toLowerCase().includes(q) || (staff.name.toLowerCase().includes(q) ||
staff.id.toString().includes(q) || staff.id.toString().includes(q) ||
staff.title.toLowerCase().includes(q)) &&
staff.currentPosition.toLowerCase().includes(q)) &&
Object.entries(filters).every(([filterKey, filterValue]) => { Object.entries(filters).every(([filterKey, filterValue]) => {
const staffColumnValue = staff[filterKey as keyof StaffResult]; const staffColumnValue = staff[filterKey as keyof StaffResult];
return staffColumnValue === filterValue || filterValue === "All"; return staffColumnValue === filterValue || filterValue === "All";
@@ -239,7 +189,7 @@ const StaffAllocation: React.FC<Props> = ({
); );
}), }),
); );
}, [allStaff, filters, query]);
}, [allStaffs, filters, query]);


// Tab related // Tab related
const [tabIndex, setTabIndex] = React.useState(0); const [tabIndex, setTabIndex] = React.useState(0);
@@ -319,21 +269,29 @@ const StaffAllocation: React.FC<Props> = ({
<Tabs value={tabIndex} onChange={handleTabChange}> <Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Staff Pool")} /> <Tab label={t("Staff Pool")} />
<Tab <Tab
label={`${t("Allocated Staff")} (${selectedStaff.length})`}
label={`${t("Allocated Staff")} (${selectedStaffIds.length})`}
/> />
</Tabs> </Tabs>
<Box sx={{ marginInline: -3 }}> <Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && ( {tabIndex === 0 && (
<SearchResults <SearchResults
noWrapper noWrapper
items={differenceBy(filteredStaff, selectedStaff, "id")}
items={differenceWith(
filteredStaff,
selectedStaffIds,
(staff, staffId) => staff.id === staffId,
)}
columns={staffPoolColumns} columns={staffPoolColumns}
/> />
)} )}
{tabIndex === 1 && ( {tabIndex === 1 && (
<SearchResults <SearchResults
noWrapper noWrapper
items={selectedStaff}
items={intersectionWith(
allStaffs,
selectedStaffIds,
(staff, staffId) => staff.id === staffId,
)}
columns={allocatedStaffColumns} columns={allocatedStaffColumns}
/> />
)} )}


Ładowanie…
Anuluj
Zapisz