@@ -6,6 +6,7 @@ import Box from "@mui/material/Box"; | |||
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | |||
import Stack from "@mui/material/Stack"; | |||
import Breadcrumb from "@/components/Breadcrumb"; | |||
import { I18nProvider } from "@/i18n"; | |||
export default async function MainLayout({ | |||
children, | |||
@@ -31,10 +32,12 @@ export default async function MainLayout({ | |||
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" }, | |||
}} | |||
> | |||
<Stack spacing={2}> | |||
<Breadcrumb /> | |||
{children} | |||
</Stack> | |||
<I18nProvider namespaces={["common"]}> | |||
<Stack spacing={2}> | |||
<Breadcrumb /> | |||
{children} | |||
</Stack> | |||
</I18nProvider> | |||
</Box> | |||
</> | |||
); | |||
@@ -11,6 +11,8 @@ export interface Customer { | |||
address: string | null; | |||
district: string | null; | |||
customerType: CustomerType; | |||
contacts: Contact[]; | |||
} | |||
export interface SaveCustomerResponse { | |||
@@ -40,6 +42,7 @@ export interface Subsidiary { | |||
district: string | null; | |||
email: string | null; | |||
subsidiaryType: SubsidiaryType; | |||
subsidiaryContacts: Contact[]; | |||
} | |||
export interface SubsidiaryTable { | |||
@@ -20,6 +20,8 @@ export interface CreateProjectInputs { | |||
projectLeadId: number; | |||
projectActualStart: string; | |||
projectActualEnd: string; | |||
projectStatus: string; | |||
isClpProject: boolean; | |||
// Project info | |||
serviceTypeId: number; | |||
@@ -28,11 +30,14 @@ export interface CreateProjectInputs { | |||
locationId: number; | |||
buildingTypeIds: number[]; | |||
workNatureIds: number[]; | |||
taskTemplateId?: number | "All"; | |||
// Client details | |||
clientId: Customer["id"]; | |||
clientContactId: number; | |||
clientContactId?: number; | |||
clientSubsidiaryId?: number; | |||
subsidiaryContactId: number; | |||
isSubsidiaryContact?: boolean; | |||
// Allocation | |||
totalManhour: number; | |||
@@ -12,6 +12,7 @@ export interface ProjectResult { | |||
category: string; | |||
team: string; | |||
client: string; | |||
status: string; | |||
} | |||
export interface ProjectCategory { | |||
@@ -10,7 +10,9 @@ export interface Customer { | |||
brNo: string | null; | |||
address: string | null; | |||
district: string | null; | |||
customerType: CustomerType | |||
customerType: CustomerType; | |||
contacts: Contact[]; | |||
} | |||
export interface CustomerTable { | |||
@@ -40,7 +42,9 @@ export interface Subsidiary { | |||
brNo: string | null; | |||
address: string | null; | |||
district: string | null; | |||
subsidiaryType: SubsidiaryType | |||
subsidiaryType: SubsidiaryType; | |||
contacts: Contact[]; | |||
} | |||
export interface SaveSubsidiaryResponse { | |||
@@ -76,7 +76,7 @@ const hasErrorsInTab = ( | |||
switch (tabIndex) { | |||
case 0: | |||
return ( | |||
errors.projectName || errors.projectCode || errors.projectDescription | |||
errors.projectName || errors.projectDescription || errors.clientId | |||
); | |||
case 2: | |||
return ( | |||
@@ -114,6 +114,8 @@ const CreateProject: React.FC<Props> = ({ | |||
const { t } = useTranslation(); | |||
const router = useRouter(); | |||
console.log(defaultInputs) | |||
const handleCancel = () => { | |||
router.replace("/projects"); | |||
}; | |||
@@ -219,6 +221,7 @@ const CreateProject: React.FC<Props> = ({ | |||
data.projectActualEnd = dayjs().format("YYYY-MM-DD"); | |||
} | |||
data.taskTemplateId = data.taskTemplateId === "All" ? undefined : data.taskTemplateId; | |||
const response = await saveProject(data); | |||
if (response.id > 0) { | |||
@@ -248,7 +251,8 @@ const CreateProject: React.FC<Props> = ({ | |||
if ( | |||
errors.projectName || | |||
errors.projectDescription || | |||
errors.projectCode | |||
// errors.projectCode || | |||
errors.clientId | |||
) { | |||
setTabIndex(0); | |||
} else if (errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups) { | |||
@@ -266,6 +270,7 @@ const CreateProject: React.FC<Props> = ({ | |||
allocatedStaffIds: [], | |||
milestones: {}, | |||
totalManhour: 0, | |||
taskTemplateId: "All", | |||
...defaultInputs, | |||
// manhourPercentageByGrade should have a sensible default | |||
@@ -289,7 +294,8 @@ const CreateProject: React.FC<Props> = ({ | |||
> | |||
{isEditMode && !(formProps.getValues("projectDeleted") === true) && ( | |||
<Stack direction="row" gap={1}> | |||
{!formProps.getValues("projectActualStart") && ( | |||
{/* {!formProps.getValues("projectActualStart") && ( */} | |||
{formProps.getValues("projectStatus") === "Pending to Start" && ( | |||
<Button | |||
name="start" | |||
type="submit" | |||
@@ -300,8 +306,9 @@ const CreateProject: React.FC<Props> = ({ | |||
{t("Start Project")} | |||
</Button> | |||
)} | |||
{formProps.getValues("projectActualStart") && | |||
!formProps.getValues("projectActualEnd") && ( | |||
{/* {formProps.getValues("projectActualStart") && | |||
!formProps.getValues("projectActualEnd") && ( */} | |||
{formProps.getValues("projectStatus") === "On-going" && ( | |||
<Button | |||
name="complete" | |||
type="submit" | |||
@@ -313,8 +320,10 @@ const CreateProject: React.FC<Props> = ({ | |||
</Button> | |||
)} | |||
{!( | |||
formProps.getValues("projectActualStart") && | |||
formProps.getValues("projectActualEnd") | |||
// formProps.getValues("projectActualStart") && | |||
// formProps.getValues("projectActualEnd") | |||
formProps.getValues("projectStatus") === "Completed" || | |||
formProps.getValues("projectStatus") === "Deleted" | |||
) && ( | |||
<Button | |||
variant="outlined" | |||
@@ -412,7 +421,7 @@ const CreateProject: React.FC<Props> = ({ | |||
startIcon={<Check />} | |||
type="submit" | |||
disabled={ | |||
formProps.getValues("projectDeleted") === true || | |||
formProps.getValues("projectDeleted") === true || formProps.getValues("projectStatus") === "Deleted" || | |||
(!!formProps.getValues("projectActualStart") && | |||
!!formProps.getValues("projectActualEnd")) | |||
} | |||
@@ -85,6 +85,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
); | |||
const [customerContacts, setCustomerContacts] = useState<Contact[]>([]); | |||
const [subsidiaryContacts, setSubsidiaryContacts] = useState<Contact[]>([]); | |||
const [customerSubsidiaryIds, setCustomerSubsidiaryIds] = useState<number[]>( | |||
[], | |||
); | |||
@@ -92,21 +93,44 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
const selectedCustomerContactId = watch("clientContactId"); | |||
const selectedCustomerContact = useMemo( | |||
() => | |||
customerContacts.find( | |||
(contact) => contact.id === selectedCustomerContactId, | |||
), | |||
[customerContacts, selectedCustomerContactId], | |||
subsidiaryContacts.length > 0 ? | |||
subsidiaryContacts.find((contact) => contact.id === selectedCustomerContactId) | |||
: customerContacts.find( | |||
(contact) => contact.id === selectedCustomerContactId, | |||
), | |||
[subsidiaryContacts, customerContacts, selectedCustomerContactId], | |||
); | |||
// get customer (client) contact combo | |||
useEffect(() => { | |||
if (selectedCustomerId !== undefined) { | |||
fetchCustomer(selectedCustomerId).then(({ contacts, subsidiaryIds }) => { | |||
setCustomerContacts(contacts); | |||
setCustomerSubsidiaryIds(subsidiaryIds); | |||
if (subsidiaryIds.length > 0) setValue("clientSubsidiaryId", subsidiaryIds[0]) | |||
else setValue("clientSubsidiaryId", undefined) | |||
// if (contacts.length > 0) setValue("clientContactId", contacts[0].id) | |||
// else setValue("clientContactId", undefined) | |||
}); | |||
} | |||
}, [selectedCustomerId]); | |||
const clientSubsidiaryId = watch("clientSubsidiaryId") | |||
useEffect(() => { | |||
if (Boolean(clientSubsidiaryId)) { | |||
// get subsidiary contact combo | |||
const contacts = allSubsidiaries.find(subsidiary => subsidiary.id === clientSubsidiaryId)?.subsidiaryContacts!! | |||
setSubsidiaryContacts(contacts) | |||
setValue("clientContactId", contacts[0].id) | |||
setValue("isSubsidiaryContact", true) | |||
} else if (customerContacts?.length > 0) { | |||
setSubsidiaryContacts([]) | |||
setValue("clientContactId", customerContacts[0].id) | |||
setValue("isSubsidiaryContact", false) | |||
} | |||
}, [customerContacts, clientSubsidiaryId]); | |||
// Automatically add the team lead to the allocated staff list | |||
const selectedTeamLeadId = watch("projectLeadId"); | |||
useEffect(() => { | |||
@@ -139,10 +163,13 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
<TextField | |||
label={t("Project Code")} | |||
fullWidth | |||
{...register("projectCode", { | |||
required: "Project code required!", | |||
})} | |||
error={Boolean(errors.projectCode)} | |||
disabled | |||
{...register("projectCode", | |||
// { | |||
// required: "Project code required!", | |||
// } | |||
)} | |||
// error={Boolean(errors.projectCode)} | |||
/> | |||
</Grid> | |||
<Grid item xs={6}> | |||
@@ -354,6 +381,16 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
{...register("expectedProjectFee", { valueAsNumber: true })} | |||
/> | |||
</Grid> | |||
<Grid item xs={6}> | |||
<Checkbox | |||
{...register("isClpProject")} | |||
defaultChecked={watch("isClpProject")} | |||
/> | |||
<Typography variant="overline" display="inline"> | |||
{t("CLP Project")} | |||
</Typography> | |||
</Grid> | |||
</Grid> | |||
</Box> | |||
@@ -373,12 +410,15 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
</Stack> | |||
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
<Grid item xs={6}> | |||
<FormControl fullWidth> | |||
<FormControl fullWidth error={Boolean(errors.clientId)}> | |||
<InputLabel>{t("Client")}</InputLabel> | |||
<Controller | |||
defaultValue={allCustomers[0].id} | |||
defaultValue={allCustomers[0]?.id} | |||
control={control} | |||
name="clientId" | |||
rules={{ | |||
required: "Please select a client", | |||
}} | |||
render={({ field }) => ( | |||
<Select label={t("Client")} {...field}> | |||
{allCustomers.map((customer, index) => ( | |||
@@ -408,6 +448,50 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
<Grid item sx={{ display: { xs: "none", sm: "block" } }} /> | |||
{customerContacts.length > 0 && ( | |||
<> | |||
{customerSubsidiaryIds.length > 0 && ( | |||
<Grid item xs={6}> | |||
<FormControl | |||
fullWidth | |||
error={Boolean(errors.clientSubsidiaryId)} | |||
> | |||
<InputLabel>{t("Client Subsidiary")}</InputLabel> | |||
<Controller | |||
// rules={{ | |||
// validate: (value) => { | |||
// if ( | |||
// !customerSubsidiaryIds.find( | |||
// (subsidiaryId) => subsidiaryId === value, | |||
// ) | |||
// ) { | |||
// return t("Please choose a valid subsidiary"); | |||
// } else return true; | |||
// }, | |||
// }} | |||
defaultValue={customerSubsidiaryIds[0]} | |||
control={control} | |||
name="clientSubsidiaryId" | |||
render={({ field }) => ( | |||
<Select label={t("Client Subsidiary")} {...field}> | |||
{customerSubsidiaryIds | |||
.filter((subId) => subsidiaryMap[subId]) | |||
.map((subsidiaryId, index) => { | |||
const subsidiary = subsidiaryMap[subsidiaryId]; | |||
return ( | |||
<MenuItem | |||
key={`${subsidiaryId}-${index}`} | |||
value={subsidiaryId} | |||
> | |||
{`${subsidiary.code} - ${subsidiary.name}`} | |||
</MenuItem> | |||
); | |||
})} | |||
</Select> | |||
)} | |||
/> | |||
</FormControl> | |||
</Grid> | |||
)} | |||
<Grid item xs={6}> | |||
<FormControl | |||
fullWidth | |||
@@ -418,33 +502,44 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
rules={{ | |||
validate: (value) => { | |||
if ( | |||
!customerContacts.find( | |||
(customerContacts.length > 0 && !customerContacts.find( | |||
(contact) => contact.id === value, | |||
)) && (subsidiaryContacts?.length > 0 && !subsidiaryContacts.find( | |||
(contact) => contact.id === value, | |||
) | |||
)) | |||
) { | |||
return t("Please provide a valid contact"); | |||
} else return true; | |||
}, | |||
}} | |||
defaultValue={customerContacts[0].id} | |||
defaultValue={subsidiaryContacts?.length > 0 ? subsidiaryContacts[0].id : customerContacts[0].id} | |||
control={control} | |||
name="clientContactId" | |||
render={({ field }) => ( | |||
<Select label={t("Client Lead")} {...field}> | |||
{customerContacts.map((contact, index) => ( | |||
<MenuItem | |||
key={`${contact.id}-${index}`} | |||
value={contact.id} | |||
> | |||
{contact.name} | |||
</MenuItem> | |||
))} | |||
{subsidiaryContacts?.length > 0 ? | |||
subsidiaryContacts.map((contact, index) => ( | |||
<MenuItem | |||
key={`${contact.id}-${index}`} | |||
value={contact.id} | |||
> | |||
{contact.name} | |||
</MenuItem> | |||
)) | |||
: customerContacts.map((contact, index) => ( | |||
<MenuItem | |||
key={`${contact.id}-${index}`} | |||
value={contact.id} | |||
> | |||
{contact.name} | |||
</MenuItem> | |||
))} | |||
</Select> | |||
)} | |||
/> | |||
</FormControl> | |||
</Grid> | |||
<Grid item sx={{ display: { xs: "none", sm: "block" } }} /> | |||
<Grid container sx={{ display: { xs: "none", sm: "block" } }} /> | |||
<Grid item xs={6}> | |||
<TextField | |||
label={t("Client Lead Phone Number")} | |||
@@ -467,50 +562,6 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
</Grid> | |||
</> | |||
)} | |||
{customerSubsidiaryIds.length > 0 && ( | |||
<Grid item xs={6}> | |||
<FormControl | |||
fullWidth | |||
error={Boolean(errors.clientSubsidiaryId)} | |||
> | |||
<InputLabel>{t("Client Subsidiary")}</InputLabel> | |||
<Controller | |||
// rules={{ | |||
// validate: (value) => { | |||
// if ( | |||
// !customerSubsidiaryIds.find( | |||
// (subsidiaryId) => subsidiaryId === value, | |||
// ) | |||
// ) { | |||
// return t("Please choose a valid subsidiary"); | |||
// } else return true; | |||
// }, | |||
// }} | |||
defaultValue={customerSubsidiaryIds[0]} | |||
control={control} | |||
name="clientSubsidiaryId" | |||
render={({ field }) => ( | |||
<Select label={t("Client Lead")} {...field}> | |||
{customerSubsidiaryIds | |||
.filter((subId) => subsidiaryMap[subId]) | |||
.map((subsidiaryId, index) => { | |||
const subsidiary = subsidiaryMap[subsidiaryId]; | |||
return ( | |||
<MenuItem | |||
key={`${subsidiaryId}-${index}`} | |||
value={subsidiaryId} | |||
> | |||
{`${subsidiary.code} - ${subsidiary.name}`} | |||
</MenuItem> | |||
); | |||
})} | |||
</Select> | |||
)} | |||
/> | |||
</FormControl> | |||
</Grid> | |||
)} | |||
</Grid> | |||
</Box> | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
@@ -33,7 +33,7 @@ const TaskSetup: React.FC<Props> = ({ | |||
isActive, | |||
}) => { | |||
const { t } = useTranslation(); | |||
const { setValue, watch, clearErrors, setError } = useFormContext<CreateProjectInputs>(); | |||
const { setValue, watch, clearErrors, setError, formState: { defaultValues } } = useFormContext<CreateProjectInputs>(); | |||
const currentTaskGroups = watch("taskGroups"); | |||
const currentTaskIds = Object.values(currentTaskGroups).reduce<Task["id"][]>( | |||
(acc, group) => { | |||
@@ -48,7 +48,7 @@ const TaskSetup: React.FC<Props> = ({ | |||
const [selectedTaskTemplateId, setSelectedTaskTemplateId] = useState< | |||
"All" | number | |||
>("All"); | |||
>(watch("taskTemplateId") ?? "All"); | |||
const onSelectTaskTemplate = useCallback( | |||
(e: SelectChangeEvent<number | "All">) => { | |||
if (e.target.value === "All" || isNumber(e.target.value)) { | |||
@@ -64,7 +64,8 @@ const TaskSetup: React.FC<Props> = ({ | |||
(template) => template.id === selectedTaskTemplateId, | |||
) | |||
if (selectedTaskTemplateId !== "All") { | |||
if (selectedTaskTemplateId !== "All" && selectedTaskTemplateId !== watch("taskTemplateId")) { | |||
// update the "manhour allocation by grade" by task template | |||
const updatedManhourPercentageByGrade: ManhourAllocation = watch("manhourPercentageByGrade") | |||
selectedTaskTemplate?.gradeAllocations.forEach((gradeAllocation) => { | |||
@@ -73,28 +74,30 @@ const TaskSetup: React.FC<Props> = ({ | |||
setValue("manhourPercentageByGrade", updatedManhourPercentageByGrade) | |||
if (Object.values(updatedManhourPercentageByGrade).reduce((acc, value) => acc + value, 0) === 100) clearErrors("manhourPercentageByGrade") | |||
else setError("manhourPercentageByGrade", {message: "manhourPercentageByGrade value is not valid", type: "invalid"}) | |||
else setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" }) | |||
// update the "manhour allocation by grade by stage" by task template | |||
const updatedTaskGroups = watch("taskGroups") | |||
const taskGroupsKeys = Object.keys(updatedTaskGroups) | |||
selectedTaskTemplate?.groupAllocations.forEach((groupAllocation) => { | |||
const taskGroupId = groupAllocation.taskGroup.id | |||
if(taskGroupsKeys.includes(taskGroupId.toString())) { | |||
updatedTaskGroups[taskGroupId] = {...updatedTaskGroups[taskGroupId], percentAllocation: groupAllocation?.percentage} | |||
if (taskGroupsKeys.includes(taskGroupId.toString())) { | |||
updatedTaskGroups[taskGroupId] = { ...updatedTaskGroups[taskGroupId], percentAllocation: groupAllocation?.percentage } | |||
} | |||
}) | |||
const percentageToZeroGroupIds = difference(taskGroupsKeys.map(key => parseFloat(key)), selectedTaskTemplate?.groupAllocations.map(groupAllocation => groupAllocation.taskGroup.id)!!) | |||
percentageToZeroGroupIds.forEach((percentageToZeroGroupId) => { | |||
updatedTaskGroups[percentageToZeroGroupId] = {...updatedTaskGroups[percentageToZeroGroupId], percentAllocation: 0} | |||
updatedTaskGroups[percentageToZeroGroupId] = { ...updatedTaskGroups[percentageToZeroGroupId], percentAllocation: 0 } | |||
}) | |||
setValue("taskGroups", updatedTaskGroups) | |||
if (Object.values(updatedTaskGroups).reduce((acc, value) => acc + value.percentAllocation, 0) === 100) clearErrors("taskGroups") | |||
else setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"}) | |||
else setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) | |||
} | |||
setValue("taskTemplateId", selectedTaskTemplateId) | |||
const taskList = | |||
selectedTaskTemplateId === "All" | |||
? tasks | |||
@@ -176,7 +179,26 @@ const TaskSetup: React.FC<Props> = ({ | |||
}; | |||
}, {}); | |||
setValue("taskGroups", newTaskGroups); | |||
// update the "manhour allocation by grade by stage" by task template | |||
const taskGroupsKeys = Object.keys(newTaskGroups) | |||
const selectedTaskTemplate = taskTemplates.find( | |||
(template) => template.id === selectedTaskTemplateId, | |||
) | |||
selectedTaskTemplate?.groupAllocations.forEach((groupAllocation) => { | |||
const taskGroupId = groupAllocation.taskGroup.id | |||
if (taskGroupsKeys.includes(taskGroupId.toString())) { | |||
newTaskGroups[taskGroupId] = { ...newTaskGroups[taskGroupId], percentAllocation: groupAllocation?.percentage } | |||
} | |||
}) | |||
const percentageToZeroGroupIds = difference(taskGroupsKeys.map(key => parseFloat(key)), selectedTaskTemplate?.groupAllocations.map(groupAllocation => groupAllocation.taskGroup.id)!!) | |||
percentageToZeroGroupIds.forEach((percentageToZeroGroupId) => { | |||
newTaskGroups[percentageToZeroGroupId] = { ...newTaskGroups[percentageToZeroGroupId], percentAllocation: 0 } | |||
}) | |||
setValue("taskGroups", newTaskGroups) | |||
if (Object.values(newTaskGroups).reduce((acc, value) => acc + value.percentAllocation, 0) === 100) clearErrors("taskGroups") | |||
else setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) | |||
}} | |||
allItemsLabel={t("Task Pool")} | |||
selectedItemsLabel={t("Project Task List")} | |||
@@ -46,6 +46,12 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
type: "select", | |||
options: uniq(projects.map((project) => project.team)), | |||
}, | |||
{ | |||
label: t("Status"), | |||
paramName: "status", | |||
type: "select", | |||
options: uniq(projects.map((project) => project.status)), | |||
}, | |||
], | |||
[t, projectCategories, projects], | |||
); | |||
@@ -74,6 +80,7 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
{ name: "category", label: t("Project Category") }, | |||
{ name: "team", label: t("Team") }, | |||
{ name: "client", label: t("Client") }, | |||
{ name: "status", label: t("Status") }, | |||
], | |||
[t, onProjectClick], | |||
); | |||
@@ -90,7 +97,8 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
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), | |||
(query.team === "All" || p.team === query.team) && | |||
(query.status === "All" || p.status === query.status), | |||
), | |||
); | |||
}} | |||