@@ -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 | |||