| @@ -0,0 +1,62 @@ | |||||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||||
| import { fetchGrades } from "@/app/api/grades"; | |||||
| import { | |||||
| 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 { Metadata } from "next"; | |||||
| interface Props { | |||||
| params: { | |||||
| projectId: string; | |||||
| }; | |||||
| } | |||||
| export const metadata: Metadata = { | |||||
| title: "Edit Project", | |||||
| }; | |||||
| const Projects: React.FC<Props> = async ({ params }) => { | |||||
| const { t } = await getServerI18n("projects"); | |||||
| // Preload necessary dependencies | |||||
| fetchAllTasks(); | |||||
| fetchTaskTemplates(); | |||||
| fetchProjectCategories(); | |||||
| fetchProjectContractTypes(); | |||||
| fetchProjectFundingTypes(); | |||||
| fetchProjectLocationTypes(); | |||||
| fetchProjectServiceTypes(); | |||||
| fetchProjectBuildingTypes(); | |||||
| fetchProjectWorkNatures(); | |||||
| fetchAllCustomers(); | |||||
| fetchAllSubsidiaries(); | |||||
| fetchGrades(); | |||||
| preloadTeamLeads(); | |||||
| preloadStaff(); | |||||
| // TODO: Handle not found | |||||
| const fetchedProject = await fetchProjectDetails(params.projectId); | |||||
| return ( | |||||
| <> | |||||
| <Typography variant="h4">{t("Edit Project")}</Typography> | |||||
| <I18nProvider namespaces={["projects"]}> | |||||
| <CreateProject isEditMode projectId={params.projectId} /> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default Projects; | |||||
| @@ -4,6 +4,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { Task, TaskGroup } from "../tasks"; | import { Task, TaskGroup } from "../tasks"; | ||||
| import { Customer } from "../customer"; | import { Customer } from "../customer"; | ||||
| import { revalidateTag } from "next/cache"; | |||||
| export interface CreateProjectInputs { | export interface CreateProjectInputs { | ||||
| // Project details | // Project details | ||||
| @@ -62,9 +63,12 @@ export interface PaymentInputs { | |||||
| } | } | ||||
| export const saveProject = async (data: CreateProjectInputs) => { | export const saveProject = async (data: CreateProjectInputs) => { | ||||
| return serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||||
| const newProject = await serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| }); | }); | ||||
| revalidateTag("projects"); | |||||
| return newProject; | |||||
| }; | }; | ||||
| @@ -3,6 +3,7 @@ import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| import { Task, TaskGroup } from "../tasks"; | import { Task, TaskGroup } from "../tasks"; | ||||
| import { CreateProjectInputs } from "./actions"; | |||||
| export interface ProjectResult { | export interface ProjectResult { | ||||
| id: number; | id: number; | ||||
| @@ -55,8 +56,8 @@ export interface AssignedProject { | |||||
| tasks: Task[]; | tasks: Task[]; | ||||
| milestones: { | milestones: { | ||||
| [taskGroupId: TaskGroup["id"]]: { | [taskGroupId: TaskGroup["id"]]: { | ||||
| startDate: string; | |||||
| endDate: string; | |||||
| startDate?: string; | |||||
| endDate?: string; | |||||
| }; | }; | ||||
| }; | }; | ||||
| // Manhour info | // Manhour info | ||||
| @@ -145,3 +146,12 @@ export const fetchAssignedProjects = cache(async () => { | |||||
| }, | }, | ||||
| ); | ); | ||||
| }); | }); | ||||
| export const fetchProjectDetails = cache(async (projectId: string) => { | |||||
| return serverFetchJson<CreateProjectInputs>( | |||||
| `${BASE_API_URL}/projects/projectDetails/${projectId}`, | |||||
| { | |||||
| next: { tags: [`projectDetails_${projectId}`] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| @@ -22,7 +22,7 @@ import { | |||||
| useForm, | useForm, | ||||
| } from "react-hook-form"; | } from "react-hook-form"; | ||||
| import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | ||||
| import { Error } from "@mui/icons-material"; | |||||
| import { Delete, Error, PlayArrow } from "@mui/icons-material"; | |||||
| import { | import { | ||||
| BuildingType, | BuildingType, | ||||
| ContractType, | ContractType, | ||||
| @@ -38,6 +38,8 @@ import { Grade } from "@/app/api/grades"; | |||||
| import { Customer, Subsidiary } from "@/app/api/customer"; | import { Customer, Subsidiary } from "@/app/api/customer"; | ||||
| export interface Props { | export interface Props { | ||||
| isEditMode: boolean; | |||||
| defaultInputs?: CreateProjectInputs; | |||||
| allTasks: Task[]; | allTasks: Task[]; | ||||
| projectCategories: ProjectCategory[]; | projectCategories: ProjectCategory[]; | ||||
| taskTemplates: TaskTemplate[]; | taskTemplates: TaskTemplate[]; | ||||
| @@ -69,6 +71,8 @@ const hasErrorsInTab = ( | |||||
| }; | }; | ||||
| const CreateProject: React.FC<Props> = ({ | const CreateProject: React.FC<Props> = ({ | ||||
| isEditMode, | |||||
| defaultInputs, | |||||
| allTasks, | allTasks, | ||||
| projectCategories, | projectCategories, | ||||
| taskTemplates, | taskTemplates, | ||||
| @@ -90,7 +94,7 @@ const CreateProject: React.FC<Props> = ({ | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const handleCancel = () => { | const handleCancel = () => { | ||||
| router.back(); | |||||
| router.replace("/projects"); | |||||
| }; | }; | ||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| @@ -128,7 +132,7 @@ const CreateProject: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| const formProps = useForm<CreateProjectInputs>({ | const formProps = useForm<CreateProjectInputs>({ | ||||
| defaultValues: { | |||||
| defaultValues: defaultInputs ?? { | |||||
| taskGroups: {}, | taskGroups: {}, | ||||
| allocatedStaffIds: [], | allocatedStaffIds: [], | ||||
| milestones: {}, | milestones: {}, | ||||
| @@ -142,76 +146,95 @@ const CreateProject: React.FC<Props> = ({ | |||||
| const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
| return ( | return ( | ||||
| <FormProvider {...formProps}> | |||||
| <Stack | |||||
| spacing={2} | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||||
| > | |||||
| <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||||
| <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 and Resource")} iconPosition="end" /> | |||||
| <Tab label={t("Milestone")} iconPosition="end" /> | |||||
| </Tabs> | |||||
| { | |||||
| <ProjectClientDetails | |||||
| buildingTypes={buildingTypes} | |||||
| workNatures={workNatures} | |||||
| contractTypes={contractTypes} | |||||
| fundingTypes={fundingTypes} | |||||
| locationTypes={locationTypes} | |||||
| serviceTypes={serviceTypes} | |||||
| allCustomers={allCustomers} | |||||
| allSubsidiaries={allSubsidiaries} | |||||
| projectCategories={projectCategories} | |||||
| teamLeads={teamLeads} | |||||
| isActive={tabIndex === 0} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <TaskSetup | |||||
| allTasks={allTasks} | |||||
| taskTemplates={taskTemplates} | |||||
| isActive={tabIndex === 1} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <StaffAllocation | |||||
| isActive={tabIndex === 2} | |||||
| allTasks={allTasks} | |||||
| grades={grades} | |||||
| allStaffs={allStaffs} | |||||
| /> | |||||
| } | |||||
| {<Milestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||||
| {serverError && ( | |||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||||
| {serverError} | |||||
| </Typography> | |||||
| )} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Close />} | |||||
| onClick={handleCancel} | |||||
| > | |||||
| {t("Cancel")} | |||||
| <> | |||||
| {isEditMode && ( | |||||
| <Stack direction="row" gap={1}> | |||||
| <Button variant="contained" startIcon={<PlayArrow />} color="success"> | |||||
| {t("Start Project")} | |||||
| </Button> | </Button> | ||||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||||
| {t("Confirm")} | |||||
| <Button variant="outlined" startIcon={<Delete />} color="error"> | |||||
| {t("Delete Project")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | |||||
| </FormProvider> | |||||
| )} | |||||
| <FormProvider {...formProps}> | |||||
| <Stack | |||||
| spacing={2} | |||||
| component="form" | |||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||||
| > | |||||
| <Tabs | |||||
| value={tabIndex} | |||||
| onChange={handleTabChange} | |||||
| variant="scrollable" | |||||
| > | |||||
| <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 and Resource")} | |||||
| iconPosition="end" | |||||
| /> | |||||
| <Tab label={t("Milestone")} iconPosition="end" /> | |||||
| </Tabs> | |||||
| { | |||||
| <ProjectClientDetails | |||||
| buildingTypes={buildingTypes} | |||||
| workNatures={workNatures} | |||||
| contractTypes={contractTypes} | |||||
| fundingTypes={fundingTypes} | |||||
| locationTypes={locationTypes} | |||||
| serviceTypes={serviceTypes} | |||||
| allCustomers={allCustomers} | |||||
| allSubsidiaries={allSubsidiaries} | |||||
| projectCategories={projectCategories} | |||||
| teamLeads={teamLeads} | |||||
| isActive={tabIndex === 0} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <TaskSetup | |||||
| allTasks={allTasks} | |||||
| taskTemplates={taskTemplates} | |||||
| isActive={tabIndex === 1} | |||||
| /> | |||||
| } | |||||
| { | |||||
| <StaffAllocation | |||||
| isActive={tabIndex === 2} | |||||
| allTasks={allTasks} | |||||
| grades={grades} | |||||
| allStaffs={allStaffs} | |||||
| /> | |||||
| } | |||||
| {<Milestone allTasks={allTasks} isActive={tabIndex === 3} />} | |||||
| {serverError && ( | |||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||||
| {serverError} | |||||
| </Typography> | |||||
| )} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Close />} | |||||
| onClick={handleCancel} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||||
| {isEditMode ? t("Save") : t("Confirm")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </FormProvider> | |||||
| </> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -4,6 +4,7 @@ import { | |||||
| fetchProjectBuildingTypes, | fetchProjectBuildingTypes, | ||||
| fetchProjectCategories, | fetchProjectCategories, | ||||
| fetchProjectContractTypes, | fetchProjectContractTypes, | ||||
| fetchProjectDetails, | |||||
| fetchProjectFundingTypes, | fetchProjectFundingTypes, | ||||
| fetchProjectLocationTypes, | fetchProjectLocationTypes, | ||||
| fetchProjectServiceTypes, | fetchProjectServiceTypes, | ||||
| @@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | ||||
| import { fetchGrades } from "@/app/api/grades"; | import { fetchGrades } from "@/app/api/grades"; | ||||
| const CreateProjectWrapper: React.FC = async () => { | |||||
| type CreateProjectProps = { isEditMode: false }; | |||||
| interface EditProjectProps { | |||||
| isEditMode: true; | |||||
| projectId: string; | |||||
| } | |||||
| type Props = CreateProjectProps | EditProjectProps; | |||||
| const CreateProjectWrapper: React.FC<Props> = async (props) => { | |||||
| const [ | const [ | ||||
| tasks, | tasks, | ||||
| taskTemplates, | taskTemplates, | ||||
| @@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => { | |||||
| fetchGrades(), | fetchGrades(), | ||||
| ]); | ]); | ||||
| const projectInfo = props.isEditMode | |||||
| ? await fetchProjectDetails(props.projectId) | |||||
| : undefined; | |||||
| return ( | return ( | ||||
| <CreateProject | <CreateProject | ||||
| isEditMode={props.isEditMode} | |||||
| defaultInputs={projectInfo} | |||||
| allTasks={tasks} | allTasks={tasks} | ||||
| projectCategories={projectCategories} | projectCategories={projectCategories} | ||||
| taskTemplates={taskTemplates} | taskTemplates={taskTemplates} | ||||
| @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import EditNote from "@mui/icons-material/EditNote"; | import EditNote from "@mui/icons-material/EditNote"; | ||||
| import uniq from "lodash/uniq"; | import uniq from "lodash/uniq"; | ||||
| import { useRouter } from "next/navigation"; | |||||
| interface Props { | interface Props { | ||||
| projects: ProjectResult[]; | projects: ProjectResult[]; | ||||
| @@ -17,6 +18,7 @@ type SearchQuery = Partial<Omit<ProjectResult, "id">>; | |||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | ||||
| const router = useRouter(); | |||||
| const { t } = useTranslation("projects"); | const { t } = useTranslation("projects"); | ||||
| const [filteredProjects, setFilteredProjects] = useState(projects); | const [filteredProjects, setFilteredProjects] = useState(projects); | ||||
| @@ -51,9 +53,12 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||||
| setFilteredProjects(projects); | setFilteredProjects(projects); | ||||
| }, [projects]); | }, [projects]); | ||||
| const onProjectClick = useCallback((project: ProjectResult) => { | |||||
| console.log(project); | |||||
| }, []); | |||||
| const onProjectClick = useCallback( | |||||
| (project: ProjectResult) => { | |||||
| router.push(`/projects/edit/${project.id}`); | |||||
| }, | |||||
| [router], | |||||
| ); | |||||
| const columns = useMemo<Column<ProjectResult>[]>( | const columns = useMemo<Column<ProjectResult>[]>( | ||||
| () => [ | () => [ | ||||
| @@ -9,9 +9,9 @@ import { Box, Input, SxProps, TableCell } from "@mui/material"; | |||||
| interface Props<T> { | interface Props<T> { | ||||
| value: T; | value: T; | ||||
| onChange: (newValue?: T) => void; | |||||
| onChange: (newValue: T) => void; | |||||
| renderValue?: (value: T) => string; | renderValue?: (value: T) => string; | ||||
| convertValue: (inputValue?: string) => T; | |||||
| convertValue: (inputValue: string) => T; | |||||
| cellSx?: SxProps; | cellSx?: SxProps; | ||||
| inputSx?: SxProps; | inputSx?: SxProps; | ||||
| } | } | ||||
| @@ -25,7 +25,7 @@ const TableCellEdit = <T,>({ | |||||
| inputSx, | inputSx, | ||||
| }: Props<T>) => { | }: Props<T>) => { | ||||
| const [editMode, setEditMode] = useState(false); | const [editMode, setEditMode] = useState(false); | ||||
| const [input, setInput] = useState<string>(); | |||||
| const [input, setInput] = useState<string>(""); | |||||
| const inputRef = useRef<HTMLInputElement>(null); | const inputRef = useRef<HTMLInputElement>(null); | ||||
| const onClick = useCallback(() => { | const onClick = useCallback(() => { | ||||
| @@ -41,7 +41,7 @@ const TableCellEdit = <T,>({ | |||||
| const onBlur = useCallback(() => { | const onBlur = useCallback(() => { | ||||
| setEditMode(false); | setEditMode(false); | ||||
| onChange(convertValue(input)); | onChange(convertValue(input)); | ||||
| setInput(undefined); | |||||
| setInput(""); | |||||
| }, [convertValue, input, onChange]); | }, [convertValue, input, onChange]); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -37,7 +37,6 @@ type TimeEntryRow = Partial< | |||||
| _error: string; | _error: string; | ||||
| isPlanned: boolean; | isPlanned: boolean; | ||||
| id: string; | id: string; | ||||
| taskGroupId: number; | |||||
| } | } | ||||
| >; | >; | ||||
| @@ -221,6 +220,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
| valueOptions() { | valueOptions() { | ||||
| return assignedProjects.map((p) => ({ value: p.id, label: p.name })); | return assignedProjects.map((p) => ({ value: p.id, label: p.name })); | ||||
| }, | }, | ||||
| valueGetter({ value }) { | |||||
| return value ?? ""; | |||||
| }, | |||||
| }, | }, | ||||
| { | { | ||||
| field: "taskGroupId", | field: "taskGroupId", | ||||
| @@ -228,6 +230,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
| width: 200, | width: 200, | ||||
| editable: true, | editable: true, | ||||
| type: "singleSelect", | type: "singleSelect", | ||||
| valueGetter({ value }) { | |||||
| return value ?? ""; | |||||
| }, | |||||
| valueOptions(params) { | valueOptions(params) { | ||||
| const updatedRow = params.id | const updatedRow = params.id | ||||
| ? apiRef.current.getRowWithUpdatedValues(params.id, "") | ? apiRef.current.getRowWithUpdatedValues(params.id, "") | ||||
| @@ -253,6 +258,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||||
| width: 200, | width: 200, | ||||
| editable: true, | editable: true, | ||||
| type: "singleSelect", | type: "singleSelect", | ||||
| valueGetter({ value }) { | |||||
| return value ?? ""; | |||||
| }, | |||||
| valueOptions(params) { | valueOptions(params) { | ||||
| const updatedRow = params.id | const updatedRow = params.id | ||||
| ? apiRef.current.getRowWithUpdatedValues(params.id, "") | ? apiRef.current.getRowWithUpdatedValues(params.id, "") | ||||