浏览代码

Merge branch 'main' of https://git.2fi-solutions.com/wayne.lee/tsms

# Conflicts:
#	src/components/SearchBox/SearchBox.tsx
tags/Baseline_30082024_FRONTEND_UAT
leoho2fi 1年前
父节点
当前提交
833087782c
共有 94 个文件被更改,包括 2215 次插入1337 次删除
  1. +16
    -12
      src/app/(main)/analytics/ProjectCompletionReport/page.tsx
  2. +30
    -0
      src/app/(main)/analytics/ProjectPandLReport/page.tsx
  3. +22
    -18
      src/app/(main)/analytics/ResourceOverconsumptionReport/page.tsx
  4. +1
    -1
      src/app/(main)/analytics/StaffMonthlyWorkHoursAnalysisReport/page.tsx
  5. +17
    -0
      src/app/(main)/projects/createSub/not-found.tsx
  6. +1
    -1
      src/app/(main)/projects/createSub/page.tsx
  7. +1
    -1
      src/app/(main)/projects/editSub/not-found.tsx
  8. +76
    -0
      src/app/(main)/projects/editSub/page.tsx
  9. +1
    -1
      src/app/(main)/projects/page.tsx
  10. +27
    -34
      src/app/(main)/settings/changepassword/page.tsx
  11. +1
    -1
      src/app/(main)/settings/group/create/page.tsx
  12. +2
    -2
      src/app/(main)/settings/group/page.tsx
  13. +3
    -7
      src/app/(main)/settings/staff/user/page.tsx
  14. +2
    -2
      src/app/(main)/settings/user/edit/page.tsx
  15. +4
    -17
      src/app/api/cashflow/index.ts
  16. +0
    -62
      src/app/api/clientprojects/index.ts
  17. +66
    -0
      src/app/api/financialsummary/actions.ts
  18. +27
    -0
      src/app/api/financialsummary/index.ts
  19. +2
    -2
      src/app/api/projects/actions.ts
  20. +1
    -0
      src/app/api/projects/index.ts
  21. +0
    -42
      src/app/api/report3/index.ts
  22. +40
    -1
      src/app/api/reports/actions.ts
  23. +40
    -0
      src/app/api/reports/index.ts
  24. +29
    -0
      src/app/api/teamprojects/actions.ts
  25. +7
    -28
      src/app/api/teamprojects/index.ts
  26. +66
    -2
      src/app/api/timesheets/utils.ts
  27. +3
    -2
      src/components/Breadcrumb/Breadcrumb.tsx
  28. +2
    -9
      src/components/ChangePassword/ChangePasswordForm.tsx
  29. +0
    -4
      src/components/ChangePassword/ChangePasswordWrapper.tsx
  30. +20
    -9
      src/components/ControlledAutoComplete/ControlledAutoComplete.tsx
  31. +19
    -20
      src/components/CreateGroup/AuthorityAllocation.tsx
  32. +1
    -1
      src/components/CreateGroup/CreateGroup.tsx
  33. +3
    -3
      src/components/CreateGroup/GroupInfo.tsx
  34. +13
    -16
      src/components/CreateGroup/UserAllocation.tsx
  35. +1
    -0
      src/components/CreateProject/CreateProject.tsx
  36. +34
    -16
      src/components/CreateProject/Milestone.tsx
  37. +16
    -8
      src/components/CreateProject/ProjectClientDetails.tsx
  38. +21
    -16
      src/components/CreateProject/StaffAllocation.tsx
  39. +3
    -3
      src/components/CreateProject/TaskSetup.tsx
  40. +3
    -6
      src/components/CreateTeam/StaffAllocation.tsx
  41. +6
    -0
      src/components/CustomDatagrid/CustomDatagrid.tsx
  42. +33
    -5
      src/components/DateHoursTable/DateHoursList.tsx
  43. +30
    -8
      src/components/DateHoursTable/DateHoursTable.tsx
  44. +10
    -14
      src/components/EditTeam/Allocation.tsx
  45. +100
    -96
      src/components/EditUser/AuthAllocation.tsx
  46. +67
    -48
      src/components/EditUser/EditUser.tsx
  47. +9
    -6
      src/components/EditUser/EditUserWrapper.tsx
  48. +39
    -2
      src/components/EditUser/UserDetail.tsx
  49. +12
    -16
      src/components/EditUserGroup/AuthorityAllocation.tsx
  50. +3
    -3
      src/components/EditUserGroup/GroupInfo.tsx
  51. +12
    -15
      src/components/EditUserGroup/UserAllocation.tsx
  52. +28
    -0
      src/components/ErrorAlert/ErrorAlert.tsx
  53. +1
    -0
      src/components/ErrorAlert/index.ts
  54. +44
    -39
      src/components/GenerateMonthlyWorkHoursReport/GenerateMonthlyWorkHoursReport.tsx
  55. +63
    -0
      src/components/GenerateProjectPandLReport/GenerateProjectPandLReport.tsx
  56. +38
    -0
      src/components/GenerateProjectPandLReport/GenerateProjectPandLReportLoading.tsx
  57. +18
    -0
      src/components/GenerateProjectPandLReport/GenerateProjectPandLReportWrapper.tsx
  58. +1
    -0
      src/components/GenerateProjectPandLReport/index.ts
  59. +7
    -1
      src/components/LeaveTable/LeaveEditModal.tsx
  60. +6
    -6
      src/components/NavigationContent/NavigationContent.tsx
  61. +0
    -124
      src/components/ProgressByClient/ProgressByClient.tsx
  62. +96
    -12
      src/components/ProgressByTeam/ProgressByTeam.tsx
  63. +31
    -5
      src/components/ProgressByTeamSearch/ProgressByTeamSearch.tsx
  64. +41
    -6
      src/components/ProjectCashFlow/ProjectCashFlow.tsx
  65. +95
    -0
      src/components/ProjectCompletionReport/ProjectCompletionReport.tsx
  66. +2
    -2
      src/components/ProjectCompletionReport/ProjectCompletionReportLoading.tsx
  67. +18
    -0
      src/components/ProjectCompletionReport/ProjectCompletionReportWrapper.tsx
  68. +1
    -0
      src/components/ProjectCompletionReport/index.ts
  69. +13
    -15
      src/components/ProjectFinancialSummary/ProjectFinancialCard.tsx
  70. +73
    -121
      src/components/ProjectFinancialSummary/ProjectFinancialSummary.tsx
  71. +3
    -2
      src/components/ProjectSearch/ProjectSearch.tsx
  72. +0
    -313
      src/components/Report/ReportSearchBox3/SearchBox3.tsx
  73. +0
    -3
      src/components/Report/ReportSearchBox3/index.ts
  74. +0
    -17
      src/components/Report/ResourceOverconsumptionReport/ResourceOverconsumptionReport.tsx
  75. +0
    -2
      src/components/Report/ResourceOverconsumptionReport/index.ts
  76. +0
    -45
      src/components/Report/ResourceOverconsumptionReportGen/ResourceOverconsumptionReportGen.tsx
  77. +0
    -19
      src/components/Report/ResourceOverconsumptionReportGen/ResourceOverconsumptionReportGenWrapper.tsx
  78. +0
    -2
      src/components/Report/ResourceOverconsumptionReportGen/index.ts
  79. +96
    -0
      src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReport.tsx
  80. +41
    -0
      src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReportLoading.tsx
  81. +20
    -0
      src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReportWrapper.tsx
  82. +1
    -0
      src/components/ResourceOverconsumptionReport/index.ts
  83. +84
    -27
      src/components/SearchBox/SearchBox.tsx
  84. +32
    -1
      src/components/TimesheetModal/TimesheetModal.tsx
  85. +4
    -2
      src/components/TimesheetTable/EntryInputTable.tsx
  86. +5
    -3
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  87. +3
    -0
      src/components/TimesheetTable/MobileTimesheetTable.tsx
  88. +13
    -9
      src/components/UserGroupSearch/UserGroupSearch.tsx
  89. +192
    -0
      src/components/utils/numberInput.tsx
  90. +7
    -0
      src/i18n/zh/changePassword.json
  91. +27
    -0
      src/i18n/zh/group.json
  92. +9
    -1
      src/i18n/zh/report.json
  93. +7
    -0
      src/i18n/zh/user.json
  94. +153
    -0
      src/theme/colorConst.js

+ 16
- 12
src/app/(main)/analytics/ProjectCompletionReport/page.tsx 查看文件

