| @@ -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 { Task, TaskGroup } from "../tasks"; | |||
| import { Customer } from "../customer"; | |||
| import { revalidateTag } from "next/cache"; | |||
| export interface CreateProjectInputs { | |||
| // Project details | |||
| @@ -62,9 +63,12 @@ export interface PaymentInputs { | |||
| } | |||
| export const saveProject = async (data: CreateProjectInputs) => { | |||
| return serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||
| const newProject = await serverFetchJson(`${BASE_API_URL}/projects/new`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| 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 "server-only"; | |||
| import { Task, TaskGroup } from "../tasks"; | |||
| import { CreateProjectInputs } from "./actions"; | |||
| export interface ProjectResult { | |||
| id: number; | |||
| @@ -55,8 +56,8 @@ export interface AssignedProject { | |||
| tasks: Task[]; | |||
| milestones: { | |||
| [taskGroupId: TaskGroup["id"]]: { | |||
| startDate: string; | |||
| endDate: string; | |||
| startDate?: string; | |||
| endDate?: string; | |||
| }; | |||
| }; | |||
| // 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, | |||
| } from "react-hook-form"; | |||
| import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | |||
| import { Error } from "@mui/icons-material"; | |||
| import { Delete, Error, PlayArrow } from "@mui/icons-material"; | |||
| import { | |||
| BuildingType, | |||
| ContractType, | |||
| @@ -38,6 +38,8 @@ import { Grade } from "@/app/api/grades"; | |||
| import { Customer, Subsidiary } from "@/app/api/customer"; | |||
| export interface Props { | |||
| isEditMode: boolean; | |||
| defaultInputs?: CreateProjectInputs; | |||
| allTasks: Task[]; | |||
| projectCategories: ProjectCategory[]; | |||
| taskTemplates: TaskTemplate[]; | |||
| @@ -69,6 +71,8 @@ const hasErrorsInTab = ( | |||
| }; | |||
| const CreateProject: React.FC<Props> = ({ | |||
| isEditMode, | |||
| defaultInputs, | |||
| allTasks, | |||
| projectCategories, | |||
| taskTemplates, | |||
| @@ -90,7 +94,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| const router = useRouter(); | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| router.replace("/projects"); | |||
| }; | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| @@ -128,7 +132,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| ); | |||
| const formProps = useForm<CreateProjectInputs>({ | |||
| defaultValues: { | |||
| defaultValues: defaultInputs ?? { | |||
| taskGroups: {}, | |||
| allocatedStaffIds: [], | |||
| milestones: {}, | |||
| @@ -142,76 +146,95 @@ const CreateProject: React.FC<Props> = ({ | |||
| const errors = formProps.formState.errors; | |||
| 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 variant="contained" startIcon={<Check />} type="submit"> | |||
| {t("Confirm")} | |||
| <Button variant="outlined" startIcon={<Delete />} color="error"> | |||
| {t("Delete Project")} | |||
| </Button> | |||
| </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, | |||
| fetchProjectCategories, | |||
| fetchProjectContractTypes, | |||
| fetchProjectDetails, | |||
| fetchProjectFundingTypes, | |||
| fetchProjectLocationTypes, | |||
| fetchProjectServiceTypes, | |||
| @@ -13,7 +14,15 @@ import { fetchStaff, fetchTeamLeads } from "@/app/api/staff"; | |||
| import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; | |||
| 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 [ | |||
| tasks, | |||
| taskTemplates, | |||
| @@ -46,8 +55,14 @@ const CreateProjectWrapper: React.FC = async () => { | |||
| fetchGrades(), | |||
| ]); | |||
| const projectInfo = props.isEditMode | |||
| ? await fetchProjectDetails(props.projectId) | |||
| : undefined; | |||
| return ( | |||
| <CreateProject | |||
| isEditMode={props.isEditMode} | |||
| defaultInputs={projectInfo} | |||
| allTasks={tasks} | |||
| projectCategories={projectCategories} | |||
| taskTemplates={taskTemplates} | |||
| @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| import uniq from "lodash/uniq"; | |||
| import { useRouter } from "next/navigation"; | |||
| interface Props { | |||
| projects: ProjectResult[]; | |||
| @@ -17,6 +18,7 @@ type SearchQuery = Partial<Omit<ProjectResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
| const router = useRouter(); | |||
| const { t } = useTranslation("projects"); | |||
| const [filteredProjects, setFilteredProjects] = useState(projects); | |||
| @@ -51,9 +53,12 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
| setFilteredProjects(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>[]>( | |||
| () => [ | |||
| @@ -9,9 +9,9 @@ import { Box, Input, SxProps, TableCell } from "@mui/material"; | |||
| interface Props<T> { | |||
| value: T; | |||
| onChange: (newValue?: T) => void; | |||
| onChange: (newValue: T) => void; | |||
| renderValue?: (value: T) => string; | |||
| convertValue: (inputValue?: string) => T; | |||
| convertValue: (inputValue: string) => T; | |||
| cellSx?: SxProps; | |||
| inputSx?: SxProps; | |||
| } | |||
| @@ -25,7 +25,7 @@ const TableCellEdit = <T,>({ | |||
| inputSx, | |||
| }: Props<T>) => { | |||
| const [editMode, setEditMode] = useState(false); | |||
| const [input, setInput] = useState<string>(); | |||
| const [input, setInput] = useState<string>(""); | |||
| const inputRef = useRef<HTMLInputElement>(null); | |||
| const onClick = useCallback(() => { | |||
| @@ -41,7 +41,7 @@ const TableCellEdit = <T,>({ | |||
| const onBlur = useCallback(() => { | |||
| setEditMode(false); | |||
| onChange(convertValue(input)); | |||
| setInput(undefined); | |||
| setInput(""); | |||
| }, [convertValue, input, onChange]); | |||
| useEffect(() => { | |||
| @@ -37,7 +37,6 @@ type TimeEntryRow = Partial< | |||
| _error: string; | |||
| isPlanned: boolean; | |||
| id: string; | |||
| taskGroupId: number; | |||
| } | |||
| >; | |||
| @@ -221,6 +220,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
| valueOptions() { | |||
| return assignedProjects.map((p) => ({ value: p.id, label: p.name })); | |||
| }, | |||
| valueGetter({ value }) { | |||
| return value ?? ""; | |||
| }, | |||
| }, | |||
| { | |||
| field: "taskGroupId", | |||
| @@ -228,6 +230,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
| width: 200, | |||
| editable: true, | |||
| type: "singleSelect", | |||
| valueGetter({ value }) { | |||
| return value ?? ""; | |||
| }, | |||
| valueOptions(params) { | |||
| const updatedRow = params.id | |||
| ? apiRef.current.getRowWithUpdatedValues(params.id, "") | |||
| @@ -253,6 +258,9 @@ const EntryInputTable: React.FC<Props> = ({ day, assignedProjects }) => { | |||
| width: 200, | |||
| editable: true, | |||
| type: "singleSelect", | |||
| valueGetter({ value }) { | |||
| return value ?? ""; | |||
| }, | |||
| valueOptions(params) { | |||
| const updatedRow = params.id | |||
| ? apiRef.current.getRowWithUpdatedValues(params.id, "") | |||