@@ -0,0 +1,50 @@ | |||||
"use server"; | |||||
import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
import { BASE_API_URL } from "@/config/api"; | |||||
import { Task, TaskGroup } from "../tasks"; | |||||
export interface CreateProjectInputs { | |||||
// Project details | |||||
projectCode: string; | |||||
projectSubcode: string; | |||||
projectName: string; | |||||
projectCategory: string; | |||||
projectDescription: string; | |||||
// Client details | |||||
clientCode: string; | |||||
clientName: string; | |||||
clientPhone: string; | |||||
clientEmail: string; | |||||
clientSubsidiary: string; | |||||
// Tasks | |||||
tasks: { | |||||
[taskId: Task["id"]]: { | |||||
manhourAllocation: { | |||||
[grade: string]: number; | |||||
}; | |||||
}; | |||||
}; | |||||
// Staff | |||||
allocatedStaffIds: number[]; | |||||
// Milestones | |||||
milestones: { | |||||
[taskGroupId: TaskGroup["id"]]: { | |||||
startDate: string; | |||||
endDate: string; | |||||
payments: []; | |||||
}; | |||||
}; | |||||
} | |||||
export const saveProject = async (data: CreateProjectInputs) => { | |||||
return serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||||
method: "POST", | |||||
body: JSON.stringify(data), | |||||
headers: { "Content-Type": "application/json" }, | |||||
}); | |||||
}; |
@@ -14,12 +14,14 @@ import TaskSetup from "./TaskSetup"; | |||||
import StaffAllocation from "./StaffAllocation"; | import StaffAllocation from "./StaffAllocation"; | ||||
import ResourceMilestone from "./ResourceMilestone"; | import ResourceMilestone from "./ResourceMilestone"; | ||||
import { Task } from "@/app/api/tasks"; | import { Task } from "@/app/api/tasks"; | ||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
export interface Props { | export interface Props { | ||||
mockTasks: Task[]; | |||||
allTasks: Task[]; | |||||
} | } | ||||
const CreateProject: React.FC<Props> = ({ mockTasks }) => { | |||||
const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||||
const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
const router = useRouter(); | const router = useRouter(); | ||||
@@ -35,27 +37,49 @@ const CreateProject: React.FC<Props> = ({ mockTasks }) => { | |||||
[], | [], | ||||
); | ); | ||||
const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>((data) => { | |||||
console.log(data); | |||||
}, []); | |||||
const formProps = useForm<CreateProjectInputs>({ | |||||
defaultValues: { | |||||
tasks: {}, | |||||
allocatedStaffIds: [], | |||||
milestones: {}, | |||||
}, | |||||
}); | |||||
return ( | return ( | ||||
<> | |||||
<Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||||
<Tab label={t("Project and Client Details")} /> | |||||
<Tab label={t("Project Task Setup")} /> | |||||
<Tab label={t("Staff Allocation")} /> | |||||
<Tab label={t("Resource and Milestone")} /> | |||||
</Tabs> | |||||
{tabIndex === 0 && <ProjectClientDetails />} | |||||
{tabIndex === 1 && <TaskSetup />} | |||||
{tabIndex === 2 && <StaffAllocation initiallySelectedStaff={[]} />} | |||||
{tabIndex === 3 && <ResourceMilestone tasks={mockTasks} />} | |||||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
<Button variant="outlined" startIcon={<Close />} onClick={handleCancel}> | |||||
{t("Cancel")} | |||||
</Button> | |||||
<Button variant="contained" startIcon={<Check />}> | |||||
{t("Confirm")} | |||||
</Button> | |||||
<FormProvider {...formProps}> | |||||
<Stack | |||||
spacing={2} | |||||
component="form" | |||||
onSubmit={formProps.handleSubmit(onSubmit)} | |||||
> | |||||
<Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||||
<Tab label={t("Project and Client Details")} /> | |||||
<Tab label={t("Project Task Setup")} /> | |||||
<Tab label={t("Staff Allocation")} /> | |||||
<Tab label={t("Resource and Milestone")} /> | |||||
</Tabs> | |||||
{tabIndex === 0 && <ProjectClientDetails />} | |||||
{tabIndex === 1 && <TaskSetup allTasks={allTasks} />} | |||||
{tabIndex === 2 && <StaffAllocation />} | |||||
{tabIndex === 3 && <ResourceMilestone allTasks={allTasks} />} | |||||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Close />} | |||||
onClick={handleCancel} | |||||
> | |||||
{t("Cancel")} | |||||
</Button> | |||||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||||
{t("Confirm")} | |||||
</Button> | |||||
</Stack> | |||||
</Stack> | </Stack> | ||||
</> | |||||
</FormProvider> | |||||
); | ); | ||||
}; | }; | ||||
@@ -4,7 +4,7 @@ import CreateProject from "./CreateProject"; | |||||
const CreateProjectWrapper: React.FC = async () => { | const CreateProjectWrapper: React.FC = async () => { | ||||
const tasks = await fetchAllTasks(); | const tasks = await fetchAllTasks(); | ||||
return <CreateProject mockTasks={tasks} />; | |||||
return <CreateProject allTasks={tasks} />; | |||||
}; | }; | ||||
export default CreateProjectWrapper; | export default CreateProjectWrapper; |
@@ -15,9 +15,12 @@ import { useTranslation } from "react-i18next"; | |||||
import CardActions from "@mui/material/CardActions"; | import CardActions from "@mui/material/CardActions"; | ||||
import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
import { useFormContext } from "react-hook-form"; | |||||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
const ProjectClientDetails: React.FC = () => { | const ProjectClientDetails: React.FC = () => { | ||||
const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
const { register } = useFormContext<CreateProjectInputs>(); | |||||
return ( | return ( | ||||
<Card> | <Card> | ||||
@@ -28,13 +31,25 @@ const ProjectClientDetails: React.FC = () => { | |||||
</Typography> | </Typography> | ||||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Project Code")} fullWidth /> | |||||
<TextField | |||||
label={t("Project Code")} | |||||
fullWidth | |||||
{...register("projectCode")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Project Subcode")} fullWidth /> | |||||
<TextField | |||||
label={t("Project Subcode")} | |||||
fullWidth | |||||
{...register("projectSubcode")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Project Name")} fullWidth /> | |||||
<TextField | |||||
label={t("Project Name")} | |||||
fullWidth | |||||
{...register("projectName")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<FormControl fullWidth> | <FormControl fullWidth> | ||||
@@ -60,7 +75,11 @@ const ProjectClientDetails: React.FC = () => { | |||||
</FormControl> | </FormControl> | ||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Project Description")} fullWidth /> | |||||
<TextField | |||||
label={t("Project Description")} | |||||
fullWidth | |||||
{...register("projectDescription")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
</Grid> | </Grid> | ||||
</Box> | </Box> | ||||
@@ -71,16 +90,32 @@ const ProjectClientDetails: React.FC = () => { | |||||
</Typography> | </Typography> | ||||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Client Code and Name")} fullWidth /> | |||||
<TextField | |||||
label={t("Client Code and Name")} | |||||
fullWidth | |||||
{...register("clientCode")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Client Lead Name")} fullWidth /> | |||||
<TextField | |||||
label={t("Client Lead Name")} | |||||
fullWidth | |||||
{...register("clientName")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Client Lead Phone Number")} fullWidth /> | |||||
<TextField | |||||
label={t("Client Lead Phone Number")} | |||||
fullWidth | |||||
{...register("clientPhone")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<TextField label={t("Client Lead Email")} fullWidth /> | |||||
<TextField | |||||
label={t("Client Lead Email")} | |||||
fullWidth | |||||
{...register("clientEmail")} | |||||
/> | |||||
</Grid> | </Grid> | ||||
<Grid item xs={6}> | <Grid item xs={6}> | ||||
<FormControl fullWidth> | <FormControl fullWidth> | ||||
@@ -5,18 +5,24 @@ import CardContent from "@mui/material/CardContent"; | |||||
import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
import React, { useCallback, useMemo, useState } from "react"; | |||||
import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
import CardActions from "@mui/material/CardActions"; | import CardActions from "@mui/material/CardActions"; | ||||
import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
import { | import { | ||||
Alert, | Alert, | ||||
Box, | |||||
FormControl, | FormControl, | ||||
Grid, | Grid, | ||||
InputLabel, | InputLabel, | ||||
List, | |||||
ListItemButton, | |||||
ListItemText, | |||||
MenuItem, | MenuItem, | ||||
Paper, | |||||
Select, | Select, | ||||
SelectChangeEvent, | SelectChangeEvent, | ||||
Stack, | Stack, | ||||
TextField, | |||||
} from "@mui/material"; | } from "@mui/material"; | ||||
import { Task, TaskGroup } from "@/app/api/tasks"; | import { Task, TaskGroup } from "@/app/api/tasks"; | ||||
import uniqBy from "lodash/uniqBy"; | import uniqBy from "lodash/uniqBy"; | ||||
@@ -24,13 +30,26 @@ import { moneyFormatter } from "@/app/utils/formatUtil"; | |||||
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | ||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
import { useFormContext } from "react-hook-form"; | |||||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
interface Props { | interface Props { | ||||
allTasks: Task[]; | |||||
} | |||||
interface ResourceSectionProps { | |||||
tasks: Task[]; | tasks: Task[]; | ||||
defaultManhourBreakdownByGrade: { [grade: string]: number }; | |||||
onSetManhours: (hours: number, taskId: Task["id"]) => void; | |||||
onAllocateManhours: () => void; | |||||
} | } | ||||
const ResourceMilestone: React.FC<Props> = ({ tasks }) => { | |||||
const ResourceMilestone: React.FC<Props> = ({ allTasks }) => { | |||||
const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
const { getValues } = useFormContext<CreateProjectInputs>(); | |||||
const tasks = useMemo(() => { | |||||
return allTasks.filter((task) => getValues("tasks")[task.id]); | |||||
}, [allTasks, getValues]); | |||||
const taskGroups = useMemo(() => { | const taskGroups = useMemo(() => { | ||||
return uniqBy( | return uniqBy( | ||||
@@ -41,18 +60,23 @@ const ResourceMilestone: React.FC<Props> = ({ tasks }) => { | |||||
const [currentTaskGroupId, setCurrentTaskGroupId] = useState( | const [currentTaskGroupId, setCurrentTaskGroupId] = useState( | ||||
taskGroups[0].id, | taskGroups[0].id, | ||||
); | ); | ||||
const [currentTasks, setCurrentTasks] = useState<typeof tasks>( | |||||
tasks.filter((t) => t.taskGroup.id === currentTaskGroupId), | |||||
); | |||||
const onSelectTaskGroup = useCallback( | const onSelectTaskGroup = useCallback( | ||||
(event: SelectChangeEvent<TaskGroup["id"]>) => { | (event: SelectChangeEvent<TaskGroup["id"]>) => { | ||||
const id = event.target.value; | const id = event.target.value; | ||||
setCurrentTaskGroupId(typeof id === "string" ? parseInt(id) : id); | |||||
const newTaksGroupId = typeof id === "string" ? parseInt(id) : id; | |||||
setCurrentTaskGroupId(newTaksGroupId); | |||||
setCurrentTasks(tasks.filter((t) => t.taskGroup.id === newTaksGroupId)); | |||||
}, | }, | ||||
[], | |||||
[tasks], | |||||
); | ); | ||||
return ( | return ( | ||||
<> | <> | ||||
<Card> | <Card> | ||||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||||
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |||||
<FormControl> | <FormControl> | ||||
<InputLabel>{t("Task Stage")}</InputLabel> | <InputLabel>{t("Task Stage")}</InputLabel> | ||||
<Select | <Select | ||||
@@ -67,32 +91,13 @@ const ResourceMilestone: React.FC<Props> = ({ tasks }) => { | |||||
))} | ))} | ||||
</Select> | </Select> | ||||
</FormControl> | </FormControl> | ||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
{t("Resource")} | |||||
</Typography> | |||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
{t("Milestone")} | |||||
</Typography> | |||||
<LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
<Grid item xs> | |||||
<FormControl fullWidth> | |||||
<DatePicker | |||||
label={t("Stage Start Date")} | |||||
defaultValue={dayjs()} | |||||
/> | |||||
</FormControl> | |||||
</Grid> | |||||
<Grid item xs> | |||||
<FormControl fullWidth> | |||||
<DatePicker | |||||
label={t("Stage End Date")} | |||||
defaultValue={dayjs()} | |||||
/> | |||||
</FormControl> | |||||
</Grid> | |||||
</Grid> | |||||
</LocalizationProvider> | |||||
<ResourceSection | |||||
tasks={currentTasks} | |||||
defaultManhourBreakdownByGrade={{}} | |||||
onSetManhours={() => {}} | |||||
onAllocateManhours={() => {}} | |||||
/> | |||||
<MilestoneSection /> | |||||
<CardActions sx={{ justifyContent: "flex-end" }}> | <CardActions sx={{ justifyContent: "flex-end" }}> | ||||
<Button variant="text" startIcon={<RestartAlt />}> | <Button variant="text" startIcon={<RestartAlt />}> | ||||
{t("Reset")} | {t("Reset")} | ||||
@@ -112,6 +117,85 @@ const ResourceMilestone: React.FC<Props> = ({ tasks }) => { | |||||
); | ); | ||||
}; | }; | ||||
const ResourceSection: React.FC<ResourceSectionProps> = ({ | |||||
tasks, | |||||
onAllocateManhours, | |||||
onSetManhours, | |||||
defaultManhourBreakdownByGrade, | |||||
}) => { | |||||
const { t } = useTranslation(); | |||||
const [selectedTaskId, setSelectedTaskId] = useState(tasks[0].id); | |||||
const makeOnTaskSelect = useCallback( | |||||
(taskId: Task["id"]): React.MouseEventHandler => | |||||
() => { | |||||
return setSelectedTaskId(taskId); | |||||
}, | |||||
[], | |||||
); | |||||
useEffect(() => { | |||||
setSelectedTaskId(tasks[0].id); | |||||
}, [tasks]); | |||||
return ( | |||||
<Box marginBlock={4}> | |||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
{t("Resource")} | |||||
</Typography> | |||||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
<Grid item xs={6}> | |||||
<Paper elevation={2}> | |||||
<List dense sx={{ maxHeight: 300, overflow: "auto" }}> | |||||
{tasks.map((task, index) => { | |||||
return ( | |||||
<ListItemButton | |||||
selected={selectedTaskId === task.id} | |||||
key={`${task.id}-${index}`} | |||||
onClick={makeOnTaskSelect(task.id)} | |||||
> | |||||
<ListItemText primary={task.name} /> | |||||
</ListItemButton> | |||||
); | |||||
})} | |||||
</List> | |||||
</Paper> | |||||
</Grid> | |||||
<Grid item xs={6}> | |||||
<TextField label={t("Mahours Allocated to Task")} fullWidth /> | |||||
</Grid> | |||||
</Grid> | |||||
</Box> | |||||
); | |||||
}; | |||||
const MilestoneSection: React.FC = () => { | |||||
const { t } = useTranslation(); | |||||
return ( | |||||
<Box> | |||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
{t("Milestone")} | |||||
</Typography> | |||||
<LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||||
<Grid item xs> | |||||
<FormControl fullWidth> | |||||
<DatePicker | |||||
label={t("Stage Start Date")} | |||||
defaultValue={dayjs()} | |||||
/> | |||||
</FormControl> | |||||
</Grid> | |||||
<Grid item xs> | |||||
<FormControl fullWidth> | |||||
<DatePicker label={t("Stage End Date")} defaultValue={dayjs()} /> | |||||
</FormControl> | |||||
</Grid> | |||||
</Grid> | |||||
</LocalizationProvider> | |||||
</Box> | |||||
); | |||||
}; | |||||
const NoTaskState: React.FC = () => { | const NoTaskState: React.FC = () => { | ||||
const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
return ( | return ( | ||||
@@ -126,7 +210,9 @@ const NoTaskState: React.FC = () => { | |||||
}; | }; | ||||
const ResourceMilestoneWrapper: React.FC<Props> = (props) => { | const ResourceMilestoneWrapper: React.FC<Props> = (props) => { | ||||
if (props.tasks.length === 0) { | |||||
const { getValues } = useFormContext<CreateProjectInputs>(); | |||||
if (Object.keys(getValues("tasks")).length === 0) { | |||||
return <NoTaskState />; | return <NoTaskState />; | ||||
} | } | ||||
@@ -1,7 +1,7 @@ | |||||
"use client"; | "use client"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import React from "react"; | |||||
import React, { useEffect } 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"; | ||||
@@ -29,9 +29,11 @@ import { | |||||
import differenceBy from "lodash/differenceBy"; | import differenceBy from "lodash/differenceBy"; | ||||
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 { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
interface StaffResult { | interface StaffResult { | ||||
id: string; | |||||
id: number; | |||||
name: string; | name: string; | ||||
team: string; | team: string; | ||||
grade: string; | grade: string; | ||||
@@ -42,67 +44,69 @@ const mockStaffs: StaffResult[] = [ | |||||
{ | { | ||||
name: "Albert", | name: "Albert", | ||||
grade: "1", | grade: "1", | ||||
id: "1", | |||||
id: 1, | |||||
team: "ABC", | team: "ABC", | ||||
title: "Associate Quantity Surveyor", | title: "Associate Quantity Surveyor", | ||||
}, | }, | ||||
{ | { | ||||
name: "Bernard", | name: "Bernard", | ||||
grade: "2", | grade: "2", | ||||
id: "2", | |||||
id: 2, | |||||
team: "ABC", | team: "ABC", | ||||
title: "Quantity Surveyor", | title: "Quantity Surveyor", | ||||
}, | }, | ||||
{ | { | ||||
name: "Carl", | name: "Carl", | ||||
grade: "3", | grade: "3", | ||||
id: "3", | |||||
id: 3, | |||||
team: "XYZ", | team: "XYZ", | ||||
title: "Senior Quantity Surveyor", | 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: "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", | name: "Heather", | ||||
grade: "3", | grade: "3", | ||||
id: "8", | |||||
id: 8, | |||||
team: "XYZ", | team: "XYZ", | ||||
title: "Field Engineer", | title: "Field Engineer", | ||||
}, | }, | ||||
{ name: "Ivan", grade: "4", id: "9", team: "ABC", title: "Senior Manager" }, | |||||
{ name: "Ivan", grade: "4", id: 9, team: "ABC", title: "Senior Manager" }, | |||||
{ | { | ||||
name: "Jackson", | name: "Jackson", | ||||
grade: "5", | grade: "5", | ||||
id: "10", | |||||
id: 10, | |||||
team: "XYZ", | team: "XYZ", | ||||
title: "Senior Director", | title: "Senior Director", | ||||
}, | }, | ||||
{ | { | ||||
name: "Kurt", | name: "Kurt", | ||||
grade: "1", | grade: "1", | ||||
id: "11", | |||||
id: 11, | |||||
team: "ABC", | team: "ABC", | ||||
title: "Construction Assistant", | title: "Construction Assistant", | ||||
}, | }, | ||||
{ name: "Lawrence", grade: "2", id: "12", team: "ABC", title: "Operator" }, | |||||
{ name: "Lawrence", grade: "2", id: 12, team: "ABC", title: "Operator" }, | |||||
]; | ]; | ||||
interface Props { | interface Props { | ||||
allStaff?: StaffResult[]; | allStaff?: StaffResult[]; | ||||
initiallySelectedStaff: StaffResult[]; | |||||
} | } | ||||
const StaffAllocation: React.FC<Props> = ({ | |||||
allStaff = mockStaffs, | |||||
initiallySelectedStaff, | |||||
}) => { | |||||
const StaffAllocation: React.FC<Props> = ({ allStaff = mockStaffs }) => { | |||||
const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
const { setValue, getValues } = useFormContext<CreateProjectInputs>(); | |||||
const [filteredStaff, setFilteredStaff] = React.useState(allStaff); | const [filteredStaff, setFilteredStaff] = React.useState(allStaff); | ||||
const [selectedStaff, setSelectedStaff] = React.useState< | const [selectedStaff, setSelectedStaff] = React.useState< | ||||
typeof filteredStaff | typeof filteredStaff | ||||
>(initiallySelectedStaff); | |||||
>( | |||||
allStaff.filter((staff) => | |||||
getValues("allocatedStaffIds").includes(staff.id), | |||||
), | |||||
); | |||||
// Adding / Removing staff | // Adding / Removing staff | ||||
const addStaff = React.useCallback((staff: StaffResult) => { | const addStaff = React.useCallback((staff: StaffResult) => { | ||||
@@ -114,6 +118,13 @@ const StaffAllocation: React.FC<Props> = ({ | |||||
const clearStaff = React.useCallback(() => { | const clearStaff = React.useCallback(() => { | ||||
setSelectedStaff([]); | setSelectedStaff([]); | ||||
}, []); | }, []); | ||||
// Sync with form | |||||
useEffect(() => { | |||||
setValue( | |||||
"allocatedStaffIds", | |||||
selectedStaff.map((staff) => staff.id), | |||||
); | |||||
}, [selectedStaff, setValue]); | |||||
const staffPoolColumns = React.useMemo<Column<StaffResult>[]>( | const staffPoolColumns = React.useMemo<Column<StaffResult>[]>( | ||||
() => [ | () => [ | ||||
@@ -196,7 +207,7 @@ const StaffAllocation: React.FC<Props> = ({ | |||||
const q = query.toLowerCase(); | const q = query.toLowerCase(); | ||||
return ( | return ( | ||||
(staff.name.toLowerCase().includes(q) || | (staff.name.toLowerCase().includes(q) || | ||||
staff.id.toLowerCase().includes(q) || | |||||
staff.id.toString().includes(q) || | |||||
staff.title.toLowerCase().includes(q)) && | staff.title.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]; | ||||
@@ -279,7 +290,9 @@ const StaffAllocation: React.FC<Props> = ({ | |||||
</Grid> | </Grid> | ||||
<Tabs value={tabIndex} onChange={handleTabChange}> | <Tabs value={tabIndex} onChange={handleTabChange}> | ||||
<Tab label={t("Staff Pool")} /> | <Tab label={t("Staff Pool")} /> | ||||
<Tab label={`${t("Allocated Staff")} (${selectedStaff.length})`} /> | |||||
<Tab | |||||
label={`${t("Allocated Staff")} (${selectedStaff.length})`} | |||||
/> | |||||
</Tabs> | </Tabs> | ||||
<Box sx={{ marginInline: -3 }}> | <Box sx={{ marginInline: -3 }}> | ||||
{tabIndex === 0 && ( | {tabIndex === 0 && ( | ||||
@@ -7,16 +7,35 @@ import Typography from "@mui/material/Typography"; | |||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import TransferList from "../TransferList"; | import TransferList from "../TransferList"; | ||||
import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
import React from "react"; | |||||
import React, { useMemo } from "react"; | |||||
import CardActions from "@mui/material/CardActions"; | import CardActions from "@mui/material/CardActions"; | ||||
import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
import FormControl from "@mui/material/FormControl"; | import FormControl from "@mui/material/FormControl"; | ||||
import Select from "@mui/material/Select"; | import Select from "@mui/material/Select"; | ||||
import MenuItem from "@mui/material/MenuItem"; | import MenuItem from "@mui/material/MenuItem"; | ||||
import InputLabel from "@mui/material/InputLabel"; | import InputLabel from "@mui/material/InputLabel"; | ||||
import { Task } from "@/app/api/tasks"; | |||||
import { useFormContext } from "react-hook-form"; | |||||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
const TaskSetup = () => { | |||||
interface Props { | |||||
allTasks: Task[]; | |||||
} | |||||
const TaskSetup: React.FC<Props> = ({ allTasks: tasks }) => { | |||||
const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
const { getValues, setValue } = useFormContext<CreateProjectInputs>(); | |||||
const currentTasks = getValues("tasks"); | |||||
const items = useMemo( | |||||
() => tasks.map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })), | |||||
[tasks], | |||||
); | |||||
const selectedItems = useMemo(() => { | |||||
return tasks | |||||
.filter((t) => currentTasks[t.id]) | |||||
.map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })); | |||||
}, [currentTasks, tasks]); | |||||
return ( | return ( | ||||
<Card> | <Card> | ||||
@@ -45,15 +64,22 @@ const TaskSetup = () => { | |||||
</Grid> | </Grid> | ||||
</Grid> | </Grid> | ||||
<TransferList | <TransferList | ||||
allItems={[ | |||||
{ id: 1, label: "Task 1" }, | |||||
{ id: 2, label: "Task 2" }, | |||||
{ id: 3, label: "Task 3" }, | |||||
{ id: 4, label: "Task 4" }, | |||||
{ id: 5, label: "Task 5" }, | |||||
]} | |||||
initiallySelectedItems={[]} | |||||
onChange={() => {}} | |||||
allItems={items} | |||||
initiallySelectedItems={selectedItems} | |||||
onChange={(selectedTasks) => { | |||||
const newTasks = selectedTasks.reduce<CreateProjectInputs["tasks"]>( | |||||
(acc, item) => { | |||||
// Reuse the task from currentTasks if present | |||||
return { | |||||
...acc, | |||||
[item.id]: currentTasks[item.id] ?? { manhourAllocation: {} }, | |||||
}; | |||||
}, | |||||
{}, | |||||
); | |||||
setValue("tasks", newTasks); | |||||
}} | |||||
allItemsLabel={t("Task Pool")} | allItemsLabel={t("Task Pool")} | ||||
selectedItemsLabel={t("Project Task List")} | selectedItemsLabel={t("Project Task List")} | ||||
/> | /> | ||||
@@ -11,7 +11,7 @@ import Divider from "@mui/material/Divider"; | |||||
import ChevronLeft from "@mui/icons-material/ChevronLeft"; | import ChevronLeft from "@mui/icons-material/ChevronLeft"; | ||||
import ChevronRight from "@mui/icons-material/ChevronRight"; | import ChevronRight from "@mui/icons-material/ChevronRight"; | ||||
import intersection from "lodash/intersection"; | import intersection from "lodash/intersection"; | ||||
import difference from "lodash/difference"; | |||||
import differenceBy from "lodash/differenceBy"; | |||||
import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
import Paper from "@mui/material/Paper"; | import Paper from "@mui/material/Paper"; | ||||
import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
@@ -163,7 +163,7 @@ const TransferList: React.FC<TransferListProps> = ({ | |||||
const [checkedList, setCheckedList] = React.useState<LabelWithId[]>([]); | const [checkedList, setCheckedList] = React.useState<LabelWithId[]>([]); | ||||
const [leftList, setLeftList] = React.useState<LabelWithId[]>( | const [leftList, setLeftList] = React.useState<LabelWithId[]>( | ||||
difference(allItems, initiallySelectedItems), | |||||
differenceBy(allItems, initiallySelectedItems, "id"), | |||||
); | ); | ||||
const [rightList, setRightList] = React.useState<LabelWithId[]>( | const [rightList, setRightList] = React.useState<LabelWithId[]>( | ||||
initiallySelectedItems, | initiallySelectedItems, | ||||
@@ -176,7 +176,7 @@ const TransferList: React.FC<TransferListProps> = ({ | |||||
(value: LabelWithId) => () => { | (value: LabelWithId) => () => { | ||||
const isChecked = checkedList.includes(value); | const isChecked = checkedList.includes(value); | ||||
const newCheckedList = isChecked | const newCheckedList = isChecked | ||||
? difference(checkedList, [value]) | |||||
? differenceBy(checkedList, [value], "id") | |||||
: [...checkedList, value]; | : [...checkedList, value]; | ||||
setCheckedList(newCheckedList); | setCheckedList(newCheckedList); | ||||
@@ -187,7 +187,7 @@ const TransferList: React.FC<TransferListProps> = ({ | |||||
const handleToggleAll = React.useCallback( | const handleToggleAll = React.useCallback( | ||||
(items: LabelWithId[], checkedItems: LabelWithId[]) => () => { | (items: LabelWithId[], checkedItems: LabelWithId[]) => () => { | ||||
if (checkedItems.length === items.length) { | if (checkedItems.length === items.length) { | ||||
setCheckedList(difference(checkedList, checkedItems)); | |||||
setCheckedList(differenceBy(checkedList, checkedItems, "id")); | |||||
} else { | } else { | ||||
setCheckedList([...checkedList, ...items]); | setCheckedList([...checkedList, ...items]); | ||||
} | } | ||||
@@ -197,14 +197,16 @@ const TransferList: React.FC<TransferListProps> = ({ | |||||
const handleCheckedRight = () => { | const handleCheckedRight = () => { | ||||
setRightList([...rightList, ...leftListChecked].sort(compareFn)); | setRightList([...rightList, ...leftListChecked].sort(compareFn)); | ||||
setLeftList(difference(leftList, leftListChecked).sort(compareFn)); | |||||
setCheckedList(difference(checkedList, leftListChecked)); | |||||
setLeftList(differenceBy(leftList, leftListChecked, "id").sort(compareFn)); | |||||
setCheckedList(differenceBy(checkedList, leftListChecked, "id")); | |||||
}; | }; | ||||
const handleCheckedLeft = () => { | const handleCheckedLeft = () => { | ||||
setLeftList([...leftList, ...rightListChecked].sort(compareFn)); | setLeftList([...leftList, ...rightListChecked].sort(compareFn)); | ||||
setRightList(difference(rightList, rightListChecked).sort(compareFn)); | |||||
setCheckedList(difference(checkedList, rightListChecked)); | |||||
setRightList( | |||||
differenceBy(rightList, rightListChecked, "id").sort(compareFn), | |||||
); | |||||
setCheckedList(differenceBy(checkedList, rightListChecked, "id")); | |||||
}; | }; | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||