Tekijä | SHA1 | Viesti | Päivämäärä |
---|---|---|---|
|
cb76ceb602 | Add draft projects | 8 kuukautta sitten |
|
0ed51ed325 | Wait for no custom errors before submitting | 8 kuukautta sitten |
|
f0425606de | Fix money related issue in milestone payments | 8 kuukautta sitten |
@@ -47,6 +47,7 @@ | |||
"react-dom": "^18", | |||
"react-hook-form": "^7.49.2", | |||
"react-i18next": "^13.5.0", | |||
"react-idle-timer": "^5.7.2", | |||
"react-intl": "^6.5.5", | |||
"react-number-format": "^5.3.4", | |||
"react-select": "^5.8.0", | |||
@@ -9043,6 +9044,15 @@ | |||
} | |||
} | |||
}, | |||
"node_modules/react-idle-timer": { | |||
"version": "5.7.2", | |||
"resolved": "https://registry.npmjs.org/react-idle-timer/-/react-idle-timer-5.7.2.tgz", | |||
"integrity": "sha512-+BaPfc7XEUU5JFkwZCx6fO1bLVK+RBlFH+iY4X34urvIzZiZINP6v2orePx3E6pAztJGE7t4DzvL7if2SL/0GQ==", | |||
"peerDependencies": { | |||
"react": ">=16", | |||
"react-dom": ">=16" | |||
} | |||
}, | |||
"node_modules/react-intl": { | |||
"version": "6.6.2", | |||
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.2.tgz", | |||
@@ -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<Props> = 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 () => { | |||
<> | |||
<Typography variant="h4">{t("Create Project")}</Typography> | |||
<I18nProvider namespaces={["projects"]}> | |||
<CreateProject isEditMode={false} /> | |||
<CreateProject isEditMode={false} draftId={draftId} /> | |||
</I18nProvider> | |||
</> | |||
); | |||
@@ -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 ( | |||
<> | |||
<I18nProvider namespaces={["projects","common"]}> | |||
<Stack | |||
direction="row" | |||
justifyContent="space-between" | |||
flexWrap="wrap" | |||
rowGap={2} | |||
> | |||
<Typography variant="h4" marginInlineEnd={2}> | |||
{t("Project Management")} | |||
</Typography> | |||
{abilities.includes(MAINTAIN_PROJECT) && <Stack | |||
<I18nProvider namespaces={["projects", "common"]}> | |||
<Stack | |||
direction="row" | |||
justifyContent="space-between" | |||
flexWrap="wrap" | |||
rowGap={2} | |||
spacing={1} | |||
> | |||
{projects.filter(project => project.status.toLowerCase() !== "deleted").length > 0 && <Button | |||
variant="contained" | |||
color="secondary" | |||
startIcon={<Add />} | |||
LinkComponent={Link} | |||
href="/projects/createSub" | |||
> | |||
{t("Create Sub Project")} | |||
</Button>} | |||
<Button | |||
variant="contained" | |||
startIcon={<Add />} | |||
LinkComponent={Link} | |||
href="/projects/create" | |||
> | |||
{t("Create Project")} | |||
</Button> | |||
</Stack >} | |||
</Stack> | |||
<Suspense fallback={<ProjectSearch.Loading />}> | |||
<ProjectSearch /> | |||
</Suspense> | |||
<Typography variant="h4" marginInlineEnd={2}> | |||
{t("Project Management")} | |||
</Typography> | |||
{abilities.includes(MAINTAIN_PROJECT) && ( | |||
<Stack | |||
direction="row" | |||
justifyContent="space-between" | |||
flexWrap="wrap" | |||
rowGap={2} | |||
spacing={1} | |||
> | |||
{projects.filter( | |||
(project) => project.status.toLowerCase() !== "deleted", | |||
).length > 0 && ( | |||
<Button | |||
variant="contained" | |||
color="secondary" | |||
startIcon={<Add />} | |||
LinkComponent={Link} | |||
href="/projects/createSub" | |||
> | |||
{t("Create Sub Project")} | |||
</Button> | |||
)} | |||
<Button | |||
variant="contained" | |||
startIcon={<Add />} | |||
LinkComponent={Link} | |||
href="/projects/create" | |||
> | |||
{t("Create Project")} | |||
</Button> | |||
</Stack> | |||
)} | |||
</Stack> | |||
<Suspense fallback={<ProjectSearch.Loading />}> | |||
<ProjectSearch /> | |||
</Suspense> | |||
</I18nProvider> | |||
</> | |||
); | |||
@@ -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)); | |||
}; |
@@ -21,6 +21,33 @@ export const OUTPUT_DATE_FORMAT = "YYYY/MM/DD"; | |||
export const OUTPUT_TIME_FORMAT = "HH:mm:ss"; | |||
export const truncateMoney = (amount: number | undefined) => { | |||
if (!amount) { | |||
return amount; | |||
} | |||
const { maximumFractionDigits, minimumFractionDigits } = | |||
moneyFormatter.resolvedOptions(); | |||
const fractionDigits = maximumFractionDigits ?? minimumFractionDigits ?? 0; | |||
const factor = Math.pow(10, fractionDigits); | |||
const truncatedAmount = Math.floor(amount * factor) / factor; | |||
return truncatedAmount; | |||
}; | |||
export const sumMoney = (a: number, b: number) => { | |||
const { maximumFractionDigits, minimumFractionDigits } = | |||
moneyFormatter.resolvedOptions(); | |||
const fractionDigits = maximumFractionDigits ?? minimumFractionDigits ?? 0; | |||
const factor = Math.pow(10, fractionDigits); | |||
const sum = Math.round(a * factor) + Math.round(b * factor); | |||
return sum / factor; | |||
}; | |||
export const convertDateToString = ( | |||
date: Date, | |||
format: string = OUTPUT_DATE_FORMAT, | |||
@@ -33,8 +60,8 @@ export const convertDateArrayToString = ( | |||
format: string = OUTPUT_DATE_FORMAT, | |||
needTime: boolean = false, | |||
) => { | |||
if (dateArray === null){ | |||
return "-" | |||
if (dateArray === null) { | |||
return "-"; | |||
} | |||
if (dateArray.length === 6) { | |||
if (!needTime) { | |||
@@ -48,8 +75,8 @@ export const convertDateArrayToString = ( | |||
return dayjs(dateString).format(format); | |||
} | |||
} | |||
if (dateArray.length === 0){ | |||
return "-" | |||
if (dateArray.length === 0) { | |||
return "-"; | |||
} | |||
}; | |||
@@ -134,8 +161,8 @@ export function convertLocaleStringToNumber(numberString: string): number { | |||
} | |||
export function timestampToDateString(timestamp: string): string { | |||
if (timestamp === null){ | |||
return "-" | |||
if (timestamp === null) { | |||
return "-"; | |||
} | |||
const date = new Date(timestamp); | |||
const year = date.getFullYear(); | |||
@@ -26,6 +26,7 @@ import { | |||
INPUT_DATE_FORMAT, | |||
moneyFormatter, | |||
OUTPUT_DATE_FORMAT, | |||
truncateMoney, | |||
} from "@/app/utils/formatUtil"; | |||
import { PaymentInputs } from "@/app/api/projects/actions"; | |||
import dayjs from "dayjs"; | |||
@@ -94,7 +95,7 @@ const BulkAddPaymentModal: React.FC<Props> = ({ | |||
amountToDivide && | |||
description | |||
) { | |||
const dividedAmount = amountToDivide / numberOfEntries; | |||
const dividedAmount = truncateMoney(amountToDivide / numberOfEntries)!; | |||
return Array(numberOfEntries) | |||
.fill(undefined) | |||
.map((_, index) => { | |||
@@ -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<Props> = ({ | |||
isEditMode, | |||
draftId, | |||
isSubProject, | |||
mainProjects, | |||
defaultInputs, | |||
@@ -127,11 +136,46 @@ const CreateProject: React.FC<Props> = ({ | |||
abilities, | |||
}) => { | |||
const [serverError, setServerError] = useState(""); | |||
const [loading, setLoading] = useState(true); | |||
const [tabIndex, setTabIndex] = useState(0); | |||
const { t } = useTranslation(); | |||
const router = useRouter(); | |||
const formProps = useForm<CreateProjectInputs>({ | |||
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<Props> = ({ | |||
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: <Check />, | |||
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: <PlayArrow />, | |||
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: <DoneIcon />, | |||
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: <AutorenewIcon />, | |||
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: <Check />, | |||
buttonColor: "success", | |||
}; | |||
} | |||
}, [projectStatus, t]); | |||
const handleCancel = () => { | |||
router.replace("/projects"); | |||
@@ -333,6 +410,9 @@ const CreateProject: React.FC<Props> = ({ | |||
: buttonData.successTitle, | |||
t, | |||
).then(() => { | |||
if (draftId) { | |||
deleteDraft(draftId); | |||
} | |||
router.replace("/projects"); | |||
}); | |||
} else { | |||
@@ -408,58 +488,29 @@ const CreateProject: React.FC<Props> = ({ | |||
[], | |||
); | |||
const formProps = useForm<CreateProjectInputs>({ | |||
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<Props> = ({ | |||
} | |||
}, [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: <PlayArrow />, | |||
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: <DoneIcon />, | |||
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: <AutorenewIcon />, | |||
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<Props> = ({ | |||
// 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<Props> = ({ | |||
</Typography> | |||
)} | |||
<Stack direction="row" justifyContent="flex-end" gap={1}> | |||
{!isEditMode && ( | |||
<> | |||
<Button | |||
variant="outlined" | |||
color="secondary" | |||
startIcon={<EditNote />} | |||
onClick={saveDraft} | |||
> | |||
{t("Save Draft")} | |||
</Button> | |||
<Box sx={{ flex: 1, pointerEvents: "none" }} /> | |||
</> | |||
)} | |||
<Button | |||
variant="outlined" | |||
startIcon={<Close />} | |||
@@ -706,7 +735,7 @@ const CreateProject: React.FC<Props> = ({ | |||
variant="contained" | |||
startIcon={<Check />} | |||
type="submit" | |||
disabled={disableChecking()} | |||
disabled={submitDisabled} | |||
> | |||
{isEditMode ? t("Save") : t("Confirm")} | |||
</Button> | |||
@@ -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<Props> = 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<Props> = async (props) => { | |||
? await fetchMainProjects() | |||
: undefined; | |||
console.log(projectInfo) | |||
return ( | |||
<CreateProject | |||
isEditMode={props.isEditMode} | |||
draftId={props.isEditMode ? undefined : props.draftId} | |||
isSubProject={Boolean(props.isSubProject)} | |||
defaultInputs={projectInfo} | |||
allTasks={tasks} | |||
@@ -35,6 +35,7 @@ import { | |||
INPUT_DATE_FORMAT, | |||
OUTPUT_DATE_FORMAT, | |||
moneyFormatter, | |||
truncateMoney, | |||
} from "@/app/utils/formatUtil"; | |||
import isDate from "lodash/isDate"; | |||
import BulkAddPaymentModal from "./BulkAddPaymentModal"; | |||
@@ -148,7 +149,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |||
const { _isNew, _errors, ...updatedRow } = newRow; | |||
setPayments((ps) => | |||
ps.map((p) => (p.id === updatedRow.id ? updatedRow : p)), | |||
ps.map((p) => | |||
p.id === updatedRow.id | |||
? { ...updatedRow, amount: truncateMoney(updatedRow.amount) } | |||
: p, | |||
), | |||
); | |||
return updatedRow; | |||
}, | |||
@@ -246,6 +251,9 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
width: 300, | |||
editable: true, | |||
type: "number", | |||
valueGetter(params) { | |||
return truncateMoney(params.value); | |||
}, | |||
valueFormatter(params) { | |||
return moneyFormatter.format(params.value); | |||
}, | |||
@@ -121,8 +121,8 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
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( | |||
@@ -1,6 +1,6 @@ | |||
import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
import { TaskGroup } from "@/app/api/tasks"; | |||
import { moneyFormatter } from "@/app/utils/formatUtil"; | |||
import { moneyFormatter, sumMoney } from "@/app/utils/formatUtil"; | |||
import { | |||
Button, | |||
Divider, | |||
@@ -52,9 +52,12 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
<Stack spacing={1}> | |||
{taskGroups.map((group, index) => { | |||
const payments = milestones[group.id]?.payments || []; | |||
const paymentTotal = payments.reduce((acc, p) => acc + p.amount, 0); | |||
const paymentTotal = payments.reduce( | |||
(acc, p) => sumMoney(acc, p.amount), | |||
0, | |||
); | |||
projectTotal += paymentTotal; | |||
projectTotal = sumMoney(projectTotal, paymentTotal); | |||
return ( | |||
<Stack | |||
@@ -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<Omit<ProjectResult, "id">>; | |||
type SearchParamNames = keyof SearchQuery; | |||
const ProjectSearch: React.FC<Props> = ({ projects, projectCategories, abilities }) => { | |||
const ProjectSearch: React.FC<Props> = ({ | |||
projects, | |||
projectCategories, | |||
abilities, | |||
teams, | |||
customers, | |||
}) => { | |||
const router = useRouter(); | |||
const { t } = useTranslation("projects"); | |||
const [draftProjects, setDraftProjects] = useState<ProjectResultOrDraft[]>( | |||
[], | |||
); | |||
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<ProjectResultOrDraft[]>( | |||
() => [...draftProjects, ...filteredProjects], | |||
[draftProjects, filteredProjects], | |||
); | |||
const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
() => [ | |||
{ label: t("Project Code"), paramName: "code", type: "text" }, | |||
@@ -34,7 +84,13 @@ const ProjectSearch: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ projects, projectCategories, abilities | |||
onReset={onReset} | |||
/> | |||
<SearchResults<ProjectResult> | |||
items={filteredProjects} | |||
items={draftAndFilterdProjects} | |||
columns={columns} | |||
/> | |||
</> | |||
@@ -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 <ProjectSearch projects={filteredProjects} projectCategories={projectCategories} abilities={abilities}/>; | |||
return ( | |||
<ProjectSearch | |||
projects={filteredProjects} | |||
projectCategories={projectCategories} | |||
abilities={abilities} | |||
teams={teams} | |||
customers={customers} | |||
/> | |||
); | |||
}; | |||
ProjectSearchWrapper.Loading = ProjectSearchLoading; | |||
@@ -637,7 +637,10 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
setValue(day, newEntries); | |||
if (entries.some((e) => e._isNew)) { | |||
setError(day, { message: "There are some unsaved entries." }); | |||
setError(day, { | |||
message: "There are some unsaved entries.", | |||
type: "custom", | |||
}); | |||
} else { | |||
clearErrors(day); | |||
} | |||
@@ -39,6 +39,7 @@ import DateHoursList from "../DateHoursTable/DateHoursList"; | |||
import TimeLeaveInputTable from "./TimeLeaveInputTable"; | |||
import TimeLeaveMobileEntry from "./TimeLeaveMobileEntry"; | |||
import { Task } from "@/app/api/tasks"; | |||
import waitForCondition from "../utils/waitFor"; | |||
interface Props { | |||
isOpen: boolean; | |||
@@ -240,9 +241,14 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
<Button | |||
variant="contained" | |||
startIcon={<Check />} | |||
type="submit" | |||
onClick={() => { | |||
onClick={async () => { | |||
await waitForCondition(async () => { | |||
return !Object.values(formProps.formState.errors).some( | |||
(err) => err?.type === "custom", | |||
); | |||
}); | |||
formProps.clearErrors(); | |||
formProps.handleSubmit(onSubmit)(); | |||
}} | |||
> | |||
{t("Save")} | |||
@@ -0,0 +1,30 @@ | |||
/** | |||
* Wait until a condition to be true with a default timeout of 1 second (and checking every 100ms). | |||
*/ | |||
export const waitForCondition = async ( | |||
condition: () => Promise<boolean>, | |||
waitOptions: { | |||
timeout: number; | |||
interval: number; | |||
} = { timeout: 1000, interval: 100 }, | |||
) => { | |||
const startTime = Date.now(); | |||
const check = async () => { | |||
if (await condition()) { | |||
return; | |||
} else { | |||
if (Date.now() - startTime < waitOptions.timeout) { | |||
return new Promise((resolve) => { | |||
setTimeout(() => { | |||
resolve(check()); | |||
}, waitOptions.interval); | |||
}); | |||
} | |||
} | |||
}; | |||
return check(); | |||
}; | |||
export default waitForCondition; |