3 Commits

16 geänderte Dateien mit 463 neuen und 194 gelöschten Zeilen
  1. +10
    -0
      package-lock.json
  2. +16
    -3
      src/app/(main)/projects/create/page.tsx
  3. +48
    -39
      src/app/(main)/projects/page.tsx
  4. +55
    -0
      src/app/utils/draftUtils.ts
  5. +33
    -6
      src/app/utils/formatUtil.ts
  6. +2
    -1
      src/components/CreateProject/BulkAddPaymentModal.tsx
  7. +144
    -115
      src/components/CreateProject/CreateProject.tsx
  8. +7
    -4
      src/components/CreateProject/CreateProjectWrapper.tsx
  9. +9
    -1
      src/components/CreateProject/MilestoneSection.tsx
  10. +2
    -2
      src/components/CreateProject/ProjectClientDetails.tsx
  11. +6
    -3
      src/components/CreateProject/ProjectTotalFee.tsx
  12. +68
    -9
      src/components/ProjectSearch/ProjectSearch.tsx
  13. +21
    -8
      src/components/ProjectSearch/ProjectSearchWrapper.tsx
  14. +4
    -1
      src/components/TimeLeaveModal/TimeLeaveInputTable.tsx
  15. +8
    -2
      src/components/TimeLeaveModal/TimeLeaveModal.tsx
  16. +30
    -0
      src/components/utils/waitFor.ts

+ 10
- 0
package-lock.json Datei anzeigen

