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