| @@ -8,7 +8,7 @@ export default async function NotFound() { | |||
| return ( | |||
| <Stack spacing={2}> | |||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||
| <Typography variant="body1">{t("The sub project was not found or there was no any main projects!")}</Typography> | |||
| <Typography variant="body1">{t("There was no any main projects!")}</Typography> | |||
| <Link href="/projects" component={NextLink} variant="body2"> | |||
| {t("Return to all projects")} | |||
| </Link> | |||
| @@ -21,7 +21,7 @@ import { Metadata } from "next"; | |||
| import { notFound } from "next/navigation"; | |||
| export const metadata: Metadata = { | |||
| title: "Create Project", | |||
| title: "Create Sub Project", | |||
| }; | |||
| const Projects: React.FC = async () => { | |||
| @@ -0,0 +1,17 @@ | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { Stack, Typography, Link } from "@mui/material"; | |||
| import NextLink from "next/link"; | |||
| export default async function NotFound() { | |||
| const { t } = await getServerI18n("projects", "common"); | |||
| return ( | |||
| <Stack spacing={2}> | |||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||
| <Typography variant="body1">{t("The sub project was not found!")}</Typography> | |||
| <Link href="/projects" component={NextLink} variant="body2"> | |||
| {t("Return to all projects")} | |||
| </Link> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,76 @@ | |||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||
| import { fetchGrades } from "@/app/api/grades"; | |||
| import { | |||
| fetchMainProjects, | |||
| fetchProjectBuildingTypes, | |||
| fetchProjectCategories, | |||
| fetchProjectContractTypes, | |||
| fetchProjectDetails, | |||
| fetchProjectFundingTypes, | |||
| fetchProjectLocationTypes, | |||
| fetchProjectServiceTypes, | |||
| fetchProjectWorkNatures, | |||
| } from "@/app/api/projects"; | |||
| import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; | |||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
| import CreateProject from "@/components/CreateProject"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { isArray } from "lodash"; | |||
| import { Metadata } from "next"; | |||
| import { notFound } from "next/navigation"; | |||
| interface Props { | |||
| searchParams: { [key: string]: string | string[] | undefined }; | |||
| } | |||
| export const metadata: Metadata = { | |||
| title: "Edit Sub Project", | |||
| }; | |||
| const Projects: React.FC<Props> = async ({ searchParams }) => { | |||
| const { t } = await getServerI18n("projects"); | |||
| const projectId = searchParams["id"]; | |||
| if (!projectId || isArray(projectId)) { | |||
| notFound(); | |||
| } | |||
| // Preload necessary dependencies | |||
| fetchAllTasks(); | |||
| fetchTaskTemplates(); | |||
| fetchProjectCategories(); | |||
| fetchProjectContractTypes(); | |||
| fetchProjectFundingTypes(); | |||
| fetchProjectLocationTypes(); | |||
| fetchProjectServiceTypes(); | |||
| fetchProjectBuildingTypes(); | |||
| fetchProjectWorkNatures(); | |||
| fetchAllCustomers(); | |||
| fetchAllSubsidiaries(); | |||
| fetchGrades(); | |||
| preloadTeamLeads(); | |||
| preloadStaff(); | |||
| try { | |||
| await fetchProjectDetails(projectId); | |||
| const data = await fetchMainProjects(); | |||
| if (!Boolean(data) || data.length === 0) { | |||
| notFound(); | |||
| } | |||
| } catch (e) { | |||
| notFound(); | |||
| } | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Edit Sub Project")}</Typography> | |||
| <I18nProvider namespaces={["projects"]}> | |||
| <CreateProject isEditMode isSubProject projectId={projectId}/> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Projects; | |||
| @@ -36,8 +36,8 @@ export interface CreateProjectInputs { | |||
| // Client details | |||
| clientId: Customer["id"]; | |||
| clientContactId?: number; | |||
| clientSubsidiaryId?: number; | |||
| subsidiaryContactId: number; | |||
| clientSubsidiaryId?: number | null; | |||
| subsidiaryContactId?: number; | |||
| isSubsidiaryContact?: boolean; | |||
| // Allocation | |||
| @@ -13,6 +13,7 @@ export interface ProjectResult { | |||
| team: string; | |||
| client: string; | |||
| status: string; | |||
| mainProject: string; | |||
| } | |||
| export interface MainProject { | |||
| @@ -1,6 +1,6 @@ | |||
| "use client" | |||
| import { Autocomplete, MenuItem, TextField, Checkbox } from "@mui/material"; | |||
| import { Autocomplete, MenuItem, TextField, Checkbox, Chip } from "@mui/material"; | |||
| import { Controller, FieldValues, Path, Control, RegisterOptions } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; | |||
| @@ -18,6 +18,7 @@ interface Props<T extends { id?: number | string | null; label?: string; name?: | |||
| noOptionsText?: string, | |||
| isMultiple?: boolean, | |||
| rules?: RegisterOptions<FieldValues> | |||
| disabled?: boolean, | |||
| } | |||
| function ControlledAutoComplete< | |||
| @@ -27,11 +28,10 @@ function ControlledAutoComplete< | |||
| props: Props<T, TField> | |||
| ) { | |||
| const { t } = useTranslation() | |||
| const { control, options, name, label, noOptionsText, isMultiple, rules } = props; | |||
| const { control, options, name, label, noOptionsText, isMultiple, rules, disabled } = 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] = [] | |||
| @@ -42,7 +42,6 @@ function ControlledAutoComplete< | |||
| name={name} | |||
| control={control} | |||
| rules={rules} | |||
| render={({ field, fieldState, formState }) => { | |||
| return ( | |||
| @@ -51,7 +50,8 @@ function ControlledAutoComplete< | |||
| multiple | |||
| disableClearable | |||
| disableCloseOnSelect | |||
| disablePortal | |||
| // disablePortal | |||
| disabled={disabled} | |||
| noOptionsText={noOptionsText ?? t("No Options")} | |||
| value={options.filter(option => { | |||
| return field.value?.includes(option.id) | |||
| @@ -61,7 +61,7 @@ function ControlledAutoComplete< | |||
| isOptionEqualToValue={(option, value) => option.id === value.id} | |||
| renderOption={(params, option, { selected }) => { | |||
| return ( | |||
| <li {...params} key={option.id}> | |||
| <li {...params} key={option?.id}> | |||
| <Checkbox | |||
| icon={icon} | |||
| checkedIcon={checkedIcon} | |||
| @@ -72,6 +72,11 @@ function ControlledAutoComplete< | |||
| </li> | |||
| ); | |||
| }} | |||
| // renderTags={(tagValue, getTagProps) => { | |||
| // return tagValue.map((option, index) => ( | |||
| // <Chip {...getTagProps({ index })} key={option?.id} label={option.label ?? option.name} /> | |||
| // )) | |||
| // }} | |||
| onChange={(event, value) => { | |||
| field.onChange(value?.map(v => v.id)) | |||
| }} | |||
| @@ -80,7 +85,8 @@ function ControlledAutoComplete< | |||
| : | |||
| <Autocomplete | |||
| disableClearable | |||
| disablePortal | |||
| // disablePortal | |||
| disabled={disabled} | |||
| noOptionsText={noOptionsText ?? t("No Options")} | |||
| value={options.find(option => option.id === field.value) ?? options[0]} | |||
| options={options} | |||
| @@ -88,13 +94,18 @@ function ControlledAutoComplete< | |||
| isOptionEqualToValue={(option, value) => option?.id === value?.id} | |||
| renderOption={(params, option) => { | |||
| return ( | |||
| <MenuItem {...params} key={option.id} value={option.id}> | |||
| <MenuItem {...params} key={option?.id} value={option.id}> | |||
| {option.label ?? option.name} | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| // renderTags={(tagValue, getTagProps) => { | |||
| // return tagValue.map((option, index) => ( | |||
| // <Chip {...getTagProps({ index })} key={option?.id} label={option.label ?? option.name} /> | |||
| // )) | |||
| // }} | |||
| onChange={(event, value) => { | |||
| field.onChange(value?.id) | |||
| field.onChange(value?.id ?? null) | |||
| }} | |||
| renderInput={(params) => <TextField {...params} error={Boolean(formState.errors[name])} variant="outlined" label={label} />} | |||
| />) | |||
| @@ -393,6 +393,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| projectCategories={projectCategories} | |||
| teamLeads={teamLeads} | |||
| isActive={tabIndex === 0} | |||
| isEditMode={isEditMode} | |||
| /> | |||
| } | |||
| { | |||
| @@ -4,16 +4,18 @@ import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import Button from "@mui/material/Button"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import React, { SyntheticEvent, useCallback, useEffect, useMemo, useState } from "react"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import { | |||
| Alert, | |||
| Autocomplete, | |||
| FormControl, | |||
| InputLabel, | |||
| MenuItem, | |||
| Select, | |||
| SelectChangeEvent, | |||
| TextField, | |||
| } from "@mui/material"; | |||
| import { Task, TaskGroup } from "@/app/api/tasks"; | |||
| import uniqBy from "lodash/uniqBy"; | |||
| @@ -49,8 +51,8 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | |||
| taskGroups[0].id, | |||
| ); | |||
| const onSelectTaskGroup = useCallback( | |||
| (event: SelectChangeEvent<TaskGroup["id"]>) => { | |||
| const id = event.target.value; | |||
| (event: SyntheticEvent<Element, Event>, value: NonNullable<TaskGroup>) => { | |||
| const id = value.id; | |||
| const newTaksGroupId = typeof id === "string" ? parseInt(id) : id; | |||
| setCurrentTaskGroupId(newTaksGroupId); | |||
| }, | |||
| @@ -81,7 +83,7 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | |||
| } | |||
| // console.log(Object.keys(milestones).reduce((acc, key) => acc + milestones[parseFloat(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0)) | |||
| if (hasError) { | |||
| setError("milestones", {message: "milestones is not valid", type: "invalid"}) | |||
| setError("milestones", { message: "milestones is not valid", type: "invalid" }) | |||
| } else { | |||
| clearErrors("milestones") | |||
| } | |||
| @@ -92,26 +94,32 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |||
| <FormControl> | |||
| <InputLabel>{t("Task Stage")}</InputLabel> | |||
| <Select | |||
| label={t("Task Stage")} | |||
| <Autocomplete | |||
| disableClearable | |||
| // disablePortal | |||
| noOptionsText={t("No Task Stage")} | |||
| value={taskGroups.find(taskGroup => taskGroup.id === currentTaskGroupId)} | |||
| options={taskGroups} | |||
| getOptionLabel={(taskGroup) => taskGroup.name} | |||
| isOptionEqualToValue={(option, value) => option.id === value.id} | |||
| renderOption={(params, option) => { | |||
| return ( | |||
| <MenuItem {...params} key={option.id} value={option.id}> | |||
| {option.name} | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| onChange={onSelectTaskGroup} | |||
| value={currentTaskGroupId} | |||
| > | |||
| {taskGroups.map((taskGroup) => ( | |||
| <MenuItem key={taskGroup.id} value={taskGroup.id}> | |||
| {taskGroup.name} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| renderInput={(params) => <TextField {...params} variant="outlined" label={t("Task Stage")} />} | |||
| /> | |||
| </FormControl> | |||
| {/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */} | |||
| {isActive && <MilestoneSection taskGroupId={currentTaskGroupId} />} | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| {/* <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardActions> */} | |||
| </CardContent> | |||
| </Card> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| @@ -39,6 +39,7 @@ import ControlledAutoComplete from "../ControlledAutoComplete/ControlledAutoComp | |||
| interface Props { | |||
| isActive: boolean; | |||
| isSubProject: boolean; | |||
| isEditMode: boolean; | |||
| mainProjects?: MainProject[]; | |||
| projectCategories: ProjectCategory[]; | |||
| teamLeads: StaffResult[]; | |||
| @@ -55,6 +56,7 @@ interface Props { | |||
| const ProjectClientDetails: React.FC<Props> = ({ | |||
| isActive, | |||
| isSubProject, | |||
| isEditMode, | |||
| mainProjects, | |||
| projectCategories, | |||
| teamLeads, | |||
| @@ -110,6 +112,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| ); | |||
| // get customer (client) contact combo | |||
| const [firstCustomerLoaded, setFirstCustomerLoaded] = useState(false) | |||
| useEffect(() => { | |||
| if (selectedCustomerId !== undefined) { | |||
| fetchCustomer(selectedCustomerId).then(({ contacts, subsidiaryIds }) => { | |||
| @@ -118,7 +121,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| // if (subsidiaryIds.length > 0) setValue("clientSubsidiaryId", subsidiaryIds[0]) | |||
| // else | |||
| setValue("clientSubsidiaryId", undefined) | |||
| if (isEditMode && !firstCustomerLoaded) { setFirstCustomerLoaded(true) } else setValue("clientSubsidiaryId", null) | |||
| // if (contacts.length > 0) setValue("clientContactId", contacts[0].id) | |||
| // else setValue("clientContactId", undefined) | |||
| }); | |||
| @@ -130,11 +133,11 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| if (Boolean(clientSubsidiaryId)) { | |||
| // get subsidiary contact combo | |||
| const contacts = allSubsidiaries.find(subsidiary => subsidiary.id === clientSubsidiaryId)?.subsidiaryContacts!! | |||
| setSubsidiaryContacts(contacts) | |||
| setSubsidiaryContacts(() => contacts) | |||
| setValue("clientContactId", selectedCustomerId === defaultValues?.clientId && Boolean(defaultValues?.clientSubsidiaryId) ? contacts.find(contact => contact.id === defaultValues.clientContactId)?.id ?? contacts[0].id : contacts[0].id) | |||
| setValue("isSubsidiaryContact", true) | |||
| } else if (customerContacts?.length > 0) { | |||
| setSubsidiaryContacts([]) | |||
| setSubsidiaryContacts(() => []) | |||
| setValue("clientContactId", selectedCustomerId === defaultValues?.clientId && !Boolean(defaultValues?.clientSubsidiaryId) ? customerContacts.find(contact => contact.id === defaultValues.clientContactId)?.id ?? customerContacts[0].id : customerContacts[0].id) | |||
| setValue("isSubsidiaryContact", false) | |||
| } | |||
| @@ -153,7 +156,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| // Automatically update the project & client details whene select a main project | |||
| const mainProjectId = watch("mainProjectId") | |||
| useEffect(() => { | |||
| if (mainProjectId !== undefined && mainProjects !== undefined) { | |||
| if (mainProjectId !== undefined && mainProjects !== undefined && !isEditMode) { | |||
| const mainProject = mainProjects.find(project => project.projectId === mainProjectId); | |||
| if (mainProject !== undefined) { | |||
| @@ -174,7 +177,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| setValue("clientContactId", mainProject.clientContactId) | |||
| } | |||
| } | |||
| }, [getValues, mainProjectId, setValue]) | |||
| }, [getValues, mainProjectId, setValue, isEditMode]) | |||
| // const buildingTypeIdNameMap = buildingTypes.reduce<{ [id: number]: string }>( | |||
| // (acc, building) => ({ ...acc, [building.id]: building.name }), | |||
| @@ -202,6 +205,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| name="mainProjectId" | |||
| label={t("Main Project")} | |||
| noOptionsText={t("No Main Project")} | |||
| disabled={isEditMode} | |||
| /> | |||
| </Grid> | |||
| <Grid item sx={{ display: { xs: "none", sm: "block" } }} /></> | |||
| @@ -438,11 +442,11 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| )} | |||
| </Grid> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| {/* <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardActions> */} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| @@ -1,7 +1,7 @@ | |||
| "use client"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import React, { useEffect, useMemo } from "react"; | |||
| import React, { SyntheticEvent, useEffect, useMemo } from "react"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material"; | |||
| @@ -160,8 +160,8 @@ const StaffAllocation: React.FC<Props> = ({ | |||
| }, [columnFilters]); | |||
| const [filters, setFilters] = React.useState(defaultFilterValues); | |||
| const makeFilterSelect = React.useCallback( | |||
| (filter: keyof StaffResult) => (event: SelectChangeEvent<string>) => { | |||
| setFilters((f) => ({ ...f, [filter]: event.target.value })); | |||
| (filter: keyof StaffResult) => (event: SyntheticEvent<Element, Event>, value: NonNullable<string>) => { | |||
| setFilters((f) => ({ ...f, [filter]: value })); | |||
| }, | |||
| [], | |||
| ); | |||
| @@ -239,20 +239,25 @@ const StaffAllocation: React.FC<Props> = ({ | |||
| return ( | |||
| <Grid key={`${filter.toString()}-${idx}`} item xs={3}> | |||
| <FormControl fullWidth> | |||
| <InputLabel size="small">{label}</InputLabel> | |||
| <Select | |||
| label={label} | |||
| <Autocomplete | |||
| disableClearable | |||
| // disablePortal | |||
| size="small" | |||
| noOptionsText={t(`No ${label}`)} | |||
| value={filters[filter]} | |||
| options={["All", ...(filterValues[filter] ?? [])]} | |||
| getOptionLabel={(filterValue) => filterValue} | |||
| isOptionEqualToValue={(option, value) => option === value} | |||
| renderOption={(params, option) => { | |||
| return ( | |||
| <MenuItem {...params} key={option} value={option}> | |||
| {option} | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| onChange={makeFilterSelect(filter)} | |||
| > | |||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | |||
| {filterValues[filter]?.map((option, index) => ( | |||
| <MenuItem key={`${option}-${index}`} value={option}> | |||
| {option} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| renderInput={(params) => <TextField {...params} variant="outlined" label={t(label)} />} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| ); | |||
| @@ -289,11 +294,11 @@ const StaffAllocation: React.FC<Props> = ({ | |||
| )} | |||
| </Box> | |||
| </Stack> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| {/* <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />} onClick={reset}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardActions> */} | |||
| </CardContent> | |||
| </Card> | |||
| {/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */} | |||
| @@ -135,7 +135,7 @@ const TaskSetup: React.FC<Props> = ({ | |||
| <Grid item xs={6}> | |||
| <Autocomplete | |||
| disableClearable | |||
| disablePortal | |||
| // 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}))]} | |||
| @@ -207,11 +207,11 @@ const TaskSetup: React.FC<Props> = ({ | |||
| allItemsLabel={t("Task Pool")} | |||
| selectedItemsLabel={t("Project Task List")} | |||
| /> | |||
| <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| {/* <CardActions sx={{ justifyContent: "flex-end" }}> | |||
| <Button variant="text" startIcon={<RestartAlt />} onClick={onReset}> | |||
| {t("Reset")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardActions> */} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| @@ -20,7 +20,6 @@ type SearchParamNames = keyof SearchQuery; | |||
| const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
| const router = useRouter(); | |||
| const { t } = useTranslation("projects"); | |||
| console.log(projects) | |||
| const [filteredProjects, setFilteredProjects] = useState(projects); | |||
| @@ -62,7 +61,9 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
| const onProjectClick = useCallback( | |||
| (project: ProjectResult) => { | |||
| router.push(`/projects/edit?id=${project.id}`); | |||
| if (Boolean(project.mainProject)) { | |||
| router.push(`/projects/edit/sub?id=${project.id}`); | |||
| } else router.push(`/projects/edit?id=${project.id}`); | |||
| }, | |||
| [router], | |||
| ); | |||