| @@ -1,6 +1,7 @@ | |||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||
| import { fetchGrades } from "@/app/api/grades"; | |||
| import { | |||
| fetchMainProjects, | |||
| fetchProjectBuildingTypes, | |||
| fetchProjectCategories, | |||
| fetchProjectContractTypes, | |||
| @@ -12,10 +13,12 @@ import { | |||
| } from "@/app/api/projects"; | |||
| import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; | |||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
| import { ServerFetchError } from "@/app/utils/fetchUtil"; | |||
| import CreateProject from "@/components/CreateProject"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| import { notFound } from "next/navigation"; | |||
| export const metadata: Metadata = { | |||
| title: "Create Project", | |||
| @@ -39,12 +42,21 @@ const Projects: React.FC = async () => { | |||
| fetchGrades(); | |||
| preloadTeamLeads(); | |||
| preloadStaff(); | |||
| try { | |||
| const data = await fetchMainProjects(); | |||
| if (!Boolean(data) || data.length === 0) { | |||
| notFound(); | |||
| } | |||
| } catch (e) { | |||
| notFound(); | |||
| } | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Sub Project")}</Typography> | |||
| <I18nProvider namespaces={["projects"]}> | |||
| <CreateProject isEditMode={false}/> | |||
| <CreateProject isEditMode={false} isSubProject={true} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| @@ -22,6 +22,7 @@ export interface CreateProjectInputs { | |||
| projectActualEnd: string; | |||
| projectStatus: string; | |||
| isClpProject: boolean; | |||
| mainProjectId?: number | null; | |||
| // Project info | |||
| serviceTypeId: number; | |||
| @@ -15,6 +15,27 @@ export interface ProjectResult { | |||
| status: string; | |||
| } | |||
| export interface MainProject { | |||
| projectId: number; | |||
| projectCode: string; | |||
| projectName: string; | |||
| projectCategoryId: number; | |||
| projectDescription: string; | |||
| projectLeadId: number; | |||
| projectStatus: string; | |||
| isClpProject: boolean; | |||
| serviceTypeId: number; | |||
| fundingTypeId: number; | |||
| contractTypeId: number; | |||
| locationId: number; | |||
| buildingTypeIds: number[]; | |||
| workNatureIds: number[]; | |||
| clientId: number; | |||
| clientContactId: number; | |||
| clientSubsidiaryId: number; | |||
| expectedProjectFee: number; | |||
| } | |||
| export interface ProjectCategory { | |||
| id: number; | |||
| name: string; | |||
| @@ -82,7 +103,7 @@ export const fetchProjects = cache(async () => { | |||
| }); | |||
| export const fetchMainProjects = cache(async () => { | |||
| return serverFetchJson<ProjectResult[]>(`${BASE_API_URL}/projects/main`, { | |||
| return serverFetchJson<MainProject[]>(`${BASE_API_URL}/projects/main`, { | |||
| next: { tags: ["projects"] }, | |||
| }); | |||
| }); | |||
| @@ -15,7 +15,9 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/home": "User Workspace", | |||
| "/projects": "Projects", | |||
| "/projects/create": "Create Project", | |||
| "/projects/create/sub": "Sub Project", | |||
| "/projects/edit": "Edit Project", | |||
| "/projects/edit/sub": "Sub Project", | |||
| "/tasks": "Task Template", | |||
| "/tasks/create": "Create Task Template", | |||
| "/staffReimbursement": "Staff Reimbursement", | |||
| @@ -10,7 +10,7 @@ const icon = <CheckBoxOutlineBlankIcon fontSize="medium" />; | |||
| const checkedIcon = <CheckBoxIcon fontSize="medium" />; | |||
| // label -> e.g. code - name -> 001 - WL | |||
| // name -> WL | |||
| interface Props<T extends { id?: number | string; label?: string; name?: string }, TField extends FieldValues> { | |||
| interface Props<T extends { id?: number | string | null; label?: string; name?: string }, TField extends FieldValues> { | |||
| control: Control<TField>, | |||
| options: T[], | |||
| name: Path<TField>, // register name | |||
| @@ -18,7 +18,6 @@ interface Props<T extends { id?: number | string; label?: string; name?: string | |||
| noOptionsText?: string, | |||
| isMultiple?: boolean, | |||
| rules?: RegisterOptions<FieldValues> | |||
| error?: boolean, | |||
| } | |||
| function ControlledAutoComplete< | |||
| @@ -28,68 +27,78 @@ function ControlledAutoComplete< | |||
| props: Props<T, TField> | |||
| ) { | |||
| const { t } = useTranslation() | |||
| const { control, options, name, label, noOptionsText, isMultiple, rules, error } = props; | |||
| const { control, options, name, label, noOptionsText, isMultiple, rules } = props; | |||
| // set default value if value is null | |||
| if (!Boolean(isMultiple) && !Boolean(control._formValues[name])) { | |||
| console.log(name, control._formValues[name]) | |||
| control._formValues[name] = options[0]?.id ?? undefined | |||
| } else if (Boolean(isMultiple) && !Boolean(control._formValues[name])) { | |||
| control._formValues[name] = [] | |||
| } | |||
| return ( | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| rules={rules} | |||
| render={({ field }) => ( | |||
| isMultiple ? | |||
| <Autocomplete | |||
| multiple | |||
| disableClearable | |||
| disableCloseOnSelect | |||
| disablePortal | |||
| noOptionsText={noOptionsText ?? t("No Options")} | |||
| value={options.filter(option => { | |||
| // console.log(field.value) | |||
| return field.value?.includes(option.id) | |||
| })} | |||
| options={options} | |||
| getOptionLabel={(option) => option.label ?? option.name!!} | |||
| isOptionEqualToValue={(option, value) => option.id === value.id} | |||
| renderOption={(params, option, { selected }) => { | |||
| return ( | |||
| <li {...params}> | |||
| <Checkbox | |||
| icon={icon} | |||
| checkedIcon={checkedIcon} | |||
| checked={selected} | |||
| style={{ marginRight: 8 }} | |||
| /> | |||
| {option.label ?? option.name} | |||
| </li> | |||
| ); | |||
| }} | |||
| onChange={(event, value) => { | |||
| field.onChange(value?.map(v => v.id)) | |||
| }} | |||
| renderInput={(params) => <TextField {...params} error={error} variant="outlined" label={label} />} | |||
| /> | |||
| : | |||
| <Autocomplete | |||
| disableClearable | |||
| disablePortal | |||
| noOptionsText={noOptionsText ?? t("No Options")} | |||
| value={options.find(option => option.id === field.value) ?? options[0]} | |||
| options={options} | |||
| getOptionLabel={(option) => option.label ?? option.name!!} | |||
| isOptionEqualToValue={(option, value) => option.id === value.id} | |||
| renderOption={(params, option) => { | |||
| return ( | |||
| <MenuItem {...params} key={option.id} value={option.id}> | |||
| {option.label ?? option.name} | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| onChange={(event, value) => { | |||
| field.onChange(value?.id) | |||
| }} | |||
| renderInput={(params) => <TextField {...params} error={error} variant="outlined" label={label} />} | |||
| /> | |||
| )} | |||
| render={({ field, fieldState, formState }) => { | |||
| return ( | |||
| isMultiple ? | |||
| <Autocomplete | |||
| multiple | |||
| disableClearable | |||
| disableCloseOnSelect | |||
| disablePortal | |||
| noOptionsText={noOptionsText ?? t("No Options")} | |||
| value={options.filter(option => { | |||
| return field.value?.includes(option.id) | |||
| })} | |||
| options={options} | |||
| getOptionLabel={(option) => option.label ?? option.name!!} | |||
| isOptionEqualToValue={(option, value) => option.id === value.id} | |||
| renderOption={(params, option, { selected }) => { | |||
| return ( | |||
| <li {...params} key={option.id}> | |||
| <Checkbox | |||
| icon={icon} | |||
| checkedIcon={checkedIcon} | |||
| checked={selected} | |||
| style={{ marginRight: 8 }} | |||
| /> | |||
| {option.label ?? option.name} | |||
| </li> | |||
| ); | |||
| }} | |||
| onChange={(event, value) => { | |||
| field.onChange(value?.map(v => v.id)) | |||
| }} | |||
| renderInput={(params) => <TextField {...params} error={Boolean(formState.errors[name])} variant="outlined" label={label} />} | |||
| /> | |||
| : | |||
| <Autocomplete | |||
| disableClearable | |||
| disablePortal | |||
| noOptionsText={noOptionsText ?? t("No Options")} | |||
| value={options.find(option => option.id === field.value) ?? options[0]} | |||
| options={options} | |||
| getOptionLabel={(option) => option.label ?? option.name!!} | |||
| isOptionEqualToValue={(option, value) => option?.id === value?.id} | |||
| renderOption={(params, option) => { | |||
| return ( | |||
| <MenuItem {...params} key={option.id} value={option.id}> | |||
| {option.label ?? option.name} | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| onChange={(event, value) => { | |||
| field.onChange(value?.id) | |||
| }} | |||
| renderInput={(params) => <TextField {...params} error={Boolean(formState.errors[name])} variant="outlined" label={label} />} | |||
| />) | |||
| }} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -33,6 +33,7 @@ import { | |||
| ContractType, | |||
| FundingType, | |||
| LocationType, | |||
| MainProject, | |||
| ProjectCategory, | |||
| ServiceType, | |||
| WorkNature, | |||
| @@ -52,6 +53,8 @@ import dayjs from "dayjs"; | |||
| export interface Props { | |||
| isEditMode: boolean; | |||
| isSubProject: boolean; | |||
| mainProjects?: MainProject[]; | |||
| defaultInputs?: CreateProjectInputs; | |||
| allTasks: Task[]; | |||
| projectCategories: ProjectCategory[]; | |||
| @@ -93,6 +96,8 @@ const hasErrorsInTab = ( | |||
| const CreateProject: React.FC<Props> = ({ | |||
| isEditMode, | |||
| isSubProject, | |||
| mainProjects, | |||
| defaultInputs, | |||
| allTasks, | |||
| projectCategories, | |||
| @@ -269,14 +274,9 @@ const CreateProject: React.FC<Props> = ({ | |||
| milestones: {}, | |||
| totalManhour: 0, | |||
| taskTemplateId: "All", | |||
| projectCategoryId: projectCategories.length > 0 ? projectCategories[0].id : undefined, | |||
| projectLeadId: teamLeads.length > 0 ? teamLeads[0].id : undefined, | |||
| serviceTypeId: serviceTypes.length > 0 ? serviceTypes[0].id : undefined, | |||
| fundingTypeId: fundingTypes.length > 0 ? fundingTypes[0].id : undefined, | |||
| contractTypeId: contractTypes.length > 0 ? contractTypes[0].id : undefined, | |||
| locationId: locationTypes.length > 0 ? locationTypes[0].id : undefined, | |||
| clientSubsidiaryId: undefined, | |||
| clientId: allCustomers.length > 0 ? allCustomers[0].id : undefined, | |||
| projectName: mainProjects !== undefined ? mainProjects[0].projectName : undefined, | |||
| projectDescription: mainProjects !== undefined ? mainProjects[0].projectDescription : undefined, | |||
| expectedProjectFee: mainProjects !== undefined ? mainProjects[0].expectedProjectFee : undefined, | |||
| ...defaultInputs, | |||
| // manhourPercentageByGrade should have a sensible default | |||
| @@ -380,6 +380,8 @@ const CreateProject: React.FC<Props> = ({ | |||
| </Tabs> | |||
| { | |||
| <ProjectClientDetails | |||
| isSubProject={isSubProject} | |||
| mainProjects={mainProjects} | |||
| buildingTypes={buildingTypes} | |||
| workNatures={workNatures} | |||
| contractTypes={contractTypes} | |||
| @@ -1,6 +1,7 @@ | |||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
| import CreateProject from "./CreateProject"; | |||
| import { | |||
| fetchMainProjects, | |||
| fetchProjectBuildingTypes, | |||
| fetchProjectCategories, | |||
| fetchProjectContractTypes, | |||
| @@ -14,10 +15,14 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||
| import { fetchGrades } from "@/app/api/grades"; | |||
| type CreateProjectProps = { isEditMode: false }; | |||
| type CreateProjectProps = { | |||
| isEditMode: false; | |||
| isSubProject?: boolean; | |||
| }; | |||
| interface EditProjectProps { | |||
| isEditMode: true; | |||
| projectId?: string; | |||
| isSubProject?: boolean; | |||
| } | |||
| type Props = CreateProjectProps | EditProjectProps; | |||
| @@ -59,9 +64,14 @@ const CreateProjectWrapper: React.FC<Props> = async (props) => { | |||
| ? await fetchProjectDetails(props.projectId!) | |||
| : undefined; | |||
| const mainProjects = Boolean(props.isSubProject) | |||
| ? await fetchMainProjects() | |||
| : undefined; | |||
| return ( | |||
| <CreateProject | |||
| isEditMode={props.isEditMode} | |||
| isSubProject={Boolean(props.isSubProject)} | |||
| defaultInputs={projectInfo} | |||
| allTasks={tasks} | |||
| projectCategories={projectCategories} | |||
| @@ -77,6 +87,7 @@ const CreateProjectWrapper: React.FC<Props> = async (props) => { | |||
| workNatures={workNatures} | |||
| allStaffs={allStaffs} | |||
| grades={grades} | |||
| mainProjects={mainProjects} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -22,6 +22,7 @@ import { | |||
| ContractType, | |||
| FundingType, | |||
| LocationType, | |||
| MainProject, | |||
| ProjectCategory, | |||
| ServiceType, | |||
| WorkNature, | |||
| @@ -37,6 +38,8 @@ import ControlledAutoComplete from "../ControlledAutoComplete/ControlledAutoComp | |||
| interface Props { | |||
| isActive: boolean; | |||
| isSubProject: boolean; | |||
| mainProjects?: MainProject[]; | |||
| projectCategories: ProjectCategory[]; | |||
| teamLeads: StaffResult[]; | |||
| allCustomers: Customer[]; | |||
| @@ -51,6 +54,8 @@ interface Props { | |||
| const ProjectClientDetails: React.FC<Props> = ({ | |||
| isActive, | |||
| isSubProject, | |||
| mainProjects, | |||
| projectCategories, | |||
| teamLeads, | |||
| allCustomers, | |||
| @@ -70,6 +75,8 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| control, | |||
| setValue, | |||
| getValues, | |||
| reset, | |||
| resetField, | |||
| } = useFormContext<CreateProjectInputs>(); | |||
| const subsidiaryMap = useMemo<{ | |||
| @@ -136,7 +143,6 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| // Automatically add the team lead to the allocated staff list | |||
| const selectedTeamLeadId = watch("projectLeadId"); | |||
| useEffect(() => { | |||
| console.log(selectedTeamLeadId) | |||
| if (selectedTeamLeadId !== undefined) { | |||
| const currentStaffIds = getValues("allocatedStaffIds"); | |||
| const newList = uniq([...currentStaffIds, selectedTeamLeadId]); | |||
| @@ -144,6 +150,32 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| } | |||
| }, [getValues, selectedTeamLeadId, setValue]); | |||
| // Automatically update the project & client details whene select a main project | |||
| const mainProjectId = watch("mainProjectId") | |||
| useEffect(() => { | |||
| if (mainProjectId !== undefined && mainProjects !== undefined) { | |||
| const mainProject = mainProjects.find(project => project.projectId === mainProjectId); | |||
| if (mainProject !== undefined) { | |||
| setValue("projectName", mainProject.projectName) | |||
| setValue("projectCategoryId", mainProject.projectCategoryId) | |||
| setValue("projectLeadId", mainProject.projectLeadId) | |||
| setValue("serviceTypeId", mainProject.serviceTypeId) | |||
| setValue("fundingTypeId", mainProject.fundingTypeId) | |||
| setValue("contractTypeId", mainProject.contractTypeId) | |||
| setValue("locationId", mainProject.locationId) | |||
| setValue("buildingTypeIds", mainProject.buildingTypeIds) | |||
| setValue("workNatureIds", mainProject.workNatureIds) | |||
| setValue("projectDescription", mainProject.projectDescription) | |||
| setValue("expectedProjectFee", mainProject.expectedProjectFee) | |||
| setValue("isClpProject", mainProject.isClpProject) | |||
| setValue("clientId", mainProject.clientId) | |||
| setValue("clientSubsidiaryId", mainProject.clientSubsidiaryId) | |||
| setValue("clientContactId", mainProject.clientContactId) | |||
| } | |||
| } | |||
| }, [getValues, mainProjectId, setValue]) | |||
| // const buildingTypeIdNameMap = buildingTypes.reduce<{ [id: number]: string }>( | |||
| // (acc, building) => ({ ...acc, [building.id]: building.name }), | |||
| // {}, | |||
| @@ -162,6 +194,18 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| {t("Project Details")} | |||
| </Typography> | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| { | |||
| isSubProject && mainProjects !== undefined && <><Grid item xs={6}> | |||
| <ControlledAutoComplete | |||
| control={control} | |||
| options={[...mainProjects.map(mainProject => ({ id: mainProject.projectId, label: `${mainProject.projectCode} - ${mainProject.projectName}` }))]} | |||
| name="mainProjectId" | |||
| label={t("Main Project")} | |||
| noOptionsText={t("No Main Project")} | |||
| /> | |||
| </Grid> | |||
| <Grid item sx={{ display: { xs: "none", sm: "block" } }} /></> | |||
| } | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Project Code")} | |||
| @@ -283,7 +327,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| <Grid item xs={6}> | |||
| <Checkbox | |||
| {...register("isClpProject")} | |||
| defaultChecked={watch("isClpProject")} | |||
| checked={Boolean(watch("isClpProject"))} | |||
| /> | |||
| <Typography variant="overline" display="inline"> | |||
| {t("CLP Project")} | |||
| @@ -317,7 +361,6 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| rules={{ | |||
| required: "Please select a client" | |||
| }} | |||
| error={Boolean(errors.clientId)} | |||
| /> | |||
| </Grid> | |||
| <Grid item sx={{ display: { xs: "none", sm: "block" } }} /> | |||
| @@ -337,7 +380,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| <Grid item xs={6}> | |||
| <ControlledAutoComplete | |||
| control={control} | |||
| options={[{ id: undefined, label: t("No Subsidiary") }, ...customerSubsidiaryIds | |||
| options={[{ label: t("No Subsidiary") }, ...customerSubsidiaryIds | |||
| .filter((subId) => subsidiaryMap[subId]) | |||
| .map((subsidiaryId, index) => { | |||
| const subsidiary = subsidiaryMap[subsidiaryId] | |||
| @@ -368,7 +411,6 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| } else return true; | |||
| }, | |||
| }} | |||
| error={Boolean(errors.clientContactId)} | |||
| /> | |||
| </Grid> | |||
| <Grid container sx={{ display: { xs: "none", sm: "block" } }} /> | |||
| @@ -25,6 +25,7 @@ import { | |||
| Tab, | |||
| Tabs, | |||
| SelectChangeEvent, | |||
| Autocomplete, | |||
| } from "@mui/material"; | |||
| import differenceWith from "lodash/differenceWith"; | |||
| import intersectionWith from "lodash/intersectionWith"; | |||
| @@ -7,7 +7,7 @@ import Typography from "@mui/material/Typography"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import TransferList from "../TransferList"; | |||
| import Button from "@mui/material/Button"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import React, { SyntheticEvent, useCallback, useMemo, useState } from "react"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import FormControl from "@mui/material/FormControl"; | |||
| @@ -20,6 +20,7 @@ import { CreateProjectInputs, ManhourAllocation } from "@/app/api/projects/actio | |||
| import isNumber from "lodash/isNumber"; | |||
| import intersectionWith from "lodash/intersectionWith"; | |||
| import { difference } from "lodash"; | |||
| import { Autocomplete, TextField } from "@mui/material"; | |||
| interface Props { | |||
| allTasks: Task[]; | |||
| @@ -50,9 +51,9 @@ const TaskSetup: React.FC<Props> = ({ | |||
| "All" | number | |||
| >(watch("taskTemplateId") ?? "All"); | |||
| const onSelectTaskTemplate = useCallback( | |||
| (e: SelectChangeEvent<number | "All">) => { | |||
| if (e.target.value === "All" || isNumber(e.target.value)) { | |||
| setSelectedTaskTemplateId(e.target.value); | |||
| (event: SyntheticEvent<Element, Event>, value: NonNullable<{id: number | string, name: string}>) => { | |||
| if (value.id === "All" || isNumber(value.id)) { | |||
| setSelectedTaskTemplateId(value.id); | |||
| // onReset(); | |||
| } | |||
| }, | |||
| @@ -132,21 +133,24 @@ const TaskSetup: React.FC<Props> = ({ | |||
| marginBlockEnd={1} | |||
| > | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Task List Source")}</InputLabel> | |||
| <Select<"All" | number> | |||
| label={t("Task List Source")} | |||
| value={selectedTaskTemplateId} | |||
| onChange={onSelectTaskTemplate} | |||
| > | |||
| <MenuItem value={"All"}>{t("All tasks")}</MenuItem> | |||
| {taskTemplates.map((template, index) => ( | |||
| <MenuItem key={`${template.id}-${index}`} value={template.id}> | |||
| {template.name} | |||
| <Autocomplete | |||
| disableClearable | |||
| disablePortal | |||
| noOptionsText={t("No Task List Source")} | |||
| value={taskTemplates.find(taskTemplate => taskTemplate.id === selectedTaskTemplateId)} | |||
| options={[{id: "All", name: t("All tasks")}, ...taskTemplates.map(taskTemplate => ({id: taskTemplate.id, name: taskTemplate.name}))]} | |||
| getOptionLabel={(taskTemplate) => taskTemplate.name} | |||
| isOptionEqualToValue={(option, value) => option?.id === value?.id} | |||
| renderOption={(params, option) => { | |||
| return ( | |||
| <MenuItem {...params} key={option.id} value={option.id}> | |||
| {option.name} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| ); | |||
| }} | |||
| onChange={onSelectTaskTemplate} | |||
| renderInput={(params) => <TextField {...params} variant="outlined" label={t("Task List Source")} />} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| <TransferList | |||