| @@ -14,13 +14,32 @@ import TaskSetup from "./TaskSetup"; | |||
| import StaffAllocation from "./StaffAllocation"; | |||
| import ResourceMilestone from "./ResourceMilestone"; | |||
| import { Task } from "@/app/api/tasks"; | |||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { | |||
| FieldErrors, | |||
| FormProvider, | |||
| SubmitErrorHandler, | |||
| SubmitHandler, | |||
| useForm, | |||
| } from "react-hook-form"; | |||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
| import { Error } from "@mui/icons-material"; | |||
| export interface Props { | |||
| allTasks: Task[]; | |||
| } | |||
| const hasErrorsInTab = ( | |||
| tabIndex: number, | |||
| errors: FieldErrors<CreateProjectInputs>, | |||
| ) => { | |||
| switch (tabIndex) { | |||
| case 0: | |||
| return errors.projectName; | |||
| default: | |||
| false; | |||
| } | |||
| }; | |||
| const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const { t } = useTranslation(); | |||
| @@ -41,6 +60,16 @@ const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||
| console.log(data); | |||
| }, []); | |||
| const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | |||
| (errors) => { | |||
| // Set the tab so that the focus will go there | |||
| if (errors.projectName) { | |||
| setTabIndex(0); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| const formProps = useForm<CreateProjectInputs>({ | |||
| defaultValues: { | |||
| tasks: {}, | |||
| @@ -49,23 +78,33 @@ const CreateProject: React.FC<Props> = ({ allTasks }) => { | |||
| }, | |||
| }); | |||
| const errors = formProps.formState.errors; | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| <Stack | |||
| spacing={2} | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
| > | |||
| <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")} /> | |||
| <Tab | |||
| label={t("Project and Client Details")} | |||
| icon={ | |||
| hasErrorsInTab(0, errors) ? ( | |||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
| ) : undefined | |||
| } | |||
| iconPosition="end" | |||
| /> | |||
| <Tab label={t("Project Task Setup")} iconPosition="end" /> | |||
| <Tab label={t("Staff Allocation")} iconPosition="end" /> | |||
| <Tab label={t("Resource and Milestone")} iconPosition="end" /> | |||
| </Tabs> | |||
| {tabIndex === 0 && <ProjectClientDetails />} | |||
| {tabIndex === 1 && <TaskSetup allTasks={allTasks} />} | |||
| {tabIndex === 2 && <StaffAllocation />} | |||
| {tabIndex === 3 && <ResourceMilestone allTasks={allTasks} />} | |||
| {<ProjectClientDetails isActive={tabIndex === 0} />} | |||
| {<TaskSetup allTasks={allTasks} isActive={tabIndex === 1} />} | |||
| {<StaffAllocation isActive={tabIndex === 2} />} | |||
| {<ResourceMilestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -18,12 +18,17 @@ 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<{ isActive: boolean }> = ({ | |||
| isActive, | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const { register } = useFormContext<CreateProjectInputs>(); | |||
| const { | |||
| register, | |||
| formState: { errors }, | |||
| } = useFormContext<CreateProjectInputs>(); | |||
| return ( | |||
| <Card> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent component={Stack} spacing={4}> | |||
| <Box> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| @@ -48,7 +53,10 @@ const ProjectClientDetails: React.FC = () => { | |||
| <TextField | |||
| label={t("Project Name")} | |||
| fullWidth | |||
| {...register("projectName")} | |||
| {...register("projectName", { | |||
| required: "Project name required!", | |||
| })} | |||
| error={Boolean(errors.projectName)} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| @@ -1,8 +1,6 @@ | |||
| import { manhourFormatter } from "@/app/utils/formatUtil"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| @@ -94,55 +92,47 @@ const ResourceCapacity: React.FC<Props> = ({ items = mockItems }) => { | |||
| ); | |||
| return ( | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Stack gap={2}> | |||
| <Typography variant="overline" display="block"> | |||
| {t("Resource Capacity")} | |||
| </Typography> | |||
| <Box sx={{ marginInline: -3 }}> | |||
| <TableContainer> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((column, idx) => ( | |||
| <TableCell key={`${column.name.toString()}${idx}`}> | |||
| {column.label} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {items.map((item, index) => { | |||
| return ( | |||
| <TableRow | |||
| hover | |||
| tabIndex={-1} | |||
| key={`${item.grade}-${index}`} | |||
| > | |||
| {columns.map((column, idx) => { | |||
| const columnName = column.name; | |||
| const cellData = item[columnName]; | |||
| <Stack gap={2}> | |||
| <Typography variant="overline" display="block"> | |||
| {t("Resource Capacity")} | |||
| </Typography> | |||
| <Box sx={{ marginInline: -3 }}> | |||
| <TableContainer> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((column, idx) => ( | |||
| <TableCell key={`${column.name.toString()}${idx}`}> | |||
| {column.label} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {items.map((item, index) => { | |||
| return ( | |||
| <TableRow hover tabIndex={-1} key={`${item.grade}-${index}`}> | |||
| {columns.map((column, idx) => { | |||
| const columnName = column.name; | |||
| const cellData = item[columnName]; | |||
| return ( | |||
| <TableCell key={`${columnName.toString()}-${idx}`}> | |||
| {columnName !== "headcount" && | |||
| typeof cellData === "number" | |||
| ? manhourFormatter.format(cellData) | |||
| : cellData} | |||
| </TableCell> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Box> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| return ( | |||
| <TableCell key={`${columnName.toString()}-${idx}`}> | |||
| {columnName !== "headcount" && | |||
| typeof cellData === "number" | |||
| ? manhourFormatter.format(cellData) | |||
| : cellData} | |||
| </TableCell> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Box> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| @@ -15,11 +15,9 @@ import { | |||
| MenuItem, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Stack, | |||
| } from "@mui/material"; | |||
| import { Task, TaskGroup } from "@/app/api/tasks"; | |||
| import uniqBy from "lodash/uniqBy"; | |||
| import { moneyFormatter } from "@/app/utils/formatUtil"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
| import MilestoneSection from "./MilestoneSection"; | |||
| @@ -29,11 +27,13 @@ import ProjectTotalFee from "./ProjectTotalFee"; | |||
| export interface Props { | |||
| allTasks: Task[]; | |||
| defaultManhourBreakdownByGrade?: { [gradeId: number]: number }; | |||
| isActive: boolean; | |||
| } | |||
| const ResourceMilestone: React.FC<Props> = ({ | |||
| allTasks, | |||
| defaultManhourBreakdownByGrade, | |||
| isActive, | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const { getValues } = useFormContext<CreateProjectInputs>(); | |||
| @@ -65,7 +65,7 @@ const ResourceMilestone: React.FC<Props> = ({ | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |||
| <FormControl> | |||
| <InputLabel>{t("Task Stage")}</InputLabel> | |||
| @@ -93,7 +93,7 @@ const ResourceMilestone: React.FC<Props> = ({ | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent> | |||
| <ProjectTotalFee taskGroups={taskGroups} /> | |||
| </CardContent> | |||
| @@ -102,10 +102,10 @@ const ResourceMilestone: React.FC<Props> = ({ | |||
| ); | |||
| }; | |||
| const NoTaskState: React.FC = () => { | |||
| const NoTaskState: React.FC<Pick<Props, "isActive">> = ({ isActive }) => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <Card> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent> | |||
| <Alert severity="warning"> | |||
| {t('Please add some tasks in "Project Task Setup" first!')} | |||
| @@ -119,7 +119,7 @@ const ResourceMilestoneWrapper: React.FC<Props> = (props) => { | |||
| const { getValues } = useFormContext<CreateProjectInputs>(); | |||
| if (Object.keys(getValues("tasks")).length === 0) { | |||
| return <NoTaskState />; | |||
| return <NoTaskState isActive={props.isActive} />; | |||
| } | |||
| return <ResourceMilestone {...props} />; | |||
| @@ -93,9 +93,13 @@ const mockStaffs: StaffResult[] = [ | |||
| interface Props { | |||
| allStaff?: StaffResult[]; | |||
| isActive: boolean; | |||
| } | |||
| const StaffAllocation: React.FC<Props> = ({ allStaff = mockStaffs }) => { | |||
| const StaffAllocation: React.FC<Props> = ({ | |||
| allStaff = mockStaffs, | |||
| isActive, | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const { setValue, getValues } = useFormContext<CreateProjectInputs>(); | |||
| @@ -235,7 +239,7 @@ const StaffAllocation: React.FC<Props> = ({ allStaff = mockStaffs }) => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Stack gap={2}> | |||
| <Typography variant="overline" display="block"> | |||
| @@ -318,7 +322,11 @@ const StaffAllocation: React.FC<Props> = ({ allStaff = mockStaffs }) => { | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| <ResourceCapacity /> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <ResourceCapacity /> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -20,9 +20,10 @@ import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
| interface Props { | |||
| allTasks: Task[]; | |||
| isActive: boolean; | |||
| } | |||
| const TaskSetup: React.FC<Props> = ({ allTasks: tasks }) => { | |||
| const TaskSetup: React.FC<Props> = ({ allTasks: tasks, isActive }) => { | |||
| const { t } = useTranslation(); | |||
| const { getValues, setValue } = useFormContext<CreateProjectInputs>(); | |||
| const currentTasks = getValues("tasks"); | |||
| @@ -38,7 +39,7 @@ const TaskSetup: React.FC<Props> = ({ allTasks: tasks }) => { | |||
| }, [currentTasks, tasks]); | |||
| return ( | |||
| <Card> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Task List Setup")} | |||
| @@ -28,24 +28,28 @@ const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||
| label: t("Client name"), | |||
| paramName: "client", | |||
| type: "select", | |||
| options: ["A", "B"], | |||
| options: ["Client A", "Client B", "Client C"], | |||
| }, | |||
| { | |||
| label: t("Project category"), | |||
| paramName: "category", | |||
| type: "select", | |||
| options: ["A", "B"], | |||
| options: ["Confirmed Project", "Project to be bidded"], | |||
| }, | |||
| { | |||
| label: t("Team"), | |||
| paramName: "team", | |||
| type: "select", | |||
| options: ["A", "B"], | |||
| options: ["TW", "WY"], | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredProjects(projects); | |||
| }, [projects]); | |||
| const onProjectClick = useCallback((project: ProjectResult) => { | |||
| console.log(project); | |||
| }, []); | |||
| @@ -72,8 +76,18 @@ const ProjectSearch: React.FC<Props> = ({ projects }) => { | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| console.log(query); | |||
| setFilteredProjects( | |||
| projects.filter( | |||
| (p) => | |||
| p.code.toLowerCase().includes(query.code.toLowerCase()) && | |||
| p.name.toLowerCase().includes(query.name.toLowerCase()) && | |||
| (query.client === "All" || p.client === query.client) && | |||
| (query.category === "All" || p.category === query.category) && | |||
| (query.team === "All" || p.team === query.team), | |||
| ), | |||
| ); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults<ProjectResult> | |||
| items={filteredProjects} | |||
| @@ -55,8 +55,8 @@ const TaskTemplateSearch: React.FC<Props> = ({ taskTemplates }) => { | |||
| setFilteredTemplates( | |||
| taskTemplates.filter( | |||
| (task) => | |||
| task.code.toLowerCase().includes(query.code) && | |||
| task.name.toLowerCase().includes(query.name), | |||
| task.code.toLowerCase().includes(query.code.toLowerCase()) && | |||
| task.name.toLowerCase().includes(query.name.toLowerCase()), | |||
| ), | |||
| ); | |||
| }} | |||
| @@ -277,6 +277,7 @@ const components: ThemeOptions["components"] = { | |||
| fontWeight: 500, | |||
| lineHeight: 1.71, | |||
| minWidth: "auto", | |||
| minHeight: 48, | |||
| paddingLeft: 0, | |||
| paddingRight: 0, | |||
| textTransform: "none", | |||