Browse Source

Add project apis

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 year ago
parent
commit
23e3faddd5
13 changed files with 158 additions and 34 deletions
  1. +2
    -0
      src/app/(main)/projects/create/page.tsx
  2. +4
    -0
      src/app/api/projects/actions.ts
  3. +42
    -9
      src/app/api/projects/index.ts
  4. +24
    -0
      src/app/api/staff/index.ts
  5. +1
    -0
      src/app/utils/fetchUtil.ts
  6. +27
    -4
      src/components/CreateProject/CreateProject.tsx
  7. +9
    -3
      src/components/CreateProject/CreateProjectWrapper.tsx
  8. +35
    -9
      src/components/CreateProject/ProjectClientDetails.tsx
  9. +6
    -0
      src/components/CreateProject/ProjectTotalFee.tsx
  10. +3
    -4
      src/components/CreateProject/ResourceMilestone.tsx
  11. +1
    -1
      src/components/CreateProject/ResourceSection.tsx
  12. +2
    -2
      src/components/ProjectSearch/ProjectSearch.tsx
  13. +2
    -2
      src/components/SearchBox/SearchBox.tsx

+ 2
- 0
src/app/(main)/projects/create/page.tsx View File

@@ -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 (
<> <>


+ 4
- 0
src/app/api/projects/actions.ts View File

@@ -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 {


+ 42
- 9
src/app/api/projects/index.ts View File

@@ -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,


+ 24
- 0
src/app/api/staff/index.ts View File

@@ -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"] },
});
});

+ 1
- 0
src/app/utils/fetchUtil.ts View File

@@ -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.");
} }
} }


+ 27
- 4
src/components/CreateProject/CreateProject.tsx View File

@@ -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"


+ 9
- 3
src/components/CreateProject/CreateProjectWrapper.tsx View File

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


+ 35
- 9
src/components/CreateProject/ProjectClientDetails.tsx View File

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


+ 6
- 0
src/components/CreateProject/ProjectTotalFee.tsx View File

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


+ 3
- 4
src/components/CreateProject/ResourceMilestone.tsx View File

@@ -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(


+ 1
- 1
src/components/CreateProject/ResourceSection.tsx View File

@@ -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" }];
} }


+ 2
- 2
src/components/ProjectSearch/ProjectSearch.tsx View File

@@ -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"),


+ 2
- 2
src/components/SearchBox/SearchBox.tsx View File

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


Loading…
Cancel
Save