diff --git a/src/app/(main)/projects/create/page.tsx b/src/app/(main)/projects/create/page.tsx index 01af3e1..5b13e05 100644 --- a/src/app/(main)/projects/create/page.tsx +++ b/src/app/(main)/projects/create/page.tsx @@ -1,4 +1,8 @@ -import { fetchAllCustomers, fetchAllSubsidiaries, fetchCustomerTypes } from "@/app/api/customer"; +import { + fetchAllCustomers, + fetchAllSubsidiaries, + fetchCustomerTypes, +} from "@/app/api/customer"; import { fetchGrades } from "@/app/api/grades"; import { fetchProjectBuildingTypes, @@ -16,6 +20,7 @@ import CreateProject from "@/components/CreateProject"; import { I18nProvider, getServerI18n } from "@/i18n"; import { MAINTAIN_PROJECT } from "@/middleware"; import Typography from "@mui/material/Typography"; +import isString from "lodash/isString"; import { Metadata } from "next"; import { notFound } from "next/navigation"; @@ -23,7 +28,11 @@ export const metadata: Metadata = { title: "Create Project", }; -const Projects: React.FC = async () => { +interface Props { + searchParams: { [key: string]: string | string[] | undefined }; +} + +const Projects: React.FC = async ({ searchParams }) => { const { t } = await getServerI18n("projects"); const abilities = await fetchUserAbilities(); @@ -32,6 +41,10 @@ const Projects: React.FC = async () => { notFound(); } + const draftId = isString(searchParams["draftId"]) + ? parseInt(searchParams["draftId"]) + : undefined; + // Preload necessary dependencies fetchAllTasks(); fetchTaskTemplates(); @@ -53,7 +66,7 @@ const Projects: React.FC = async () => { <> {t("Create Project")} - + ); diff --git a/src/app/(main)/projects/page.tsx b/src/app/(main)/projects/page.tsx index 3c39531..16a6cc8 100644 --- a/src/app/(main)/projects/page.tsx +++ b/src/app/(main)/projects/page.tsx @@ -1,8 +1,10 @@ -import { fetchProjectCategories, fetchProjects, preloadProjects } from "@/app/api/projects"; +import { fetchAllCustomers } from "@/app/api/customer"; +import { fetchProjectCategories, fetchProjects } from "@/app/api/projects"; +import { fetchTeam } from "@/app/api/team"; import { fetchUserAbilities } from "@/app/utils/fetchUtil"; import ProjectSearch from "@/components/ProjectSearch"; import { getServerI18n, I18nProvider } from "@/i18n"; -import { MAINTAIN_PROJECT, VIEW_PROJECT } from "@/middleware"; +import { MAINTAIN_PROJECT } from "@/middleware"; import Add from "@mui/icons-material/Add"; import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; @@ -18,55 +20,62 @@ export const metadata: Metadata = { const Projects: React.FC = async () => { const { t } = await getServerI18n("projects"); - // preloadProjects(); fetchProjectCategories(); + fetchTeam(); + fetchAllCustomers(); const projects = await fetchProjects(); - const abilities = await fetchUserAbilities() - if (![MAINTAIN_PROJECT].some(ability => abilities.includes(ability))) { + const abilities = await fetchUserAbilities(); + if (![MAINTAIN_PROJECT].some((ability) => abilities.includes(ability))) { notFound(); } return ( <> - - - - {t("Project Management")} - - {abilities.includes(MAINTAIN_PROJECT) && + - {projects.filter(project => project.status.toLowerCase() !== "deleted").length > 0 && } - - } - - }> - - + + {t("Project Management")} + + {abilities.includes(MAINTAIN_PROJECT) && ( + + {projects.filter( + (project) => project.status.toLowerCase() !== "deleted", + ).length > 0 && ( + + )} + + + )} + + }> + + ); diff --git a/src/app/utils/draftUtils.ts b/src/app/utils/draftUtils.ts new file mode 100644 index 0000000..3715e1b --- /dev/null +++ b/src/app/utils/draftUtils.ts @@ -0,0 +1,55 @@ +import { CreateProjectInputs } from "../api/projects/actions"; + +const STORAGE_KEY = "draftProjects"; + +const getStorage = (): { + [draftId: string]: CreateProjectInputs; +} => { + if (typeof window === "undefined") { + return {}; + } + + const storageString = localStorage.getItem(STORAGE_KEY); + if (!storageString) { + return {}; + } + + try { + return JSON.parse(storageString); + } catch { + return {}; + } +}; + +export const loadDrafts = (): [id: string, CreateProjectInputs][] => { + return Object.entries(getStorage()); +}; + +export const saveToLocalStorage = ( + draftId: number, + data: CreateProjectInputs, +) => { + const storage = getStorage(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + ...storage, + [draftId]: data, + }), + ); +}; + +export const loadDraft = (draftId: number): CreateProjectInputs | undefined => { + const storage = getStorage(); + const draft = storage[draftId]; + + return draft; +}; + +export const deleteDraft = (draftId: number) => { + const storage = getStorage(); + + delete storage[draftId]; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(storage)); +}; diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index 2ba5794..5bfcc47 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -4,12 +4,18 @@ import AutorenewIcon from "@mui/icons-material/Autorenew"; import DoneIcon from "@mui/icons-material/Done"; import Check from "@mui/icons-material/Check"; import Close from "@mui/icons-material/Close"; -import Button from "@mui/material/Button"; +import Button, { ButtonProps } from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import Tab from "@mui/material/Tab"; import Tabs, { TabsProps } from "@mui/material/Tabs"; import { useRouter } from "next/navigation"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import ProjectClientDetails from "./ProjectClientDetails"; import TaskSetup from "./TaskSetup"; @@ -28,7 +34,7 @@ import { deleteProject, saveProject, } from "@/app/api/projects/actions"; -import { Delete, Error, PlayArrow } from "@mui/icons-material"; +import { Delete, EditNote, Error, PlayArrow } from "@mui/icons-material"; import { BuildingType, ContractType, @@ -40,7 +46,7 @@ import { WorkNature, } from "@/app/api/projects"; import { StaffResult } from "@/app/api/staff"; -import { Grid, Typography } from "@mui/material"; +import { Box, Grid, Typography } from "@mui/material"; import { Grade } from "@/app/api/grades"; import { Customer, CustomerType, Subsidiary } from "@/app/api/customer"; import { isEmpty } from "lodash"; @@ -54,9 +60,11 @@ import { import dayjs from "dayjs"; import { DELETE_PROJECT } from "@/middleware"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import { deleteDraft, loadDraft, saveToLocalStorage } from "@/app/utils/draftUtils"; export interface Props { isEditMode: boolean; + draftId?: number; isSubProject: boolean; mainProjects?: MainProject[]; defaultInputs?: CreateProjectInputs; @@ -106,6 +114,7 @@ const hasErrorsInTab = ( const CreateProject: React.FC = ({ isEditMode, + draftId, isSubProject, mainProjects, defaultInputs, @@ -127,11 +136,46 @@ const CreateProject: React.FC = ({ abilities, }) => { const [serverError, setServerError] = useState(""); - const [loading, setLoading] = useState(true); const [tabIndex, setTabIndex] = useState(0); const { t } = useTranslation(); const router = useRouter(); + const formProps = useForm({ + defaultValues: { + taskGroups: {}, + allocatedStaffIds: [], + milestones: {}, + totalManhour: 0, + taskTemplateId: "All", + projectName: + mainProjects !== undefined ? mainProjects[0].projectName : undefined, + projectDescription: + mainProjects !== undefined + ? mainProjects[0].projectDescription + : undefined, + expectedProjectFee: + mainProjects !== undefined + ? mainProjects[0].expectedProjectFee + : undefined, + subContractFee: + mainProjects !== undefined ? mainProjects[0].subContractFee : undefined, + clientId: allCustomers !== undefined ? allCustomers[0].id : undefined, + ratePerManhour: 250, + ...defaultInputs, + + // manhourPercentageByGrade should have a sensible default + manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) + ? grades.reduce((acc, grade) => { + return { ...acc, [grade.id]: 100 / grades.length }; + }, {}) + : defaultInputs?.manhourPercentageByGrade, + }, + }); + + const projectName = formProps.watch("projectName"); + const projectDeleted = formProps.watch("projectDeleted"); + const projectStatus = formProps.watch("projectStatus") || ""; + const defaultBtn = { buttonName: "submit", title: t("Do you want to submit?"), @@ -139,31 +183,64 @@ const CreateProject: React.FC = ({ successTitle: t("Submit Success"), errorTitle: t("Submit Fail"), }; - const [buttonData, setButtonData] = useState<{ + const buttonData = useMemo<{ buttonName: string; title: string; confirmButtonText: string; successTitle: string; errorTitle: string; buttonText: string; - buttonIcon: any; - buttonColor: any; - }>({ - ...defaultBtn, - buttonText: t("Submit Project"), - buttonIcon: , - buttonColor: "success", - }); - - const disableChecking = () => { - return ( - loading || - formProps.getValues("projectDeleted") === true || - formProps.getValues("projectStatus")?.toLowerCase() === "deleted" || - // !!formProps.getValues("projectActualStart") && - !!(formProps.getValues("projectStatus")?.toLowerCase() === "completed") - ); - }; + buttonIcon: React.ReactNode; + buttonColor: ButtonProps["color"]; + }>(() => { + //Button Parameters// + switch (projectStatus) { + case "pending to start": + return { + buttonName: "start", + title: t("Do you want to start?"), + confirmButtonText: t("Start"), + successTitle: t("Start Success"), + errorTitle: t("Start Fail"), + buttonText: t("Start Project"), + buttonIcon: , + buttonColor: "success", + }; + case "on-going": + return { + buttonName: "complete", + title: t("Do you want to complete?"), + confirmButtonText: t("Complete"), + successTitle: t("Complete Success"), + errorTitle: t("Complete Fail"), + buttonText: t("Complete Project"), + buttonIcon: , + buttonColor: "info", + }; + case "completed": + return { + buttonName: "reopen", + title: t("Do you want to reopen?"), + confirmButtonText: t("Reopen"), + successTitle: t("Reopen Success"), + errorTitle: t("Reopen Fail"), + buttonText: t("Reopen Project"), + buttonIcon: , + buttonColor: "secondary", + }; + default: + return { + buttonName: "submit", + title: t("Do you want to submit?"), + confirmButtonText: t("Submit"), + successTitle: t("Submit Success"), + errorTitle: t("Submit Fail"), + buttonText: t("Submit Project"), + buttonIcon: , + buttonColor: "success", + }; + } + }, [projectStatus, t]); const handleCancel = () => { router.replace("/projects"); @@ -333,6 +410,9 @@ const CreateProject: React.FC = ({ : buttonData.successTitle, t, ).then(() => { + if (draftId) { + deleteDraft(draftId); + } router.replace("/projects"); }); } else { @@ -408,58 +488,29 @@ const CreateProject: React.FC = ({ [], ); - const formProps = useForm({ - defaultValues: { - taskGroups: {}, - allocatedStaffIds: [], - milestones: {}, - totalManhour: 0, - taskTemplateId: "All", - projectName: - mainProjects !== undefined ? mainProjects[0].projectName : undefined, - projectDescription: - mainProjects !== undefined - ? mainProjects[0].projectDescription - : undefined, - expectedProjectFee: - mainProjects !== undefined - ? mainProjects[0].expectedProjectFee - : undefined, - subContractFee: - mainProjects !== undefined ? mainProjects[0].subContractFee : undefined, - clientId: allCustomers !== undefined ? allCustomers[0].id : undefined, - ratePerManhour: 250, - ...defaultInputs, - - // manhourPercentageByGrade should have a sensible default - manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) - ? grades.reduce((acc, grade) => { - return { ...acc, [grade.id]: 100 / grades.length }; - }, {}) - : defaultInputs?.manhourPercentageByGrade, - }, - }); - const errors = formProps.formState.errors; // auto calculate the total project manhour const expectedProjectFee = formProps.watch("expectedProjectFee"); const ratePerManhour = formProps.watch("ratePerManhour"); const totalManhour = formProps.watch("totalManhour"); - const [firstLoaded, setFirstLoaded] = useState(false); - React.useMemo(() => { - if (firstLoaded && expectedProjectFee > 0 && ratePerManhour > 0) { - console.log(ratePerManhour, formProps.watch("totalManhour")); + const firstLoadedRef = useRef(false); + useEffect(() => { + if ( + firstLoadedRef.current && + expectedProjectFee > 0 && + ratePerManhour > 0 + ) { formProps.setValue( "totalManhour", Math.ceil(expectedProjectFee / ratePerManhour), ); } else { - setFirstLoaded(true); + firstLoadedRef.current = true; } }, [expectedProjectFee, ratePerManhour]); - React.useMemo(() => { + useEffect(() => { if ( expectedProjectFee > 0 && ratePerManhour > 0 && @@ -472,57 +523,24 @@ const CreateProject: React.FC = ({ } }, [totalManhour]); - const updateButtonData = () => { - const status = formProps.getValues("projectStatus")?.toLowerCase(); + const loading = isEditMode ? !Boolean(projectName) : false; - //Button Parameters// - switch (status) { - case "pending to start": - setButtonData({ - buttonName: "start", - title: t("Do you want to start?"), - confirmButtonText: t("Start"), - successTitle: t("Start Success"), - errorTitle: t("Start Fail"), - buttonText: t("Start Project"), - buttonIcon: , - buttonColor: "success", - }); - break; - case "on-going": - setButtonData({ - buttonName: "complete", - title: t("Do you want to complete?"), - confirmButtonText: t("Complete"), - successTitle: t("Complete Success"), - errorTitle: t("Complete Fail"), - buttonText: t("Complete Project"), - buttonIcon: , - buttonColor: "info", - }); - break; - case "completed": - setButtonData({ - buttonName: "reopen", - title: t("Do you want to reopen?"), - confirmButtonText: t("Reopen"), - successTitle: t("Reopen Success"), - errorTitle: t("Reopen Fail"), - buttonText: t("Reopen Project"), - buttonIcon: , - buttonColor: "secondary", - }); - } - }; + const submitDisabled = + loading || + projectDeleted === true || + projectStatus.toLowerCase() === "deleted" || + // !!formProps.getValues("projectActualStart") && + !!(projectStatus.toLowerCase() === "completed"); useEffect(() => { - if (!isEditMode) { - setLoading(false); - } else if (formProps?.getValues("projectName")) { - setLoading(false); - updateButtonData(); - } - }, [formProps]); + const draftInputs = draftId ? loadDraft(draftId) : undefined; + formProps.reset(draftInputs); + }, [draftId, formProps]); + + const saveDraft = useCallback(() => { + saveToLocalStorage(draftId || Date.now(), formProps.getValues()); + router.replace("/projects"); + }, [draftId, formProps, router]); return ( <> @@ -577,10 +595,8 @@ const CreateProject: React.FC = ({ // formProps.getValues("projectActualStart") && // formProps.getValues("projectActualEnd") ( - formProps.getValues("projectStatus")?.toLowerCase() === - "completed" || - formProps.getValues("projectStatus")?.toLowerCase() === - "deleted" + projectStatus.toLowerCase() === "completed" || + projectStatus.toLowerCase() === "deleted" ) ) && abilities.includes(DELETE_PROJECT) && ( @@ -694,6 +710,19 @@ const CreateProject: React.FC = ({ )} + {!isEditMode && ( + <> + + + + )} diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index cb025d5..d598eee 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -23,6 +23,7 @@ import { fetchUserAbilities, fetchUserStaff } from "@/app/utils/fetchUtil"; type CreateProjectProps = { isEditMode: false; isSubProject?: boolean; + draftId?: number; }; interface EditProjectProps { isEditMode: true; @@ -68,9 +69,11 @@ const CreateProjectWrapper: React.FC = async (props) => { fetchCustomerTypes(), fetchUserAbilities(), ]); - const userStaff = await fetchUserStaff() - const teamId = userStaff?.teamId - const filteredTeamLeads = teamLeads.filter(teamLead => teamLead.teamId === teamId) + const userStaff = await fetchUserStaff(); + const teamId = userStaff?.teamId; + const filteredTeamLeads = teamLeads.filter( + (teamLead) => teamLead.teamId === teamId, + ); const projectInfo = props.isEditMode ? await fetchProjectDetails(props.projectId!) : undefined; @@ -79,10 +82,10 @@ const CreateProjectWrapper: React.FC = async (props) => { ? await fetchMainProjects() : undefined; - console.log(projectInfo) return ( = ({ if (selectedCustomerId !== undefined) { fetchCustomer(selectedCustomerId).then( ({ contacts, subsidiaryIds, customer }) => { - console.log(contacts) - console.log(subsidiaryIds) + // console.log(contacts) + // console.log(subsidiaryIds) setCustomerContacts(contacts); setCustomerSubsidiaryIds(subsidiaryIds); setValue( diff --git a/src/components/ProjectSearch/ProjectSearch.tsx b/src/components/ProjectSearch/ProjectSearch.tsx index 3a1e5c2..dadee7b 100644 --- a/src/components/ProjectSearch/ProjectSearch.tsx +++ b/src/components/ProjectSearch/ProjectSearch.tsx @@ -1,7 +1,7 @@ "use client"; import { ProjectCategory, ProjectResult } from "@/app/api/projects"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import SearchBox, { Criterion } from "../SearchBox"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; @@ -9,23 +9,73 @@ import EditNote from "@mui/icons-material/EditNote"; import uniq from "lodash/uniq"; import { useRouter } from "next/navigation"; import { MAINTAIN_PROJECT } from "@/middleware"; -import { uniqBy } from "lodash"; +import { reverse, uniqBy } from "lodash"; +import { loadDrafts } from "@/app/utils/draftUtils"; +import { TeamResult } from "@/app/api/team"; +import { Customer } from "@/app/api/customer"; + +type ProjectResultOrDraft = ProjectResult & { isDraft?: boolean }; interface Props { projects: ProjectResult[]; projectCategories: ProjectCategory[]; - abilities: string[] + abilities: string[]; + teams: TeamResult[]; + customers: Customer[]; } type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const ProjectSearch: React.FC = ({ projects, projectCategories, abilities }) => { +const ProjectSearch: React.FC = ({ + projects, + projectCategories, + abilities, + teams, + customers, +}) => { const router = useRouter(); const { t } = useTranslation("projects"); + const [draftProjects, setDraftProjects] = useState( + [], + ); + + useEffect(() => { + const drafts = reverse(loadDrafts()); + setDraftProjects( + drafts.map(([id, inputs]) => { + const team = teams.find( + (team) => team.teamLead === inputs.projectLeadId, + ); + return { + isDraft: true, + id: parseInt(id), + code: inputs.projectCode || "", + name: inputs.projectName || t("Draft Project"), + category: + projectCategories.find((cat) => cat.id === inputs.projectCategoryId) + ?.name || "", + team: team?.code || "", + client: + customers.find((customer) => customer.id === inputs.clientId) + ?.name || "", + status: t("Draft"), + teamCodeName: team?.code || "", + teamId: team?.teamId || 0, + mainProject: "", + }; + }), + ); + }, [projectCategories, t, teams, customers]); + const [filteredProjects, setFilteredProjects] = useState(projects); + const draftAndFilterdProjects = useMemo( + () => [...draftProjects, ...filteredProjects], + [draftProjects, filteredProjects], + ); + const searchCriteria: Criterion[] = useMemo( () => [ { label: t("Project Code"), paramName: "code", type: "text" }, @@ -34,7 +84,13 @@ const ProjectSearch: React.FC = ({ projects, projectCategories, abilities label: t("Client Name"), paramName: "client", type: "autocomplete", - options: uniqBy(projects.map((project) => ({value: project.client, label: project.client})), "value").sort((a, b) => a.value >= b.value ? 1 : -1), + options: uniqBy( + projects.map((project) => ({ + value: project.client, + label: project.client, + })), + "value", + ).sort((a, b) => (a.value >= b.value ? 1 : -1)), }, { label: t("Project Category"), @@ -63,8 +119,10 @@ const ProjectSearch: React.FC = ({ projects, projectCategories, abilities }, [projects]); const onProjectClick = useCallback( - (project: ProjectResult) => { - if (Boolean(project.mainProject)) { + (project: ProjectResultOrDraft) => { + if (project.isDraft && project.id) { + router.push(`/projects/create?draftId=${project.id}`); + } else if (Boolean(project.mainProject)) { router.push(`/projects/editSub?id=${project.id}`); } else router.push(`/projects/edit?id=${project.id}`); }, @@ -103,7 +161,8 @@ const ProjectSearch: React.FC = ({ projects, projectCategories, abilities (query.client === "All" || p.client === query.client) && (query.category === "All" || p.category === query.category) && // (query.team === "All" || p.team === query.team) && - (query.team === "All" || query.team.toLowerCase().includes(p.team.toLowerCase())) && + (query.team === "All" || + query.team.toLowerCase().includes(p.team.toLowerCase())) && (query.status === "All" || p.status === query.status), ), ); @@ -111,7 +170,7 @@ const ProjectSearch: React.FC = ({ projects, projectCategories, abilities onReset={onReset} /> - items={filteredProjects} + items={draftAndFilterdProjects} columns={columns} /> diff --git a/src/components/ProjectSearch/ProjectSearchWrapper.tsx b/src/components/ProjectSearch/ProjectSearchWrapper.tsx index 4cba478..60e3b60 100644 --- a/src/components/ProjectSearch/ProjectSearchWrapper.tsx +++ b/src/components/ProjectSearch/ProjectSearchWrapper.tsx @@ -6,6 +6,8 @@ import { fetchUserAbilities, fetchUserStaff } from "@/app/utils/fetchUtil"; import { authOptions } from "@/config/authConfig"; import { getServerSession } from "next-auth"; import { VIEW_ALL_PROJECTS } from "@/middleware"; +import { fetchTeam } from "@/app/api/team"; +import { fetchAllCustomers } from "@/app/api/customer"; interface SubComponents { Loading: typeof ProjectSearchLoading; @@ -13,20 +15,31 @@ interface SubComponents { const ProjectSearchWrapper: React.FC & SubComponents = async () => { const projectCategories = await fetchProjectCategories(); - const userStaff = await fetchUserStaff() - const teamId = userStaff?.teamId + const userStaff = await fetchUserStaff(); + const teamId = userStaff?.teamId; const projects = await fetchProjects(); + const teams = await fetchTeam(); + const customers = await fetchAllCustomers(); - const abilities = await fetchUserAbilities() - const isViewAllProjectRight = [VIEW_ALL_PROJECTS].some((ability) => abilities.includes(ability)) + const abilities = await fetchUserAbilities(); + const isViewAllProjectRight = [VIEW_ALL_PROJECTS].some((ability) => + abilities.includes(ability), + ); - - let filteredProjects = projects + let filteredProjects = projects; if (!isViewAllProjectRight) { - filteredProjects = projects.filter(project => project.teamId === teamId) + filteredProjects = projects.filter((project) => project.teamId === teamId); } - return ; + return ( + + ); }; ProjectSearchWrapper.Loading = ProjectSearchLoading;