@@ -1,24 +1,28 @@
//src\app\(main)\analytics\ProjectCompletionReport\page.tsx
import { Metadata } from "next";
import { I18nProvider } from "@/i18n";
import { I18nProvider, getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import ProjectCompletionReportComponent from "@/components/Report/ProjectCompletionReport";
import { Suspense } from "react";
import ProjectCompletionReport from "@/components/ProjectCompletionReport";

export const metadata: Metadata = {
title: "Project Completion Report",
};

const ProjectCompletionReport: React.FC = () => {
const ProjectCompletionReportPage: React.FC = async () => {
const { t } = await getServerI18n("report");

return (
<I18nProvider namespaces={["analytics"]}>
<Typography variant="h4" marginInlineEnd={2}>
Project Completion Report
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Project Completion Report")}
</Typography>
{/* <Suspense fallback={<ProgressCashFlowSearch.Loading />}>
<ProgressCashFlowSearch/>
</Suspense> */}
<ProjectCompletionReportComponent />
</I18nProvider>
<I18nProvider namespaces={["report", "common"]}>
<Suspense fallback={<ProjectCompletionReport.Loading />}>
<ProjectCompletionReport />
</Suspense>
</I18nProvider>
</>
);
};
export default ProjectCompletionReport;
export default ProjectCompletionReportPage;

+ 30
- 0
src/app/(main)/analytics/ProjectPandLReport/page.tsx 查看文件

@@ -0,0 +1,30 @@
import { Metadata } from "next";
import { Suspense } from "react";
import { I18nProvider, getServerI18n } from "@/i18n";
import { fetchProjects } from "@/app/api/projects";
import GenerateProjectPandLReport from "@/components/GenerateProjectPandLReport";
import { Typography } from "@mui/material";

export const metadata: Metadata = {
title: "Project Cash Flow Report",
};

const ProjectPandLReport: React.FC = async () => {
const { t } = await getServerI18n("reports");
fetchProjects();

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Project P&L Report")}
</Typography>
<I18nProvider namespaces={["report", "common"]}>
<Suspense fallback={<GenerateProjectPandLReport.Loading />}>
<GenerateProjectPandLReport />
</Suspense>
</I18nProvider>
</>
);
};

export default ProjectPandLReport;

+ 22
- 18
src/app/(main)/analytics/ResourceOverconsumptionReport/page.tsx 查看文件

@@ -1,24 +1,28 @@
//src\app\(main)\analytics\ResourceOvercomsumptionReport\page.tsx
import { Metadata } from "next";
import { I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import ResourceOverconsumptionReportComponent from "@/components/Report/ResourceOverconsumptionReport";
import { Suspense } from "react";
import { I18nProvider, getServerI18n } from "@/i18n";
import ResourceOverconsumptionReport from "@/components/ResourceOverconsumptionReport";
import { Typography } from "@mui/material";

export const metadata: Metadata = {
title: "Resource Overconsumption Report",
title: "Staff Monthly Work Hours Analysis Report",
};

const ResourceOverconsumptionReport: React.FC = () => {
return (
<I18nProvider namespaces={["analytics"]}>
<Typography variant="h4" marginInlineEnd={2}>
Resource Overconsumption Report
</Typography>
{/* <Suspense fallback={<ProgressCashFlowSearch.Loading />}>
<ProgressCashFlowSearch/>
</Suspense> */}
<ResourceOverconsumptionReportComponent />
</I18nProvider>
);
const StaffMonthlyWorkHoursAnalysisReport: React.FC = async () => {
const { t } = await getServerI18n("report");

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Project Resource Overconsumption Report")}
</Typography>
<I18nProvider namespaces={["report", "common"]}>
<Suspense fallback={<ResourceOverconsumptionReport.Loading />}>
<ResourceOverconsumptionReport />
</Suspense>
</I18nProvider>
</>
);
};
export default ResourceOverconsumptionReport;

export default StaffMonthlyWorkHoursAnalysisReport;

+ 1
- 1
src/app/(main)/analytics/StaffMonthlyWorkHoursAnalysisReport/page.tsx 查看文件

@@ -9,7 +9,7 @@ export const metadata: Metadata = {
};

const StaffMonthlyWorkHoursAnalysisReport: React.FC = async () => {
const { t } = await getServerI18n("User Group");
const { t } = await getServerI18n("report");

return (
<>


+ 17
- 0
src/app/(main)/projects/createSub/not-found.tsx 查看文件

@@ -0,0 +1,17 @@
import { getServerI18n } from "@/i18n";
import { Stack, Typography, Link } from "@mui/material";
import NextLink from "next/link";

export default async function NotFound() {
const { t } = await getServerI18n("projects", "common");

return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">{t("There was no any main projects!")}</Typography>
<Link href="/projects" component={NextLink} variant="body2">
{t("Return to all projects")}
</Link>
</Stack>
);
}

src/app/(main)/projects/create/sub/page.tsx → src/app/(main)/projects/createSub/page.tsx 查看文件

@@ -21,7 +21,7 @@ import { Metadata } from "next";
import { notFound } from "next/navigation";

export const metadata: Metadata = {
title: "Create Project",
title: "Create Sub Project",
};

const Projects: React.FC = async () => {

src/app/(main)/projects/create/sub/not-found.tsx → src/app/(main)/projects/editSub/not-found.tsx 查看文件

@@ -8,7 +8,7 @@ export default async function NotFound() {
return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">{t("The sub project was not found or there was no any main projects!")}</Typography>
<Typography variant="body1">{t("The sub project was not found!")}</Typography>
<Link href="/projects" component={NextLink} variant="body2">
{t("Return to all projects")}
</Link>

+ 76
- 0
src/app/(main)/projects/editSub/page.tsx 查看文件

@@ -0,0 +1,76 @@
import { fetchAllCustomers, fetchAllSubsidiaries } from "@/app/api/customer";
import { fetchGrades } from "@/app/api/grades";
import {
fetchMainProjects,
fetchProjectBuildingTypes,
fetchProjectCategories,
fetchProjectContractTypes,
fetchProjectDetails,
fetchProjectFundingTypes,
fetchProjectLocationTypes,
fetchProjectServiceTypes,
fetchProjectWorkNatures,
} from "@/app/api/projects";
import { preloadStaff, preloadTeamLeads } from "@/app/api/staff";
import { fetchAllTasks, fetchTaskTemplates } from "@/app/api/tasks";
import CreateProject from "@/components/CreateProject";
import { I18nProvider, getServerI18n } from "@/i18n";
import Typography from "@mui/material/Typography";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";

interface Props {
searchParams: { [key: string]: string | string[] | undefined };
}

export const metadata: Metadata = {
title: "Edit Sub Project",
};

const Projects: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("projects");
const projectId = searchParams["id"];

if (!projectId || isArray(projectId)) {
notFound();
}

// Preload necessary dependencies
fetchAllTasks();
fetchTaskTemplates();
fetchProjectCategories();
fetchProjectContractTypes();
fetchProjectFundingTypes();
fetchProjectLocationTypes();
fetchProjectServiceTypes();
fetchProjectBuildingTypes();
fetchProjectWorkNatures();
fetchAllCustomers();
fetchAllSubsidiaries();
fetchGrades();
preloadTeamLeads();
preloadStaff();

try {
await fetchProjectDetails(projectId);
const data = await fetchMainProjects();

if (!Boolean(data) || data.length === 0) {
notFound();
}
} catch (e) {
notFound();
}

return (
<>
<Typography variant="h4">{t("Edit Sub Project")}</Typography>
<I18nProvider namespaces={["projects"]}>
<CreateProject isEditMode isSubProject projectId={projectId}/>
</I18nProvider>
</>
);
};

export default Projects;

+ 1
- 1
src/app/(main)/projects/page.tsx 查看文件

@@ -43,7 +43,7 @@ const Projects: React.FC = async () => {
color="secondary"
startIcon={<Add />}
LinkComponent={Link}
href="/projects/create/sub"
href="/projects/createSub"
>
{t("Create Sub Project")}
</Button>}


+ 27
- 34
src/app/(main)/settings/changepassword/page.tsx 查看文件

@@ -14,40 +14,33 @@ import { Metadata } from "next";
import Link from "next/link";
import { Suspense } from "react";


export const metadata: Metadata = {
title: "Change Password",
};
title: "Change Password",
};

const ChangePasswordPage: React.FC = async () => {
const { t } = await getServerI18n("changePassword");
// preloadTeamLeads();
// preloadStaff();
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Change Password")}
</Typography>
</Stack>
<I18nProvider namespaces={["common", "changePassword"]}>
<Suspense fallback={<ChangePassword.Loading />}>
<ChangePassword />
</Suspense>
</I18nProvider>
</>
);
};

const ChangePasswordPage: React.FC = async () => {
const { t } = await getServerI18n("User Group");
// preloadTeamLeads();
// preloadStaff();
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Change Password")}
</Typography>
</Stack>
{/* <I18nProvider namespaces={["User Group", "common"]}>
<Suspense fallback={<UserGroupSearch.Loading />}>
<UserGroupSearch />
</Suspense>
</I18nProvider> */}
<I18nProvider namespaces={["User Group", "common"]}>
<Suspense fallback={<ChangePassword.Loading />}>
<ChangePassword />
</Suspense>
</I18nProvider>
</>
);
};
export default ChangePasswordPage;
export default ChangePasswordPage;

+ 1
- 1
src/app/(main)/settings/group/create/page.tsx 查看文件

@@ -11,7 +11,7 @@ const CreateStaff: React.FC = async () => {

return (
<>
<Typography variant="h4">{t("Create Group")}</Typography>
<Typography variant="h4">{t("Create User Group")}</Typography>
<I18nProvider namespaces={["group"]}>
<CreateGroup />
</I18nProvider>


+ 2
- 2
src/app/(main)/settings/group/page.tsx 查看文件

@@ -20,7 +20,7 @@ export const metadata: Metadata = {


const UserGroup: React.FC = async () => {
const { t } = await getServerI18n("User Group");
const { t } = await getServerI18n("group");
// preloadTeamLeads();
// preloadStaff();
return (
@@ -43,7 +43,7 @@ export const metadata: Metadata = {
{t("Create User Group")}
</Button>
</Stack>
<I18nProvider namespaces={["User Group", "common"]}>
<I18nProvider namespaces={["group", "common"]}>
<Suspense fallback={<UserGroupSearch.Loading />}>
<UserGroupSearch />
</Suspense>


+ 3
- 7
src/app/(main)/settings/staff/user/page.tsx 查看文件

@@ -7,19 +7,15 @@ import { Suspense } from "react";
import { preloadUser } from "@/app/api/user";
import { searchParamsProps } from "@/app/utils/fetchUtil";

const User: React.FC<searchParamsProps> = async ({
searchParams
}) => {
const User: React.FC<searchParamsProps> = async ({ searchParams }) => {
const { t } = await getServerI18n("user");
preloadUser()
preloadUser();
return (
<>
<Typography variant="h4">{t("Edit User")}</Typography>
<I18nProvider namespaces={["user", "common"]}>
<Suspense fallback={<EditUser.Loading />}>
<EditUser
// id={parseInt(searchParams.id as string)}
/>
<EditUser searchParams={searchParams} />
</Suspense>
</I18nProvider>
</>


+ 2
- 2
src/app/(main)/settings/user/edit/page.tsx 查看文件

@@ -20,10 +20,10 @@ const EditUserPage: React.FC<searchParamsProps> = async ({

return (
<>
<I18nProvider namespaces={["team", "common"]}>
<I18nProvider namespaces={["user", "common"]}>
<Suspense fallback={<EditUser.Loading />}>
<EditUser
// id={id}
searchParams={searchParams}
/>
</Suspense>
</I18nProvider>


+ 4
- 17
src/app/api/cashflow/index.ts 查看文件

@@ -1,4 +1,7 @@
"use server";
import { cache } from "react";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";

export interface CashFlow {
id: number;
@@ -19,21 +22,5 @@ export const preloadProjects = () => {
};

export const fetchProjectsCashFlow = cache(async () => {
return mockProjects;
return serverFetchJson<CashFlow[]>(`${BASE_API_URL}/dashboard/searchCashFlowProject`);
});

const mockProjects: CashFlow[] = [
{
id: 1,
projectCode: "CUST-001",
projectName: "Client A",
team: "N/A",
teamLeader: "N/A",
startDate: "5",
startDateFrom: "5",
startDateTo: "5",
targetEndDate: "s",
client: "ss",
subsidiary: "ss",
},
];

+ 0
- 62
src/app/api/clientprojects/index.ts 查看文件

@@ -15,72 +15,10 @@ export interface ClientProjectResult {
projectNo: number;
}

// export interface ClientSubsidiaryProjectResult {
// projectId: number;
// projectCode: string;
// projectName: string;
// team: string;
// teamLead: string;
// expectedStage: string;
// budgetedManhour: number;
// spentManhour: number;
// remainedManhour: number;
// manhourConsumptionPercentage: number;
// comingPaymentMilestone: string;
// }

export const preloadClientProjects = () => {
fetchAllClientProjects();
};

// export const fetchClientProjects = cache(async () => {
// return mockProjects;
// });

export const fetchAllClientProjects = cache(async () => {
return serverFetchJson<ClientProjectResult[]>(`${BASE_API_URL}/dashboard/searchCustomerSubsidiary`);
});

// export const fetchAllClientSubsidiaryProjects = cache(async (customerId: number, subsidiaryId: number) => {
// return serverFetchJson<ClientSubsidiaryProjectResult>(
// `${BASE_API_URL}/dashboard/searchCustomerSubsidiaryProject/?${customerId}&${subsidiaryId}`,
// {
// next: { tags: [`allClientSubsidiaryProjects`] },
// },
// );
// });

// const mockProjects: ClientProjectResult[] = [
// {
// id: 1,
// clientCode: "CUST-001",
// clientName: "Client A",
// SubsidiaryClientCode: "N/A",
// SubsidiaryClientName: "N/A",
// NoOfProjects: 5,
// },
// {
// id: 2,
// clientCode: "CUST-001",
// clientName: "Client A",
// SubsidiaryClientCode: "SUBS-001",
// SubsidiaryClientName: "Subsidiary A",
// NoOfProjects: 5,
// },
// {
// id: 3,
// clientCode: "CUST-001",
// clientName: "Client A",
// SubsidiaryClientCode: "SUBS-002",
// SubsidiaryClientName: "Subsidiary B",
// NoOfProjects: 3,
// },
// {
// id: 4,
// clientCode: "CUST-001",
// clientName: "Client A",
// SubsidiaryClientCode: "SUBS-003",
// SubsidiaryClientName: "Subsidiary C",
// NoOfProjects: 1,
// },
// ];

+ 66
- 0
src/app/api/financialsummary/actions.ts 查看文件

@@ -0,0 +1,66 @@
"use server";

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { Dayjs } from "dayjs";
import { cache } from "react";


export interface FinancialSummaryByClientResult {
teamId:number;
id:number;
customerCode: string;
customerName: string;
projectNo: number;
totalFee: number;
totalBudget: number;
cumulativeExpenditure: number;
totalInvoiced: number;
totalReceived: number;
cashFlowStatus: string;
cpi: number;
totalUninvoiced: number;
}

export interface FinancialSummaryByProjectResult {
teamId:number;
id:number;
projectCode: string;
projectName: string;
customerName: string;
projectNo: number;
totalFee: number;
totalBudget: number;
cumulativeExpenditure: number;
totalInvoiced: number;
totalReceived: number;
cashFlowStatus: string;
cpi: number;
totalUninvoiced: number;
}

export const searchFinancialSummaryByClient = cache(async (teamId?: number) => {
if (teamId === undefined) {
return serverFetchJson<FinancialSummaryByClientResult[]>(
`${BASE_API_URL}/dashboard/searchFinancialSummaryByClient`
);
} else {
return serverFetchJson<FinancialSummaryByClientResult[]>(
`${BASE_API_URL}/dashboard/searchFinancialSummaryByClient?teamId=${teamId}`
);
}
});

export const searchFinancialSummaryByProject = cache(async (teamId?: number, customerId?:number) => {
if (teamId === undefined) {
return serverFetchJson<FinancialSummaryByProjectResult[]>(
`${BASE_API_URL}/dashboard/searchFinancialSummaryByProject`
);
} else {
return serverFetchJson<FinancialSummaryByProjectResult[]>(
`${BASE_API_URL}/dashboard/searchFinancialSummaryByProject?teamId=${teamId}&customerId=${customerId}`
);
}
});

+ 27
- 0
src/app/api/financialsummary/index.ts 查看文件

@@ -0,0 +1,27 @@
"use server";
import { cache } from "react";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
// import "server-only";


export interface FinancialSummaryCardResult {
teamId: number;
teamName: string;
projectNo: number;
totalFee: number;
totalBudget: number;
cumulativeExpenditure: number;
totalInvoiced: number;
totalReceived: number;
cashFlowStatus: string;
cpi: number;
}

export const preloadFinancialSummaryCard = () => {
fetchFinancialSummaryCard();
};

export const fetchFinancialSummaryCard = cache(async () => {
return serverFetchJson<FinancialSummaryCardResult[]>(`${BASE_API_URL}/dashboard/searchFinancialSummaryCard`);
});

+ 2
- 2
src/app/api/projects/actions.ts 查看文件

@@ -36,8 +36,8 @@ export interface CreateProjectInputs {
// Client details
clientId: Customer["id"];
clientContactId?: number;
clientSubsidiaryId?: number;
subsidiaryContactId: number;
clientSubsidiaryId?: number | null;
subsidiaryContactId?: number;
isSubsidiaryContact?: boolean;

// Allocation


+ 1
- 0
src/app/api/projects/index.ts 查看文件

@@ -13,6 +13,7 @@ export interface ProjectResult {
team: string;
client: string;
status: string;
mainProject: string;
}

export interface MainProject {


+ 0
- 42
src/app/api/report3/index.ts 查看文件

@@ -1,42 +0,0 @@
//src\app\api\report\index.ts
import { cache } from "react";

export interface ResourceOverconsumption {
id: number;
projectCode: string;
projectName: string;
team: string;
teamLeader: string;
startDate: string;
startDateFrom: string;
startDateTo: string;
targetEndDate: string;
client: string;
subsidiary: string;
status: string;
}

export const preloadProjects = () => {
fetchProjectsResourceOverconsumption();
};

export const fetchProjectsResourceOverconsumption = cache(async () => {
return mockProjects;
});

const mockProjects: ResourceOverconsumption[] = [
{
id: 1,
projectCode: "CUST-001",
projectName: "Client A",
team: "N/A",
teamLeader: "N/A",
startDate: "1/2/2024",
startDateFrom: "1/2/2024",
startDateTo: "1/2/2024",
targetEndDate: "30/3/2024",
client: "ss",
subsidiary: "sus",
status: "1",
},
];

+ 40
- 1
src/app/api/reports/actions.ts 查看文件

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

import { serverFetchBlob, serverFetchJson } from "@/app/utils/fetchUtil";
import { MonthlyWorkHoursReportRequest, ProjectCashFlowReportRequest,LateStartReportRequest } from ".";
import { MonthlyWorkHoursReportRequest, ProjectCashFlowReportRequest, LateStartReportRequest, ProjectResourceOverconsumptionReportRequest, ProjectPandLReportRequest, ProjectCompletionReportRequest } from ".";
import { BASE_API_URL } from "@/config/api";

export interface FileResponse {
@@ -35,6 +35,32 @@ export const fetchMonthlyWorkHoursReport = async (data: MonthlyWorkHoursReportRe
return reportBlob
};

export const fetchProjectResourceOverconsumptionReport = async (data: ProjectResourceOverconsumptionReportRequest) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/reports/ProjectResourceOverconsumptionReport`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

return reportBlob
};

export const fetchProjectCompletionReport = async (data: ProjectCompletionReportRequest) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/reports/ProjectCompletionReportwithOutstandingAccountsReceivable`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

return reportBlob
};

// export const fetchLateStartReport = async (data: LateStartReportRequest) => {
// const response = await serverFetchBlob<FileResponse>(
// `${BASE_API_URL}/reports/downloadLateStartReport`,
@@ -57,3 +83,16 @@ export const fetchLateStartReport = async (data: LateStartReportRequest) => {
return response
};

export const fetchProjectPandLReport = async (data: ProjectPandLReportRequest) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/reports/projectpandlreport`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);

return reportBlob
};


+ 40
- 0
src/app/api/reports/index.ts 查看文件

@@ -4,6 +4,19 @@ export interface FinancialStatusReportFilter {
project: string[];
}

// - Project P&L Report
export interface ProjectPandLReportFilter {
project: string[];
startMonth: string;
startMonthTo: string;
}

export interface ProjectPandLReportRequest {
projectId: number;
startMonth: string;
endMonth: string;
}

// - Project Cash Flow Report
export interface ProjectCashFlowReportFilter {
project: string[];
@@ -15,6 +28,7 @@ export interface ProjectCashFlowReportRequest {
dateType: string;
}


// - Monthly Work Hours Report
export interface MonthlyWorkHoursReportFilter {
staff: string[];
@@ -25,6 +39,20 @@ export interface MonthlyWorkHoursReportRequest {
id: number;
yearMonth: string;
}
// - Project Resource Overconsumption Report
export interface ProjectResourceOverconsumptionReportFilter {
team: string[];
customer: string[];
status: string[];
lowerLimit: number;
}

export interface ProjectResourceOverconsumptionReportRequest {
teamId?: number
custId?: number
status: "All" | "Within Budget" | "Potential Overconsumption" | "Overconsumption"
lowerLimit: number
}

export interface LateStartReportFilter {
remainedDays: number;
@@ -38,3 +66,15 @@ export interface LateStartReportRequest {
remainedDate: string;
remainedDateTo: string;
}

export interface ProjectCompletionReportFilter {
startDate: String;
startDateTo: String;
outstanding: String;
}

export interface ProjectCompletionReportRequest {
startDate: String;
endDate: String;
outstanding: Boolean;
}

+ 29
- 0
src/app/api/teamprojects/actions.ts 查看文件

@@ -0,0 +1,29 @@
"use server";

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { Dayjs } from "dayjs";
import { cache } from "react";


export interface ClientSubsidiaryProjectResult {
color: string;
projectId: number;
projectCode: string;
projectName: string;
team: string;
teamLead: string;
expectedStage: string;
budgetedManhour: number;
spentManhour: number;
remainedManhour: number;
manhourConsumptionPercentage: number;
comingPaymentMilestone: string;
}

export const fetchAllTeamProjects = cache(async (teamLeadId: number) => {
return serverFetchJson<ClientSubsidiaryProjectResult[]>(
`${BASE_API_URL}/dashboard/searchTeamProject?teamLeadId=${teamLeadId}`
);

});

+ 7
- 28
src/app/api/teamprojects/index.ts 查看文件

@@ -1,10 +1,15 @@
import { cache } from "react";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import "server-only";

export interface TeamProjectResult {
id: number;
teamId: number;
teamLeadId: number;
teamCode: string;
teamName: string;
NoOfProjects: number;
projectNo: number;
}

export const preloadProjects = () => {
@@ -12,32 +17,6 @@ export const preloadProjects = () => {
};

export const fetchTeamProjects = cache(async () => {
return mockProjects;
return serverFetchJson<TeamProjectResult[]>(`${BASE_API_URL}/dashboard/searchTeamProjectNo`);
});

const mockProjects: TeamProjectResult[] = [
{
id: 1,
teamCode: "TEAM-001",
teamName: "Team A",
NoOfProjects: 5,
},
{
id: 2,
teamCode: "TEAM-002",
teamName: "Team B",
NoOfProjects: 5,
},
{
id: 3,
teamCode: "TEAM-003",
teamName: "Team C",
NoOfProjects: 3,
},
{
id: 4,
teamCode: "TEAM-004",
teamName: "Team D",
NoOfProjects: 1,
},
];

+ 66
- 2
src/app/api/timesheets/utils.ts 查看文件

@@ -1,4 +1,13 @@
import { LeaveEntry, TimeEntry } from "./actions";
import { getPublicHolidaysForNYears } from "@/app/utils/holidayUtils";
import { HolidaysResult } from "../holidays";
import {
LeaveEntry,
RecordLeaveInput,
RecordTimesheetInput,
TimeEntry,
} from "./actions";
import { convertDateArrayToString } from "@/app/utils/formatUtil";
import compact from "lodash/compact";

export type TimeEntryError = {
[field in keyof TimeEntry]?: string;
@@ -6,7 +15,7 @@ export type TimeEntryError = {

/**
* @param entry - the time entry
* @returns the field where there is an error, or an empty string if there is none
* @returns an object where the keys are the error fields and the values the error message, and undefined if there are no errors
*/
export const validateTimeEntry = (
entry: Partial<TimeEntry>,
@@ -58,6 +67,61 @@ export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => {
return error;
};

export const validateTimesheet = (
timesheet: RecordTimesheetInput,
leaveRecords: RecordLeaveInput,
companyHolidays: HolidaysResult[],
): { [date: string]: string } | undefined => {
const errors: { [date: string]: string } = {};

const holidays = new Set(
compact([
...getPublicHolidaysForNYears(2).map((h) => h.date),
...companyHolidays.map((h) => convertDateArrayToString(h.date)),
]),
);

Object.keys(timesheet).forEach((date) => {
const timeEntries = timesheet[date];

// Check each entry
for (const entry of timeEntries) {
const entryErrors = validateTimeEntry(entry, holidays.has(date));

if (entryErrors) {
errors[date] = "There are errors in the entries";
return;
}
}

// Check total hours
const leaves = leaveRecords[date];
const leaveHours =
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0;

const totalNormalHours = timeEntries.reduce((acc, entry) => {
return acc + (entry.inputHours || 0);
}, 0);

const totalOtHours = timeEntries.reduce((acc, entry) => {
return acc + (entry.otHours || 0);
}, 0);

if (totalNormalHours > DAILY_NORMAL_MAX_HOURS) {
errors[date] =
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.";
} else if (
totalNormalHours + totalOtHours + leaveHours >
TIMESHEET_DAILY_MAX_HOURS
) {
errors[date] =
"The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}";
}
});

return Object.keys(errors).length > 0 ? errors : undefined;
};

export const DAILY_NORMAL_MAX_HOURS = 8;
export const LEAVE_DAILY_MAX_HOURS = 8;
export const TIMESHEET_DAILY_MAX_HOURS = 20;

+ 3
- 2
src/components/Breadcrumb/Breadcrumb.tsx 查看文件

@@ -13,11 +13,12 @@ import { I18nProvider } from "@/i18n";
const pathToLabelMap: { [path: string]: string } = {
"": "Overview",
"/home": "User Workspace",
"/dashboard": "Dashboard",
"/projects": "Projects",
"/projects/create": "Create Project",
"/projects/create/sub": "Sub Project",
"/projects/createSub": "Sub Project",
"/projects/edit": "Edit Project",
"/projects/edit/sub": "Sub Project",
"/projects/editSub": "Sub Project",
"/tasks": "Task Template",
"/tasks/create": "Create Task Template",
"/staffReimbursement": "Staff Reimbursement",


+ 2
- 9
src/components/ChangePassword/ChangePasswordForm.tsx 查看文件

@@ -14,7 +14,7 @@ import { Visibility, VisibilityOff } from "@mui/icons-material";
import { IconButton, InputAdornment } from "@mui/material";

const ChagnePasswordForm: React.FC = () => {
const { t } = useTranslation();
const { t } = useTranslation("changePassword");
const [showNewPassword, setShowNewPassword] = useState(false);
const handleClickShowNewPassword = () => setShowNewPassword(!showNewPassword);
@@ -33,19 +33,12 @@ const ChagnePasswordForm: React.FC = () => {
setValue,
} = useFormContext<PasswordInputs>();

// const resetGroup = useCallback(() => {
// console.log(defaultValues);
// if (defaultValues !== undefined) {
// resetField("description");
// }
// }, [defaultValues]);

return (
<Card sx={{ display: "block" }}>
<CardContent component={Stack} spacing={4}>
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Group Info")}
{t("Please Fill in All Fields")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6}>


+ 0
- 4
src/components/ChangePassword/ChangePasswordWrapper.tsx 查看文件

@@ -7,10 +7,6 @@ interface SubComponents {
}

const ChangePasswordWrapper: React.FC & SubComponents = async () => {
// const records = await fetchAuth()
// const users = await fetchUser()
// console.log(users)
// const auth = records.records as auth[]

return <ChangePassword />;
};


+ 20
- 9
src/components/ControlledAutoComplete/ControlledAutoComplete.tsx 查看文件

@@ -1,6 +1,6 @@
"use client"

import { Autocomplete, MenuItem, TextField, Checkbox } from "@mui/material";
import { Autocomplete, MenuItem, TextField, Checkbox, Chip } from "@mui/material";
import { Controller, FieldValues, Path, Control, RegisterOptions } from "react-hook-form";
import { useTranslation } from "react-i18next";
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
@@ -18,6 +18,7 @@ interface Props<T extends { id?: number | string | null; label?: string; name?:
noOptionsText?: string,
isMultiple?: boolean,
rules?: RegisterOptions<FieldValues>
disabled?: boolean,
}

function ControlledAutoComplete<
@@ -27,11 +28,10 @@ function ControlledAutoComplete<
props: Props<T, TField>
) {
const { t } = useTranslation()
const { control, options, name, label, noOptionsText, isMultiple, rules } = props;
const { control, options, name, label, noOptionsText, isMultiple, rules, disabled } = props;

// set default value if value is null
if (!Boolean(isMultiple) && !Boolean(control._formValues[name])) {
console.log(name, control._formValues[name])
control._formValues[name] = options[0]?.id ?? undefined
} else if (Boolean(isMultiple) && !Boolean(control._formValues[name])) {
control._formValues[name] = []
@@ -42,7 +42,6 @@ function ControlledAutoComplete<
name={name}
control={control}
rules={rules}

render={({ field, fieldState, formState }) => {

return (
@@ -51,7 +50,8 @@ function ControlledAutoComplete<
multiple
disableClearable
disableCloseOnSelect
disablePortal
// disablePortal
disabled={disabled}
noOptionsText={noOptionsText ?? t("No Options")}
value={options.filter(option => {
return field.value?.includes(option.id)
@@ -61,7 +61,7 @@ function ControlledAutoComplete<
isOptionEqualToValue={(option, value) => option.id === value.id}
renderOption={(params, option, { selected }) => {
return (
<li {...params} key={option.id}>
<li {...params} key={option?.id}>
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
@@ -72,6 +72,11 @@ function ControlledAutoComplete<
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
return tagValue.map((option, index) => (
<Chip {...getTagProps({ index })} key={option?.id} label={option.label ?? option.name} />
))
}}
onChange={(event, value) => {
field.onChange(value?.map(v => v.id))
}}
@@ -80,7 +85,8 @@ function ControlledAutoComplete<
:
<Autocomplete
disableClearable
disablePortal
// disablePortal
disabled={disabled}
noOptionsText={noOptionsText ?? t("No Options")}
value={options.find(option => option.id === field.value) ?? options[0]}
options={options}
@@ -88,13 +94,18 @@ function ControlledAutoComplete<
isOptionEqualToValue={(option, value) => option?.id === value?.id}
renderOption={(params, option) => {
return (
<MenuItem {...params} key={option.id} value={option.id}>
<MenuItem {...params} key={option?.id} value={option.id}>
{option.label ?? option.name}
</MenuItem>
);
}}
renderTags={(tagValue, getTagProps) => {
return tagValue.map((option, index) => (
<Chip {...getTagProps({ index })} key={option?.id} label={option.label ?? option.name} />
))
}}
onChange={(event, value) => {
field.onChange(value?.id)
field.onChange(value?.id ?? null)
}}
renderInput={(params) => <TextField {...params} error={Boolean(formState.errors[name])} variant="outlined" label={label} />}
/>)


+ 19
- 20
src/components/CreateGroup/AuthorityAllocation.tsx 查看文件

@@ -84,8 +84,8 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
onClick: addAuth,
buttonIcon: <Add />,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
{ label: t("Authority"), name: "authority" },
{ label: t("Description"), name: "name" },
// { label: t("Current Position"), name: "currentPosition" },
],
[addAuth, t]
@@ -97,10 +97,10 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
label: t("Remove"),
name: "id",
onClick: removeAuth,
buttonIcon: <Remove color="warning"/>,
buttonIcon: <Remove color="warning" />,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
{ label: t("Authority"), name: "authority" },
{ label: t("Description"), name: "name" },
],
[removeAuth, selectedAuths, t]
);
@@ -115,16 +115,13 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
setFilteredAuths(
initialAuths.filter(
(a) =>
a.authority.toLowerCase().includes(query.toLowerCase()) ||
a.name?.toLowerCase().includes(query.toLowerCase())
)
);
}, [auth, query]);

const resetAuth = React.useCallback(() => {
@@ -152,7 +149,7 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("Authority")}
{/* {t("Authority")} */}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
@@ -162,7 +159,7 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or position.")}
placeholder={t("Search by ") + t("Authority") + " / " + t("Description")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
@@ -178,18 +175,20 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Authority Pool")} />
<Tab
label={`${t("Allocated Authority")} (${selectedAuths.length})`}
label={`${t("Allocated Authority")} (${
selectedAuths.length
})`}
/>
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredAuths, selectedAuths, "id")}
columns={AuthPoolColumns}
/>
)}
{tabIndex === 1 && (
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedAuths}


+ 1
- 1
src/components/CreateGroup/CreateGroup.tsx 查看文件

@@ -22,7 +22,7 @@ const CreateGroup: React.FC<Props> = ({ auth, users }) => {
const [serverError, setServerError] = useState("");
const router = useRouter();
const [tabIndex, setTabIndex] = useState(0);
const { t } = useTranslation();
const { t } = useTranslation("group");

const errors = formProps.formState.errors;



+ 3
- 3
src/components/CreateGroup/GroupInfo.tsx 查看文件

@@ -51,13 +51,13 @@ const GroupInfo: React.FC = () => {
Boolean(errors.name) &&
(errors.name?.message
? t(errors.name.message)
: t("Please input correct name"))
: t("Please input correct ") + t("Group Name"))
}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("Group Description")}
label={t("Description")}
fullWidth
multiline
rows={4}
@@ -67,7 +67,7 @@ const GroupInfo: React.FC = () => {
Boolean(errors.description) &&
(errors.description?.message
? t(errors.description.message)
: t("Please input correct description"))
: t("Please input correct ") + t("Description"))
}
/>
</Grid>


+ 13
- 16
src/components/CreateGroup/UserAllocation.tsx 查看文件

@@ -85,8 +85,8 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
onClick: addUser,
buttonIcon: <Add />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
{ label: t("Username"), name: "username" },
{ label: t("Staff Name"), name: "name" },
],
[addUser, t]
);
@@ -99,8 +99,8 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
onClick: removeUser,
buttonIcon: <Remove color="warning" />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
{ label: t("Username"), name: "username" },
{ label: t("Staff Name"), name: "name" },
],
[removeUser, selectedUsers, t]
);
@@ -116,16 +116,13 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
const q = query.toLowerCase();
setFilteredUsers(
initialUsers.filter((u) => (
u.username.toLowerCase().includes(q) ||
u.name.toLowerCase().includes(q)
))
);
}, [users, query]);

const resetUser = React.useCallback(() => {
@@ -153,7 +150,7 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
>
<Stack gap={2}>
<Typography variant="overline" display="block">
{t("User")}
{/* {t("User")} */}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
<Grid item xs={6} display="flex" alignItems="center">
@@ -163,7 +160,7 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or position.")}
placeholder={t("Search by ") + t("Username") + " / " + t("Staff Name")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">


+ 1
- 0
src/components/CreateProject/CreateProject.tsx 查看文件

@@ -393,6 +393,7 @@ const CreateProject: React.FC<Props> = ({
projectCategories={projectCategories}
teamLeads={teamLeads}
isActive={tabIndex === 0}
isEditMode={isEditMode}
/>
}
{


+ 34
- 16
src/components/CreateProject/Milestone.tsx 查看文件

@@ -4,16 +4,18 @@ import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { SyntheticEvent, useCallback, useEffect, useMemo, useState } from "react";
import CardActions from "@mui/material/CardActions";
import RestartAlt from "@mui/icons-material/RestartAlt";
import {
Alert,
Autocomplete,
FormControl,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
TextField,
} from "@mui/material";
import { Task, TaskGroup } from "@/app/api/tasks";
import uniqBy from "lodash/uniqBy";
@@ -48,13 +50,23 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => {
const [currentTaskGroupId, setCurrentTaskGroupId] = useState(
taskGroups[0].id,
);
const onSelectTaskGroup = useCallback(

/*const onSelectTaskGroup = useCallback(
(event: SelectChangeEvent<TaskGroup["id"]>) => {
const id = event.target.value;
const newTaksGroupId = typeof id === "string" ? parseInt(id) : id;
setCurrentTaskGroupId(newTaksGroupId);
},
[],
);*/

const onSelectTaskGroup = useCallback(
(event: SyntheticEvent<Element, Event>, value: NonNullable<TaskGroup>) => {
const id = value.id;
const newTaksGroupId = typeof id === "string" ? parseInt(id) : id;
setCurrentTaskGroupId(newTaksGroupId);
},
[],
);

// handle error checking
@@ -81,7 +93,7 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => {
}
// console.log(Object.keys(milestones).reduce((acc, key) => acc + milestones[parseFloat(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0))
if (hasError) {
setError("milestones", {message: "milestones is not valid", type: "invalid"})
setError("milestones", { message: "milestones is not valid", type: "invalid" })
} else {
clearErrors("milestones")
}
@@ -92,26 +104,32 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => {
<Card sx={{ display: isActive ? "block" : "none" }}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<FormControl>
<InputLabel>{t("Task Stage")}</InputLabel>
<Select
label={t("Task Stage")}
<Autocomplete
disableClearable
// disablePortal
noOptionsText={t("No Task Stage")}
value={taskGroups.find(taskGroup => taskGroup.id === currentTaskGroupId)}
options={taskGroups}
getOptionLabel={(taskGroup) => taskGroup.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
renderOption={(params, option) => {
return (
<MenuItem {...params} key={option.id} value={option.id}>
{option.name}
</MenuItem>
);
}}
onChange={onSelectTaskGroup}
value={currentTaskGroupId}
>
{taskGroups.map((taskGroup) => (
<MenuItem key={taskGroup.id} value={taskGroup.id}>
{taskGroup.name}
</MenuItem>
))}
</Select>
renderInput={(params) => <TextField {...params} variant="outlined" label={t("Task Stage")} />}
/>
</FormControl>
{/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */}
{isActive && <MilestoneSection taskGroupId={currentTaskGroupId} />}
<CardActions sx={{ justifyContent: "flex-end" }}>
{/* <CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />}>
{t("Reset")}
</Button>
</CardActions>
</CardActions> */}
</CardContent>
</Card>
<Card sx={{ display: isActive ? "block" : "none" }}>


+ 16
- 8
src/components/CreateProject/ProjectClientDetails.tsx 查看文件

@@ -39,6 +39,7 @@ import ControlledAutoComplete from "../ControlledAutoComplete/ControlledAutoComp
interface Props {
isActive: boolean;
isSubProject: boolean;
isEditMode: boolean;
mainProjects?: MainProject[];
projectCategories: ProjectCategory[];
teamLeads: StaffResult[];
@@ -55,6 +56,7 @@ interface Props {
const ProjectClientDetails: React.FC<Props> = ({
isActive,
isSubProject,
isEditMode,
mainProjects,
projectCategories,
teamLeads,
@@ -110,6 +112,8 @@ const ProjectClientDetails: React.FC<Props> = ({
);

// get customer (client) contact combo
const [firstCustomerLoaded, setFirstCustomerLoaded] = useState(false)
const [isMainProjectInfoLoading, setIsMainProjectInfoLoading] = useState(false)
useEffect(() => {
if (selectedCustomerId !== undefined) {
fetchCustomer(selectedCustomerId).then(({ contacts, subsidiaryIds }) => {
@@ -117,8 +121,10 @@ const ProjectClientDetails: React.FC<Props> = ({
setCustomerSubsidiaryIds(subsidiaryIds);

// if (subsidiaryIds.length > 0) setValue("clientSubsidiaryId", subsidiaryIds[0])
// else
setValue("clientSubsidiaryId", undefined)
// else
if (isEditMode && !firstCustomerLoaded) { setFirstCustomerLoaded(true) }
else if (!isEditMode && isMainProjectInfoLoading) { setIsMainProjectInfoLoading(false) }
else setValue("clientSubsidiaryId", null)
// if (contacts.length > 0) setValue("clientContactId", contacts[0].id)
// else setValue("clientContactId", undefined)
});
@@ -130,11 +136,11 @@ const ProjectClientDetails: React.FC<Props> = ({
if (Boolean(clientSubsidiaryId)) {
// get subsidiary contact combo
const contacts = allSubsidiaries.find(subsidiary => subsidiary.id === clientSubsidiaryId)?.subsidiaryContacts!!
setSubsidiaryContacts(contacts)
setSubsidiaryContacts(() => contacts)
setValue("clientContactId", selectedCustomerId === defaultValues?.clientId && Boolean(defaultValues?.clientSubsidiaryId) ? contacts.find(contact => contact.id === defaultValues.clientContactId)?.id ?? contacts[0].id : contacts[0].id)
setValue("isSubsidiaryContact", true)
} else if (customerContacts?.length > 0) {
setSubsidiaryContacts([])
setSubsidiaryContacts(() => [])
setValue("clientContactId", selectedCustomerId === defaultValues?.clientId && !Boolean(defaultValues?.clientSubsidiaryId) ? customerContacts.find(contact => contact.id === defaultValues.clientContactId)?.id ?? customerContacts[0].id : customerContacts[0].id)
setValue("isSubsidiaryContact", false)
}
@@ -153,10 +159,11 @@ const ProjectClientDetails: React.FC<Props> = ({
// Automatically update the project & client details whene select a main project
const mainProjectId = watch("mainProjectId")
useEffect(() => {
if (mainProjectId !== undefined && mainProjects !== undefined) {
if (mainProjectId !== undefined && mainProjects !== undefined && !isEditMode) {
const mainProject = mainProjects.find(project => project.projectId === mainProjectId);

if (mainProject !== undefined) {
setIsMainProjectInfoLoading(() => true)
setValue("projectName", mainProject.projectName)
setValue("projectCategoryId", mainProject.projectCategoryId)
setValue("projectLeadId", mainProject.projectLeadId)
@@ -174,7 +181,7 @@ const ProjectClientDetails: React.FC<Props> = ({
setValue("clientContactId", mainProject.clientContactId)
}
}
}, [getValues, mainProjectId, setValue])
}, [getValues, mainProjectId, setValue, isEditMode])

// const buildingTypeIdNameMap = buildingTypes.reduce<{ [id: number]: string }>(
// (acc, building) => ({ ...acc, [building.id]: building.name }),
@@ -202,6 +209,7 @@ const ProjectClientDetails: React.FC<Props> = ({
name="mainProjectId"
label={t("Main Project")}
noOptionsText={t("No Main Project")}
disabled={isEditMode}
/>
</Grid>
<Grid item sx={{ display: { xs: "none", sm: "block" } }} /></>
@@ -438,11 +446,11 @@ const ProjectClientDetails: React.FC<Props> = ({
)}
</Grid>
</Box>
<CardActions sx={{ justifyContent: "flex-end" }}>
{/* <CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />}>
{t("Reset")}
</Button>
</CardActions>
</CardActions> */}
</CardContent>
</Card>
);


+ 21
- 16
src/components/CreateProject/StaffAllocation.tsx 查看文件

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

import { useTranslation } from "react-i18next";
import React, { useEffect, useMemo } from "react";
import React, { SyntheticEvent, useEffect, useMemo } from "react";
import RestartAlt from "@mui/icons-material/RestartAlt";
import SearchResults, { Column } from "../SearchResults";
import { Search, Clear, PersonAdd, PersonRemove } from "@mui/icons-material";
@@ -160,8 +160,8 @@ const StaffAllocation: React.FC<Props> = ({
}, [columnFilters]);
const [filters, setFilters] = React.useState(defaultFilterValues);
const makeFilterSelect = React.useCallback(
(filter: keyof StaffResult) => (event: SelectChangeEvent<string>) => {
setFilters((f) => ({ ...f, [filter]: event.target.value }));
(filter: keyof StaffResult) => (event: SyntheticEvent<Element, Event>, value: NonNullable<string>) => {
setFilters((f) => ({ ...f, [filter]: value }));
},
[],
);
@@ -239,20 +239,25 @@ const StaffAllocation: React.FC<Props> = ({
return (
<Grid key={`${filter.toString()}-${idx}`} item xs={3}>
<FormControl fullWidth>
<InputLabel size="small">{label}</InputLabel>
<Select
label={label}
<Autocomplete
disableClearable
// disablePortal
size="small"
noOptionsText={t(`No ${label}`)}
value={filters[filter]}
options={["All", ...(filterValues[filter] ?? [])]}
getOptionLabel={(filterValue) => filterValue}
isOptionEqualToValue={(option, value) => option === value}
renderOption={(params, option) => {
return (
<MenuItem {...params} key={option} value={option}>
{option}
</MenuItem>
);
}}
onChange={makeFilterSelect(filter)}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{filterValues[filter]?.map((option, index) => (
<MenuItem key={`${option}-${index}`} value={option}>
{option}
</MenuItem>
))}
</Select>
renderInput={(params) => <TextField {...params} variant="outlined" label={t(label)} />}
/>
</FormControl>
</Grid>
);
@@ -289,11 +294,11 @@ const StaffAllocation: React.FC<Props> = ({
)}
</Box>
</Stack>
<CardActions sx={{ justifyContent: "flex-end" }}>
{/* <CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />} onClick={reset}>
{t("Reset")}
</Button>
</CardActions>
</CardActions> */}
</CardContent>
</Card>
{/* MUI X-Grid will throw an error if it is rendered without any dimensions; so not rendering when not active */}


+ 3
- 3
src/components/CreateProject/TaskSetup.tsx 查看文件

@@ -135,7 +135,7 @@ const TaskSetup: React.FC<Props> = ({
<Grid item xs={6}>
<Autocomplete
disableClearable
disablePortal
// disablePortal
noOptionsText={t("No Task List Source")}
value={taskTemplates.find(taskTemplate => taskTemplate.id === selectedTaskTemplateId)}
options={[{id: "All", name: t("All tasks")}, ...taskTemplates.map(taskTemplate => ({id: taskTemplate.id, name: taskTemplate.name}))]}
@@ -207,11 +207,11 @@ const TaskSetup: React.FC<Props> = ({
allItemsLabel={t("Task Pool")}
selectedItemsLabel={t("Project Task List")}
/>
<CardActions sx={{ justifyContent: "flex-end" }}>
{/* <CardActions sx={{ justifyContent: "flex-end" }}>
<Button variant="text" startIcon={<RestartAlt />} onClick={onReset}>
{t("Reset")}
</Button>
</CardActions>
</CardActions> */}
</CardContent>
</Card>
);


+ 3
- 6
src/components/CreateTeam/StaffAllocation.tsx 查看文件

@@ -48,7 +48,6 @@ const StaffAllocation: React.FC<Props> = ({ allStaffs: staff }) => {
} = useFormContext<CreateTeamInputs>();

const initialStaffs = staff.map((s) => ({ ...s }));
// console.log(initialStaffs)
const [filteredStaff, setFilteredStaff] = useState(initialStaffs);
const [selectedStaff, setSelectedStaff] = useState<typeof filteredStaff>(
initialStaffs.filter((s) => getValues("addStaffIds")?.includes(s.id))
@@ -158,15 +157,13 @@ const StaffAllocation: React.FC<Props> = ({ allStaffs: staff }) => {
}, []);

React.useEffect(() => {
const q = query.toLowerCase();
setFilteredStaff(
initialStaffs.filter((i) => {
const q = query.toLowerCase();
return (
initialStaffs.filter((i) => (
i.staffId.toLowerCase().includes(q) ||
i.name.toLowerCase().includes(q) ||
i.currentPosition.toLowerCase().includes(q)
);
})
))
);
}, [staff, query]);



+ 6
- 0
src/components/CustomDatagrid/CustomDatagrid.tsx 查看文件

@@ -15,6 +15,7 @@ interface CustomDatagridProps {
dataGridHeight?: number | string;
[key: string]: any;
checkboxSelection?: boolean;
onRowClick?: any;
onRowSelectionModelChange?: (
newSelectionModel: GridRowSelectionModel,
) => void;
@@ -34,6 +35,7 @@ const CustomDatagrid: React.FC<CustomDatagridProps> = ({
checkboxSelection, // Destructure the new prop
onRowSelectionModelChange, // Destructure the new prop
selectionModel,
onRowClick,
columnGroupingModel,
pageSize,
...props
@@ -195,6 +197,7 @@ const CustomDatagrid: React.FC<CustomDatagridProps> = ({
rows={rowsWithDefaultValues}
columns={modifiedColumns}
editMode="row"
onRowClick={onRowClick}
checkboxSelection={checkboxSelection}
onRowSelectionModelChange={onRowSelectionModelChange}
experimentalFeatures={{ columnGrouping: true }}
@@ -226,6 +229,7 @@ const CustomDatagrid: React.FC<CustomDatagridProps> = ({
rows={rowsWithDefaultValues}
columns={modifiedColumns}
editMode="row"
onRowClick={onRowClick}
checkboxSelection={checkboxSelection}
onRowSelectionModelChange={onRowSelectionModelChange}
experimentalFeatures={{ columnGrouping: true }}
@@ -257,6 +261,7 @@ const CustomDatagrid: React.FC<CustomDatagridProps> = ({
rows={rowsWithDefaultValues}
columns={modifiedColumns}
editMode="row"
onRowClick={onRowClick}
checkboxSelection={checkboxSelection}
onRowSelectionModelChange={onRowSelectionModelChange}
experimentalFeatures={{ columnGrouping: true }}
@@ -289,6 +294,7 @@ const CustomDatagrid: React.FC<CustomDatagridProps> = ({
rows={rowsWithDefaultValues}
columns={modifiedColumns}
editMode="row"
onRowClick={onRowClick}
style={{ marginRight: 0 }}
checkboxSelection={checkboxSelection}
onRowSelectionModelChange={onRowSelectionModelChange}


+ 33
- 5
src/components/DateHoursTable/DateHoursList.tsx 查看文件

@@ -17,6 +17,7 @@ import dayjs from "dayjs";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
DAILY_NORMAL_MAX_HOURS,
LEAVE_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
} from "@/app/api/timesheets/utils";
@@ -32,6 +33,7 @@ interface Props<EntryComponentProps = object> {
EntryComponentProps & { date: string }
>;
entryComponentProps: EntryComponentProps;
errorComponent?: React.ReactNode;
}

function DateHoursList<EntryTableProps>({
@@ -41,6 +43,7 @@ function DateHoursList<EntryTableProps>({
EntryComponent,
entryComponentProps,
companyHolidays,
errorComponent,
}: Props<EntryTableProps>) {
const {
t,
@@ -83,15 +86,22 @@ function DateHoursList<EntryTableProps>({
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0;

const timesheet = timesheetEntries[day];
const timesheetHours =
const timesheetNormalHours =
timesheet?.reduce(
(acc, entry) =>
acc + (entry.inputHours || 0) + (entry.otHours || 0),
(acc, entry) => acc + (entry.inputHours || 0),
0,
) || 0;
const timesheetOtHours =
timesheet?.reduce(
(acc, entry) => acc + (entry.otHours || 0),
0,
) || 0;
const timesheetHours = timesheetNormalHours + timesheetOtHours;

const dailyTotal = leaveHours + timesheetHours;

const normalHoursExceeded =
timesheetNormalHours > DAILY_NORMAL_MAX_HOURS;
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS;
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS;

@@ -122,7 +132,9 @@ function DateHoursList<EntryTableProps>({
sx={{
display: "flex",
justifyContent: "space-between",
flexWrap: "wrap",
alignItems: "baseline",
color: normalHoursExceeded ? "error.main" : undefined,
}}
>
<Typography variant="body2">
@@ -131,6 +143,21 @@ function DateHoursList<EntryTableProps>({
<Typography>
{manhourFormatter.format(timesheetHours)}
</Typography>
{normalHoursExceeded && (
<Typography
component="div"
width="100%"
variant="caption"
paddingInlineEnd="40%"
>
{t(
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.",
{
DAILY_NORMAL_MAX_HOURS,
},
)}
</Typography>
)}
</Box>
<Box
sx={{
@@ -182,9 +209,9 @@ function DateHoursList<EntryTableProps>({
variant="caption"
>
{t(
"The daily total hours cannot be more than {{hours}}",
"The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}",
{
hours: TIMESHEET_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
},
)}
</Typography>
@@ -198,6 +225,7 @@ function DateHoursList<EntryTableProps>({
})}
</Box>
)}
{errorComponent}
<Box padding={2} display="flex" justifyContent="flex-end">
{isDateSelected ? (
<Button


+ 30
- 8
src/components/DateHoursTable/DateHoursTable.tsx 查看文件

@@ -21,6 +21,7 @@ import dayjs from "dayjs";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
DAILY_NORMAL_MAX_HOURS,
LEAVE_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
} from "@/app/api/timesheets/utils";
@@ -112,14 +113,15 @@ function DayRow<EntryTableProps>({
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0;

const timesheet = timesheetEntries[day];
const timesheetHours =
timesheet?.reduce(
(acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0),
0,
) || 0;
const timesheetNormalHours =
timesheet?.reduce((acc, entry) => acc + (entry.inputHours || 0), 0) || 0;
const timesheetOtHours =
timesheet?.reduce((acc, entry) => acc + (entry.otHours || 0), 0) || 0;
const timesheetHours = timesheetNormalHours + timesheetOtHours;

const dailyTotal = leaveHours + timesheetHours;

const normalHoursExceeded = timesheetNormalHours > DAILY_NORMAL_MAX_HOURS;
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS;
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS;

@@ -146,7 +148,27 @@ function DayRow<EntryTableProps>({
)}
</TableCell>
{/* Timesheet */}
<TableCell>{manhourFormatter.format(timesheetHours)}</TableCell>
<TableCell
sx={{
color: normalHoursExceeded ? "error.main" : undefined,
}}
>
<Box display="flex" gap={1} alignItems="center">
{manhourFormatter.format(timesheetHours)}
{normalHoursExceeded && (
<Tooltip
title={t(
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.",
{
DAILY_NORMAL_MAX_HOURS,
},
)}
>
<Info fontSize="small" />
</Tooltip>
)}
</Box>
</TableCell>
{/* Leave total */}
<TableCell
sx={{
@@ -177,9 +199,9 @@ function DayRow<EntryTableProps>({
{dailyTotalExceeded && (
<Tooltip
title={t(
"The daily total hours cannot be more than {{hours}}",
"The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}",
{
hours: TIMESHEET_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
},
)}
>


+ 10
- 14
src/components/EditTeam/Allocation.tsx 查看文件

@@ -60,24 +60,20 @@ const Allocation: React.FC<Props> = ({ allStaffs: staff, teamLead }) => {
return rearrangedStaff.filter((s) => getValues("addStaffIds")?.includes(s.id))
}
);
const [seletedTeamLead, setSeletedTeamLead] = useState<number>();
const [deletedStaffIds, setDeletedStaffIds] = useState<number[]>([]);

// Adding / Removing staff
const addStaff = useCallback((staff: StaffResult) => {
setSelectedStaff((s) => [...s, staff]);
// setDeletedStaffIds((s) => s.filter((s) => s === selectedStaff.id))
}, []);

const removeStaff = useCallback((staff: StaffResult) => {
setSelectedStaff((s) => s.filter((s) => s.id !== staff.id));
// setDeletedStaffIds((s) => s)
setDeletedStaffIds((prevIds) => [...prevIds, staff.id]);
}, []);

const setTeamLead = useCallback(
(staff: StaffResult) => {
setSeletedTeamLead(staff.id);
const rearrangedList = getValues("addStaffIds").reduce<number[]>(
(acc, num, index) => {
if (num === staff.id && index !== 0) {
@@ -171,16 +167,16 @@ const Allocation: React.FC<Props> = ({ allStaffs: staff, teamLead }) => {
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
setFilteredStaff(
initialStaffs.filter((i) => {
const q = query.toLowerCase();
return (
i.staffId.toLowerCase().includes(q) ||
i.name.toLowerCase().includes(q) ||
i.currentPosition.toLowerCase().includes(q)
);
})
);
}, [staff, query]);

useEffect(() => {


+ 100
- 96
src/components/EditUser/AuthAllocation.tsx 查看文件

@@ -1,93 +1,97 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Add, Clear, PersonAdd, PersonRemove, Remove, Search } from "@mui/icons-material";
import {
Add,
Clear,
PersonAdd,
PersonRemove,
Remove,
Search,
} from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import {
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
FieldErrors,
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
useFormContext,
} from "react-hook-form";
import {
Box,
Card,
CardContent,
Grid,
IconButton,
InputAdornment,
Stack,
Tab,
Tabs,
TabsProps,
TextField,
Typography,
} from "@mui/material";
import { differenceBy } from "lodash";
Box,
Card,
CardContent,
Grid,
IconButton,
InputAdornment,
Stack,
Tab,
Tabs,
TabsProps,
TextField,
Typography,
} from "@mui/material";
import { differenceBy } from "lodash";
import { UserInputs } from "@/app/api/user/actions";
import { auth } from "@/app/api/group/actions";
import SearchResults, { Column } from "../SearchResults";

export interface Props {
auths: auth[]
}
auths: auth[];
}

const AuthAllocation: React.FC<Props> = ({ auths }) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const id = parseInt(searchParams.get("id") || "0");
const {
setValue,
getValues,
formState: { defaultValues },
reset,
resetField,
} = useFormContext<UserInputs>();
const initialAuths = auths.map((u) => ({ ...u })).sort((a, b) => a.id - b.id);
const [filteredAuths, setFilteredAuths] = useState(initialAuths);
const [selectedAuths, setSelectedAuths] = useState<typeof filteredAuths>(
() => {
return filteredAuths.filter(
(s) => getValues("addAuthIds")?.includes(s.id)
);
}
const { t } = useTranslation();
const searchParams = useSearchParams();
const id = parseInt(searchParams.get("id") || "0");
const {
setValue,
getValues,
formState: { defaultValues },
reset,
resetField,
} = useFormContext<UserInputs>();
const initialAuths = auths.map((u) => ({ ...u })).sort((a, b) => a.id - b.id);
const [filteredAuths, setFilteredAuths] = useState(initialAuths);
const [selectedAuths, setSelectedAuths] = useState<typeof filteredAuths>(
() => {
return filteredAuths.filter(
(s) => getValues("addAuthIds")?.includes(s.id)
);
const [removeAuthIds, setRemoveAuthIds] = useState<number[]>([]);
}
);
const [removeAuthIds, setRemoveAuthIds] = useState<number[]>([]);

// Adding / Removing Auth
const addAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => [...a, auth]);
}, []);
const removeAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => a.filter((a) => a.id !== auth.id));
setRemoveAuthIds((prevIds) => [...prevIds, auth.id]);
}, []);
// Adding / Removing Auth
const addAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => [...a, auth]);
}, []);
const removeAuth = useCallback((auth: auth) => {
setSelectedAuths((a) => a.filter((a) => a.id !== auth.id));
setRemoveAuthIds((prevIds) => [...prevIds, auth.id]);
}, []);

const clearAuth = useCallback(() => {
if (defaultValues !== undefined) {
resetField("addAuthIds");
setSelectedAuths(
initialAuths.filter((auth) => defaultValues.addAuthIds?.includes(auth.id))
);
}
}, [defaultValues]);
const clearAuth = useCallback(() => {
if (defaultValues !== undefined) {
resetField("addAuthIds");
setSelectedAuths(
initialAuths.filter(
(auth) => defaultValues.addAuthIds?.includes(auth.id)
)
);
}
}, [defaultValues]);

// Sync with form
// Sync with form
useEffect(() => {
setValue(
"addAuthIds",
selectedAuths.map((a) => a.id)
);
setValue(
"removeAuthIds",
removeAuthIds
);
setValue("removeAuthIds", removeAuthIds);
}, [selectedAuths, removeAuthIds, setValue]);

const AuthPoolColumns = useMemo<Column<auth>[]>(
() => [
{
@@ -97,8 +101,7 @@ const AuthAllocation: React.FC<Props> = ({ auths }) => {
buttonIcon: <Add />,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
// { label: t("Current Position"), name: "currentPosition" },
{ label: t("description"), name: "name" },
],
[addAuth, t]
);
@@ -109,10 +112,10 @@ const AuthAllocation: React.FC<Props> = ({ auths }) => {
label: t("Remove"),
name: "id",
onClick: removeAuth,
buttonIcon: <Remove color="warning"/>,
buttonIcon: <Remove color="warning" />,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
{ label: t("description"), name: "name" },
],
[removeAuth, selectedAuths, t]
);
@@ -128,16 +131,14 @@ const AuthAllocation: React.FC<Props> = ({ auths }) => {
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
setFilteredAuths(
initialAuths.filter((a) =>
(
a.authority.toLowerCase().includes(query.toLowerCase()) ||
a.name?.toLowerCase().includes(query.toLowerCase())
)
)
);
}, [auths, query]);

const resetAuth = React.useCallback(() => {
@@ -147,16 +148,16 @@ const AuthAllocation: React.FC<Props> = ({ auths }) => {

const formProps = useForm({});

// Tab related
const [tabIndex, setTabIndex] = React.useState(0);
const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[]
);
// Tab related
const [tabIndex, setTabIndex] = React.useState(0);
const handleTabChange = React.useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[]
);

return (
return (
<>
<FormProvider {...formProps}>
<Card sx={{ display: "block" }}>
@@ -175,7 +176,9 @@ return (
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or position.")}
placeholder={t(
"Search by Authority or description or position."
)}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">
@@ -191,18 +194,20 @@ return (
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label={t("Authority Pool")} />
<Tab
label={`${t("Allocated Authority")} (${selectedAuths.length})`}
label={`${t("Allocated Authority")} (${
selectedAuths.length
})`}
/>
</Tabs>
<Box sx={{ marginInline: -3 }}>
{tabIndex === 0 && (
{tabIndex === 0 && (
<SearchResults
noWrapper
items={differenceBy(filteredAuths, selectedAuths, "id")}
columns={AuthPoolColumns}
/>
)}
{tabIndex === 1 && (
{tabIndex === 1 && (
<SearchResults
noWrapper
items={selectedAuths}
@@ -216,6 +221,5 @@ return (
</FormProvider>
</>
);

}
export default AuthAllocation
};
export default AuthAllocation;

+ 67
- 48
src/components/EditUser/EditUser.tsx 查看文件

@@ -1,6 +1,12 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import SearchResults, { Column } from "../SearchResults";
// import { TeamResult } from "@/app/api/team";
import { useTranslation } from "react-i18next";
@@ -26,28 +32,38 @@ import {
} from "react-hook-form";
import { Check, Close, Error, RestartAlt } from "@mui/icons-material";
import { StaffResult } from "@/app/api/staff";
import { UserInputs, adminChangePassword, editUser, fetchUserDetails } from "@/app/api/user/actions";
import {
UserInputs,
adminChangePassword,
editUser,
fetchUserDetails,
} from "@/app/api/user/actions";
import UserDetail from "./UserDetail";
import { UserResult, passwordRule } from "@/app/api/user";
import { auth, fetchAuth } from "@/app/api/group/actions";
import AuthAllocation from "./AuthAllocation";

interface Props {
rules: passwordRule
}
user: UserResult;
rules: passwordRule;
auths: auth[];
}

const EditUser: React.FC<Props> = async ({
rules
}) => {
const { t } = useTranslation();
const EditUser: React.FC<Props> = async ({ user, rules, auths }) => {
const { t } = useTranslation("user");
const formProps = useForm<UserInputs>();
const searchParams = useSearchParams();
const id = parseInt(searchParams.get("id") || "0");
const [tabIndex, setTabIndex] = useState(0);
const router = useRouter();
const [serverError, setServerError] = useState("");
const [data, setData] = useState<UserResult>();
const [auths, setAuths] = useState<auth[]>();
const addAuthIds =
auths && auths.length > 0
? auths
.filter((item) => item.v === 1)
.map((item) => item.id)
.sort((a, b) => a - b)
: [];

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
@@ -57,33 +73,27 @@ const EditUser: React.FC<Props> = async ({
);

const errors = formProps.formState.errors;
console.log("asdasd")
const fetchUserDetail = async () => {
try {
// fetch user info
const userDetail = await fetchUserDetails(id);
console.log(userDetail);
const _data = userDetail.data as UserResult;
console.log(_data);
setData(_data);
//fetch user auths
const authDetail = await fetchAuth("user", id);
setAuths(authDetail.records)
const addAuthIds = authDetail.records.filter((item) => item.v === 1).map((item) => item.id).sort((a, b) => a - b);

const resetForm = React.useCallback(() => {
console.log("triggerred");
console.log(addAuthIds);
try {
formProps.reset({
name: _data.username,
email: _data.email,
addAuthIds: addAuthIds || []
name: user.username,
email: user.email,
addAuthIds: addAuthIds,
removeAuthIds: [],
password: "",
});
console.log(formProps.formState.defaultValues);
} catch (error) {
console.log(error);
setServerError(t("An error has occurred. Please try again later."));
}
}
}, [auths, user]);

useEffect(() => {
fetchUserDetail();
resetForm();
}, []);

const hasErrorsInTab = (
@@ -92,10 +102,8 @@ console.log("asdasd")
) => {
switch (tabIndex) {
case 0:
console.log("yolo")
return Object.keys(errors).length > 0;
default:
console.log("yolo")
false;
}
};
@@ -107,39 +115,50 @@ console.log("asdasd")
const onSubmit = useCallback<SubmitHandler<UserInputs>>(
async (data) => {
try {
let haveError = false
let regex_pw = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/
let pw = ''
let haveError = false;
let regex_pw =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/;
let pw = "";
if (data.password && data.password.length > 0) {
pw = data.password
pw = data.password;
if (pw.length < rules.min) {
haveError = true
formProps.setError("password", { message: t("The password requires 8-20 characters."), type: "required" })
haveError = true;
formProps.setError("password", {
message: t("The password requires 8-20 characters."),
type: "required",
});
}
if (pw.length > rules.max) {
haveError = true
formProps.setError("password", { message: t("The password requires 8-20 characters."), type: "required" })
haveError = true;
formProps.setError("password", {
message: t("The password requires 8-20 characters."),
type: "required",
});
}
if (!regex_pw.test(pw)) {
haveError = true
formProps.setError("password", { message: "A combination of uppercase letters, lowercase letters, numbers, and symbols is required.", type: "required" })
}
haveError = true;
formProps.setError("password", {
message:
"A combination of uppercase letters, lowercase letters, numbers, and symbols is required.",
type: "required",
});
}
}
const userData = {
name: data.name,
locked: false,
addAuthIds: data.addAuthIds || [],
removeAuthIds: data.removeAuthIds || [],
}
};
const pwData = {
id: id,
password: "",
newPassword: pw
}
newPassword: pw,
};
if (haveError) {
return
return;
}
console.log("passed")
console.log("passed");
await editUser(id, userData);
if (data.password && data.password.length > 0) {
await adminChangePassword(pwData);
@@ -196,12 +215,12 @@ console.log("asdasd")
</Tabs>
</Stack>
{tabIndex == 0 && <UserDetail />}
{tabIndex === 1 && <AuthAllocation auths={auths!}/>}
{tabIndex === 1 && <AuthAllocation auths={auths!} />}
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
variant="text"
startIcon={<RestartAlt />}
// onClick={() => console.log("asdasd")}
onClick={resetForm}
>
{t("Reset")}
</Button>


+ 9
- 6
src/components/EditUser/EditUserWrapper.tsx 查看文件

@@ -6,20 +6,23 @@ import { useSearchParams } from "next/navigation";
import { fetchTeam, fetchTeamDetail } from "@/app/api/team";
import { fetchStaff } from "@/app/api/staff";
import { fetchPwRules, fetchUser, fetchUserDetail } from "@/app/api/user";
import { searchParamsProps } from "@/app/utils/fetchUtil";
import { fetchUserDetails } from "@/app/api/user/actions";
import { fetchAuth } from "@/app/api/group/actions";

interface SubComponents {
Loading: typeof EditUserLoading;
}

interface Props {
// id: number
}
const EditUserWrapper: React.FC<Props> & SubComponents = async ({
// id
const EditUserWrapper: React.FC<searchParamsProps> & SubComponents = async ({
searchParams
}) => {
const id = parseInt(searchParams.id as string)
const pwRule = await fetchPwRules()
const user = await fetchUserDetails(id);
const auths = await fetchAuth("user", id);

return <EditUser rules={pwRule} />
return <EditUser user={user.data} rules={pwRule} auths={auths.records}/>
};

EditUserWrapper.Loading = EditUserLoading;


+ 39
- 2
src/components/EditUser/UserDetail.tsx 查看文件

@@ -9,13 +9,13 @@ import {
Stack,
TextField,
Typography,
makeStyles,
} from "@mui/material";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";



const UserDetail: React.FC = () => {

const { t } = useTranslation();
const {
register,
@@ -45,6 +45,30 @@ const UserDetail: React.FC = () => {
label={t("password")}
fullWidth
{...register("password")}
// helperText={
// Boolean(errors.password) &&
// (errors.password?.message
// ? t(errors.password.message)
// :
// (<>
// - 8-20 characters
// <br/>
// - Uppercase letters
// <br/>
// - Lowercase letters
// <br/>
// - Numbers
// <br/>
// - Symbols
// </>)
// )
// }
helperText={
Boolean(errors.password) &&
(errors.password?.message
? t(errors.password.message)
: t("Please input correct password"))
}
error={Boolean(errors.password)}
/>
</Grid>
@@ -55,3 +79,16 @@ const UserDetail: React.FC = () => {
};

export default UserDetail;


{/* <>
- 8-20 characters
<br/>
- Uppercase letters
<br/>
- Lowercase letters
<br/>
- Numbers
<br/>
- Symbols
</> */}

+ 12
- 16
src/components/EditUserGroup/AuthorityAllocation.tsx 查看文件

@@ -41,7 +41,6 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
reset,
resetField,
} = useFormContext<CreateGroupInputs>();
console.log(auth)
const initialAuths = auth.map((a) => ({ ...a })).sort((a, b) => a.id - b.id);
const [filteredAuths, setFilteredAuths] = useState(initialAuths);
const [selectedAuths, setSelectedAuths] = useState<typeof filteredAuths>(
@@ -86,8 +85,8 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
onClick: addAuth,
buttonIcon: <Add />,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
{ label: t("Authority"), name: "authority" },
{ label: t("Description"), name: "name" },
// { label: t("Current Position"), name: "currentPosition" },
],
[addAuth, t]
@@ -101,8 +100,8 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
onClick: removeAuth,
buttonIcon: <Remove color="warning"/>,
},
{ label: t("authority"), name: "authority" },
{ label: t("Auth Name"), name: "name" },
{ label: t("Authority"), name: "authority" },
{ label: t("Description"), name: "name" },
],
[removeAuth, selectedAuths, t]
);
@@ -117,16 +116,13 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
const q = query.toLowerCase();
setFilteredAuths(
initialAuths.filter((a) => (
a.authority.toLowerCase().includes(q) ||
a.name.toLowerCase().includes(q)
))
);
}, [auth, query]);

const resetAuth = React.useCallback(() => {
@@ -164,7 +160,7 @@ const AuthorityAllocation: React.FC<Props> = ({ auth }) => {
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or position.")}
placeholder={t("Search by ")+ t("Authority") + " / " + t("Description")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">


+ 3
- 3
src/components/EditUserGroup/GroupInfo.tsx 查看文件

@@ -51,13 +51,13 @@ const GroupInfo: React.FC = () => {
Boolean(errors.name) &&
(errors.name?.message
? t(errors.name.message)
: t("Please input correct name"))
: t("Please input correct ") + t("name"))
}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("Group Description")}
label={t("Description")}
fullWidth
multiline
rows={4}
@@ -67,7 +67,7 @@ const GroupInfo: React.FC = () => {
Boolean(errors.description) &&
(errors.description?.message
? t(errors.description.message)
: t("Please input correct description"))
: t("Please input correct ") + t("Description"))
}
/>
</Grid>


+ 12
- 15
src/components/EditUserGroup/UserAllocation.tsx 查看文件

@@ -92,8 +92,8 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
onClick: addUser,
buttonIcon: <Add />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
{ label: t("Username"), name: "username" },
{ label: t("Staff Name"), name: "name" },
],
[addUser, t]
);
@@ -106,8 +106,8 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
onClick: removeUser,
buttonIcon: <Remove color="warning" />,
},
{ label: t("User Name"), name: "username" },
{ label: t("name"), name: "name" },
{ label: t("Username"), name: "username" },
{ label: t("Staff Name"), name: "name" },
],
[removeUser, selectedUsers, t]
);
@@ -123,16 +123,13 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
}, []);

React.useEffect(() => {
// setFilteredStaff(
// initialStaffs.filter((s) => {
// const q = query.toLowerCase();
// // s.staffId.toLowerCase().includes(q)
// // const q = query.toLowerCase();
// // return s.name.toLowerCase().includes(q);
// // s.code.toString().includes(q) ||
// // (s.brNo != null && s.brNo.toLowerCase().includes(q))
// })
// );
const q = query.toLowerCase();
setFilteredUsers(
initialUsers.filter((item) => (
item.username.toLowerCase().includes(q) ||
item.name.toLowerCase().includes(q)
))
);
}, [users, query]);

const resetUser = React.useCallback(() => {
@@ -170,7 +167,7 @@ const UserAllocation: React.FC<Props> = ({ users }) => {
fullWidth
onChange={onQueryInputChange}
value={query}
placeholder={t("Search by staff ID, name or position.")}
placeholder={t("Search by ") + t("Username") + " / " + t("Staff Name")}
InputProps={{
endAdornment: query && (
<InputAdornment position="end">


+ 28
- 0
src/components/ErrorAlert/ErrorAlert.tsx 查看文件

@@ -0,0 +1,28 @@
import { Alert, AlertTitle, Box } from "@mui/material";
import compact from "lodash/compact";
import { useTranslation } from "react-i18next";

interface Props {
errors: (string | undefined)[];
}

const ErrorAlert: React.FC<Props> = ({ errors }) => {
const { t } = useTranslation("common");

if (compact(errors).length === 0) return null;

return (
<Alert severity="error">
<AlertTitle>{t("There are some errors")}</AlertTitle>
<Box component="ul">
{errors.map((error, index) => (
<Box component="li" key={`${error}-${index}`}>
{error}
</Box>
))}
</Box>
</Alert>
);
};

export default ErrorAlert;

+ 1
- 0
src/components/ErrorAlert/index.ts 查看文件

@@ -0,0 +1 @@
export { default } from "./ErrorAlert";

+ 44
- 39
src/components/GenerateMonthlyWorkHoursReport/GenerateMonthlyWorkHoursReport.tsx 查看文件

@@ -3,61 +3,66 @@ import React, { useMemo } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import { ProjectResult } from "@/app/api/projects";
import { fetchMonthlyWorkHoursReport, fetchProjectCashFlowReport } from "@/app/api/reports/actions";
import {
fetchMonthlyWorkHoursReport,
fetchProjectCashFlowReport,
} from "@/app/api/reports/actions";
import { downloadFile } from "@/app/utils/commonUtil";
import { BASE_API_URL } from "@/config/api";
import { MonthlyWorkHoursReportFilter } from "@/app/api/reports";
import { records } from "@/app/api/staff/actions";
import { StaffResult } from "@/app/api/staff";
import dayjs from "dayjs";

interface Props {
staffs: StaffResult[]
staffs: StaffResult[];
}

type SearchQuery = Partial<Omit<MonthlyWorkHoursReportFilter, "id">>;
type SearchParamNames = keyof SearchQuery;

const GenerateMonthlyWorkHoursReport: React.FC<Props> = ({ staffs }) => {
const { t } = useTranslation();
const staffCombo = staffs.map(staff => `${staff.name} - ${staff.staffId}`)
console.log(staffs)
const { t } = useTranslation("report");
const staffCombo = staffs.map((staff) => `${staff.name} - ${staff.staffId}`);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("Staff"),
paramName: "staff",
type: "select",
options: staffCombo,
needAll: false
},
{
label: t("date"),
paramName: "date",
type: "monthYear",
},
],
[t],
);
const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("Staff"),
paramName: "staff",
type: "select",
options: staffCombo,
needAll: false,
},
{
label: t("Date"),
paramName: "date",
type: "monthYear",
},
],
[t]
);

return (
return (
<>
<SearchBox
<SearchBox
criteria={searchCriteria}
onSearch={async (query: any) => {
const index = staffCombo.findIndex(staff => staff === query.staff)
if (query.staff.length > 0 && query.staff.toLocaleLowerCase() !== "all" && query.date.length > 0) {
const index = staffCombo.findIndex(staff => staff === query.staff)
const response = await fetchMonthlyWorkHoursReport({ id: staffs[index].id, yearMonth: query.date })
if (response) {
downloadFile(new Uint8Array(response.blobValue), response.filename!!)
}
}
}
}
/>
</>
)
}
const index = staffCombo.findIndex((staff) => staff === query.staff);
const response = await fetchMonthlyWorkHoursReport({
id: staffs[index].id,
yearMonth: query.date,
});
if (response) {
downloadFile(
new Uint8Array(response.blobValue),
response.filename!!
);
}
}}
/>
</>
);
};

export default GenerateMonthlyWorkHoursReport
export default GenerateMonthlyWorkHoursReport;

+ 63
- 0
src/components/GenerateProjectPandLReport/GenerateProjectPandLReport.tsx 查看文件

@@ -0,0 +1,63 @@
"use client";

import React, { useMemo } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import { ProjectResult } from "@/app/api/projects";
import { ProjectPandLReportFilter } from "@/app/api/reports";
import { fetchProjectPandLReport } from "@/app/api/reports/actions";
import { downloadFile } from "@/app/utils/commonUtil";
import { dateTypeCombo } from "@/app/utils/comboUtil";
import { FormHelperText } from "@mui/material";
import { errorDialog, errorDialogWithContent } from "../Swal/CustomAlerts";

interface Props {
projects: ProjectResult[];
}

type SearchQuery = Partial<Omit<ProjectPandLReportFilter, "id">>;
type SearchParamNames = keyof SearchQuery;

const GenerateProjectPandLReport: React.FC<Props> = ({ projects }) => {
const { t } = useTranslation("report");
const projectCombo = projects.map(project => `${project.code} - ${project.name}`)

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Project *"), paramName: "project", type: "select", options: projectCombo, needAll: false},
{ label: t("Start Month *"), label2: t("End Month *"), paramName: "startMonth", type: "dateRange", needMonth: true },

],
[t],
);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={async (query) => {

if (query.project.length > 0 && query.project.toLocaleLowerCase() !== "all") {
const projectIndex = projectCombo.findIndex(project => project === query.project)
console.log(projects[projectIndex].id, query.startMonth, query.startMonthTo)
if(projects[projectIndex].id != null && query.startMonth != "" && query.startMonthTo != undefined){
const response = await fetchProjectPandLReport({ projectId: projects[projectIndex].id, startMonth: query.startMonth, endMonth: query.startMonthTo })
if (response) {
downloadFile(new Uint8Array(response.blobValue), response.filename!!)
}
}else{
errorDialogWithContent(t("Download Fail"),
t(`Please check the required field`), t)
.then(() => {
window.location.reload()
})
}
}
}}
formType={"download"}
/>
</>
);
};

export default GenerateProjectPandLReport;

+ 38
- 0
src/components/GenerateProjectPandLReport/GenerateProjectPandLReportLoading.tsx 查看文件

@@ -0,0 +1,38 @@
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const GenerateProjectPandLReportLoading: React.FC = () => {
return (
<>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={60} />
<Skeleton
variant="rounded"
height={50}
width={100}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
</Stack>
</CardContent>
</Card>
</>
);
};

export default GenerateProjectPandLReportLoading;

+ 18
- 0
src/components/GenerateProjectPandLReport/GenerateProjectPandLReportWrapper.tsx 查看文件

@@ -0,0 +1,18 @@
import React from "react";
import GenerateProjectPandLReportLoading from "./GenerateProjectPandLReportLoading";
import { fetchProjects } from "@/app/api/projects";
import GenerateProjectPandLReport from "./GenerateProjectPandLReport";

interface SubComponents {
Loading: typeof GenerateProjectPandLReportLoading;
}

const GenerateProjectPandLReportWrapper: React.FC & SubComponents = async () => {
const projects = await fetchProjects();

return <GenerateProjectPandLReport projects={projects} />;
};

GenerateProjectPandLReportWrapper.Loading = GenerateProjectPandLReportLoading;

export default GenerateProjectPandLReportWrapper;

+ 1
- 0
src/components/GenerateProjectPandLReport/index.ts 查看文件

@@ -0,0 +1 @@
export { default } from "./GenerateProjectPandLReportWrapper";

+ 7
- 1
src/components/LeaveTable/LeaveEditModal.tsx 查看文件

@@ -101,9 +101,15 @@ const LeaveEditModal: React.FC<Props> = ({
fullWidth
{...register("inputHours", {
setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
validate: (value) => 0 < value && value <= LEAVE_DAILY_MAX_HOURS,
validate: (value) =>
(0 < value && value <= LEAVE_DAILY_MAX_HOURS) ||
t(
"Input hours should be between 0 and {{LEAVE_DAILY_MAX_HOURS}}",
{ LEAVE_DAILY_MAX_HOURS },
),
})}
error={Boolean(formState.errors.inputHours)}
helperText={formState.errors.inputHours?.message}
/>
<TextField
label={t("Remark")}


+ 6
- 6
src/components/NavigationContent/NavigationContent.tsx 查看文件

@@ -162,11 +162,11 @@ const NavigationContent: React.FC<Props> = ({ abilities }) => {
label: "Completion Report",
path: "/analytics/ProjectCompletionReport",
},
{
icon: <Analytics />,
label: "Completion Report with Outstanding Un-billed Hours Report",
path: "/analytics/ProjectCompletionReportWO",
},
// {
// icon: <Analytics />,
// label: "Completion Report with Outstanding Un-billed Hours Report",
// path: "/analytics/ProjectCompletionReportWO",
// },
{
icon: <Analytics />,
label: "Project Claims Report",
@@ -175,7 +175,7 @@ const NavigationContent: React.FC<Props> = ({ abilities }) => {
{
icon: <Analytics />,
label: "Project P&L Report",
path: "/analytics/ProjectPLReport",
path: "/analytics/ProjectPandLReport",
},
{
icon: <Analytics />,


+ 0
- 124
src/components/ProgressByClient/ProgressByClient.tsx 查看文件

@@ -303,130 +303,6 @@ const ProgressByClient: React.FC<Props> = () => {
flex:0.1
},
];
const optionstest: ApexOptions = {
chart: {
height: 350,
type: "line",
},
stroke: {
width: [0, 0, 2, 2],
},
plotOptions: {
bar: {
horizontal: false,
distributed: false,
},
},
dataLabels: {
enabled: false,
},
xaxis: {
categories: [
"Q1",
"Q2",
"Q3",
"Q4",
"Q5",
"Q6",
"Q7",
"Q8",
"Q9",
"Q10",
"Q11",
"Q12",
],
},
yaxis: [
{
title: {
text: "Monthly Income and Expenditure(HKD)",
},
min: 0,
max: 350000,
tickAmount: 5,
labels: {
formatter: function (val) {
return val.toLocaleString()
}
}
},
{
show: false,
seriesName: "Monthly_Expenditure",
title: {
text: "Monthly Expenditure (HKD)",
},
min: 0,
max: 350000,
tickAmount: 5,
},
{
seriesName: "Cumulative_Income",
opposite: true,
title: {
text: "Cumulative Income and Expenditure(HKD)",
},
min: 0,
max: 850000,
tickAmount: 5,
labels: {
formatter: function (val) {
return val.toLocaleString()
}
}
},
{
show: false,
seriesName: "Cumulative_Expenditure",
opposite: true,
title: {
text: "Cumulative Expenditure (HKD)",
},
min: 0,
max: 850000,
tickAmount: 5,
},
],
grid: {
borderColor: "#f1f1f1",
},
annotations: {},
series: [
{
name: "Monthly_Income",
type: "column",
color: "#ffde91",
data: [0, 110000, 0, 0, 185000, 0, 0, 189000, 0, 0, 300000, 0],
},
{
name: "Monthly_Expenditure",
type: "column",
color: "#82b59a",
data: [
0, 160000, 120000, 120000, 55000, 55000, 55000, 55000, 55000, 70000,
55000, 55000,
],
},
{
name: "Cumulative_Income",
type: "line",
color: "#EE6D7A",
data: [
0, 100000, 100000, 100000, 300000, 300000, 300000, 500000, 500000,
500000, 800000, 800000,
],
},
{
name: "Cumulative_Expenditure",
type: "line",
color: "#7cd3f2",
data: [
0, 198000, 240000, 400000, 410000, 430000, 510000, 580000, 600000,
710000, 730000, 790000,
],
},
],
};

const options2: ApexOptions = {
chart: {


+ 96
- 12
src/components/ProgressByTeam/ProgressByTeam.tsx 查看文件

@@ -18,9 +18,13 @@ import { AnyARecord, AnyCnameRecord } from "dns";
import SearchBox, { Criterion } from "../SearchBox";
import ProgressByTeamSearch from "@/components/ProgressByTeamSearch";
import { Suspense } from "react";
import { useSearchParams } from 'next/navigation';
import { fetchAllTeamProjects} from "@/app/api/teamprojects/actions";
// const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });

const ProgressByTeam: React.FC = () => {
const searchParams = useSearchParams();
const teamLeadId = searchParams.get('teamLeadId');
const [activeTab, setActiveTab] = useState("financialSummary");
const [SearchCriteria, setSearchCriteria] = React.useState({});
const { t } = useTranslation("dashboard");
@@ -44,6 +48,64 @@ const ProgressByTeam: React.FC = () => {
const [receiptFromDate, setReceiptFromDate] = useState(null);
const [receiptToDate, setReceiptToDate] = useState(null);
const [selectedRows, setSelectedRows] = useState([]);
const [chartProjectName, setChartProjectName]:any[] = useState([]);
const [chartManhourConsumptionPercentage, setChartManhourConsumptionPercentage]:any[] = useState([]);
const color = ["#f57f90", "#94f7d6", "#87c5f5", "#ab95f5", "#fcd68b",
"#f58a9b", "#8ef4d1", "#92caf9", "#a798f9", "#fad287",
"#f595a6", "#88f1cc", "#9dcff5", "#a39bf5", "#f8de83",
"#f5a0b1", "#82eec7", "#a8d4f1", "#9f9ef1", "#f6ea7f",
"#f5abb4", "#7cebca", "#b3d9ed", "#9ba1ed", "#f4f67b",
"#f5b6b7", "#76e8cd", "#bed6e9", "#97a4e9", "#f2fa77",
"#f5c1ba", "#70e5d0", "#c9d3e5", "#93a7e5", "#f0fe73",
"#f5ccbd", "#6ae2d3", "#d4d0e1", "#8faae1", "#eefe6f",
"#f5d7c0", "#64dfd6", "#dfc5dd", "#8badd5", "#ecfe6b",
"#f5e2c3", "#5edcd9", "#eabada", "#87b0c9", "#eafc67",
"#f5edc6", "#58d9dc", "#f5afd6", "#83b3bd", "#e8fc63",
"#f5f8c9", "#52d6df", "#ffacd2", "#7fb6b1", "#e6fc5f",
"#f5ffcc", "#4cd3e2", "#ffa9ce", "#7bb9a5", "#e4fc5b",
"#f2ffcf", "#46d0e5", "#ffa6ca", "#77bc99", "#e2fc57",
"#efffd2", "#40cde8", "#ffa3c6", "#73bf8d", "#e0fc53",
"#ecffd5", "#3acaeb", "#ffa0c2", "#6fc281", "#defb4f",
"#e9ffd8", "#34c7ee", "#ff9dbe", "#6bc575", "#dcfb4b",
"#e6ffdb", "#2ec4f1", "#ff9aba", "#67c869", "#dafb47",
"#e3ffde", "#28c1f4", "#ff97b6", "#63cb5d", "#d8fb43",
"#e0ffe1", "#22bef7", "#ff94b2", "#5fce51", "#d6fb3f",
"#ddfee4", "#1cbbfa", "#ff91ae", "#5bd145", "#d4fb3b",
"#dafee7", "#16b8fd", "#ff8eaa", "#57d439", "#d2fb37",
"#d7feea", "#10b5ff", "#ff8ba6", "#53d72d", "#d0fb33",
"#d4feed", "#0ab2ff", "#ff88a2", "#4fda21", "#cefb2f",
"#d1fef0", "#04afff", "#ff859e", "#4bdd15", "#ccfb2b"];
const [teamProjectResult, setTeamProjectResult]:any[] = useState([]);

const fetchData = async () => {
if (teamLeadId) {
try {
const clickResult = await fetchAllTeamProjects(
Number(teamLeadId))
console.log(clickResult)
setTeamProjectResult(clickResult);
} catch (error) {
console.error('Error fetching team projects:', error);
}
}
}

useEffect(() => {
const projectName = []
const manhourConsumptionPercentage = []
for (let i = 0; i < teamProjectResult.length; i++){
teamProjectResult[i].color = color[i]
projectName.push(teamProjectResult[i].projectName)
manhourConsumptionPercentage.push(teamProjectResult[i].manhourConsumptionPercentage)
}
setChartProjectName(projectName)
setChartManhourConsumptionPercentage(manhourConsumptionPercentage)
}, [teamProjectResult]);

useEffect(() => {
fetchData()
}, [teamLeadId]);

const rows = [
{
id: 1,
@@ -373,7 +435,35 @@ const ProgressByTeam: React.FC = () => {
type: "bar",
height: 350,
},
colors: ["#f57f90", "#94f7d6", "#87c5f5", "#ab95f5", "#fcd68b"],
series: [{
name: "Project Resource Consumption Percentage",
data: chartManhourConsumptionPercentage,
},],
colors: ["#f57f90", "#94f7d6", "#87c5f5", "#ab95f5", "#fcd68b",
"#f58a9b", "#8ef4d1", "#92caf9", "#a798f9", "#fad287",
"#f595a6", "#88f1cc", "#9dcff5", "#a39bf5", "#f8de83",
"#f5a0b1", "#82eec7", "#a8d4f1", "#9f9ef1", "#f6ea7f",
"#f5abb4", "#7cebca", "#b3d9ed", "#9ba1ed", "#f4f67b",
"#f5b6b7", "#76e8cd", "#bed6e9", "#97a4e9", "#f2fa77",
"#f5c1ba", "#70e5d0", "#c9d3e5", "#93a7e5", "#f0fe73",
"#f5ccbd", "#6ae2d3", "#d4d0e1", "#8faae1", "#eefe6f",
"#f5d7c0", "#64dfd6", "#dfc5dd", "#8badd5", "#ecfe6b",
"#f5e2c3", "#5edcd9", "#eabada", "#87b0c9", "#eafc67",
"#f5edc6", "#58d9dc", "#f5afd6", "#83b3bd", "#e8fc63",
"#f5f8c9", "#52d6df", "#ffacd2", "#7fb6b1", "#e6fc5f",
"#f5ffcc", "#4cd3e2", "#ffa9ce", "#7bb9a5", "#e4fc5b",
"#f2ffcf", "#46d0e5", "#ffa6ca", "#77bc99", "#e2fc57",
"#efffd2", "#40cde8", "#ffa3c6", "#73bf8d", "#e0fc53",
"#ecffd5", "#3acaeb", "#ffa0c2", "#6fc281", "#defb4f",
"#e9ffd8", "#34c7ee", "#ff9dbe", "#6bc575", "#dcfb4b",
"#e6ffdb", "#2ec4f1", "#ff9aba", "#67c869", "#dafb47",
"#e3ffde", "#28c1f4", "#ff97b6", "#63cb5d", "#d8fb43",
"#e0ffe1", "#22bef7", "#ff94b2", "#5fce51", "#d6fb3f",
"#ddfee4", "#1cbbfa", "#ff91ae", "#5bd145", "#d4fb3b",
"#dafee7", "#16b8fd", "#ff8eaa", "#57d439", "#d2fb37",
"#d7feea", "#10b5ff", "#ff8ba6", "#53d72d", "#d0fb33",
"#d4feed", "#0ab2ff", "#ff88a2", "#4fda21", "#cefb2f",
"#d1fef0", "#04afff", "#ff859e", "#4bdd15", "#ccfb2b"],
plotOptions: {
bar: {
horizontal: true,
@@ -384,13 +474,7 @@ const ProgressByTeam: React.FC = () => {
enabled: false,
},
xaxis: {
categories: [
"Consultancy Project 123",
"Consultancy Project 456",
"Construction Project A",
"Construction Project B",
"Construction Project C",
],
categories: chartProjectName,
},
yaxis: {
title: {
@@ -414,7 +498,7 @@ const ProgressByTeam: React.FC = () => {
};

const handleSelectionChange = (newSelectionModel: GridRowSelectionModel) => {
const selectedRowsData = rows2.filter((row) =>
const selectedRowsData = teamProjectResult.filter((row:any) =>
newSelectionModel.includes(row.id),
);
console.log(selectedRowsData);
@@ -427,7 +511,7 @@ const ProgressByTeam: React.FC = () => {
if (i === selectedRowsData.length && i > 0) {
projectArray.push("Remained");
} else if (selectedRowsData.length > 0) {
projectArray.push(selectedRowsData[i].project);
projectArray.push(selectedRowsData[i].projectName);
totalBudgetManhour += Number(selectedRowsData[i].budgetedManhour);
totalSpent += Number(selectedRowsData[i].spentManhour);
pieChartColorArray.push(selectedRowsData[i].color);
@@ -485,7 +569,7 @@ const ProgressByTeam: React.FC = () => {
<div style={{ display: "inline-block", width: "99%" }}>
<ReactApexChart
options={options}
series={series}
series={options.series}
type="bar"
height={350}
/>
@@ -507,7 +591,7 @@ const ProgressByTeam: React.FC = () => {
style={{ display: "inline-block", width: "99%", marginLeft: 10 }}
>
<CustomDatagrid
rows={rows2}
rows={teamProjectResult}
columns={columns2}
columnWidth={200}
dataGridHeight={300}


+ 31
- 5
src/components/ProgressByTeamSearch/ProgressByTeamSearch.tsx 查看文件

@@ -1,11 +1,13 @@
"use client";

import { ProjectResult } from "@/app/api/projects";
import React, { useMemo, useState } from "react";
import React, { useMemo, useState, useCallback } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import { TeamProjectResult } from "@/app/api/teamprojects";
import VisibilityIcon from '@mui/icons-material/Visibility';
import { useRouter, useSearchParams } from "next/navigation";

interface Props {
projects: TeamProjectResult[];
@@ -15,7 +17,7 @@ type SearchParamNames = keyof SearchQuery;

const ProgressByClientSearch: React.FC<Props> = ({ projects }) => {
const { t } = useTranslation("projects");
const router = useRouter();
// If project searching is done on the server-side, then no need for this.
const [filteredProjects, setFilteredProjects] = useState(projects);

@@ -27,13 +29,31 @@ const ProgressByClientSearch: React.FC<Props> = ({ projects }) => {
[t],
);

const onTaskClick = useCallback(async (teamProjectResult: TeamProjectResult) => {
try {
console.log(teamProjectResult)
router.push(
`/dashboard/ProjectStatusByTeam?teamLeadId=${teamProjectResult.teamLeadId}`
);
} catch (error) {
console.error('Error fetching team projects:', error);
}
}, []);


const columns = useMemo<Column<TeamProjectResult>[]>(
() => [
{
name: "id",
label: t("Details"),
onClick: onTaskClick,
buttonIcon: <VisibilityIcon />,
},
{ name: "teamCode", label: t("Team Code") },
{ name: "teamName", label: t("Team Name") },
{ name: "NoOfProjects", label: t("No. of Projects") },
{ name: "projectNo", label: t("No. of Projects") },
],
[t],
[onTaskClick, t],
);

return (
@@ -41,7 +61,13 @@ const ProgressByClientSearch: React.FC<Props> = ({ projects }) => {
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
console.log(query);
setFilteredProjects(
projects.filter(
(cp) =>
cp.teamCode.toLowerCase().includes(query.teamCode.toLowerCase()) &&
cp.teamName.toLowerCase().includes(query.teamName.toLowerCase())
),
);
}}
/>
<SearchResults<TeamProjectResult>


+ 41
- 6
src/components/ProjectCashFlow/ProjectCashFlow.tsx 查看文件

@@ -19,17 +19,34 @@ import SearchBox, { Criterion } from "../SearchBox";
import ProgressByClientSearch from "@/components/ProgressByClientSearch";
import { Suspense } from "react";
import ProgressCashFlowSearch from "@/components/ProgressCashFlowSearch";
import { fetchProjectsCashFlow} from "@/app/api/cashflow";
import { Input, Label } from "reactstrap";
import { CashFlow } from "@/app/api/cashflow";

interface Props {
projects: CashFlow[];
}
type SearchQuery = Partial<Omit<CashFlow, "id">>;
type SearchParamNames = keyof SearchQuery;

const ProjectCashFlow: React.FC = () => {
const { t } = useTranslation("projects");
const todayDate = new Date();
const [selectionModel, setSelectionModel]: any[] = React.useState([]);
const [projectData, setProjectData]: any[] = React.useState([]);
const [cashFlowYear, setCashFlowYear]: any[] = React.useState(
todayDate.getFullYear(),
);
const [anticipateCashFlowYear, setAnticipateCashFlowYear]: any[] = React.useState(
todayDate.getFullYear(),
);
const fetchData = async () => {
const cashFlowProject = await fetchProjectsCashFlow();
setProjectData(cashFlowProject)
}
useEffect(() => {
fetchData()
}, []);
const columns = [
{
id: "projectCode",
@@ -50,8 +67,8 @@ const ProjectCashFlow: React.FC = () => {
flex: 1,
},
{
id: "teamLeader",
field: "teamLeader",
id: "teamLead",
field: "teamLead",
headerName: "Team Leader",
flex: 1,
},
@@ -530,8 +547,6 @@ const ProjectCashFlow: React.FC = () => {
remarks: "Monthly Manpower Expenditure",
},
];

const [projectData, setProjectData]: any[] = React.useState(rows);
const [ledgerData, setLedgerData]: any[] = React.useState(ledgerRows);
const handleSelectionChange = (newSelectionModel: GridRowSelectionModel) => {
const selectedRowsData = projectData.filter((row: any) =>
@@ -540,11 +555,31 @@ const ProjectCashFlow: React.FC = () => {
console.log(selectedRowsData);
};

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: "Project Code", paramName: "projectCode", type: "text" },
{ label: "Project Name", paramName: "projectName", type: "text" },
{
label: "Start Date From",
label2: "Start Date To",
paramName: "startDateFrom",
type: "dateRange",
},
],
[t],
);

return (
<>
<Suspense fallback={<ProgressCashFlowSearch.Loading />}>
{/* <Suspense fallback={<ProgressCashFlowSearch.Loading />}>
<ProgressCashFlowSearch />
</Suspense>
</Suspense> */}
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
console.log(query);
}}
/>
<CustomDatagrid
rows={projectData}
columns={columns}


+ 95
- 0
src/components/ProjectCompletionReport/ProjectCompletionReport.tsx 查看文件

@@ -0,0 +1,95 @@
"use client";
import {
ProjectCompletionReportFilter,
ProjectCompletionReportRequest,
} from "@/app/api/reports";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import dayjs from "dayjs";
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import { downloadFile } from "@/app/utils/commonUtil";
import { fetchProjectCompletionReport } from "@/app/api/reports/actions";

interface Props {
// team: TeamResult[]
// customer: Customer[]
}

type SearchQuery = Partial<Omit<ProjectCompletionReportFilter, "id">>;
type SearchParamNames = keyof SearchQuery;

const ProjectCompletionReport: React.FC<Props> = (
{
// team,
// customer
}
) => {
const { t } = useTranslation("report");
const [error, setError] = useState("");
const outstandingList = ["Regular", "Outstanding Accounts Receivable"]

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("startDate"),
label2: t("endDate"),
paramName: "startDate",
type: "dateRange",
},
{
label: t("Type"),
paramName: "outstanding",
type: "select",
needAll: false,
options: outstandingList
},
],
[t]
);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={async (query: any) => {
console.log(query);
let postData: ProjectCompletionReportRequest = {
startDate: "",
endDate: dayjs().format(INPUT_DATE_FORMAT).toString(),
outstanding: false
};
if (query.endDate && query.endDate.length > 0) {
postData.endDate = query.endDate;
}

// check if start date exist
if (query.startDate.length === 0) {
setError(t("Start Date cant be empty"));
} else {
postData.startDate = query.startDate;
if (query.outstanding && query.outstanding === "Outstanding Accounts Receivable") {
// outstanding report
postData.outstanding = true
}
console.log(postData)
const response =
await fetchProjectCompletionReport(
postData
);
// normal report

if (response) {
downloadFile(
new Uint8Array(response.blobValue),
response.filename!!
);
}
}
}}
/>
</>
);
};

export default ProjectCompletionReport;

src/components/Report/ResourceOverconsumptionReportGen/ResourceOverconsumptionReportGenLoading.tsx → src/components/ProjectCompletionReport/ProjectCompletionReportLoading.tsx 查看文件

@@ -6,7 +6,7 @@ import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const ResourceOvercomsumptionReportGenLoading: React.FC = () => {
export const ProjectCompletionReportLoading: React.FC = () => {
return (
<>
<Card>
@@ -38,4 +38,4 @@ export const ResourceOvercomsumptionReportGenLoading: React.FC = () => {
);
};

export default ResourceOvercomsumptionReportGenLoading;
export default ProjectCompletionReportLoading;

+ 18
- 0
src/components/ProjectCompletionReport/ProjectCompletionReportWrapper.tsx 查看文件

@@ -0,0 +1,18 @@
import React from "react";
import { fetchAllCustomers } from "@/app/api/customer";
import { fetchTeam } from "@/app/api/team";
import ProjectCompletionReportLoading from "./ProjectCompletionReportLoading";
import ProjectCompletionReport from "./ProjectCompletionReport";

interface SubComponents {
Loading: typeof ProjectCompletionReportLoading;
}

const ProjectCompletionReportWrapper: React.FC & SubComponents = async () => {
return <ProjectCompletionReport/>
};

ProjectCompletionReportWrapper.Loading = ProjectCompletionReportLoading;

export default ProjectCompletionReportWrapper;

+ 1
- 0
src/components/ProjectCompletionReport/index.ts 查看文件

@@ -0,0 +1 @@
export { default } from "./ProjectCompletionReportWrapper";

+ 13
- 15
src/components/ProjectFinancialSummary/ProjectFinancialCard.tsx 查看文件

@@ -20,14 +20,14 @@ import { Suspense } from "react";

interface Props {
Title: string;
TotalActiveProjectNumber: string;
TotalFees: string;
TotalBudget: string;
TotalCumulative: string;
TotalInvoicedAmount: string;
TotalReceivedAmount: string;
TotalActiveProjectNumber: number;
TotalFees: number;
TotalBudget: number;
TotalCumulative: number;
TotalInvoicedAmount: number;
TotalReceivedAmount: number;
CashFlowStatus: string;
CostPerformanceIndex: string;
CostPerformanceIndex: number;
ClickedIndex: number;
Index: number;
}
@@ -53,8 +53,6 @@ const ProjectFinancialCard: React.FC<Props> = ({
: "border-green-200 border-solid";
const selectedBackgroundColor =
ClickedIndex === Index ? "rgb(235 235 235)" : "rgb(255 255 255)";
console.log(ClickedIndex);
console.log(Index);
return (
<Card
style={{
@@ -77,42 +75,42 @@ const ProjectFinancialCard: React.FC<Props> = ({
Total Active Project
</div>
<div className="text-lg font-medium ml-5" style={{ color: "#6b87cf" }}>
{TotalActiveProjectNumber}
{TotalActiveProjectNumber.toLocaleString()}
</div>
<hr />
<div className="text-sm font-medium ml-5" style={{ color: "#898d8d" }}>
Total Fees
</div>
<div className="text-lg font-medium ml-5" style={{ color: "#6b87cf" }}>
{TotalFees}
{TotalFees.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div className="text-sm font-medium ml-5" style={{ color: "#898d8d" }}>
Total Budget
</div>
<div className="text-lg font-medium ml-5" style={{ color: "#6b87cf" }}>
{TotalBudget}
{TotalBudget.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div className="text-sm font-medium ml-5" style={{ color: "#898d8d" }}>
Total Cumulative Expenditure
</div>
<div className="text-lg font-medium ml-5" style={{ color: "#6b87cf" }}>
{TotalCumulative}
{TotalCumulative.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div className="text-sm font-medium ml-5" style={{ color: "#898d8d" }}>
Total Invoiced Amount
</div>
<div className="text-lg font-medium ml-5" style={{ color: "#6b87cf" }}>
{TotalInvoicedAmount}
{TotalInvoicedAmount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div className="text-sm font-medium ml-5" style={{ color: "#898d8d" }}>
Total Received Amount
</div>
<div className="text-lg font-medium ml-5" style={{ color: "#6b87cf" }}>
{TotalReceivedAmount}
{TotalReceivedAmount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div className="text-sm font-medium ml-5" style={{ color: "#898d8d" }}>


+ 73
- 121
src/components/ProjectFinancialSummary/ProjectFinancialSummary.tsx 查看文件

@@ -18,86 +18,33 @@ import { AnyARecord, AnyCnameRecord } from "dns";
import SearchBox, { Criterion } from "../SearchBox";
import ProgressByClientSearch from "@/components/ProgressByClientSearch";
import { Suspense } from "react";
import { fetchFinancialSummaryCard } from "@/app/api/financialsummary";
import { searchFinancialSummaryByClient,searchFinancialSummaryByProject } from "@/app/api/financialsummary/actions";
import ProjectFinancialCard from "./ProjectFinancialCard";
import VisibilityIcon from '@mui/icons-material/Visibility';

const ProjectFinancialSummary: React.FC = () => {
const [SearchCriteria, setSearchCriteria] = React.useState({});
const { t } = useTranslation("dashboard");
const [selectionModel, setSelectionModel]: any[] = React.useState([]);
const projectFinancialData = [
{
id: 1,
title: "All Teams",
activeProject: "147",
fees: "22,800,000.00",
budget: "18,240,000.00",
cumulativeExpenditure: "17,950,000.00",
invoicedAmount: "18,240,000.00",
receivedAmount: "10,900,000.00",
cashFlowStatus: "Negative",
CPI: "0.69",
},
{
id: 2,
title: "XXX Team",
activeProject: "25",
fees: "1,500,000.00",
budget: "1,200,000.00",
cumulativeExpenditure: "1,250,000.00",
invoicedAmount: "900,000.00",
receivedAmount: "650,000.00",
cashFlowStatus: "Negative",
CPI: "0.72",
},
{
id: 3,
title: "YYY Team",
activeProject: "35",
fees: "5,000,000.00",
budget: "4,000,000.00",
cumulativeExpenditure: "3,200,000.00",
invoicedAmount: "3,500,000.00",
receivedAmount: "3,500,000.00",
cashFlowStatus: "Positive",
CPI: "1.09",
},
{
id: 4,
title: "ZZZ Team",
activeProject: "50",
fees: "3,500,000.00",
budget: "2,800,000.00",
cumulativeExpenditure: "5,600,000.00",
invoicedAmount: "2,500,000.00",
receivedAmount: "2,200,000.00",
cashFlowStatus: "Negative",
CPI: "0.45",
},
{
id: 5,
title: "AAA Team",
activeProject: "15",
fees: "4,800,000.00",
budget: "3,840,000.00",
cumulativeExpenditure: "2,500,000.00",
invoicedAmount: "1,500,000.00",
receivedAmount: "750,000.00",
cashFlowStatus: "Negative",
CPI: "0.60",
},
{
id: 6,
title: "BBB Team",
activeProject: "22",
fees: "8,000,000.00",
budget: "6,400,000.00",
cumulativeExpenditure: "5,400,000.00",
invoicedAmount: "4,000,000.00",
receivedAmount: "3,800,000.00",
cashFlowStatus: "Negative",
CPI: "0.74",
},
];
const [projectFinancialData, setProjectFinancialData]: any[] = React.useState([]);
const [clientFinancialRows, setClientFinancialRows]: any[] = React.useState([]);
const [projectFinancialRows, setProjectFinancialRows]: any[] = React.useState([]);
const fetchData = async () => {
const financialSummaryCard = await fetchFinancialSummaryCard();
setProjectFinancialData(financialSummaryCard)
}
const fetchTableData = async (teamId?:any) => {
const financialSummaryByClient = await searchFinancialSummaryByClient(teamId);
console.log(financialSummaryByClient)
// console.log(financialSummaryByProject)
setClientFinancialRows(financialSummaryByClient)
}
useEffect(() => {
fetchData()
fetchTableData(undefined)
}, []);

const rows0 = [{id: 1,projectCode:"M1201",projectName:"Consultancy Project C", team:"XXX", teamLeader:"XXX", startDate:"01/08/2022", targetEndDate: "01/05/2024", client:"Client A", subsidiary:"N/A"},
{id: 2,projectCode:"M1321",projectName:"Consultancy Project CCC", team:"XXX", teamLeader:"XXX", startDate:"01/08/2022", targetEndDate: "20/01/2024", client:"Client E", subsidiary:"Subsidiary B"},
@@ -115,45 +62,39 @@ const ProjectFinancialSummary: React.FC = () => {
{id: 5,projectCode:"M1354",projectName:"Consultancy Project BBB", team:"YYY", teamLeader:"YYY", startDate:"01/02/2023", targetEndDate: "31/01/2024", client:"Client D", subsidiary:"Subsidiary C"}
]

const projectFinancialRows = [{id: 1,projectCode:"M1354",projectName:"Consultanct Project BBB",clientName:"Client D",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"}
]
// const projectFinancialRows = [{id: 1,projectCode:"M1354",projectName:"Consultanct Project BBB",clientName:"Client D",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"}
// ]

const clientFinancialRows =[{id: 1,clientCode:"Cust-02",clientName:"Client B",totalProjectInvolved:"1",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"},
{id: 2,clientCode:"Cust-03",clientName:"Client C",totalProjectInvolved:"1",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"},
{id: 3,clientCode:"Cust-04",clientName:"Client D",totalProjectInvolved:"4",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"}
]
// const clientFinancialRows =[{id: 1,clientCode:"Cust-02",clientName:"Client B",totalProjectInvolved:"1",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"},
// {id: 2,clientCode:"Cust-03",clientName:"Client C",totalProjectInvolved:"1",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"},
// {id: 3,clientCode:"Cust-04",clientName:"Client D",totalProjectInvolved:"4",cashFlowStatus:"Positive",cpi:"1.25", totalFees:"500,000.00", totalBudget:"400,000.00", totalCumulativeExpenditure:"280,000.00", totalInvoicedAmount: "350,000.00", totalUnInvoicedAmount:"150,000.00", totalReceivedAmount:"350,000.00"}
// ]

const [isCardClickedIndex, setIsCardClickedIndex] = React.useState(0);

const [selectedTeamData, setSelectedTeamData]: any[] = React.useState(rows0);

const handleCardClick = (r: any) => {
setIsCardClickedIndex(r);
if (r === 0) {
setSelectedTeamData(rows0);
} else if (r === 1) {
setSelectedTeamData(rows1);
} else if (r === 2) {
setSelectedTeamData(rows2);
}
const handleCardClick = (r: any, index:any) => {
fetchTableData(r.teamId)
setIsCardClickedIndex(index)
};

const columns = [
{
id: 'clientCode',
field: 'clientCode',
id: 'customerCode',
field: 'customerCode',
headerName: "Client Code",
flex: 0.7,
},
{
id: 'clientName',
field: 'clientName',
id: 'customerName',
field: 'customerName',
headerName: "Client Name",
flex: 1,
},
{
id: 'totalProjectInvolved',
field: 'totalProjectInvolved',
id: 'projectNo',
field: 'projectNo',
headerName: "Total Project Involved",
flex: 1,
},
@@ -163,6 +104,7 @@ const ProjectFinancialSummary: React.FC = () => {
headerName: "Cash Flow Status",
flex: 1,
renderCell: (params:any) => {
console.log(params.row)
if (params.row.cashFlowStatus === "Positive") {
return (
<span className="text-lime-500">{params.row.cashFlowStatus}</span>
@@ -192,13 +134,13 @@ const ProjectFinancialSummary: React.FC = () => {
},
},
{
id: 'totalFees',
field: 'totalFees',
id: 'totalFee',
field: 'totalFee',
headerName: "Total Fees (HKD)",
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalFees}</span>
<span>${params.row.totalFee}</span>
)
},
},
@@ -214,46 +156,46 @@ const ProjectFinancialSummary: React.FC = () => {
},
},
{
id: 'totalCumulativeExpenditure',
field: 'totalCumulativeExpenditure',
id: 'cumulativeExpenditure',
field: 'cumulativeExpenditure',
headerName: "Total Cumulative Expenditure (HKD)",
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalCumulativeExpenditure}</span>
<span>${params.row.cumulativeExpenditure}</span>
)
},
},
{
id: 'totalInvoicedAmount',
field: 'totalInvoicedAmount',
id: 'totalInvoiced',
field: 'totalInvoiced',
headerName: "Total Invoiced Amount (HKD)",
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalInvoicedAmount}</span>
<span>${params.row.totalInvoiced}</span>
)
},
},
{
id: 'totalUnInvoicedAmount',
field: 'totalUnInvoicedAmount',
id: 'totalUnInvoiced',
field: 'totalUnInvoiced',
headerName: "Total Un-invoiced Amount (HKD)",
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalUnInvoicedAmount}</span>
<span>${params.row.totalUninvoiced}</span>
)
},
},
{
id: 'totalReceivedAmount',
field: 'totalReceivedAmount',
id: 'totalReceived',
field: 'totalReceived',
headerName: "Total Received Amount (HKD)",
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalReceivedAmount}</span>
<span>${params.row.totalReceived}</span>
)
},
},
@@ -322,8 +264,8 @@ const columns2 = [
flex: 1,
},
{
id: 'clientName',
field: 'clientName',
id: 'customerName',
field: 'customerName',
headerName: "Client Name",
flex: 1,
},
@@ -365,7 +307,7 @@ const columns2 = [
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalFees}</span>
<span>${params.row.totalFee}</span>
)
},
},
@@ -387,7 +329,7 @@ const columns2 = [
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalCumulativeExpenditure}</span>
<span>${params.row.cumulativeExpenditure}</span>
)
},
},
@@ -398,7 +340,7 @@ const columns2 = [
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalInvoicedAmount}</span>
<span>${params.row.totalInvoiced}</span>
)
},
},
@@ -409,7 +351,7 @@ const columns2 = [
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalUnInvoicedAmount}</span>
<span>${params.row.totalUninvoiced}</span>
)
},
},
@@ -420,7 +362,7 @@ const columns2 = [
flex: 1,
renderCell: (params:any) => {
return (
<span>${params.row.totalReceivedAmount}</span>
<span>${params.row.totalReceived}</span>
)
},
},
@@ -432,15 +374,25 @@ const columns2 = [
);
console.log(selectedRowsData);
};
const fetchProjectTableData = async (teamId?:any,customerId?:any) => {
const financialSummaryByProject = await searchFinancialSummaryByProject(teamId,customerId);
setProjectFinancialRows(financialSummaryByProject)
}

const handleRowClick = (params:any) => {
console.log(params.row.teamId);
fetchProjectTableData(params.row.teamId,params.row.cid)
};

return (
<Grid item sm>
<Card>
<CardHeader className="text-slate-500" title="Active Project Financial Status"/>
<div className="ml-10 mr-10" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'start'}}>
{projectFinancialData.map((record, index) => (
<div className="hover:cursor-pointer ml-4 mt-5 mb-4 inline-block" key={index} onClick={(r) => handleCardClick(index)}>
<ProjectFinancialCard Title={record.title} TotalActiveProjectNumber={record.activeProject} TotalFees={record.fees} TotalBudget={record.budget} TotalCumulative={record.cumulativeExpenditure} TotalInvoicedAmount={record.invoicedAmount} TotalReceivedAmount={record.receivedAmount} CashFlowStatus={record.cashFlowStatus} CostPerformanceIndex={record.CPI} ClickedIndex={isCardClickedIndex} Index={index}/>
{projectFinancialData.map((record:any, index:any) => (
<div className="hover:cursor-pointer ml-4 mt-5 mb-4 inline-block" key={index} onClick={(r) => handleCardClick(record,index)}>
<ProjectFinancialCard Title={record.teamName} TotalActiveProjectNumber={record.projectNo} TotalFees={record.totalFee} TotalBudget={record.totalBudget} TotalCumulative={record.cumulativeExpenditure} TotalInvoicedAmount={record.totalInvoiced} TotalReceivedAmount={record.totalReceived} CashFlowStatus={record.cashFlowStatus} CostPerformanceIndex={record.cpi} ClickedIndex={isCardClickedIndex} Index={index}/>
</div>
))}
</div>
@@ -449,7 +401,7 @@ const columns2 = [
<CardHeader className="text-slate-500" title="Financial Status (by Client)"/>
<div style={{display:"inline-block",width:"99%",marginLeft:10}}>
{/* <CustomDatagrid rows={clientFinancialRows} columns={columns} columnWidth={200} dataGridHeight={300} checkboxSelection={true} onRowSelectionModelChange={handleSelectionChange} selectionModel={selectionModel}/> */}
<CustomDatagrid rows={clientFinancialRows} columns={columns} columnWidth={200} dataGridHeight={300}/>
<CustomDatagrid onRowClick={handleRowClick} rows={clientFinancialRows} columns={columns} columnWidth={200} dataGridHeight={300}/>
</div>
</Card>
<Card className="mt-5">


+ 3
- 2
src/components/ProjectSearch/ProjectSearch.tsx 查看文件

@@ -20,7 +20,6 @@ type SearchParamNames = keyof SearchQuery;
const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => {
const router = useRouter();
const { t } = useTranslation("projects");
console.log(projects)

const [filteredProjects, setFilteredProjects] = useState(projects);

@@ -62,7 +61,9 @@ const ProjectSearch: React.FC<Props> = ({ projects, projectCategories }) => {

const onProjectClick = useCallback(
(project: ProjectResult) => {
router.push(`/projects/edit?id=${project.id}`);
if (Boolean(project.mainProject)) {
router.push(`/projects/editSub?id=${project.id}`);
} else router.push(`/projects/edit?id=${project.id}`);
},
[router],
);


+ 0
- 313
src/components/Report/ReportSearchBox3/SearchBox3.tsx 查看文件

@@ -1,313 +0,0 @@
//src\components\ReportSearchBox3\SearchBox3.tsx
"use client";

import Grid from "@mui/material/Grid";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import TextField from "@mui/material/TextField";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import Select, { SelectChangeEvent } from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import CardActions from "@mui/material/CardActions";
import Button from "@mui/material/Button";
import RestartAlt from "@mui/icons-material/RestartAlt";
import Search from "@mui/icons-material/Search";
import dayjs from "dayjs";
import "dayjs/locale/zh-hk";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { Box } from "@mui/material";
import * as XLSX from 'xlsx-js-style';
//import { DownloadReportButton } from '../LateStartReportGen/DownloadReportButton';

interface BaseCriterion<T extends string> {
label: string;
label2?: string;
paramName: T;
paramName2?: T;
}

interface TextCriterion<T extends string> extends BaseCriterion<T> {
type: "text";
}

interface SelectCriterion<T extends string> extends BaseCriterion<T> {
type: "select";
options: string[];
}

interface DateRangeCriterion<T extends string> extends BaseCriterion<T> {
type: "dateRange";
}

export type Criterion<T extends string> =
| TextCriterion<T>
| SelectCriterion<T>
| DateRangeCriterion<T>;

interface Props<T extends string> {
criteria: Criterion<T>[];
onSearch: (inputs: Record<T, string>) => void;
onReset?: () => void;
}

function SearchBox<T extends string>({
criteria,
onSearch,
onReset,
}: Props<T>) {
const { t } = useTranslation("common");
const defaultInputs = useMemo(
() =>
criteria.reduce<Record<T, string>>(
(acc, c) => {
return { ...acc, [c.paramName]: c.type === "select" ? "All" : "" };
},
{} as Record<T, string>,
),
[criteria],
);
const [inputs, setInputs] = useState(defaultInputs);

const makeInputChangeHandler = useCallback(
(paramName: T): React.ChangeEventHandler<HTMLInputElement> => {
return (e) => {
setInputs((i) => ({ ...i, [paramName]: e.target.value }));
};
},
[],
);

const makeSelectChangeHandler = useCallback((paramName: T) => {
return (e: SelectChangeEvent) => {
setInputs((i) => ({ ...i, [paramName]: e.target.value }));
};
}, []);

const makeDateChangeHandler = useCallback((paramName: T) => {
return (e: any) => {
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") }));
};
}, []);

const makeDateToChangeHandler = useCallback((paramName: T) => {
return (e: any) => {
setInputs((i) => ({
...i,
[paramName + "To"]: dayjs(e).format("YYYY-MM-DD"),
}));
};
}, []);

const handleReset = () => {
setInputs(defaultInputs);
onReset?.();
};

const handleSearch = () => {
onSearch(inputs);
};
const handleDownload = async () => {
//setIsLoading(true);

try {
const response = await fetch('/temp/AR03_Resource Overconsumption.xlsx', {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
},
});
if (!response.ok) throw new Error('Network response was not ok.');

const data = await response.blob();
const reader = new FileReader();
reader.onload = (e) => {
if (e.target && e.target.result) {
const ab = e.target.result as ArrayBuffer;
const workbook = XLSX.read(ab, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Add the current date to cell C2
const cellAddress = 'C2';
const date = new Date().toISOString().split('T')[0]; // Format YYYY-MM-DD
const formattedDate = date.replace(/-/g, '/'); // Change format to YYYY/MM/DD
XLSX.utils.sheet_add_aoa(worksheet, [[formattedDate]], { origin: cellAddress });
// Style for cell A1: Font size 16 and bold
if (worksheet['A1']) {
worksheet['A1'].s = {
font: {
bold: true,
sz: 16, // Font size 16
//name: 'Times New Roman' // Specify font
}
};
}

// Apply styles from A2 to A4 (bold)
['A2', 'A3', 'A4'].forEach(cell => {
if (worksheet[cell]) {
worksheet[cell].s = { font: { bold: true } };
}
});

// Formatting from A6 to L6
// Apply styles from A6 to L6 (bold, bottom border, center alignment)
for (let col = 0; col < 12; col++) { // Columns A to K
const cellRef = XLSX.utils.encode_col(col) + '6';
if (worksheet[cellRef]) {
worksheet[cellRef].s = {
font: { bold: true },
alignment: { horizontal: 'center' },
border: {
bottom: { style: 'thin', color: { auto: 1 } }
}
};
}
}

const firstTableData = [
['Column1', 'Column2', 'Column3'], // Row 1
['Data1', 'Data2', 'Data3'], // Row 2
// ... more rows as needed
];
// Find the last row of the first table
let lastRowOfFirstTable = 6; // Starting row for data in the first table
while (worksheet[XLSX.utils.encode_cell({ c: 0, r: lastRowOfFirstTable })]) {
lastRowOfFirstTable++;
}
// Calculate the maximum length of content in each column and set column width
const colWidths: number[] = [];

const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "", blankrows: true }) as (string | number)[][];
jsonData.forEach((row: (string | number)[]) => {
row.forEach((cell: string | number, index: number) => {
const valueLength = cell.toString().length;
colWidths[index] = Math.max(colWidths[index] || 0, valueLength);
});
});

// Apply calculated widths to each column, skipping column A
worksheet['!cols'] = colWidths.map((width, index) => {
if (index === 0) {
return { wch: 8 }; // Set default or specific width for column A if needed
}
return { wch: width + 2 }; // Add padding to width
});

// Format filename with date
const today = new Date().toISOString().split('T')[0].replace(/-/g, '_'); // Get current date and format as YYYY_MM_DD
const filename = `AR03_Resource_Overconsumption_${today}.xlsx`; // Append formatted date to the filename

// Convert workbook back to XLSX file
XLSX.writeFile(workbook, filename);
} else {
throw new Error('Failed to load file');
}
};
reader.readAsArrayBuffer(data);
} catch (error) {
console.error('Error downloading the file: ', error);
}

//setIsLoading(false);
};
return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline">{t("Search Criteria")}</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
{criteria.map((c) => {
return (
<Grid key={c.paramName} item xs={6}>
{c.type === "text" && (
<TextField
label={c.label}
fullWidth
onChange={makeInputChangeHandler(c.paramName)}
value={inputs[c.paramName]}
/>
)}
{c.type === "select" && (
<FormControl fullWidth>
<InputLabel>{c.label}</InputLabel>
<Select
label={c.label}
onChange={makeSelectChangeHandler(c.paramName)}
value={inputs[c.paramName]}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{c.options.map((option, index) => (
<MenuItem key={`${option}-${index}`} value={option}>
{option}
</MenuItem>
))}
</Select>
</FormControl>
)}
{c.type === "dateRange" && (
<LocalizationProvider
dateAdapter={AdapterDayjs}
// TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
adapterLocale="zh-hk"
>
<Box display="flex">
<FormControl fullWidth>
<DatePicker
label={c.label}
onChange={makeDateChangeHandler(c.paramName)}
value={inputs[c.paramName] ? dayjs(inputs[c.paramName]) : null}
/>
</FormControl>
<Box
display="flex"
alignItems="center"
justifyContent="center"
marginInline={2}
>
{"-"}
</Box>
<FormControl fullWidth>
<DatePicker
label={c.label2}
onChange={makeDateToChangeHandler(c.paramName)}
value={inputs[c.paramName.concat("To") as T] ? dayjs(inputs[c.paramName.concat("To") as T]) : null}
/>
</FormControl>
</Box>
</LocalizationProvider>
)}
</Grid>
);
})}
</Grid>
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="text"
startIcon={<RestartAlt />}
onClick={handleReset}
>
{t("Reset")}
</Button>
<Button
variant="outlined"
startIcon={<Search />}
onClick={handleDownload}
>
{t("Download")}
</Button>
</CardActions>
</CardContent>
</Card>
);
}

export default SearchBox;

+ 0
- 3
src/components/Report/ReportSearchBox3/index.ts 查看文件

@@ -1,3 +0,0 @@
//src\components\SearchBox\index.ts
export { default } from "./SearchBox3";
export type { Criterion } from "./SearchBox3";

+ 0
- 17
src/components/Report/ResourceOverconsumptionReport/ResourceOverconsumptionReport.tsx 查看文件

@@ -1,17 +0,0 @@
//src\components\DelayReport\DelayReport.tsx
"use client";
import * as React from "react";
import "../../../app/global.css";
import { Suspense } from "react";
import ResourceOverconsumptionReportGen from "@/components/Report/ResourceOverconsumptionReportGen";

const ResourceOverconsumptionReport: React.FC = () => {

return (
<Suspense fallback={<ResourceOverconsumptionReportGen.Loading />}>
<ResourceOverconsumptionReportGen />
</Suspense>
);
};

export default ResourceOverconsumptionReport;

+ 0
- 2
src/components/Report/ResourceOverconsumptionReport/index.ts 查看文件

@@ -1,2 +0,0 @@
//src\components\LateStartReport\index.ts
export { default } from "./ResourceOverconsumptionReport";

+ 0
- 45
src/components/Report/ResourceOverconsumptionReportGen/ResourceOverconsumptionReportGen.tsx 查看文件

@@ -1,45 +0,0 @@
//src\components\LateStartReportGen\LateStartReportGen.tsx
"use client";
import React, { useMemo, useState } from "react";
import SearchBox, { Criterion } from "../ReportSearchBox3";
import { useTranslation } from "react-i18next";
import { ResourceOverconsumption } from "@/app/api/report3";

interface Props {
projects: ResourceOverconsumption[];
}
type SearchQuery = Partial<Omit<ResourceOverconsumption, "id">>;
type SearchParamNames = keyof SearchQuery;

const ProgressByClientSearch: React.FC<Props> = ({ projects }) => {
const { t } = useTranslation("projects");

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: "Team", paramName: "team", type: "select", options: ["AAA", "BBB", "CCC"] },
{ label: "Client", paramName: "client", type: "select", options: ["Cust A", "Cust B", "Cust C"] },
{ label: "Status", paramName: "status", type: "select", options: ["Overconsumption", "Potential Overconsumption"] },
// {
// label: "Status",
// label2: "Remained Date To",
// paramName: "targetEndDate",
// type: "dateRange",
// },
],
[t],
);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
console.log(query);
}}
/>
{/* <DownloadReportButton /> */}
</>
);
};

export default ProgressByClientSearch;

+ 0
- 19
src/components/Report/ResourceOverconsumptionReportGen/ResourceOverconsumptionReportGenWrapper.tsx 查看文件

@@ -1,19 +0,0 @@
//src\components\LateStartReportGen\LateStartReportGenWrapper.tsx
import { fetchProjectsResourceOverconsumption } from "@/app/api/report3";
import React from "react";
import ResourceOvercomsumptionReportGen from "./ResourceOverconsumptionReportGen";
import ResourceOvercomsumptionReportGenLoading from "./ResourceOverconsumptionReportGenLoading";

interface SubComponents {
Loading: typeof ResourceOvercomsumptionReportGenLoading;
}

const ResourceOvercomsumptionReportGenWrapper: React.FC & SubComponents = async () => {
const clentprojects = await fetchProjectsResourceOverconsumption();

return <ResourceOvercomsumptionReportGen projects={clentprojects} />;
};

ResourceOvercomsumptionReportGenWrapper.Loading = ResourceOvercomsumptionReportGenLoading;

export default ResourceOvercomsumptionReportGenWrapper;

+ 0
- 2
src/components/Report/ResourceOverconsumptionReportGen/index.ts 查看文件

@@ -1,2 +0,0 @@
//src\components\DelayReportGen\index.ts
export { default } from "./ResourceOverconsumptionReportGenWrapper";

+ 96
- 0
src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReport.tsx 查看文件

@@ -0,0 +1,96 @@
"use client";
import React, { useMemo } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import { ProjectResult } from "@/app/api/projects";
import { fetchMonthlyWorkHoursReport, fetchProjectCashFlowReport, fetchProjectResourceOverconsumptionReport } from "@/app/api/reports/actions";
import { downloadFile } from "@/app/utils/commonUtil";
import { BASE_API_URL } from "@/config/api";
import { ProjectResourceOverconsumptionReportFilter, ProjectResourceOverconsumptionReportRequest } from "@/app/api/reports";
import { StaffResult } from "@/app/api/staff";
import { TeamResult } from "@/app/api/team";
import { Customer } from "@/app/api/customer";

interface Props {
team: TeamResult[]
customer: Customer[]
}

type SearchQuery = Partial<Omit<ProjectResourceOverconsumptionReportFilter, "id">>;
type SearchParamNames = keyof SearchQuery;

const ResourceOverconsumptionReport: React.FC<Props> = ({ team, customer }) => {
const { t } = useTranslation("report");
const teamCombo = team.map(t => `${t.name} - ${t.code}`)
const custCombo = customer.map(c => `${c.name} - ${c.code}`)
const statusCombo = ["Overconsumption", "Potential Overconsumption"]
// const staffCombo = staffs.map(staff => `${staff.name} - ${staff.staffId}`)
// console.log(staffs)

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("Team"),
paramName: "team",
type: "select",
options: teamCombo,
needAll: true
},
{
label: t("Client"),
paramName: "customer",
type: "select",
options: custCombo,
needAll: true
},
{
label: t("Status"),
paramName: "status",
type: "select",
options: statusCombo,
needAll: true
},
{
label: t("lowerLimit"),
paramName: "lowerLimit",
type: "number",
},
],
[t],
);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={async (query: any) => {
let index = 0
let postData: ProjectResourceOverconsumptionReportRequest = {
status: "All",
lowerLimit: 0.9
}
if (query.team.length > 0 && query.team.toLocaleLowerCase() !== "all") {
index = teamCombo.findIndex(team => team === query.team)
postData.teamId = team[index].id
}
if (query.customer.length > 0 && query.customer.toLocaleLowerCase() !== "all") {
index = custCombo.findIndex(customer => customer === query.customer)
postData.custId = customer[index].id
}
if (Boolean(query.lowerLimit)) {
postData.lowerLimit = query.lowerLimit/100
}
postData.status = query.status
console.log(postData)
const response = await fetchProjectResourceOverconsumptionReport(postData)
if (response) {
downloadFile(new Uint8Array(response.blobValue), response.filename!!)
}
}
}
/>
</>
)
}

export default ResourceOverconsumptionReport

+ 41
- 0
src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReportLoading.tsx 查看文件

@@ -0,0 +1,41 @@
//src\components\LateStartReportGen\LateStartReportGenLoading.tsx
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const ResourceOvercomsumptionReportLoading: React.FC = () => {
return (
<>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton
variant="rounded"
height={50}
width={100}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
</Stack>
</CardContent>
</Card>
</>
);
};

export default ResourceOvercomsumptionReportLoading;

+ 20
- 0
src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReportWrapper.tsx 查看文件

@@ -0,0 +1,20 @@
import React from "react";
import ResourceOvercomsumptionReportLoading from "./ResourceOverconsumptionReportLoading";
import ResourceOverconsumptionReport from "./ResourceOverconsumptionReport";
import { fetchAllCustomers } from "@/app/api/customer";
import { fetchTeam } from "@/app/api/team";

interface SubComponents {
Loading: typeof ResourceOvercomsumptionReportLoading;
}

const ResourceOvercomsumptionReportWrapper: React.FC & SubComponents = async () => {
const customers = await fetchAllCustomers()
const teams = await fetchTeam ()

return <ResourceOverconsumptionReport team={teams} customer={customers}/>;
};

ResourceOvercomsumptionReportWrapper.Loading = ResourceOvercomsumptionReportLoading;

export default ResourceOvercomsumptionReportWrapper;

+ 1
- 0
src/components/ResourceOverconsumptionReport/index.ts 查看文件

@@ -0,0 +1 @@
export { default } from "./ResourceOverconsumptionReportWrapper";

+ 84
- 27
src/components/SearchBox/SearchBox.tsx 查看文件

@@ -4,7 +4,7 @@ import Grid from "@mui/material/Grid";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import React, { useCallback, useMemo, useState } from "react";
import React, { FocusEvent, KeyboardEvent, PointerEvent, useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import TextField from "@mui/material/TextField";
import FormControl from "@mui/material/FormControl";
@@ -15,18 +15,29 @@ import CardActions from "@mui/material/CardActions";
import Button from "@mui/material/Button";
import RestartAlt from "@mui/icons-material/RestartAlt";
import Search from "@mui/icons-material/Search";
import FileDownload from '@mui/icons-material/FileDownload';
import FileDownload from "@mui/icons-material/FileDownload";
import dayjs from "dayjs";
import "dayjs/locale/zh-hk";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { Box } from "@mui/material";
import { Box, FormHelperText } from "@mui/material";
import { DateCalendar } from "@mui/x-date-pickers";
import { fetchLateStartReport } from "@/app/api/reports/actions";
import { LateStartReportRequest } from "@/app/api/reports";
import { fetchTeamCombo } from "@/app/api/team/actions";
import { downloadFile } from "@/app/utils/commonUtil";
import {
Unstable_NumberInput as BaseNumberInput,
NumberInputProps,
numberInputClasses,
} from "@mui/base/Unstable_NumberInput";
import {
StyledButton,
StyledInputElement,
StyledInputRoot,
} from "@/theme/colorConst";
import { InputAdornment, NumberInput } from "../utils/numberInput";

interface BaseCriterion<T extends string> {
label: string;
@@ -47,23 +58,29 @@ interface SelectCriterion<T extends string> extends BaseCriterion<T> {

interface DateRangeCriterion<T extends string> extends BaseCriterion<T> {
type: "dateRange";
needMonth?: boolean;
}

interface MonthYearCriterion<T extends string> extends BaseCriterion<T> {
type: "monthYear";
}

interface NumberCriterion<T extends string> extends BaseCriterion<T> {
type: "number";
}

export type Criterion<T extends string> =
| TextCriterion<T>
| SelectCriterion<T>
| DateRangeCriterion<T>
| MonthYearCriterion<T>;
| MonthYearCriterion<T>
| NumberCriterion<T>;

interface Props<T extends string> {
criteria: Criterion<T>[];
onSearch: (inputs: Record<T, string>) => void;
onReset?: () => void;
formType?: String,
formType?: String;
}

function SearchBox<T extends string>({
@@ -79,10 +96,14 @@ function SearchBox<T extends string>({
(acc, c) => {
return {
...acc,
[c.paramName]: c.type === "select" ?
!(c.needAll === false) ? "All" :
c.options.length > 0 ? c.options[0] : ""
: ""
[c.paramName]:
c.type === "select"
? !(c.needAll === false)
? "All"
: c.options.length > 0
? c.options[0]
: ""
: "",
};
},
{} as Record<T, string>
@@ -90,7 +111,7 @@ function SearchBox<T extends string>({
[criteria]
);
const [inputs, setInputs] = useState(defaultInputs);
const makeInputChangeHandler = useCallback(
(paramName: T): React.ChangeEventHandler<HTMLInputElement> => {
return (e) => {
@@ -99,6 +120,15 @@ function SearchBox<T extends string>({
},
[]
);
const makeNumberChangeHandler = useCallback(
(paramName: T): (event: FocusEvent<HTMLInputElement, Element> | PointerEvent<Element> | KeyboardEvent<Element>, value: number | null) => void => {
return (event, value) => {
setInputs((i) => ({ ...i, [paramName]: value }));
};
},
[]
);

const makeSelectChangeHandler = useCallback((paramName: T) => {
return (e: SelectChangeEvent) => {
@@ -106,25 +136,37 @@ function SearchBox<T extends string>({
};
}, []);

const makeDateChangeHandler = useCallback((paramName: T) => {
const makeDateChangeHandler = useCallback((paramName: T, needMonth?: boolean) => {
return (e: any) => {
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") }));
if(needMonth){
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM") }));
}else{
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM-DD") }));
}
};
}, []);

const makeMonthYearChangeHandler = useCallback((paramName: T) => {
return (e: any) => {
console.log(dayjs(e).format("YYYY-MM"))
console.log(dayjs(e).format("YYYY-MM"));
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM") }));
};
}, []);

const makeDateToChangeHandler = useCallback((paramName: T) => {
const makeDateToChangeHandler = useCallback((paramName: T, needMonth?: boolean) => {
return (e: any) => {
if(needMonth){
setInputs((i) => ({
...i,
[paramName + "To"]: dayjs(e).format("YYYY-MM"),
}));
}else{
setInputs((i) => ({
...i,
[paramName + "To"]: dayjs(e).format("YYYY-MM-DD"),
}));
}
};
}, []);

@@ -172,6 +214,15 @@ function SearchBox<T extends string>({
</Select>
</FormControl>
)}
{c.type === "number" && (
<NumberInput
// defaultValue={90}
min={50}
max={99}
onChange={makeNumberChangeHandler(c.paramName)}
endAdornment={<InputAdornment>%</InputAdornment>}
/>
)}
{c.type === "monthYear" && (
<LocalizationProvider
dateAdapter={AdapterDayjs}
@@ -179,16 +230,18 @@ function SearchBox<T extends string>({
adapterLocale="zh-hk"
>
<Box display="flex">
<DateCalendar
views={["month", "year"]}
openTo="month"
onChange={makeMonthYearChangeHandler(c.paramName)}
value={
inputs[c.paramName]
? dayjs(inputs[c.paramName])
: null
}
/>
<FormControl fullWidth>
<DatePicker
label={c.label}
onChange={makeMonthYearChangeHandler(c.paramName)}
value={
inputs[c.paramName]
? dayjs(inputs[c.paramName])
: dayjs()
}
views={["month","year"]}
/>
</FormControl>
</Box>
</LocalizationProvider>
)}
@@ -202,12 +255,13 @@ function SearchBox<T extends string>({
<FormControl fullWidth>
<DatePicker
label={c.label}
onChange={makeDateChangeHandler(c.paramName)}
onChange={makeDateChangeHandler(c.paramName, c.needMonth)}
value={
inputs[c.paramName]
? dayjs(inputs[c.paramName])
: null
}
views={c.needMonth ? ["month", "year"] : ["day", "month", "year"]}
/>
</FormControl>
<Box
@@ -221,12 +275,13 @@ function SearchBox<T extends string>({
<FormControl fullWidth>
<DatePicker
label={c.label2}
onChange={makeDateToChangeHandler(c.paramName)}
onChange={makeDateToChangeHandler(c.paramName, c.needMonth)}
value={
inputs[c.paramName.concat("To") as T]
? dayjs(inputs[c.paramName.concat("To") as T])
: null
}
views={c.needMonth ? ["month", "year"] : ["day", "month", "year"]}
/>
</FormControl>
</Box>
@@ -246,7 +301,9 @@ function SearchBox<T extends string>({
</Button>
<Button
variant="outlined"
startIcon={(formType === "download" && <FileDownload />) || <Search />}
startIcon={
(formType === "download" && <FileDownload />) || <Search />
}
onClick={handleSearch}
>
{(formType === "download" && t("Download")) || t("Search")}


+ 32
- 1
src/components/TimesheetModal/TimesheetModal.tsx 查看文件

@@ -26,6 +26,12 @@ import FullscreenModal from "../FullscreenModal";
import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable";
import useIsMobile from "@/app/utils/useIsMobile";
import { HolidaysResult } from "@/app/api/holidays";
import {
DAILY_NORMAL_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
validateTimesheet,
} from "@/app/api/timesheets/utils";
import ErrorAlert from "../ErrorAlert";

interface Props {
isOpen: boolean;
@@ -77,6 +83,15 @@ const TimesheetModal: React.FC<Props> = ({

const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>(
async (data) => {
const errors = validateTimesheet(data, leaveRecords, companyHolidays);
if (errors) {
Object.keys(errors).forEach((date) =>
formProps.setError(date, {
message: errors[date],
}),
);
return;
}
const savedRecords = await saveTimesheet(data, username);

const today = dayjs();
@@ -93,7 +108,7 @@ const TimesheetModal: React.FC<Props> = ({
formProps.reset(newFormValues);
onClose();
},
[formProps, onClose, username],
[companyHolidays, formProps, leaveRecords, onClose, username],
);

const onCancel = useCallback(() => {
@@ -110,6 +125,20 @@ const TimesheetModal: React.FC<Props> = ({
[onClose],
);

const errorComponent = (
<ErrorAlert
errors={Object.keys(formProps.formState.errors).map((date) => {
const error = formProps.formState.errors[date]?.message;
return error
? `${date}: ${t(error, {
TIMESHEET_DAILY_MAX_HOURS,
DAILY_NORMAL_MAX_HOURS,
})}`
: undefined;
})}
/>
);

const matches = useIsMobile();

return (
@@ -138,6 +167,7 @@ const TimesheetModal: React.FC<Props> = ({
leaveRecords={leaveRecords}
/>
</Box>
{errorComponent}
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
@@ -176,6 +206,7 @@ const TimesheetModal: React.FC<Props> = ({
assignedProjects={assignedProjects}
allProjects={allProjects}
leaveRecords={leaveRecords}
errorComponent={errorComponent}
/>
</Box>
</FullscreenModal>


+ 4
- 2
src/components/TimesheetTable/EntryInputTable.tsx 查看文件

@@ -89,7 +89,8 @@ const EntryInputTable: React.FC<Props> = ({
}, {});
}, [assignedProjects]);

const { getValues, setValue } = useFormContext<RecordTimesheetInput>();
const { getValues, setValue, clearErrors } =
useFormContext<RecordTimesheetInput>();
const currentEntries = getValues(day);

const [entries, setEntries] = useState<TimeEntryRow[]>(currentEntries || []);
@@ -398,7 +399,8 @@ const EntryInputTable: React.FC<Props> = ({
...entry,
})),
]);
}, [getValues, entries, setValue, day]);
clearErrors(day);
}, [getValues, entries, setValue, day, clearErrors]);

const hasOutOfPlannedStages = entries.some(
(entry) => entry.isPlanned !== undefined && !entry.isPlanned,


+ 5
- 3
src/components/TimesheetTable/MobileTimesheetEntry.tsx 查看文件

@@ -51,7 +51,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({
const holiday = getHolidayForDate(date, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

const { watch, setValue } = useFormContext<RecordTimesheetInput>();
const { watch, setValue, clearErrors } = useFormContext<RecordTimesheetInput>();
const currentEntries = watch(date);

// Edit modal
@@ -70,13 +70,14 @@ const MobileTimesheetEntry: React.FC<Props> = ({
date,
currentEntries.filter((entry) => entry.id !== defaultValues.id),
);
clearErrors(date);
setEditModalOpen(false);
}
: undefined,
});
setEditModalOpen(true);
},
[currentEntries, date, setValue],
[clearErrors, currentEntries, date, setValue],
);

const closeEditModal = useCallback(() => {
@@ -93,12 +94,13 @@ const MobileTimesheetEntry: React.FC<Props> = ({
...(e.id === existingEntry.id ? entry : e),
})),
);
clearErrors(date);
} else {
setValue(date, [...currentEntries, entry]);
}
setEditModalOpen(false);
},
[currentEntries, date, setValue],
[clearErrors, currentEntries, date, setValue],
);

return (


+ 3
- 0
src/components/TimesheetTable/MobileTimesheetTable.tsx 查看文件

@@ -14,6 +14,7 @@ interface Props {
assignedProjects: AssignedProject[];
leaveRecords: RecordLeaveInput;
companyHolidays: HolidaysResult[];
errorComponent?: React.ReactNode;
}

const MobileTimesheetTable: React.FC<Props> = ({
@@ -21,6 +22,7 @@ const MobileTimesheetTable: React.FC<Props> = ({
assignedProjects,
leaveRecords,
companyHolidays,
errorComponent,
}) => {
const { watch } = useFormContext<RecordTimesheetInput>();
const currentInput = watch();
@@ -34,6 +36,7 @@ const MobileTimesheetTable: React.FC<Props> = ({
timesheetEntries={currentInput}
EntryComponent={MobileTimesheetEntry}
entryComponentProps={{ allProjects, assignedProjects, companyHolidays }}
errorComponent={errorComponent}
/>
);
};


+ 13
- 9
src/components/UserGroupSearch/UserGroupSearch.tsx 查看文件

@@ -26,10 +26,15 @@ const UserGroupSearch: React.FC<Props> = ({ users }) => {
const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("User Name"),
label: t("Group Name"),
paramName: "name",
type: "text",
},
{
label: t("Description"),
paramName: "description",
type: "text",
},
],
[t]
);
@@ -75,14 +80,13 @@ const UserGroupSearch: React.FC<Props> = ({ users }) => {
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
// setFilteredUser(
// users.filter(
// (t) =>
// t.name.toLowerCase().includes(query.name.toLowerCase()) &&
// t.code.toLowerCase().includes(query.code.toLowerCase()) &&
// t.description.toLowerCase().includes(query.description.toLowerCase())
// )
// )
setFilteredUser(
users.filter(
(u) =>
u.name.toLowerCase().includes(query.name.toLowerCase()) &&
u.description.toLowerCase().includes(query.description.toLowerCase())
)
)
}}
/>
<SearchResults<UserGroupResult> items={filteredUser} columns={columns} />


+ 192
- 0
src/components/utils/numberInput.tsx 查看文件

@@ -0,0 +1,192 @@
import * as React from 'react';
import {
Unstable_NumberInput as BaseNumberInput,
NumberInputProps,
numberInputClasses,
} from '@mui/base/Unstable_NumberInput';
import { styled } from '@mui/system';
// FocusEvent<HTMLInputElement, Element> | PointerEvent<Element> | KeyboardEvent<Element>
export const NumberInput = React.forwardRef(function CustomNumberInput(
props: NumberInputProps,
ref: React.ForwardedRef<HTMLDivElement>,
) {
return (
<BaseNumberInput
slots={{
root: StyledInputRoot,
input: StyledInputElement,
incrementButton: StyledButton,
decrementButton: StyledButton,
}}
slotProps={{
incrementButton: {
children: '▴',
},
decrementButton: {
children: '▾',
},
}}
{...props}
ref={ref}
/>
);
});
export const InputAdornment = styled('div')(
({ theme }) => `
margin: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
grid-row: 1/3;
color: ${theme.palette.mode === 'dark' ? grey[500] : grey[700]};
`,
);

export default function NumberInputBasic() {
const [value, setValue] = React.useState<number | null>(null);
return (
<NumberInput
aria-label="Demo number input"
placeholder="Type a number…"
value={value}
onChange={(event, val) => setValue(val)}
/>
);
}

const blue = {
100: '#DAECFF',
200: '#80BFFF',
400: '#3399FF',
500: '#007FFF',
600: '#0072E5',
};

const grey = {
50: '#F3F6F9',
100: '#E5EAF2',
200: '#DAE2ED',
300: '#C7D0DD',
400: '#B0B8C4',
500: '#9DA8B7',
600: '#6B7A90',
700: '#434D5B',
800: '#303740',
900: '#1C2025',
};

const StyledInputRoot = styled('div')(
({ theme }) => `
font-family: 'IBM Plex Sans', sans-serif;
font-weight: 400;
border-radius: 8px;
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
display: grid;
grid-template-columns: 1fr 19px;
grid-template-rows: 1fr 1fr;
overflow: hidden;
column-gap: 8px;
padding: 4px;

&.${numberInputClasses.focused} {
border-color: ${blue[400]};
box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
}

&:hover {
border-color: ${blue[400]};
}

// firefox
&:focus-visible {
outline: 0;
}
`,
);

const StyledInputElement = styled('input')(
({ theme }) => `
font-size: 0.875rem;
font-family: inherit;
font-weight: 400;
line-height: 1.5;
grid-column: 1/2;
grid-row: 1/3;
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
background: inherit;
border: none;
border-radius: inherit;
padding: 8px 12px;
outline: 0;
`,
);

const StyledButton = styled('button')(
({ theme }) => `
display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
appearance: none;
padding: 0;
width: 19px;
height: 19px;
font-family: system-ui, sans-serif;
font-size: 0.875rem;
line-height: 1;
box-sizing: border-box;
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
border: 0;
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 120ms;

&:hover {
background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
cursor: pointer;
}

&.${numberInputClasses.incrementButton} {
grid-column: 2/3;
grid-row: 1/2;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border: 1px solid;
border-bottom: 0;
&:hover {
cursor: pointer;
background: ${blue[400]};
color: ${grey[50]};
}

border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]};
background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
}

&.${numberInputClasses.decrementButton} {
grid-column: 2/3;
grid-row: 2/3;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border: 1px solid;
&:hover {
cursor: pointer;
background: ${blue[400]};
color: ${grey[50]};
}

border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]};
background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
}
& .arrow {
transform: translateY(-1px);
}
`,
);

+ 7
- 0
src/i18n/zh/changePassword.json 查看文件

@@ -0,0 +1,7 @@
{
"Change Password": "更改密碼",
"Please Fill in All Fields": "請填寫以下項目",
"Input Old Password": "舊密碼",
"Input New Password": "新密碼",
"Input New Password Again": "重複輸入新密碼"
}

+ 27
- 0
src/i18n/zh/group.json 查看文件

@@ -0,0 +1,27 @@
{
"User Group": "用戶群組",
"Create User Group": "建立用戶群組",
"Edit User Group": "編輯用戶群組",
"Edit": "編輯",
"Group Name": "名稱",
"Description": "描述",
"Group Info": "群組資料",
"Add": "新增",
"User": "用戶",
"Remove": "移除",
"Confirm": "確定",
"Cancel": "取消",
"Delete": "刪除",
"Search by ": "搜尋",
"Delete Success": "刪除成功",
"Please input correct ": "請輸入正確",
"Authority Allocation": "權限分配",
"Authority Pool": "權限池",
"Allocated Authority": "已分配權限",
"User Allocation": "用戶分配",
"User Pool": "用戶池",
"Allocated Users": "已分配用戶",
"Username": "用戶名",
"Staff Name": "員工名字",
"Authority": "權限"
}

+ 9
- 1
src/i18n/zh/report.json 查看文件

@@ -1,4 +1,12 @@
{
"Staff Monthly Work Hours Analysis Report": "Staff Monthly Work Hours Analysis Report",
"Project Resource Overconsumption Report": "Project Resource Overconsumption Report",

"Project": "項目",
"Date Type": "日期類型"
"Date Type": "日期類型",
"Date": "日期",
"Team": "隊伍",
"Client": "客戶",
"Status": "狀態",
"Staff": "員工"
}

+ 7
- 0
src/i18n/zh/user.json 查看文件

@@ -0,0 +1,7 @@
{
"Edit User": "編輯用戶",
"User Detail": "用戶資料",
"User Authority": "權限",
"username": "用戶名",
"password": "更改密碼"
}

+ 153
- 0
src/theme/colorConst.js 查看文件

@@ -1,5 +1,11 @@
import { createTheme } from "@mui/material";
import { aborted } from "util";
import { styled } from '@mui/system';
import {
Unstable_NumberInput as BaseNumberInput,
NumberInputProps,
numberInputClasses,
} from '@mui/base/Unstable_NumberInput';

// - - - - - - WORK IN PROGRESS - - - - - - //

@@ -415,3 +421,150 @@ export const TSMS_LONG_BUTTON_THEME = createTheme({
},
},
});

export default function NumberInputBasic() {
const [value, setValue] = React.useState<number | null>(null);
return (
<NumberInput
aria-label="Demo number input"
placeholder="Type a number…"
value={value}
onChange={(event, val) => setValue(val)}
/>
);
}
const blue = {
100: '#DAECFF',
200: '#80BFFF',
400: '#3399FF',
500: '#007FFF',
600: '#0072E5',
};

const grey = {
50: '#F3F6F9',
100: '#E5EAF2',
200: '#DAE2ED',
300: '#C7D0DD',
400: '#B0B8C4',
500: '#9DA8B7',
600: '#6B7A90',
700: '#434D5B',
800: '#303740',
900: '#1C2025',
};
export const StyledInputRoot = styled('div')(
({ theme }) => `
font-family: 'IBM Plex Sans', sans-serif;
font-weight: 400;
border-radius: 8px;
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
display: grid;
grid-template-columns: 1fr 19px;
grid-template-rows: 1fr 1fr;
overflow: hidden;
column-gap: 8px;
padding: 4px;

&.${numberInputClasses.focused} {
border-color: ${blue[400]};
box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
}

&:hover {
border-color: ${blue[400]};
}

// firefox
&:focus-visible {
outline: 0;
}
`,
);

export const StyledInputElement = styled('input')(
({ theme }) => `
font-size: 0.875rem;
font-family: inherit;
font-weight: 400;
line-height: 1.5;
grid-column: 1/2;
grid-row: 1/3;
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
background: inherit;
border: none;
border-radius: inherit;
padding: 8px 12px;
outline: 0;
`,
);

export const StyledButton = styled('button')(
({ theme }) => `
display: flex;
flex-flow: row nowrap;
justify-content: center;
align-items: center;
appearance: none;
padding: 0;
width: 19px;
height: 19px;
font-family: system-ui, sans-serif;
font-size: 0.875rem;
line-height: 1;
box-sizing: border-box;
background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
border: 0;
color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 120ms;

&:hover {
background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
cursor: pointer;
}

&.${numberInputClasses.incrementButton} {
grid-column: 2/3;
grid-row: 1/2;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border: 1px solid;
border-bottom: 0;
&:hover {
cursor: pointer;
background: ${blue[400]};
color: ${grey[50]};
}

border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]};
background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
}

&.${numberInputClasses.decrementButton} {
grid-column: 2/3;
grid-row: 2/3;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border: 1px solid;
&:hover {
cursor: pointer;
background: ${blue[400]};
color: ${grey[50]};
}

border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]};
background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
}
& .arrow {
transform: translateY(-1px);
}
`,
);

正在加载...
取消
保存