| @@ -1,4 +1,5 @@ | |||||
| import { fetchProjectCategories } from "@/app/api/projects"; | import { fetchProjectCategories } from "@/app/api/projects"; | ||||
| import { preloadStaff } from "@/app/api/staff"; | |||||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | ||||
| import CreateProject from "@/components/CreateProject"; | import CreateProject from "@/components/CreateProject"; | ||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | import { I18nProvider, getServerI18n } from "@/i18n"; | ||||
| @@ -16,6 +17,7 @@ const Projects: React.FC = async () => { | |||||
| fetchAllTasks(); | fetchAllTasks(); | ||||
| fetchTaskTemplates(); | fetchTaskTemplates(); | ||||
| fetchProjectCategories(); | fetchProjectCategories(); | ||||
| preloadStaff(); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -10,6 +10,7 @@ export interface CreateProjectInputs { | |||||
| projectName: string; | projectName: string; | ||||
| projectCategoryId: number; | projectCategoryId: number; | ||||
| projectDescription: string; | projectDescription: string; | ||||
| projectLeadId: number; | |||||
| // Client details | // Client details | ||||
| clientCode: string; | clientCode: string; | ||||
| @@ -37,6 +38,9 @@ export interface CreateProjectInputs { | |||||
| payments: PaymentInputs[]; | payments: PaymentInputs[]; | ||||
| }; | }; | ||||
| }; | }; | ||||
| // Miscellaneous | |||||
| expectedProjectFee: string; | |||||
| } | } | ||||
| export interface ManhourAllocation { | export interface ManhourAllocation { | ||||
| @@ -1,18 +1,34 @@ | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import "server-only"; | import "server-only"; | ||||
| import { Staff } from "../staff"; | |||||
| interface Project { | |||||
| id: number; | |||||
| code: string; | |||||
| name: string; | |||||
| projectCategory: { | |||||
| name: string; | |||||
| }; | |||||
| teamLead: Staff; | |||||
| customer: { | |||||
| name: string; | |||||
| }; | |||||
| } | |||||
| export interface ProjectResult { | export interface ProjectResult { | ||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| category: "Confirmed Project" | "Project to be bidded"; | |||||
| category: string; | |||||
| team: string; | team: string; | ||||
| client: string; | client: string; | ||||
| } | } | ||||
| export interface ProjectCategory { | export interface ProjectCategory { | ||||
| id: number; | id: number; | ||||
| label: string; | |||||
| name: string; | |||||
| } | } | ||||
| export const preloadProjects = () => { | export const preloadProjects = () => { | ||||
| @@ -21,18 +37,35 @@ export const preloadProjects = () => { | |||||
| }; | }; | ||||
| export const fetchProjects = cache(async () => { | export const fetchProjects = cache(async () => { | ||||
| return mockProjects; | |||||
| const projects = await serverFetchJson<Project[]>( | |||||
| `${BASE_API_URL}/projects`, | |||||
| { | |||||
| next: { tags: ["projects"] }, | |||||
| }, | |||||
| ); | |||||
| // TODO: Replace this with a project | |||||
| return projects.map<ProjectResult>( | |||||
| ({ id, code, name, projectCategory, teamLead, customer }) => ({ | |||||
| id, | |||||
| code, | |||||
| name, | |||||
| category: projectCategory.name, | |||||
| team: teamLead.team.code, | |||||
| client: customer.name, | |||||
| }), | |||||
| ); | |||||
| }); | }); | ||||
| export const fetchProjectCategories = cache(async () => { | export const fetchProjectCategories = cache(async () => { | ||||
| return mockProjectCategories; | |||||
| return serverFetchJson<ProjectCategory[]>( | |||||
| `${BASE_API_URL}/projects/categories`, | |||||
| { | |||||
| next: { tags: ["projectCategories"] }, | |||||
| }, | |||||
| ); | |||||
| }); | }); | ||||
| const mockProjectCategories: ProjectCategory[] = [ | |||||
| { id: 1, label: "Confirmed Project" }, | |||||
| { id: 2, label: "Project to be bidded" }, | |||||
| ]; | |||||
| const mockProjects: ProjectResult[] = [ | const mockProjects: ProjectResult[] = [ | ||||
| { | { | ||||
| id: 1, | id: 1, | ||||
| @@ -0,0 +1,24 @@ | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | |||||
| import { cache } from "react"; | |||||
| import "server-only"; | |||||
| export interface Staff { | |||||
| id: number; | |||||
| name: string; | |||||
| staffId: string; | |||||
| team: { | |||||
| name: string; | |||||
| code: string; | |||||
| }; | |||||
| } | |||||
| export const preloadStaff = () => { | |||||
| fetchTeamLeads(); | |||||
| }; | |||||
| export const fetchTeamLeads = cache(async () => { | |||||
| return serverFetchJson<Staff[]>(`${BASE_API_URL}/staffs/teamLeads`, { | |||||
| next: { tags: ["teamLeads"] }, | |||||
| }); | |||||
| }); | |||||
| @@ -32,6 +32,7 @@ export async function serverFetchJson<T>(...args: FetchParams) { | |||||
| case 401: | case 401: | ||||
| signOutUser(); | signOutUser(); | ||||
| default: | default: | ||||
| console.error(await response.text()); | |||||
| throw Error("Something went wrong fetching data in server."); | throw Error("Something went wrong fetching data in server."); | ||||
| } | } | ||||
| } | } | ||||
| @@ -21,14 +21,17 @@ import { | |||||
| SubmitHandler, | SubmitHandler, | ||||
| useForm, | useForm, | ||||
| } from "react-hook-form"; | } from "react-hook-form"; | ||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||||
| import { CreateProjectInputs, saveProject } from "@/app/api/projects/actions"; | |||||
| import { Error } from "@mui/icons-material"; | import { Error } from "@mui/icons-material"; | ||||
| import { ProjectCategory } from "@/app/api/projects"; | import { ProjectCategory } from "@/app/api/projects"; | ||||
| import { Staff } from "@/app/api/staff"; | |||||
| import { Typography } from "@mui/material"; | |||||
| export interface Props { | export interface Props { | ||||
| allTasks: Task[]; | allTasks: Task[]; | ||||
| projectCategories: ProjectCategory[]; | projectCategories: ProjectCategory[]; | ||||
| taskTemplates: TaskTemplate[]; | taskTemplates: TaskTemplate[]; | ||||
| teamLeads: Staff[]; | |||||
| } | } | ||||
| const hasErrorsInTab = ( | const hasErrorsInTab = ( | ||||
| @@ -47,7 +50,9 @@ const CreateProject: React.FC<Props> = ({ | |||||
| allTasks, | allTasks, | ||||
| projectCategories, | projectCategories, | ||||
| taskTemplates, | taskTemplates, | ||||
| teamLeads, | |||||
| }) => { | }) => { | ||||
| const [serverError, setServerError] = useState(""); | |||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| @@ -63,9 +68,19 @@ const CreateProject: React.FC<Props> = ({ | |||||
| [], | [], | ||||
| ); | ); | ||||
| const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>((data) => { | |||||
| console.log(data); | |||||
| }, []); | |||||
| const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>( | |||||
| async (data) => { | |||||
| try { | |||||
| console.log(data); | |||||
| setServerError(""); | |||||
| await saveProject(data); | |||||
| router.replace("/projects"); | |||||
| } catch (e) { | |||||
| setServerError(t("An error has occurred. Please try again later.")); | |||||
| } | |||||
| }, | |||||
| [router, t], | |||||
| ); | |||||
| const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | ||||
| (errors) => { | (errors) => { | ||||
| @@ -82,6 +97,8 @@ const CreateProject: React.FC<Props> = ({ | |||||
| tasks: {}, | tasks: {}, | ||||
| allocatedStaffIds: [], | allocatedStaffIds: [], | ||||
| milestones: {}, | milestones: {}, | ||||
| // TODO: Remove this | |||||
| clientSubsidiary: "Test subsidiary", | |||||
| }, | }, | ||||
| }); | }); | ||||
| @@ -111,6 +128,7 @@ const CreateProject: React.FC<Props> = ({ | |||||
| { | { | ||||
| <ProjectClientDetails | <ProjectClientDetails | ||||
| projectCategories={projectCategories} | projectCategories={projectCategories} | ||||
| teamLeads={teamLeads} | |||||
| isActive={tabIndex === 0} | isActive={tabIndex === 0} | ||||
| /> | /> | ||||
| } | } | ||||
| @@ -123,6 +141,11 @@ const CreateProject: React.FC<Props> = ({ | |||||
| } | } | ||||
| {<StaffAllocation isActive={tabIndex === 2} />} | {<StaffAllocation isActive={tabIndex === 2} />} | ||||
| {<ResourceMilestone allTasks={allTasks} isActive={tabIndex === 3} />} | {<ResourceMilestone allTasks={allTasks} isActive={tabIndex === 3} />} | ||||
| {serverError && ( | |||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||||
| {serverError} | |||||
| </Typography> | |||||
| )} | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -1,17 +1,23 @@ | |||||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | ||||
| import CreateProject from "./CreateProject"; | import CreateProject from "./CreateProject"; | ||||
| import { fetchProjectCategories } from "@/app/api/projects"; | import { fetchProjectCategories } from "@/app/api/projects"; | ||||
| import { fetchTeamLeads } from "@/app/api/staff"; | |||||
| const CreateProjectWrapper: React.FC = async () => { | const CreateProjectWrapper: React.FC = async () => { | ||||
| const tasks = await fetchAllTasks(); | |||||
| const taskTemplates = await fetchTaskTemplates(); | |||||
| const projectCategories = await fetchProjectCategories(); | |||||
| const [tasks, taskTemplates, projectCategories, teamLeads] = | |||||
| await Promise.all([ | |||||
| fetchAllTasks(), | |||||
| fetchTaskTemplates(), | |||||
| fetchProjectCategories(), | |||||
| fetchTeamLeads(), | |||||
| ]); | |||||
| return ( | return ( | ||||
| <CreateProject | <CreateProject | ||||
| allTasks={tasks} | allTasks={tasks} | ||||
| projectCategories={projectCategories} | projectCategories={projectCategories} | ||||
| taskTemplates={taskTemplates} | taskTemplates={taskTemplates} | ||||
| teamLeads={teamLeads} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -18,15 +18,18 @@ import Button from "@mui/material/Button"; | |||||
| import { Controller, useFormContext } from "react-hook-form"; | import { Controller, useFormContext } from "react-hook-form"; | ||||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | import { CreateProjectInputs } from "@/app/api/projects/actions"; | ||||
| import { ProjectCategory } from "@/app/api/projects"; | import { ProjectCategory } from "@/app/api/projects"; | ||||
| import { Staff } from "@/app/api/staff"; | |||||
| interface Props { | interface Props { | ||||
| isActive: boolean; | isActive: boolean; | ||||
| projectCategories: ProjectCategory[]; | projectCategories: ProjectCategory[]; | ||||
| teamLeads: Staff[]; | |||||
| } | } | ||||
| const ProjectClientDetails: React.FC<Props> = ({ | const ProjectClientDetails: React.FC<Props> = ({ | ||||
| isActive, | isActive, | ||||
| projectCategories, | projectCategories, | ||||
| teamLeads | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { | const { | ||||
| @@ -47,7 +50,10 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||||
| <TextField | <TextField | ||||
| label={t("Project Code")} | label={t("Project Code")} | ||||
| fullWidth | fullWidth | ||||
| {...register("projectCode")} | |||||
| {...register("projectCode", { | |||||
| required: "Project code required!", | |||||
| })} | |||||
| error={Boolean(errors.projectCode)} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| @@ -74,7 +80,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||||
| key={`${category.id}-${index}`} | key={`${category.id}-${index}`} | ||||
| value={category.id} | value={category.id} | ||||
| > | > | ||||
| {t(category.label)} | |||||
| {t(category.name)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -85,18 +91,38 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <InputLabel>{t("Team Lead")}</InputLabel> | <InputLabel>{t("Team Lead")}</InputLabel> | ||||
| <Select label={t("Team Lead")} value={"00539 - Ming CHAN (MC)"}> | |||||
| <MenuItem value={"00539 - Ming CHAN (MC)"}> | |||||
| {"00539 - Ming CHAN (MC)"} | |||||
| </MenuItem> | |||||
| </Select> | |||||
| <Controller | |||||
| defaultValue={teamLeads[0].id} | |||||
| control={control} | |||||
| name="projectLeadId" | |||||
| render={({ field }) => ( | |||||
| <Select label={t("Team Lead")} {...field}> | |||||
| {teamLeads.map((staff, index) => ( | |||||
| <MenuItem key={`${staff.id}-${index}`} value={staff.id}> | |||||
| {`${staff.staffId} - ${staff.name} (${staff.team.code})`} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| )} | |||||
| /> | |||||
| </FormControl> | </FormControl> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("Project Description")} | label={t("Project Description")} | ||||
| fullWidth | fullWidth | ||||
| {...register("projectDescription")} | |||||
| {...register("projectDescription", { | |||||
| required: "Please enter a description", | |||||
| })} | |||||
| error={Boolean(errors.projectDescription)} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| label={t("Expected Total Project Fee")} | |||||
| fullWidth | |||||
| type="number" | |||||
| {...register("expectedProjectFee")} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| @@ -116,7 +142,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("ClientName")} | |||||
| label={t("Client Name")} | |||||
| fullWidth | fullWidth | ||||
| {...register("clientName")} | {...register("clientName")} | ||||
| /> | /> | ||||
| @@ -14,6 +14,7 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { watch } = useFormContext<CreateProjectInputs>(); | const { watch } = useFormContext<CreateProjectInputs>(); | ||||
| const milestones = watch("milestones"); | const milestones = watch("milestones"); | ||||
| const expectedTotalFee = Number(watch("expectedProjectFee")); | |||||
| let projectTotal = 0; | let projectTotal = 0; | ||||
| @@ -40,6 +41,11 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||||
| <Typography variant="h6">{t("Project Total Fee")}</Typography> | <Typography variant="h6">{t("Project Total Fee")}</Typography> | ||||
| <Typography>{moneyFormatter.format(projectTotal)}</Typography> | <Typography>{moneyFormatter.format(projectTotal)}</Typography> | ||||
| </Stack> | </Stack> | ||||
| {projectTotal > expectedTotalFee && ( | |||||
| <Typography variant="caption" color="warning.main" alignSelf="flex-end"> | |||||
| {t("Project total fee is larger than the expected total fee!")} | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | </Stack> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -36,10 +36,9 @@ const ResourceMilestone: React.FC<Props> = ({ | |||||
| isActive, | isActive, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const { getValues } = useFormContext<CreateProjectInputs>(); | |||||
| const tasks = useMemo(() => { | |||||
| return allTasks.filter((task) => getValues("tasks")[task.id]); | |||||
| }, [allTasks, getValues]); | |||||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||||
| const tasks = allTasks.filter((task) => watch("tasks")[task.id]); | |||||
| const taskGroups = useMemo(() => { | const taskGroups = useMemo(() => { | ||||
| return uniqBy( | return uniqBy( | ||||
| @@ -90,7 +90,7 @@ const ResourceSection: React.FC<Props> = ({ | |||||
| const rows = useMemo<Row[]>(() => { | const rows = useMemo<Row[]>(() => { | ||||
| const initialAllocation = | const initialAllocation = | ||||
| getValues("tasks")[selectedTaskId].manhourAllocation; | |||||
| getValues("tasks")[selectedTaskId]?.manhourAllocation; | |||||
| if (!isEmpty(initialAllocation)) { | if (!isEmpty(initialAllocation)) { | ||||
| return [{ ...initialAllocation, id: "manhourAllocation" }]; | return [{ ...initialAllocation, id: "manhourAllocation" }]; | ||||
| } | } | ||||
| @@ -6,7 +6,7 @@ import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import { useTranslation } from "react-i18next"; | 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"; | |||||
| interface Props { | interface Props { | ||||
| projects: ProjectResult[]; | projects: ProjectResult[]; | ||||
| @@ -35,7 +35,7 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||||
| label: t("Project category"), | label: t("Project category"), | ||||
| paramName: "category", | paramName: "category", | ||||
| type: "select", | type: "select", | ||||
| options: projectCategories.map((category) => category.label), | |||||
| options: projectCategories.map((category) => category.name), | |||||
| }, | }, | ||||
| { | { | ||||
| label: t("Team"), | label: t("Team"), | ||||
| @@ -135,8 +135,8 @@ function SearchBox<T extends string>({ | |||||
| value={inputs[c.paramName]} | value={inputs[c.paramName]} | ||||
| > | > | ||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | <MenuItem value={"All"}>{t("All")}</MenuItem> | ||||
| {c.options.map((option) => ( | |||||
| <MenuItem key={option} value={option}> | |||||
| {c.options.map((option, index) => ( | |||||
| <MenuItem key={`${option}-${index}`} value={option}> | |||||
| {option} | {option} | ||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||