From 7716e99f6e90ba3cf1924aaa92fcd79f6fcc90e9 Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Mon, 20 May 2024 18:20:37 +0800 Subject: [PATCH] update project & searchbox; create a new controlled component - autocomplete --- .../(main)/projects/create/sub/not-found.tsx | 17 + src/app/(main)/projects/create/sub/page.tsx | 53 +++ src/app/(main)/projects/page.tsx | 38 +- src/app/api/projects/index.ts | 6 + .../ControlledAutoComplete.tsx | 97 ++++ .../ControlledAutoComplete/index.ts | 1 + .../CreateProject/CreateProject.tsx | 8 + .../CreateProject/ProjectClientDetails.tsx | 434 ++++++------------ src/components/SearchBox/SearchBox.tsx | 42 +- 9 files changed, 368 insertions(+), 328 deletions(-) create mode 100644 src/app/(main)/projects/create/sub/not-found.tsx create mode 100644 src/app/(main)/projects/create/sub/page.tsx create mode 100644 src/components/ControlledAutoComplete/ControlledAutoComplete.tsx create mode 100644 src/components/ControlledAutoComplete/index.ts diff --git a/src/app/(main)/projects/create/sub/not-found.tsx b/src/app/(main)/projects/create/sub/not-found.tsx new file mode 100644 index 0000000..1cc4df3 --- /dev/null +++ b/src/app/(main)/projects/create/sub/not-found.tsx @@ -0,0 +1,17 @@ +import { getServerI18n } from "@/i18n"; +import { Stack, Typography, Link } from "@mui/material"; +import NextLink from "next/link"; + +export default async function NotFound() { + const { t } = await getServerI18n("projects", "common"); + + return ( + + {t("Not Found")} + {t("The sub project was not found or there was no any main projects!")} + + {t("Return to all projects")} + + + ); +} diff --git a/src/app/(main)/projects/create/sub/page.tsx b/src/app/(main)/projects/create/sub/page.tsx new file mode 100644 index 0000000..bd5837e --- /dev/null +++ b/src/app/(main)/projects/create/sub/page.tsx @@ -0,0 +1,53 @@ +import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer"; +import { fetchGrades } from "@/app/api/grades"; +import { + fetchProjectBuildingTypes, + fetchProjectCategories, + fetchProjectContractTypes, + fetchProjectFundingTypes, + fetchProjectLocationTypes, + fetchProjectServiceTypes, + fetchProjectWorkNatures, + fetchProjects, +} from "@/app/api/projects"; +import { preloadStaff, preloadTeamLeads } from "@/app/api/staff"; +import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks"; +import CreateProject from "@/components/CreateProject"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import Typography from "@mui/material/Typography"; +import { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Project", +}; + +const Projects: React.FC = async () => { + const { t } = await getServerI18n("projects"); + + // Preload necessary dependencies + fetchAllTasks(); + fetchTaskTemplates(); + fetchProjectCategories(); + fetchProjectContractTypes(); + fetchProjectFundingTypes(); + fetchProjectLocationTypes(); + fetchProjectServiceTypes(); + fetchProjectBuildingTypes(); + fetchProjectWorkNatures(); + fetchAllCustomers(); + fetchAllSubsidiaries(); + fetchGrades(); + preloadTeamLeads(); + preloadStaff(); + + return ( + <> + {t("Create Sub Project")} + + + + + ); +}; + +export default Projects; diff --git a/src/app/(main)/projects/page.tsx b/src/app/(main)/projects/page.tsx index 1fe1800..129d601 100644 --- a/src/app/(main)/projects/page.tsx +++ b/src/app/(main)/projects/page.tsx @@ -1,7 +1,8 @@ -import { preloadProjects } from "@/app/api/projects"; +import { fetchProjectCategories, fetchProjects, preloadProjects } from "@/app/api/projects"; import ProjectSearch from "@/components/ProjectSearch"; import { getServerI18n } from "@/i18n"; import Add from "@mui/icons-material/Add"; +import { ButtonGroup } from "@mui/material"; import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; @@ -15,7 +16,9 @@ export const metadata: Metadata = { const Projects: React.FC = async () => { const { t } = await getServerI18n("projects"); - preloadProjects(); + // preloadProjects(); + fetchProjectCategories(); + const projects = await fetchProjects(); return ( <> @@ -28,14 +31,31 @@ const Projects: React.FC = async () => { {t("Projects")} - + {projects.filter(project => project.status.toLowerCase() !== "deleted").length > 0 && } + + }> diff --git a/src/app/api/projects/index.ts b/src/app/api/projects/index.ts index 90d1f4a..7a64ea1 100644 --- a/src/app/api/projects/index.ts +++ b/src/app/api/projects/index.ts @@ -81,6 +81,12 @@ export const fetchProjects = cache(async () => { }); }); +export const fetchMainProjects = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/projects/main`, { + next: { tags: ["projects"] }, + }); +}); + export const fetchProjectCategories = cache(async () => { return serverFetchJson( `${BASE_API_URL}/projects/categories`, diff --git a/src/components/ControlledAutoComplete/ControlledAutoComplete.tsx b/src/components/ControlledAutoComplete/ControlledAutoComplete.tsx new file mode 100644 index 0000000..d8a2962 --- /dev/null +++ b/src/components/ControlledAutoComplete/ControlledAutoComplete.tsx @@ -0,0 +1,97 @@ +"use client" + +import { Autocomplete, MenuItem, TextField, Checkbox } from "@mui/material"; +import { Controller, FieldValues, Path, Control, RegisterOptions } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; + +const icon = ; +const checkedIcon = ; +// label -> e.g. code - name -> 001 - WL +// name -> WL +interface Props { + control: Control, + options: T[], + name: Path, // register name + label?: string, // display label + noOptionsText?: string, + isMultiple?: boolean, + rules?: RegisterOptions + error?: boolean, +} + +function ControlledAutoComplete< + T extends { id?: number | string; label?: string; name?: string }, + TField extends FieldValues +>( + props: Props +) { + const { t } = useTranslation() + const { control, options, name, label, noOptionsText, isMultiple, rules, error } = props; + + return ( + ( + isMultiple ? + { + // console.log(field.value) + return field.value?.includes(option.id) + })} + options={options} + getOptionLabel={(option) => option.label ?? option.name!!} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOption={(params, option, { selected }) => { + return ( +
  • + + {option.label ?? option.name} +
  • + ); + }} + onChange={(event, value) => { + field.onChange(value?.map(v => v.id)) + }} + renderInput={(params) => } + /> + : + option.id === field.value) ?? options[0]} + options={options} + getOptionLabel={(option) => option.label ?? option.name!!} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderOption={(params, option) => { + return ( + + {option.label ?? option.name} + + ); + }} + onChange={(event, value) => { + field.onChange(value?.id) + }} + renderInput={(params) => } + /> + )} + /> + ) +} + +export default ControlledAutoComplete; \ No newline at end of file diff --git a/src/components/ControlledAutoComplete/index.ts b/src/components/ControlledAutoComplete/index.ts new file mode 100644 index 0000000..7e12f58 --- /dev/null +++ b/src/components/ControlledAutoComplete/index.ts @@ -0,0 +1 @@ +export { default } from "./ControlledAutoComplete"; \ No newline at end of file diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index fac9c79..937bac5 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -269,6 +269,14 @@ const CreateProject: React.FC = ({ milestones: {}, totalManhour: 0, taskTemplateId: "All", + projectCategoryId: projectCategories.length > 0 ? projectCategories[0].id : undefined, + projectLeadId: teamLeads.length > 0 ? teamLeads[0].id : undefined, + serviceTypeId: serviceTypes.length > 0 ? serviceTypes[0].id : undefined, + fundingTypeId: fundingTypes.length > 0 ? fundingTypes[0].id : undefined, + contractTypeId: contractTypes.length > 0 ? contractTypes[0].id : undefined, + locationId: locationTypes.length > 0 ? locationTypes[0].id : undefined, + clientSubsidiaryId: undefined, + clientId: allCustomers.length > 0 ? allCustomers[0].id : undefined, ...defaultInputs, // manhourPercentageByGrade should have a sensible default diff --git a/src/components/CreateProject/ProjectClientDetails.tsx b/src/components/CreateProject/ProjectClientDetails.tsx index aba8916..d77defe 100644 --- a/src/components/CreateProject/ProjectClientDetails.tsx +++ b/src/components/CreateProject/ProjectClientDetails.tsx @@ -31,8 +31,9 @@ import { Contact, Customer, Subsidiary } from "@/app/api/customer"; import Link from "next/link"; import React, { useEffect, useMemo, useState } from "react"; import { fetchCustomer } from "@/app/api/customer/actions"; -import { Checkbox, ListItemText } from "@mui/material"; +import { Autocomplete, Checkbox, ListItemText } from "@mui/material"; import uniq from "lodash/uniq"; +import ControlledAutoComplete from "../ControlledAutoComplete/ControlledAutoComplete"; interface Props { isActive: boolean; @@ -64,7 +65,7 @@ const ProjectClientDetails: React.FC = ({ const { t } = useTranslation(); const { register, - formState: { errors }, + formState: { errors, defaultValues }, watch, control, setValue, @@ -108,8 +109,9 @@ const ProjectClientDetails: React.FC = ({ setCustomerContacts(contacts); setCustomerSubsidiaryIds(subsidiaryIds); - if (subsidiaryIds.length > 0) setValue("clientSubsidiaryId", subsidiaryIds[0]) - else setValue("clientSubsidiaryId", undefined) + // if (subsidiaryIds.length > 0) setValue("clientSubsidiaryId", subsidiaryIds[0]) + // else + setValue("clientSubsidiaryId", undefined) // if (contacts.length > 0) setValue("clientContactId", contacts[0].id) // else setValue("clientContactId", undefined) }); @@ -122,18 +124,19 @@ const ProjectClientDetails: React.FC = ({ // get subsidiary contact combo const contacts = allSubsidiaries.find(subsidiary => subsidiary.id === clientSubsidiaryId)?.subsidiaryContacts!! setSubsidiaryContacts(contacts) - setValue("clientContactId", contacts[0].id) + setValue("clientContactId", selectedCustomerId === defaultValues?.clientId && Boolean(defaultValues?.clientSubsidiaryId) ? contacts.find(contact => contact.id === defaultValues.clientContactId)?.id ?? contacts[0].id : contacts[0].id) setValue("isSubsidiaryContact", true) } else if (customerContacts?.length > 0) { setSubsidiaryContacts([]) - setValue("clientContactId", customerContacts[0].id) + setValue("clientContactId", selectedCustomerId === defaultValues?.clientId && !Boolean(defaultValues?.clientSubsidiaryId) ? customerContacts.find(contact => contact.id === defaultValues.clientContactId)?.id ?? customerContacts[0].id : customerContacts[0].id) setValue("isSubsidiaryContact", false) } - }, [customerContacts, clientSubsidiaryId]); + }, [customerContacts, clientSubsidiaryId, selectedCustomerId]); // Automatically add the team lead to the allocated staff list const selectedTeamLeadId = watch("projectLeadId"); useEffect(() => { + console.log(selectedTeamLeadId) if (selectedTeamLeadId !== undefined) { const currentStaffIds = getValues("allocatedStaffIds"); const newList = uniq([...currentStaffIds, selectedTeamLeadId]); @@ -141,15 +144,15 @@ const ProjectClientDetails: React.FC = ({ } }, [getValues, selectedTeamLeadId, setValue]); - const buildingTypeIdNameMap = buildingTypes.reduce<{ [id: number]: string }>( - (acc, building) => ({ ...acc, [building.id]: building.name }), - {}, - ); + // const buildingTypeIdNameMap = buildingTypes.reduce<{ [id: number]: string }>( + // (acc, building) => ({ ...acc, [building.id]: building.name }), + // {}, + // ); - const workNatureIdNameMap = workNatures.reduce<{ [id: number]: string }>( - (acc, wn) => ({ ...acc, [wn.id]: wn.name }), - {}, - ); + // const workNatureIdNameMap = workNatures.reduce<{ [id: number]: string }>( + // (acc, wn) => ({ ...acc, [wn.id]: wn.name }), + // {}, + // ); return ( @@ -164,12 +167,12 @@ const ProjectClientDetails: React.FC = ({ label={t("Project Code")} fullWidth disabled - {...register("projectCode", - // { - // required: "Project code required!", - // } + {...register("projectCode", + // { + // required: "Project code required!", + // } )} - // error={Boolean(errors.projectCode)} + // error={Boolean(errors.projectCode)} /> @@ -183,184 +186,79 @@ const ProjectClientDetails: React.FC = ({ /> - - {t("Project Category")} - ( - - )} - /> - + - - {t("Team Lead")} - ( - - )} - /> - + ({ ...staff, label: `${staff.staffId} - ${staff.name} (${staff.team})` }))} + name="projectLeadId" + label={t("Team Lead")} + noOptionsText={t("No Team Lead")} + /> - - {t("Service Type")} - ( - - )} - /> - + - - {t("Funding Type")} - ( - - )} - /> - + - - {t("Contract Type")} - ( - - )} - /> - + - - {t("Location")} - ( - - )} - /> - + - - {t("Building Types")} - ( - - )} - /> - + - - {t("Work Nature")} - ( - - )} - /> - + @@ -383,13 +281,13 @@ const ProjectClientDetails: React.FC = ({ - - - {t("CLP Project")} - + + + {t("CLP Project")} + @@ -410,29 +308,17 @@ const ProjectClientDetails: React.FC = ({ - - {t("Client")} - ( - - )} - /> - + ({ ...customer, label: `${customer.code} - ${customer.name}` }))} + name="clientId" + label={t("Client")} + noOptionsText={t("No Client")} + rules={{ + required: "Please select a client" + }} + error={Boolean(errors.clientId)} + /> @@ -448,96 +334,42 @@ const ProjectClientDetails: React.FC = ({ {customerContacts.length > 0 && ( <> - {customerSubsidiaryIds.length > 0 && ( - - - {t("Client Subsidiary")} - { - // if ( - // !customerSubsidiaryIds.find( - // (subsidiaryId) => subsidiaryId === value, - // ) - // ) { - // return t("Please choose a valid subsidiary"); - // } else return true; - // }, - // }} - defaultValue={customerSubsidiaryIds[0]} - control={control} - name="clientSubsidiaryId" - render={({ field }) => ( - - )} - /> - - - )} - subsidiaryMap[subId]) + .map((subsidiaryId, index) => { + const subsidiary = subsidiaryMap[subsidiaryId] + return { id: subsidiary.id, label: `${subsidiary.code} - ${subsidiary.name}` } + })]} + name="clientSubsidiaryId" + label={t("Client Subsidiary")} + noOptionsText={t("No Client Subsidiary")} + /> + + + { + if ( + (customerContacts.length > 0 && !customerContacts.find( + (contact) => contact.id === value, + )) && (subsidiaryContacts?.length > 0 && !subsidiaryContacts.find( + (contact) => contact.id === value, + )) + ) { + return t("Please provide a valid contact"); + } else return true; + }, + }} error={Boolean(errors.clientContactId)} - > - {t("Client Lead")} - { - if ( - (customerContacts.length > 0 && !customerContacts.find( - (contact) => contact.id === value, - )) && (subsidiaryContacts?.length > 0 && !subsidiaryContacts.find( - (contact) => contact.id === value, - )) - ) { - return t("Please provide a valid contact"); - } else return true; - }, - }} - defaultValue={subsidiaryContacts?.length > 0 ? subsidiaryContacts[0].id : customerContacts[0].id} - control={control} - name="clientContactId" - render={({ field }) => ( - - )} - /> - + /> diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index d8f502c..686eb8d 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -73,14 +73,20 @@ function SearchBox({ () => criteria.reduce>( (acc, c) => { - return { ...acc, [c.paramName]: c.type === "select" ? !(c.needAll === false) ? "All" : c.options[0] : "" }; + return { + ...acc, + [c.paramName]: c.type === "select" ? + !(c.needAll === false) ? "All" : + c.options.length > 0 ? c.options[0] : "" + : "" + }; }, {} as Record ), [criteria] ); const [inputs, setInputs] = useState(defaultInputs); - + const makeInputChangeHandler = useCallback( (paramName: T): React.ChangeEventHandler => { return (e) => { @@ -226,22 +232,22 @@ function SearchBox({ ); })} - - - - + + + + );