| @@ -1,4 +1,5 @@ | |||
| import { fetchProjectCategories } from "@/app/api/projects"; | |||
| import { preloadStaff } from "@/app/api/staff"; | |||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
| import CreateProject from "@/components/CreateProject"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| @@ -16,6 +17,7 @@ const Projects: React.FC = async () => { | |||
| fetchAllTasks(); | |||
| fetchTaskTemplates(); | |||
| fetchProjectCategories(); | |||
| preloadStaff(); | |||
| return ( | |||
| <> | |||
| @@ -10,6 +10,7 @@ export interface CreateProjectInputs { | |||
| projectName: string; | |||
| projectCategoryId: number; | |||
| projectDescription: string; | |||
| projectLeadId: number; | |||
| // Client details | |||
| clientCode: string; | |||
| @@ -37,6 +38,9 @@ export interface CreateProjectInputs { | |||
| payments: PaymentInputs[]; | |||
| }; | |||
| }; | |||
| // Miscellaneous | |||
| expectedProjectFee: string; | |||
| } | |||
| 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 "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 { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| category: "Confirmed Project" | "Project to be bidded"; | |||
| category: string; | |||
| team: string; | |||
| client: string; | |||
| } | |||
| export interface ProjectCategory { | |||
| id: number; | |||
| label: string; | |||
| name: string; | |||
| } | |||
| export const preloadProjects = () => { | |||
| @@ -21,18 +37,35 @@ export const preloadProjects = () => { | |||
| }; | |||
| 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 () => { | |||
| 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[] = [ | |||
| { | |||
| 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: | |||
| signOutUser(); | |||
| default: | |||
| console.error(await response.text()); | |||
| throw Error("Something went wrong fetching data in server."); | |||
| } | |||
| } | |||
| @@ -21,14 +21,17 @@ import { | |||
| SubmitHandler, | |||
| useForm, | |||
| } 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 { ProjectCategory } from "@/app/api/projects"; | |||
| import { Staff } from "@/app/api/staff"; | |||
| import { Typography } from "@mui/material"; | |||
| export interface Props { | |||
| allTasks: Task[]; | |||
| projectCategories: ProjectCategory[]; | |||
| taskTemplates: TaskTemplate[]; | |||
| teamLeads: Staff[]; | |||
| } | |||
| const hasErrorsInTab = ( | |||
| @@ -47,7 +50,9 @@ const CreateProject: React.FC<Props> = ({ | |||
| allTasks, | |||
| projectCategories, | |||
| taskTemplates, | |||
| teamLeads, | |||
| }) => { | |||
| const [serverError, setServerError] = useState(""); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const { t } = useTranslation(); | |||
| 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>>( | |||
| (errors) => { | |||
| @@ -82,6 +97,8 @@ const CreateProject: React.FC<Props> = ({ | |||
| tasks: {}, | |||
| allocatedStaffIds: [], | |||
| milestones: {}, | |||
| // TODO: Remove this | |||
| clientSubsidiary: "Test subsidiary", | |||
| }, | |||
| }); | |||
| @@ -111,6 +128,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| { | |||
| <ProjectClientDetails | |||
| projectCategories={projectCategories} | |||
| teamLeads={teamLeads} | |||
| isActive={tabIndex === 0} | |||
| /> | |||
| } | |||
| @@ -123,6 +141,11 @@ const CreateProject: React.FC<Props> = ({ | |||
| } | |||
| {<StaffAllocation isActive={tabIndex === 2} />} | |||
| {<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}> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -1,17 +1,23 @@ | |||
| import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; | |||
| import CreateProject from "./CreateProject"; | |||
| import { fetchProjectCategories } from "@/app/api/projects"; | |||
| import { fetchTeamLeads } from "@/app/api/staff"; | |||
| 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 ( | |||
| <CreateProject | |||
| allTasks={tasks} | |||
| projectCategories={projectCategories} | |||
| taskTemplates={taskTemplates} | |||
| teamLeads={teamLeads} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -18,15 +18,18 @@ import Button from "@mui/material/Button"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
| import { ProjectCategory } from "@/app/api/projects"; | |||
| import { Staff } from "@/app/api/staff"; | |||
| interface Props { | |||
| isActive: boolean; | |||
| projectCategories: ProjectCategory[]; | |||
| teamLeads: Staff[]; | |||
| } | |||
| const ProjectClientDetails: React.FC<Props> = ({ | |||
| isActive, | |||
| projectCategories, | |||
| teamLeads | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const { | |||
| @@ -47,7 +50,10 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| <TextField | |||
| label={t("Project Code")} | |||
| fullWidth | |||
| {...register("projectCode")} | |||
| {...register("projectCode", { | |||
| required: "Project code required!", | |||
| })} | |||
| error={Boolean(errors.projectCode)} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| @@ -74,7 +80,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| key={`${category.id}-${index}`} | |||
| value={category.id} | |||
| > | |||
| {t(category.label)} | |||
| {t(category.name)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| @@ -85,18 +91,38 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <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> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Project Description")} | |||
| 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> | |||
| @@ -116,7 +142,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("ClientName")} | |||
| label={t("Client Name")} | |||
| fullWidth | |||
| {...register("clientName")} | |||
| /> | |||
| @@ -14,6 +14,7 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||
| const milestones = watch("milestones"); | |||
| const expectedTotalFee = Number(watch("expectedProjectFee")); | |||
| let projectTotal = 0; | |||
| @@ -40,6 +41,11 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| <Typography variant="h6">{t("Project Total Fee")}</Typography> | |||
| <Typography>{moneyFormatter.format(projectTotal)}</Typography> | |||
| </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> | |||
| ); | |||
| }; | |||
| @@ -36,10 +36,9 @@ const ResourceMilestone: React.FC<Props> = ({ | |||
| isActive, | |||
| }) => { | |||
| 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(() => { | |||
| return uniqBy( | |||
| @@ -90,7 +90,7 @@ const ResourceSection: React.FC<Props> = ({ | |||
| const rows = useMemo<Row[]>(() => { | |||
| const initialAllocation = | |||
| getValues("tasks")[selectedTaskId].manhourAllocation; | |||
| getValues("tasks")[selectedTaskId]?.manhourAllocation; | |||
| if (!isEmpty(initialAllocation)) { | |||
| return [{ ...initialAllocation, id: "manhourAllocation" }]; | |||
| } | |||
| @@ -6,7 +6,7 @@ import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| import uniq from 'lodash/uniq'; | |||
| import uniq from "lodash/uniq"; | |||
| interface Props { | |||
| projects: ProjectResult[]; | |||
| @@ -35,7 +35,7 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => { | |||
| label: t("Project category"), | |||
| paramName: "category", | |||
| type: "select", | |||
| options: projectCategories.map((category) => category.label), | |||
| options: projectCategories.map((category) => category.name), | |||
| }, | |||
| { | |||
| label: t("Team"), | |||
| @@ -135,8 +135,8 @@ function SearchBox<T extends string>({ | |||
| value={inputs[c.paramName]} | |||
| > | |||
| <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} | |||
| </MenuItem> | |||
| ))} | |||