@@ -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> | ||||
))} | ))} | ||||