@@ -47,6 +47,7 @@
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.49.2", "react-hook-form": "^7.49.2",
"react-i18next": "^13.5.0", "react-i18next": "^13.5.0",
"react-idle-timer": "^5.7.2",
"react-intl": "^6.5.5", "react-intl": "^6.5.5",
"react-number-format": "^5.3.4", "react-number-format": "^5.3.4",
"react-select": "^5.8.0", "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": { "node_modules/react-intl": {
"version": "6.6.2", "version": "6.6.2",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.2.tgz", "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.2.tgz",


+ 16
- 3
src/app/(main)/projects/create/page.tsx Datei anzeigen

@@ -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 { fetchGrades } from "@/app/api/grades";
import { import {
fetchProjectBuildingTypes, fetchProjectBuildingTypes,
@@ -16,6 +20,7 @@ import CreateProject from "@/components/CreateProject";
import { I18nProvider, getServerI18n } from "@/i18n"; import { I18nProvider, getServerI18n } from "@/i18n";
import { MAINTAIN_PROJECT } from "@/middleware"; import { MAINTAIN_PROJECT } from "@/middleware";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import isString from "lodash/isString";
import { Metadata } from "next"; import { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";


@@ -23,7 +28,11 @@ export const metadata: Metadata = {
title: "Create Project", 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 { t } = await getServerI18n("projects");


const abilities = await fetchUserAbilities(); const abilities = await fetchUserAbilities();
@@ -32,6 +41,10 @@ const Projects: React.FC = async () => {
notFound(); notFound();
} }


const draftId = isString(searchParams["draftId"])
? parseInt(searchParams["draftId"])
: undefined;

// Preload necessary dependencies // Preload necessary dependencies
fetchAllTasks(); fetchAllTasks();
fetchTaskTemplates(); fetchTaskTemplates();
@@ -53,7 +66,7 @@ const Projects: React.FC = async () => {
<> <>
<Typography variant="h4">{t("Create Project")}</Typography> <Typography variant="h4">{t("Create Project")}</Typography>
<I18nProvider namespaces={["projects"]}> <I18nProvider namespaces={["projects"]}>
<CreateProject isEditMode={false} />
<CreateProject isEditMode={false} draftId={draftId} />
</I18nProvider> </I18nProvider>
</> </>
); );


+ 48
- 39
src/app/(main)/projects/page.tsx Datei anzeigen

@@ -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 { fetchUserAbilities } from "@/app/utils/fetchUtil";
import ProjectSearch from "@/components/ProjectSearch"; import ProjectSearch from "@/components/ProjectSearch";
import { getServerI18n, I18nProvider } from "@/i18n"; 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 Add from "@mui/icons-material/Add";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack"; import Stack from "@mui/material/Stack";
@@ -18,55 +20,62 @@ export const metadata: Metadata = {


const Projects: React.FC = async () => { const Projects: React.FC = async () => {
const { t } = await getServerI18n("projects"); const { t } = await getServerI18n("projects");
// preloadProjects();
fetchProjectCategories(); fetchProjectCategories();
fetchTeam();
fetchAllCustomers();
const projects = await fetchProjects(); 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(); notFound();
} }


return ( 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" direction="row"
justifyContent="space-between" justifyContent="space-between"
flexWrap="wrap" flexWrap="wrap"
rowGap={2} 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> </I18nProvider>
</> </>
); );


+ 55
- 0
src/app/utils/draftUtils.ts Datei anzeigen

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

+ 33
- 6
src/app/utils/formatUtil.ts Datei anzeigen

@@ -21,6 +21,33 @@ export const OUTPUT_DATE_FORMAT = "YYYY/MM/DD";


export const OUTPUT_TIME_FORMAT = "HH:mm:ss"; 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 = ( export const convertDateToString = (
date: Date, date: Date,
format: string = OUTPUT_DATE_FORMAT, format: string = OUTPUT_DATE_FORMAT,
@@ -33,8 +60,8 @@ export const convertDateArrayToString = (
format: string = OUTPUT_DATE_FORMAT, format: string = OUTPUT_DATE_FORMAT,
needTime: boolean = false, needTime: boolean = false,
) => { ) => {
if (dateArray === null){
return "-"
if (dateArray === null) {
return "-";
} }
if (dateArray.length === 6) { if (dateArray.length === 6) {
if (!needTime) { if (!needTime) {
@@ -48,8 +75,8 @@ export const convertDateArrayToString = (
return dayjs(dateString).format(format); 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 { export function timestampToDateString(timestamp: string): string {
if (timestamp === null){
return "-"
if (timestamp === null) {
return "-";
} }
const date = new Date(timestamp); const date = new Date(timestamp);
const year = date.getFullYear(); const year = date.getFullYear();


+ 2
- 1
src/components/CreateProject/BulkAddPaymentModal.tsx Datei anzeigen

@@ -26,6 +26,7 @@ import {
INPUT_DATE_FORMAT, INPUT_DATE_FORMAT,
moneyFormatter, moneyFormatter,
OUTPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT,
truncateMoney,
} from "@/app/utils/formatUtil"; } from "@/app/utils/formatUtil";
import { PaymentInputs } from "@/app/api/projects/actions"; import { PaymentInputs } from "@/app/api/projects/actions";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -94,7 +95,7 @@ const BulkAddPaymentModal: React.FC<Props> = ({
amountToDivide && amountToDivide &&
description description
) { ) {
const dividedAmount = amountToDivide / numberOfEntries;
const dividedAmount = truncateMoney(amountToDivide / numberOfEntries)!;
return Array(numberOfEntries) return Array(numberOfEntries)
.fill(undefined) .fill(undefined)
.map((_, index) => { .map((_, index) => {


+ 144
- 115
src/components/CreateProject/CreateProject.tsx Datei anzeigen

@@ -4,12 +4,18 @@ import AutorenewIcon from "@mui/icons-material/Autorenew";
import DoneIcon from "@mui/icons-material/Done"; import DoneIcon from "@mui/icons-material/Done";
import Check from "@mui/icons-material/Check"; import Check from "@mui/icons-material/Check";
import Close from "@mui/icons-material/Close"; 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 Stack from "@mui/material/Stack";
import Tab from "@mui/material/Tab"; import Tab from "@mui/material/Tab";
import Tabs, { TabsProps } from "@mui/material/Tabs"; import Tabs, { TabsProps } from "@mui/material/Tabs";
import { useRouter } from "next/navigation"; 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 { useTranslation } from "react-i18next";
import ProjectClientDetails from "./ProjectClientDetails"; import ProjectClientDetails from "./ProjectClientDetails";
import TaskSetup from "./TaskSetup"; import TaskSetup from "./TaskSetup";
@@ -28,7 +34,7 @@ import {
deleteProject, deleteProject,
saveProject, saveProject,
} from "@/app/api/projects/actions"; } from "@/app/api/projects/actions";
import { Delete, Error, PlayArrow } from "@mui/icons-material";
import { Delete, EditNote, Error, PlayArrow } from "@mui/icons-material";
import { import {
BuildingType, BuildingType,
ContractType, ContractType,
@@ -40,7 +46,7 @@ import {
WorkNature, WorkNature,
} from "@/app/api/projects"; } from "@/app/api/projects";
import { StaffResult } from "@/app/api/staff"; 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 { Grade } from "@/app/api/grades";
import { Customer, CustomerType, Subsidiary } from "@/app/api/customer"; import { Customer, CustomerType, Subsidiary } from "@/app/api/customer";
import { isEmpty } from "lodash"; import { isEmpty } from "lodash";
@@ -54,9 +60,11 @@ import {
import dayjs from "dayjs"; import dayjs from "dayjs";
import { DELETE_PROJECT } from "@/middleware"; import { DELETE_PROJECT } from "@/middleware";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { deleteDraft, loadDraft, saveToLocalStorage } from "@/app/utils/draftUtils";


export interface Props { export interface Props {
isEditMode: boolean; isEditMode: boolean;
draftId?: number;
isSubProject: boolean; isSubProject: boolean;
mainProjects?: MainProject[]; mainProjects?: MainProject[];
defaultInputs?: CreateProjectInputs; defaultInputs?: CreateProjectInputs;
@@ -106,6 +114,7 @@ const hasErrorsInTab = (


const CreateProject: React.FC<Props> = ({ const CreateProject: React.FC<Props> = ({
isEditMode, isEditMode,
draftId,
isSubProject, isSubProject,
mainProjects, mainProjects,
defaultInputs, defaultInputs,
@@ -127,11 +136,46 @@ const CreateProject: React.FC<Props> = ({
abilities, abilities,
}) => { }) => {
const [serverError, setServerError] = useState(""); const [serverError, setServerError] = useState("");
const [loading, setLoading] = useState(true);
const [tabIndex, setTabIndex] = useState(0); const [tabIndex, setTabIndex] = useState(0);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); 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 = { const defaultBtn = {
buttonName: "submit", buttonName: "submit",
title: t("Do you want to submit?"), title: t("Do you want to submit?"),
@@ -139,31 +183,64 @@ const CreateProject: React.FC<Props> = ({
successTitle: t("Submit Success"), successTitle: t("Submit Success"),
errorTitle: t("Submit Fail"), errorTitle: t("Submit Fail"),
}; };
const [buttonData, setButtonData] = useState<{
const buttonData = useMemo<{
buttonName: string; buttonName: string;
title: string; title: string;
confirmButtonText: string; confirmButtonText: string;
successTitle: string; successTitle: string;
errorTitle: string; errorTitle: string;
buttonText: 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 = () => { const handleCancel = () => {
router.replace("/projects"); router.replace("/projects");
@@ -333,6 +410,9 @@ const CreateProject: React.FC<Props> = ({
: buttonData.successTitle, : buttonData.successTitle,
t, t,
).then(() => { ).then(() => {
if (draftId) {
deleteDraft(draftId);
}
router.replace("/projects"); router.replace("/projects");
}); });
} else { } 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; const errors = formProps.formState.errors;


// auto calculate the total project manhour // auto calculate the total project manhour
const expectedProjectFee = formProps.watch("expectedProjectFee"); const expectedProjectFee = formProps.watch("expectedProjectFee");
const ratePerManhour = formProps.watch("ratePerManhour"); const ratePerManhour = formProps.watch("ratePerManhour");
const totalManhour = formProps.watch("totalManhour"); 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( formProps.setValue(
"totalManhour", "totalManhour",
Math.ceil(expectedProjectFee / ratePerManhour), Math.ceil(expectedProjectFee / ratePerManhour),
); );
} else { } else {
setFirstLoaded(true);
firstLoadedRef.current = true;
} }
}, [expectedProjectFee, ratePerManhour]); }, [expectedProjectFee, ratePerManhour]);


React.useMemo(() => {
useEffect(() => {
if ( if (
expectedProjectFee > 0 && expectedProjectFee > 0 &&
ratePerManhour > 0 && ratePerManhour > 0 &&
@@ -472,57 +523,24 @@ const CreateProject: React.FC<Props> = ({
} }
}, [totalManhour]); }, [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(() => { 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 ( return (
<> <>
@@ -577,10 +595,8 @@ const CreateProject: React.FC<Props> = ({
// formProps.getValues("projectActualStart") && // formProps.getValues("projectActualStart") &&
// formProps.getValues("projectActualEnd") // formProps.getValues("projectActualEnd")
( (
formProps.getValues("projectStatus")?.toLowerCase() ===
"completed" ||
formProps.getValues("projectStatus")?.toLowerCase() ===
"deleted"
projectStatus.toLowerCase() === "completed" ||
projectStatus.toLowerCase() === "deleted"
) )
) && ) &&
abilities.includes(DELETE_PROJECT) && ( abilities.includes(DELETE_PROJECT) && (
@@ -694,6 +710,19 @@ const CreateProject: React.FC<Props> = ({
</Typography> </Typography>
)} )}
<Stack direction="row" justifyContent="flex-end" gap={1}> <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 <Button
variant="outlined" variant="outlined"
startIcon={<Close />} startIcon={<Close />}
@@ -706,7 +735,7 @@ const CreateProject: React.FC<Props> = ({
variant="contained" variant="contained"
startIcon={<Check />} startIcon={<Check />}
type="submit" type="submit"
disabled={disableChecking()}
disabled={submitDisabled}
> >
{isEditMode ? t("Save") : t("Confirm")} {isEditMode ? t("Save") : t("Confirm")}
</Button> </Button>


+ 7
- 4
src/components/CreateProject/CreateProjectWrapper.tsx Datei anzeigen

@@ -23,6 +23,7 @@ import { fetchUserAbilities, fetchUserStaff } from "@/app/utils/fetchUtil";
type CreateProjectProps = { type CreateProjectProps = {
isEditMode: false; isEditMode: false;
isSubProject?: boolean; isSubProject?: boolean;
draftId?: number;
}; };
interface EditProjectProps { interface EditProjectProps {
isEditMode: true; isEditMode: true;
@@ -68,9 +69,11 @@ const CreateProjectWrapper: React.FC<Props> = async (props) => {
fetchCustomerTypes(), fetchCustomerTypes(),
fetchUserAbilities(), 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 const projectInfo = props.isEditMode
? await fetchProjectDetails(props.projectId!) ? await fetchProjectDetails(props.projectId!)
: undefined; : undefined;
@@ -79,10 +82,10 @@ const CreateProjectWrapper: React.FC<Props> = async (props) => {
? await fetchMainProjects() ? await fetchMainProjects()
: undefined; : undefined;


console.log(projectInfo)
return ( return (
<CreateProject <CreateProject
isEditMode={props.isEditMode} isEditMode={props.isEditMode}
draftId={props.isEditMode ? undefined : props.draftId}
isSubProject={Boolean(props.isSubProject)} isSubProject={Boolean(props.isSubProject)}
defaultInputs={projectInfo} defaultInputs={projectInfo}
allTasks={tasks} allTasks={tasks}


+ 9
- 1
src/components/CreateProject/MilestoneSection.tsx Datei anzeigen

@@ -35,6 +35,7 @@ import {
INPUT_DATE_FORMAT, INPUT_DATE_FORMAT,
OUTPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT,
moneyFormatter, moneyFormatter,
truncateMoney,
} from "@/app/utils/formatUtil"; } from "@/app/utils/formatUtil";
import isDate from "lodash/isDate"; import isDate from "lodash/isDate";
import BulkAddPaymentModal from "./BulkAddPaymentModal"; import BulkAddPaymentModal from "./BulkAddPaymentModal";
@@ -148,7 +149,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _isNew, _errors, ...updatedRow } = newRow; const { _isNew, _errors, ...updatedRow } = newRow;
setPayments((ps) => 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; return updatedRow;
}, },
@@ -246,6 +251,9 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
width: 300, width: 300,
editable: true, editable: true,
type: "number", type: "number",
valueGetter(params) {
return truncateMoney(params.value);
},
valueFormatter(params) { valueFormatter(params) {
return moneyFormatter.format(params.value); return moneyFormatter.format(params.value);
}, },


+ 2
- 2
src/components/CreateProject/ProjectClientDetails.tsx Datei anzeigen

@@ -121,8 +121,8 @@ const ProjectClientDetails: React.FC<Props> = ({
if (selectedCustomerId !== undefined) { if (selectedCustomerId !== undefined) {
fetchCustomer(selectedCustomerId).then( fetchCustomer(selectedCustomerId).then(
({ contacts, subsidiaryIds, customer }) => { ({ contacts, subsidiaryIds, customer }) => {
console.log(contacts)
console.log(subsidiaryIds)
// console.log(contacts)
// console.log(subsidiaryIds)
setCustomerContacts(contacts); setCustomerContacts(contacts);
setCustomerSubsidiaryIds(subsidiaryIds); setCustomerSubsidiaryIds(subsidiaryIds);
setValue( setValue(


+ 6
- 3
src/components/CreateProject/ProjectTotalFee.tsx Datei anzeigen

@@ -1,6 +1,6 @@
import { CreateProjectInputs } from "@/app/api/projects/actions"; import { CreateProjectInputs } from "@/app/api/projects/actions";
import { TaskGroup } from "@/app/api/tasks"; import { TaskGroup } from "@/app/api/tasks";
import { moneyFormatter } from "@/app/utils/formatUtil";
import { moneyFormatter, sumMoney } from "@/app/utils/formatUtil";
import { import {
Button, Button,
Divider, Divider,
@@ -52,9 +52,12 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => {
<Stack spacing={1}> <Stack spacing={1}>
{taskGroups.map((group, index) => { {taskGroups.map((group, index) => {
const payments = milestones[group.id]?.payments || []; 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 ( return (
<Stack <Stack


+ 68
- 9
src/components/ProjectSearch/ProjectSearch.tsx Datei anzeigen

@@ -1,7 +1,7 @@
"use client"; "use client";


import { ProjectCategory, ProjectResult } from "@/app/api/projects"; 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 SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults"; import SearchResults, { Column } from "../SearchResults";
@@ -9,23 +9,73 @@ import EditNote from "@mui/icons-material/EditNote";
import uniq from "lodash/uniq"; import uniq from "lodash/uniq";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MAINTAIN_PROJECT } from "@/middleware"; 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 { interface Props {
projects: ProjectResult[]; projects: ProjectResult[];
projectCategories: ProjectCategory[]; projectCategories: ProjectCategory[];
abilities: string[]
abilities: string[];
teams: TeamResult[];
customers: Customer[];
} }


type SearchQuery = Partial<Omit<ProjectResult, "id">>; type SearchQuery = Partial<Omit<ProjectResult, "id">>;
type SearchParamNames = keyof SearchQuery; 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 router = useRouter();
const { t } = useTranslation("projects"); 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 [filteredProjects, setFilteredProjects] = useState(projects);


const draftAndFilterdProjects = useMemo<ProjectResultOrDraft[]>(
() => [...draftProjects, ...filteredProjects],
[draftProjects, filteredProjects],
);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo( const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [ () => [
{ label: t("Project Code"), paramName: "code", type: "text" }, { label: t("Project Code"), paramName: "code", type: "text" },
@@ -34,7 +84,13 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories, abilities
label: t("Client Name"), label: t("Client Name"),
paramName: "client", paramName: "client",
type: "autocomplete", 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"), label: t("Project Category"),
@@ -63,8 +119,10 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories, abilities
}, [projects]); }, [projects]);


const onProjectClick = useCallback( 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}`); router.push(`/projects/editSub?id=${project.id}`);
} else router.push(`/projects/edit?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.client === "All" || p.client === query.client) &&
(query.category === "All" || p.category === query.category) && (query.category === "All" || p.category === query.category) &&
// (query.team === "All" || p.team === query.team) && // (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), (query.status === "All" || p.status === query.status),
), ),
); );
@@ -111,7 +170,7 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories, abilities
onReset={onReset} onReset={onReset}
/> />
<SearchResults<ProjectResult> <SearchResults<ProjectResult>
items={filteredProjects}
items={draftAndFilterdProjects}
columns={columns} columns={columns}
/> />
</> </>


+ 21
- 8
src/components/ProjectSearch/ProjectSearchWrapper.tsx Datei anzeigen

@@ -6,6 +6,8 @@ import { fetchUserAbilities, fetchUserStaff } from "@/app/utils/fetchUtil";
import { authOptions } from "@/config/authConfig"; import { authOptions } from "@/config/authConfig";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { VIEW_ALL_PROJECTS } from "@/middleware"; import { VIEW_ALL_PROJECTS } from "@/middleware";
import { fetchTeam } from "@/app/api/team";
import { fetchAllCustomers } from "@/app/api/customer";


interface SubComponents { interface SubComponents {
Loading: typeof ProjectSearchLoading; Loading: typeof ProjectSearchLoading;
@@ -13,20 +15,31 @@ interface SubComponents {


const ProjectSearchWrapper: React.FC & SubComponents = async () => { const ProjectSearchWrapper: React.FC & SubComponents = async () => {
const projectCategories = await fetchProjectCategories(); 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 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) { 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; ProjectSearchWrapper.Loading = ProjectSearchLoading;


+ 4
- 1
src/components/TimeLeaveModal/TimeLeaveInputTable.tsx Datei anzeigen

@@ -637,7 +637,10 @@ const TimeLeaveInputTable: React.FC<Props> = ({
setValue(day, newEntries); setValue(day, newEntries);


if (entries.some((e) => e._isNew)) { 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 { } else {
clearErrors(day); clearErrors(day);
} }


+ 8
- 2
src/components/TimeLeaveModal/TimeLeaveModal.tsx Datei anzeigen

@@ -39,6 +39,7 @@ import DateHoursList from "../DateHoursTable/DateHoursList";
import TimeLeaveInputTable from "./TimeLeaveInputTable"; import TimeLeaveInputTable from "./TimeLeaveInputTable";
import TimeLeaveMobileEntry from "./TimeLeaveMobileEntry"; import TimeLeaveMobileEntry from "./TimeLeaveMobileEntry";
import { Task } from "@/app/api/tasks"; import { Task } from "@/app/api/tasks";
import waitForCondition from "../utils/waitFor";


interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -240,9 +241,14 @@ const TimeLeaveModal: React.FC<Props> = ({
<Button <Button
variant="contained" variant="contained"
startIcon={<Check />} startIcon={<Check />}
type="submit"
onClick={() => {
onClick={async () => {
await waitForCondition(async () => {
return !Object.values(formProps.formState.errors).some(
(err) => err?.type === "custom",
);
});
formProps.clearErrors(); formProps.clearErrors();
formProps.handleSubmit(onSubmit)();
}} }}
> >
{t("Save")} {t("Save")}


+ 30
- 0
src/components/utils/waitFor.ts Datei anzeigen

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

Laden…
Abbrechen
Speichern