Przeglądaj źródła

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

tags/Baseline_30082024_FRONTEND_UAT
leoho2fi 1 rok temu
rodzic
commit
6bf8b9990a
26 zmienionych plików z 863 dodań i 148 usunięć
  1. +75
    -1
      src/app/api/cashflow/index.ts
  2. +4
    -0
      src/app/api/reports/index.ts
  3. +13
    -5
      src/app/api/timesheets/utils.ts
  4. +1
    -1
      src/app/utils/formatUtil.ts
  5. +16
    -0
      src/app/utils/manhourUtils.ts
  6. +1
    -0
      src/components/CostAndExpenseReport/CostAndExpenseReport.tsx
  7. +9
    -8
      src/components/CreateCompany/CompanyDetails.tsx
  8. +1
    -1
      src/components/CreateCompany/CreateCompany.tsx
  9. +1
    -0
      src/components/GenerateMonthlyWorkHoursReport/GenerateMonthlyWorkHoursReport.tsx
  10. +31
    -3
      src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx
  11. +143
    -38
      src/components/ProjectCashFlow/ProjectCashFlow.tsx
  12. +1
    -0
      src/components/ProjectCompletionReport/ProjectCompletionReport.tsx
  13. +1
    -0
      src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReport.tsx
  14. +7
    -16
      src/components/SearchBox/SearchBox.tsx
  15. +15
    -2
      src/components/TimesheetModal/TimesheetModal.tsx
  16. +85
    -45
      src/components/TimesheetTable/EntryInputTable.tsx
  17. +309
    -0
      src/components/TimesheetTable/FastTimeEntryModal.tsx
  18. +42
    -13
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  19. +8
    -1
      src/components/TimesheetTable/MobileTimesheetTable.tsx
  20. +81
    -13
      src/components/TimesheetTable/ProjectSelect.tsx
  21. +5
    -0
      src/components/TimesheetTable/TimesheetEditModal.tsx
  22. +3
    -1
      src/components/TimesheetTable/TimesheetTable.tsx
  23. +3
    -0
      src/components/UserWorkspacePage/UserWorkspacePage.tsx
  24. +2
    -0
      src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx
  25. +3
    -0
      src/i18n/en/report.json
  26. +3
    -0
      src/i18n/zh/report.json

+ 75
- 1
src/app/api/cashflow/index.ts Wyświetl plik

@@ -11,12 +11,53 @@ export interface CashFlow {
teamLeader: string;
startDate: string;
startDateFrom: string;
startDateTo: string;
startDateFromTo: string;
targetEndDate: string;
client: string;
subsidiary: string;
}

export interface CashFlowByMonthChartResult {
monthInvoice: string;
invoiceMonth: string;
income: number;
cumulativeIncome:number;
monthExpenditure:string;
recordMonth:string;
expenditure:number;
cumulativeExpenditure:number;
incomeList: any[];
expenditureList: any[];
}

export interface CashFlowReceivableAndExpenditure {
receivedPercentage: number;
expenditurePercentage: number;
totalInvoiced: number;
totalReceived: number;
receivable: number;
totalBudget: number;
totalExpenditure: number;
expenditureReceivable: number;
}
export interface CashFlowAnticipatedChartResult {
anticipateIncomeList: any[];
anticipateExpenditure: any[];
monthanticipateIncome: number;
anticipateIncomeDate: number;
anticipateIncome:number;
anticipateExpenditureList: any[];
id: number;
name: string;
planStart: string;
planEnd: string;
startMonth: number;
Duration: number;
AverageManhours: number;
teamLead: number;
totalManhour: number;
}

export const preloadProjects = () => {
fetchProjectsCashFlow();
};
@@ -24,3 +65,36 @@ export const preloadProjects = () => {
export const fetchProjectsCashFlow = cache(async () => {
return serverFetchJson<CashFlow[]>(`${BASE_API_URL}/dashboard/searchCashFlowProject`);
});

export const fetchProjectsCashFlowMonthlyChart = cache(async (projectIdList: number[], year: number) => {
if (projectIdList.length !== 0) {
const queryParams = new URLSearchParams();
queryParams.append('projectIdList', projectIdList.join(','));
return serverFetchJson<CashFlowByMonthChartResult[]>(`${BASE_API_URL}/dashboard/searchCashFlowByMonth?${queryParams.toString()}&year=${year}`);
} else {
return [];
}
});

export const fetchProjectsCashFlowReceivableAndExpenditure = cache(async (projectIdList: number[]) => {
if (projectIdList.length !== 0) {
const queryParams = new URLSearchParams();
queryParams.append('projectIdList', projectIdList.join(','));
return serverFetchJson<CashFlowReceivableAndExpenditure[]>(`${BASE_API_URL}/dashboard/searchCashFlowReceivableAndExpenditure?${queryParams.toString()}`);
} else {
return [];
}
});

export const fetchProjectsCashFlowAnticipate = cache(async (projectIdList: number[],year:number) => {
if (projectIdList.length !== 0) {
const queryParams = new URLSearchParams();
queryParams.append('projectIdList', projectIdList.join(','));
return serverFetchJson<CashFlowAnticipatedChartResult[]>(`${BASE_API_URL}/dashboard/searchCashFlowAnticipate?${queryParams.toString()}&year=${year}`);
} else {
return [];
}
});

+ 4
- 0
src/app/api/reports/index.ts Wyświetl plik

@@ -32,11 +32,15 @@ export interface ProjectCashFlowReportRequest {
export interface ProjectPotentialDelayReportFilter {
team: string[];
client: string[];
numberOfDays: number;
projectCompletion: number;
}

export interface ProjectPotentialDelayReportRequest {
teamId: number | "All";
clientId: number | "All";
numberOfDays: number;
projectCompletion: number;
}

// - Monthly Work Hours Report


+ 13
- 5
src/app/api/timesheets/utils.ts Wyświetl plik

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

interface TimeEntryValidationOptions {
skipTaskValidation?: boolean;
}

/**
* @param entry - the time entry
* @returns an object where the keys are the error fields and the values the error message, and undefined if there are no errors
@@ -20,6 +24,7 @@ export type TimeEntryError = {
export const validateTimeEntry = (
entry: Partial<TimeEntry>,
isHoliday: boolean,
options: TimeEntryValidationOptions = {},
): TimeEntryError | undefined => {
// Test for errors
const error: TimeEntryError = {};
@@ -41,10 +46,12 @@ export const validateTimeEntry = (

// If there is a project id, there should also be taskGroupId, taskId, inputHours
if (entry.projectId) {
if (!entry.taskGroupId) {
error.taskGroupId = "Required";
} else if (!entry.taskId) {
error.taskId = "Required";
if (!options.skipTaskValidation) {
if (!entry.taskGroupId) {
error.taskGroupId = "Required";
} else if (!entry.taskId) {
error.taskId = "Required";
}
}
} else {
if (!entry.remark) {
@@ -71,6 +78,7 @@ export const validateTimesheet = (
timesheet: RecordTimesheetInput,
leaveRecords: RecordLeaveInput,
companyHolidays: HolidaysResult[],
options: TimeEntryValidationOptions = {},
): { [date: string]: string } | undefined => {
const errors: { [date: string]: string } = {};

@@ -86,7 +94,7 @@ export const validateTimesheet = (

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

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


+ 1
- 1
src/app/utils/formatUtil.ts Wyświetl plik

@@ -52,7 +52,7 @@ export const convertTimeArrayToString = (
format: string = OUTPUT_TIME_FORMAT,
needTime: boolean = false,
) => {
let timeString = "";
let timeString = null;

if (timeArray !== null && timeArray !== undefined) {
const hour = timeArray[0] || 0;


+ 16
- 0
src/app/utils/manhourUtils.ts Wyświetl plik

@@ -1,3 +1,19 @@
import zipWith from "lodash/zipWith";

export const roundToNearestQuarter = (n: number): number => {
return Math.round(n / 0.25) * 0.25;
};

export const distributeQuarters = (hours: number, parts: number): number[] => {
if (!parts) return [];

const numQuarters = hours * 4;
const equalParts = Math.floor(numQuarters / parts);
const remainders = Array(numQuarters % parts).fill(1);

return zipWith(
Array(parts).fill(equalParts),
remainders,
(a, b) => a + (b || 0),
).map((quarters) => quarters / 4);
};

+ 1
- 0
src/components/CostAndExpenseReport/CostAndExpenseReport.tsx Wyświetl plik

@@ -49,6 +49,7 @@ const CostAndExpenseReport: React.FC<Props> = ({ team, customer }) => {
return (
<>
<SearchBox
formType={"download"}
criteria={searchCriteria}
onSearch={async (query: any) => {
let index = 0


+ 9
- 8
src/components/CreateCompany/CompanyDetails.tsx Wyświetl plik

@@ -47,10 +47,10 @@ const CompanyDetails: React.FC<Props> = ({
// console.log(content)

useEffect(() => {
setValue("normalHourFrom", convertTimeArrayToString(content.normalHourFrom, "HH:mm:ss", false));
setValue("normalHourTo", convertTimeArrayToString(content.normalHourTo, "HH:mm:ss", false));
setValue("otHourFrom", convertTimeArrayToString(content.otHourFrom, "HH:mm:ss", false));
setValue("otHourTo", convertTimeArrayToString(content.otHourTo, "HH:mm:ss", false));
setValue("normalHourFrom", convertTimeArrayToString(content.normalHourFrom, "HH:mm:ss", false) ?? "09:00:00");
setValue("normalHourTo", convertTimeArrayToString(content.normalHourTo, "HH:mm:ss", false) ?? "18:00:00");
setValue("otHourFrom", convertTimeArrayToString(content.otHourFrom, "HH:mm:ss", false) ?? "20:00:00");
setValue("otHourTo", convertTimeArrayToString(content.otHourTo, "HH:mm:ss", false) ?? "08:00:00");
}, [content])

return (
@@ -125,10 +125,11 @@ const CompanyDetails: React.FC<Props> = ({
<FormControl fullWidth>
<TimePicker
label={t("Normal Hour From")}
value={content.normalHourFrom !== undefined && content.normalHourFrom !== null ?
defaultValue={content.normalHourFrom !== undefined && content.normalHourFrom !== null ?
dayjs().hour(content.normalHourFrom[0]).minute(content.normalHourFrom[1]) :
dayjs().hour(9).minute(0)}
onChange={(time) => {
console.log(time?.format("HH:mm:ss"))
if (!time) return;
setValue("normalHourFrom", time.format("HH:mm:ss"));
}}
@@ -144,7 +145,7 @@ const CompanyDetails: React.FC<Props> = ({
<FormControl fullWidth>
<TimePicker
label={t("Normal Hour To")}
value={content.normalHourTo !== undefined && content.normalHourTo !== null ?
defaultValue={content.normalHourTo !== undefined && content.normalHourTo !== null ?
dayjs().hour(content.normalHourTo[0]).minute(content.normalHourTo[1]) :
dayjs().hour(18).minute(0)}
onChange={(time) => {
@@ -163,7 +164,7 @@ const CompanyDetails: React.FC<Props> = ({
<FormControl fullWidth>
<TimePicker
label={t("OT Hour From")}
value={content.otHourFrom !== undefined && content.otHourFrom !== null ?
defaultValue={content.otHourFrom !== undefined && content.otHourFrom !== null ?
dayjs().hour(content.otHourFrom[0]).minute(content.otHourFrom[1]) :
dayjs().hour(20).minute(0)}
onChange={(time) => {
@@ -182,7 +183,7 @@ const CompanyDetails: React.FC<Props> = ({
<FormControl fullWidth>
<TimePicker
label={t("OT Hour To")}
value={content.otHourTo !== undefined && content.otHourTo !== null ?
defaultValue={content.otHourTo !== undefined && content.otHourTo !== null ?
dayjs().hour(content.otHourTo[0]).minute(content.otHourTo[1]) :
dayjs().hour(8).minute(0)}
onChange={(time) => {


+ 1
- 1
src/components/CreateCompany/CreateCompany.tsx Wyświetl plik

@@ -69,7 +69,7 @@ const CreateCompany: React.FC<Props> = ({
contactName: company?.contactName,
phone: company?.phone,
otHourTo: "",
otHourFrom: "",
otHourFrom: "",
normalHourTo: "",
normalHourFrom: "",
currency: company?.currency,


+ 1
- 0
src/components/GenerateMonthlyWorkHoursReport/GenerateMonthlyWorkHoursReport.tsx Wyświetl plik

@@ -46,6 +46,7 @@ const GenerateMonthlyWorkHoursReport: React.FC<Props> = ({ staffs }) => {
return (
<>
<SearchBox
formType={"download"}
criteria={searchCriteria}
onSearch={async (query: any) => {
const index = staffCombo.findIndex((staff) => staff === query.staff);


+ 31
- 3
src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx Wyświetl plik

@@ -4,7 +4,7 @@ import React, { useMemo } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import { ProjectPotentialDelayReportFilter } from "@/app/api/reports";
import { fetchProjectCashFlowReport, fetchProjectPotentialDelayReport } from "@/app/api/reports/actions";
import { fetchProjectPotentialDelayReport } from "@/app/api/reports/actions";
import { downloadFile } from "@/app/utils/commonUtil";
import { TeamResult } from "@/app/api/team";
import { Customer } from "@/app/api/customer";
@@ -21,13 +21,19 @@ const GenerateProjectPotentialDelayReport: React.FC<Props> = ({ teams, clients }
const { t } = useTranslation("report");
const teamCombo = teams.map(team => `${team.code} - ${team.name}`)
const clientCombo = clients.map(client => `${client.code} - ${client.name}`)
const [errors, setErrors] = React.useState({
numberOfDays: false,
projectCompletion: false,
})

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Team"), paramName: "team", type: "select", options: teamCombo },
{ label: t("Client"), paramName: "client", type: "select", options: clientCombo },
{ label: t("Number Of Days"), paramName: "numberOfDays", type: "text", textType: "number", error: errors.numberOfDays, helperText: t("Can not be null and decimal, and should be >= 0") },
{ label: t("Project Completion (<= %)"), paramName: "projectCompletion", type: "text", textType: "number", error: errors.projectCompletion, helperText: t("Can not be null and decimal, and should be in range of 0 - 100") },
],
[t],
[t, errors],
);

return (
@@ -36,10 +42,32 @@ const GenerateProjectPotentialDelayReport: React.FC<Props> = ({ teams, clients }
criteria={searchCriteria}
onSearch={async (query) => {

let hasError = false
if (query.numberOfDays.length === 0 || !Number.isInteger(parseFloat(query.numberOfDays)) || parseInt(query.numberOfDays) < 0) {
setErrors((prev) => ({...prev, numberOfDays: true}))
hasError = true
} else {
setErrors((prev) => ({...prev, numberOfDays: false}))
}

if (query.projectCompletion.length === 0 || !Number.isInteger(parseFloat(query.projectCompletion)) || parseInt(query.projectCompletion) < 0 || parseInt(query.projectCompletion) > 100) {
setErrors((prev) => ({...prev, projectCompletion: true}))
hasError = true
} else {
setErrors((prev) => ({...prev, projectCompletion: false}))
}

if (hasError) return false

const teamIndex = teamCombo.findIndex(team => team === query.team)
const clientIndex = clientCombo.findIndex(client => client === query.client)
const response = await fetchProjectPotentialDelayReport({ teamId: teams[teamIndex]?.id ?? "All", clientId: clients[clientIndex]?.id ?? "All" })
const response = await fetchProjectPotentialDelayReport({
teamId: teams[teamIndex]?.id ?? "All",
clientId: clients[clientIndex]?.id ?? "All",
numberOfDays: parseInt(query.numberOfDays),
projectCompletion: parseInt(query.projectCompletion)
})
if (response) {
downloadFile(new Uint8Array(response.blobValue), response.filename!!)
}


+ 143
- 38
src/components/ProjectCashFlow/ProjectCashFlow.tsx Wyświetl plik

@@ -19,9 +19,10 @@ 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 { fetchProjectsCashFlow,fetchProjectsCashFlowMonthlyChart,fetchProjectsCashFlowReceivableAndExpenditure,fetchProjectsCashFlowAnticipate} from "@/app/api/cashflow";
import { Input, Label } from "reactstrap";
import { CashFlow } from "@/app/api/cashflow";
import dayjs from 'dayjs';

interface Props {
projects: CashFlow[];
@@ -34,19 +35,115 @@ const ProjectCashFlow: React.FC = () => {
const todayDate = new Date();
const [selectionModel, setSelectionModel]: any[] = React.useState([]);
const [projectData, setProjectData]: any[] = React.useState([]);
const [filteredResult, setFilteredResult]:any[] = useState([]);
const [selectedProjectIdList, setSelectedProjectIdList]: any[] = React.useState([]);
const [monthlyIncomeList, setMonthlyIncomeList]: any[] = React.useState([]);
const [monthlyCumulativeIncomeList, setMonthlyCumulativeIncomeList]: any[] = React.useState([]);
const [monthlyExpenditureList, setMonthlyExpenditureList]: any[] = React.useState([]);
const [monthlyCumulativeExpenditureList, setMonthlyCumulativeExpenditureList]: any[] = React.useState([]);
const [monthlyChartLeftMax, setMonthlyChartLeftMax]: any[] = React.useState(0);
const [monthlyChartRightMax, setMonthlyChartRightMax]: any[] = React.useState(0);
const [receivedPercentage,setReceivedPercentage]: any[] = React.useState(0);
const [totalBudget,setTotalBudget]: any[] = React.useState(0);
const [totalInvoiced,setTotalInvoiced]: any[] = React.useState(0);
const [totalReceived,setTotalReceived]: any[] = React.useState(0);
const [receivable,setReceivable]: any[] = React.useState(0);
const [totalExpenditure,setTotalExpenditure]: any[] = React.useState(0);
const [expenditureReceivable,setExpenditureReceivable]: any[] = React.useState(0);
const [expenditurePercentage,setExpenditurePercentage]: any[] = React.useState(0);
const [cashFlowYear, setCashFlowYear]: any[] = React.useState(
todayDate.getFullYear(),
);
const [anticipateCashFlowYear, setAnticipateCashFlowYear]: any[] = React.useState(
todayDate.getFullYear(),
);

const handleSelectionChange = (newSelectionModel: GridRowSelectionModel) => {
const selectedRowsData = projectData.filter((row: any) =>
newSelectionModel.includes(row.id),
);
const projectIdList = []
for (var i=0; i<selectedRowsData.length; i++){
projectIdList.push(selectedRowsData[i].id)
}
setSelectedProjectIdList(projectIdList)
};

const fetchData = async () => {
const cashFlowProject = await fetchProjectsCashFlow();
console.log(cashFlowProject)
setProjectData(cashFlowProject)
setFilteredResult(cashFlowProject)
}
const fetchChartData = async () => {
const cashFlowMonthlyChartData = await fetchProjectsCashFlowMonthlyChart(selectedProjectIdList,cashFlowYear);
console.log(cashFlowMonthlyChartData)
const monthlyIncome = []
const cumulativeIncome = []
const monthlyExpenditure = []
const cumulativeExpenditure = []
var leftMax = 0
var rightMax = 0
if (cashFlowMonthlyChartData.length !== 0) {
for (var i = 0; i < cashFlowMonthlyChartData[0].incomeList.length; i++) {
if (leftMax < cashFlowMonthlyChartData[0].incomeList[i].income || leftMax < cashFlowMonthlyChartData[0].expenditureList[i].expenditure){
leftMax = Math.max(cashFlowMonthlyChartData[0].incomeList[i].income,cashFlowMonthlyChartData[0].expenditureList[i].expenditure)
}
monthlyIncome.push(cashFlowMonthlyChartData[0].incomeList[i].income)
cumulativeIncome.push(cashFlowMonthlyChartData[0].incomeList[i].cumulativeIncome)
}
for (var i = 0; i < cashFlowMonthlyChartData[0].expenditureList.length; i++) {
if (rightMax < cashFlowMonthlyChartData[0].incomeList[i].income || rightMax < cashFlowMonthlyChartData[0].expenditureList[i].expenditure){
rightMax = Math.max(cashFlowMonthlyChartData[0].incomeList[i].income,cashFlowMonthlyChartData[0].expenditureList[i].expenditure)
}
monthlyExpenditure.push(cashFlowMonthlyChartData[0].expenditureList[i].expenditure)
cumulativeExpenditure.push(cashFlowMonthlyChartData[0].expenditureList[i].cumulativeExpenditure)
}
setMonthlyIncomeList(monthlyIncome)
setMonthlyCumulativeIncomeList(cumulativeIncome)
setMonthlyExpenditureList(monthlyExpenditure)
setMonthlyCumulativeExpenditureList(cumulativeExpenditure)
setMonthlyChartLeftMax(leftMax)
setMonthlyChartRightMax(rightMax)
}
}
const fetchReceivableAndExpenditureData = async () => {
const cashFlowReceivableAndExpenditureData = await fetchProjectsCashFlowReceivableAndExpenditure(selectedProjectIdList);
if(cashFlowReceivableAndExpenditureData.length !== 0){
setReceivedPercentage(cashFlowReceivableAndExpenditureData[0].receivedPercentage)
setTotalInvoiced(cashFlowReceivableAndExpenditureData[0].totalInvoiced)
setTotalReceived(cashFlowReceivableAndExpenditureData[0].totalReceived)
setReceivable(cashFlowReceivableAndExpenditureData[0].receivable)
setExpenditurePercentage(cashFlowReceivableAndExpenditureData[0].expenditurePercentage)
setTotalBudget(cashFlowReceivableAndExpenditureData[0].totalBudget)
setTotalExpenditure(cashFlowReceivableAndExpenditureData[0].totalExpenditure)
setExpenditureReceivable(cashFlowReceivableAndExpenditureData[0].expenditureReceivable)
}
}
const fetchAnticipateData = async () => {
const cashFlowAnticipateData = await fetchProjectsCashFlowAnticipate(selectedProjectIdList,cashFlowYear);
const monthlyAnticipateIncome = []
var anticipateLeftMax = 0
if(cashFlowAnticipateData.length !== 0){
for (var i = 0; i < cashFlowAnticipateData[0].anticipateIncomeList.length; i++) {
if (anticipateLeftMax < cashFlowAnticipateData[0].anticipateIncomeList[i].anticipateIncome){
anticipateLeftMax = Math.max(cashFlowAnticipateData[0].anticipateIncomeList[i].anticipateIncome,cashFlowAnticipateData[0].anticipateIncomeList[i].anticipateIncome)
}
monthlyAnticipateIncome.push(cashFlowAnticipateData[0].anticipateIncomeList[i].anticipateIncome)
}
}
console.log(monthlyAnticipateIncome)
}
useEffect(() => {
fetchData()
}, []);

useEffect(() => {
fetchChartData()
fetchReceivableAndExpenditureData()
fetchAnticipateData()
}, [cashFlowYear,selectedProjectIdList]);
const columns = [
{
id: "projectCode",
@@ -170,7 +267,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Monthly Income and Expenditure(HKD)",
},
min: 0,
max: 350000,
max: monthlyChartLeftMax,
tickAmount: 5,
labels: {
formatter: function (val) {
@@ -185,7 +282,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Monthly Expenditure (HKD)",
},
min: 0,
max: 350000,
max: monthlyChartLeftMax,
tickAmount: 5,
},
{
@@ -195,7 +292,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Cumulative Income and Expenditure(HKD)",
},
min: 0,
max: 850000,
max: monthlyChartRightMax,
tickAmount: 5,
labels: {
formatter: function (val) {
@@ -211,7 +308,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Cumulative Expenditure (HKD)",
},
min: 0,
max: 850000,
max: monthlyChartRightMax,
tickAmount: 5,
},
],
@@ -224,34 +321,25 @@ const ProjectCashFlow: React.FC = () => {
name: "Monthly_Income",
type: "column",
color: "#ffde91",
data: [0, 110000, 0, 0, 185000, 0, 0, 189000, 0, 0, 300000, 0],
data: monthlyIncomeList,
},
{
name: "Monthly_Expenditure",
type: "column",
color: "#82b59a",
data: [
0, 160000, 120000, 120000, 55000, 55000, 55000, 55000, 55000, 70000,
55000, 55000,
],
data: monthlyExpenditureList,
},
{
name: "Cumulative_Income",
type: "line",
color: "#EE6D7A",
data: [
0, 100000, 100000, 100000, 300000, 300000, 300000, 500000, 500000,
500000, 800000, 800000,
],
data: monthlyCumulativeIncomeList,
},
{
name: "Cumulative_Expenditure",
type: "line",
color: "#7cd3f2",
data: [
0, 198000, 240000, 400000, 410000, 430000, 510000, 580000, 600000,
710000, 730000, 790000,
],
data: monthlyCumulativeExpenditureList,
},
],
};
@@ -295,7 +383,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Anticipate Monthly Income and Expenditure(HKD)",
},
min: 0,
max: 350000,
max: monthlyChartLeftMax,
tickAmount: 5,
labels: {
formatter: function (val) {
@@ -310,7 +398,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Monthly Expenditure (HKD)",
},
min: 0,
max: 350000,
max: monthlyChartLeftMax,
tickAmount: 5,
},
{
@@ -320,7 +408,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Cumulative Income and Expenditure(HKD)",
},
min: 0,
max: 850000,
max: monthlyChartRightMax,
tickAmount: 5,
labels: {
formatter: function (val) {
@@ -336,7 +424,7 @@ const ProjectCashFlow: React.FC = () => {
text: "Cumulative Expenditure (HKD)",
},
min: 0,
max: 850000,
max: monthlyChartRightMax,
tickAmount: 5,
},
],
@@ -365,7 +453,7 @@ const ProjectCashFlow: React.FC = () => {

const accountsReceivableOptions: ApexOptions = {
colors: ["#20E647"],
series: [80],
series: [receivedPercentage],
chart: {
height: 350,
type: "radialBar",
@@ -414,7 +502,7 @@ const ProjectCashFlow: React.FC = () => {

const expenditureOptions: ApexOptions = {
colors: ["#20E647"],
series: [95],
series: [expenditurePercentage],
chart: {
height: 350,
type: "radialBar",
@@ -548,12 +636,6 @@ const ProjectCashFlow: React.FC = () => {
},
];
const [ledgerData, setLedgerData]: any[] = React.useState(ledgerRows);
const handleSelectionChange = (newSelectionModel: GridRowSelectionModel) => {
const selectedRowsData = projectData.filter((row: any) =>
newSelectionModel.includes(row.id),
);
console.log(selectedRowsData);
};

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
@@ -569,6 +651,19 @@ const ProjectCashFlow: React.FC = () => {
[t],
);

function isDateInRange(dateToCheck: string, startDate: string, endDate: string): boolean {
console.log(startDate)
console.log(endDate)
if (!startDate || !endDate) {
return false;
}
const dateToCheckObj = new Date(dateToCheck);
const startDateObj = new Date(startDate);
const endDateObj = new Date(endDate);
console.log(dateToCheckObj)
return dateToCheckObj >= startDateObj && dateToCheckObj <= endDateObj;
}

return (
<>
{/* <Suspense fallback={<ProgressCashFlowSearch.Loading />}>
@@ -577,11 +672,21 @@ const ProjectCashFlow: React.FC = () => {
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
console.log(query);
console.log(query)
setFilteredResult(
projectData.filter(
(cp:any) =>
cp.projectCode.toLowerCase().includes(query.projectCode.toLowerCase()) &&
cp.projectName.toLowerCase().includes(query.projectName.toLowerCase()) &&
(query.startDateFrom || query.startDateFromTo
? isDateInRange(cp.startDate, query.startDateFrom, query.startDateFromTo)
: true)
),
);
}}
/>
<CustomDatagrid
rows={projectData}
rows={filteredResult}
columns={columns}
columnWidth={200}
dataGridHeight={300}
@@ -666,7 +771,7 @@ const ProjectCashFlow: React.FC = () => {
className="text-lg font-medium ml-5"
style={{ color: "#6b87cf" }}
>
1,000,000.00
{totalInvoiced.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div
@@ -679,7 +784,7 @@ const ProjectCashFlow: React.FC = () => {
className="text-lg font-medium ml-5"
style={{ color: "#6b87cf" }}
>
800,000.00
{totalReceived.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div
@@ -692,7 +797,7 @@ const ProjectCashFlow: React.FC = () => {
className="text-lg font-medium ml-5 mb-2"
style={{ color: "#6b87cf" }}
>
200,000.00
{receivable.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</Card>
</Card>
@@ -728,7 +833,7 @@ const ProjectCashFlow: React.FC = () => {
className="text-lg font-medium ml-5"
style={{ color: "#6b87cf" }}
>
800,000.00
{totalBudget.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div
@@ -741,7 +846,7 @@ const ProjectCashFlow: React.FC = () => {
className="text-lg font-medium ml-5"
style={{ color: "#6b87cf" }}
>
760,000.00
{totalExpenditure.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<hr />
<div
@@ -754,7 +859,7 @@ const ProjectCashFlow: React.FC = () => {
className="text-lg font-medium ml-5 mb-2"
style={{ color: "#6b87cf" }}
>
40,000.00
{expenditureReceivable.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</Card>
</Card>


+ 1
- 0
src/components/ProjectCompletionReport/ProjectCompletionReport.tsx Wyświetl plik

@@ -47,6 +47,7 @@ const ProjectCompletionReport: React.FC<Props> = (
return (
<>
<SearchBox
formType={"download"}
criteria={searchCriteria}
onSearch={async (query: any) => {
console.log(query);


+ 1
- 0
src/components/ResourceOverconsumptionReport/ResourceOverconsumptionReport.tsx Wyświetl plik

@@ -62,6 +62,7 @@ const ResourceOverconsumptionReport: React.FC<Props> = ({ team, customer }) => {
return (
<>
<SearchBox
formType={"download"}
criteria={searchCriteria}
onSearch={async (query: any) => {
let index = 0


+ 7
- 16
src/components/SearchBox/SearchBox.tsx Wyświetl plik

@@ -22,21 +22,6 @@ 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, 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> {
@@ -48,6 +33,9 @@ interface BaseCriterion<T extends string> {

interface TextCriterion<T extends string> extends BaseCriterion<T> {
type: "text";
textType?: React.HTMLInputTypeAttribute;
error?: boolean;
helperText?: React.ReactNode;
}

interface SelectCriterion<T extends string> extends BaseCriterion<T> {
@@ -190,9 +178,12 @@ function SearchBox<T extends string>({
{c.type === "text" && (
<TextField
label={c.label}
type={c.textType ?? "text"}
fullWidth
onChange={makeInputChangeHandler(c.paramName)}
value={inputs[c.paramName]}
error={Boolean(c.error)}
helperText={Boolean(c.error) && c.helperText}
/>
)}
{c.type === "select" && (
@@ -216,7 +207,7 @@ function SearchBox<T extends string>({
)}
{c.type === "number" && (
<NumberInput
// defaultValue={90}
placeholder={c.label}
min={50}
max={99}
onChange={makeNumberChangeHandler(c.paramName)}


+ 15
- 2
src/components/TimesheetModal/TimesheetModal.tsx Wyświetl plik

@@ -42,6 +42,7 @@ interface Props {
defaultTimesheets?: RecordTimesheetInput;
leaveRecords: RecordLeaveInput;
companyHolidays: HolidaysResult[];
fastEntryEnabled?: boolean;
}

const modalSx: SxProps = {
@@ -63,6 +64,7 @@ const TimesheetModal: React.FC<Props> = ({
defaultTimesheets,
leaveRecords,
companyHolidays,
fastEntryEnabled,
}) => {
const { t } = useTranslation("home");

@@ -83,7 +85,9 @@ const TimesheetModal: React.FC<Props> = ({

const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>(
async (data) => {
const errors = validateTimesheet(data, leaveRecords, companyHolidays);
const errors = validateTimesheet(data, leaveRecords, companyHolidays, {
skipTaskValidation: fastEntryEnabled,
});
if (errors) {
Object.keys(errors).forEach((date) =>
formProps.setError(date, {
@@ -108,7 +112,14 @@ const TimesheetModal: React.FC<Props> = ({
formProps.reset(newFormValues);
onClose();
},
[companyHolidays, formProps, leaveRecords, onClose, username],
[
companyHolidays,
fastEntryEnabled,
formProps,
leaveRecords,
onClose,
username,
],
);

const onCancel = useCallback(() => {
@@ -165,6 +176,7 @@ const TimesheetModal: React.FC<Props> = ({
assignedProjects={assignedProjects}
allProjects={allProjects}
leaveRecords={leaveRecords}
fastEntryEnabled={fastEntryEnabled}
/>
</Box>
{errorComponent}
@@ -202,6 +214,7 @@ const TimesheetModal: React.FC<Props> = ({
{t("Timesheet Input")}
</Typography>
<MobileTimesheetTable
fastEntryEnabled={fastEntryEnabled}
companyHolidays={companyHolidays}
assignedProjects={assignedProjects}
allProjects={allProjects}


+ 85
- 45
src/components/TimesheetTable/EntryInputTable.tsx Wyświetl plik

@@ -35,6 +35,7 @@ import {
validateTimeEntry,
} from "@/app/api/timesheets/utils";
import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
import FastTimeEntryModal from "./FastTimeEntryModal";

dayjs.extend(isBetween);

@@ -43,6 +44,7 @@ interface Props {
isHoliday: boolean;
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
fastEntryEnabled?: boolean;
}

export type TimeEntryRow = Partial<
@@ -58,6 +60,7 @@ const EntryInputTable: React.FC<Props> = ({
allProjects,
assignedProjects,
isHoliday,
fastEntryEnabled,
}) => {
const { t } = useTranslation("home");
const taskGroupsByProject = useMemo(() => {
@@ -114,7 +117,9 @@ const EntryInputTable: React.FC<Props> = ({
"",
) as TimeEntryRow;

const error = validateTimeEntry(row, isHoliday);
const error = validateTimeEntry(row, isHoliday, {
skipTaskValidation: fastEntryEnabled,
});

// Test for warnings
let isPlanned;
@@ -133,7 +138,7 @@ const EntryInputTable: React.FC<Props> = ({
apiRef.current.updateRows([{ id, _error: error, isPlanned }]);
return !error;
},
[apiRef, day, isHoliday, milestonesByProject],
[apiRef, day, fastEntryEnabled, isHoliday, milestonesByProject],
);

const handleCancel = useCallback(
@@ -230,6 +235,7 @@ const EntryInputTable: React.FC<Props> = ({
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
return (
<ProjectSelect
multiple={false}
allProjects={allProjects}
assignedProjects={assignedProjects}
value={params.value}
@@ -406,6 +412,19 @@ const EntryInputTable: React.FC<Props> = ({
(entry) => entry.isPlanned !== undefined && !entry.isPlanned,
);

// Fast entry modal
const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false);
const closeFastEntryModal = useCallback(() => {
setFastEntryModalOpen(false);
}, []);
const openFastEntryModal = useCallback(() => {
setFastEntryModalOpen(true);
}, []);
const onSaveFastEntry = useCallback(async (entries: TimeEntry[]) => {
setEntries((e) => [...e, ...entries]);
setFastEntryModalOpen(false);
}, []);

const footer = (
<Box display="flex" gap={2} alignItems="center">
<Button
@@ -417,6 +436,15 @@ const EntryInputTable: React.FC<Props> = ({
>
{t("Record time")}
</Button>
<Button
disableRipple
variant="outlined"
startIcon={<Add />}
onClick={openFastEntryModal}
size="small"
>
{t("Fast time entry")}
</Button>
{hasOutOfPlannedStages && (
<Typography color="warning.main" variant="body2">
{t("There are entries for stages out of planned dates!")}
@@ -426,49 +454,61 @@ const EntryInputTable: React.FC<Props> = ({
);

return (
<StyledDataGrid
apiRef={apiRef}
autoHeight
sx={{
"--DataGrid-overlayHeight": "100px",
".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
border: "1px solid",
borderColor: "error.main",
},
".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": {
border: "1px solid",
borderColor: "warning.main",
},
}}
disableColumnMenu
editMode="row"
rows={entries}
rowModesModel={rowModesModel}
onRowModesModelChange={setRowModesModel}
onRowEditStop={handleEditStop}
processRowUpdate={processRowUpdate}
columns={columns}
getCellClassName={(params: GridCellParams<TimeEntryRow>) => {
let classname = "";
if (params.row._error?.[params.field as keyof TimeEntry]) {
classname = "hasError";
} else if (
params.field === "taskGroupId" &&
params.row.isPlanned !== undefined &&
!params.row.isPlanned
) {
classname = "hasWarning";
}
return classname;
}}
slots={{
footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
slotProps={{
footer: { child: footer },
}}
/>
<>
<StyledDataGrid
apiRef={apiRef}
autoHeight
sx={{
"--DataGrid-overlayHeight": "100px",
".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
border: "1px solid",
borderColor: "error.main",
},
".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": {
border: "1px solid",
borderColor: "warning.main",
},
}}
disableColumnMenu
editMode="row"
rows={entries}
rowModesModel={rowModesModel}
onRowModesModelChange={setRowModesModel}
onRowEditStop={handleEditStop}
processRowUpdate={processRowUpdate}
columns={columns}
getCellClassName={(params: GridCellParams<TimeEntryRow>) => {
let classname = "";
if (params.row._error?.[params.field as keyof TimeEntry]) {
classname = "hasError";
} else if (
params.field === "taskGroupId" &&
params.row.isPlanned !== undefined &&
!params.row.isPlanned
) {
classname = "hasWarning";
}
return classname;
}}
slots={{
footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
slotProps={{
footer: { child: footer },
}}
/>
{fastEntryEnabled && (
<FastTimeEntryModal
allProjects={allProjects}
assignedProjects={assignedProjects}
open={fastEntryModalOpen}
isHoliday={Boolean(isHoliday)}
onClose={closeFastEntryModal}
onSave={onSaveFastEntry}
/>
)}
</>
);
};



+ 309
- 0
src/components/TimesheetTable/FastTimeEntryModal.tsx Wyświetl plik

@@ -0,0 +1,309 @@
import { TimeEntry } from "@/app/api/timesheets/actions";
import { Check, ExpandMore } from "@mui/icons-material";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Button,
FormControl,
FormHelperText,
InputLabel,
Modal,
ModalProps,
Paper,
SxProps,
TextField,
Typography,
} from "@mui/material";
import React, { useCallback, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import ProjectSelect from "./ProjectSelect";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import {
distributeQuarters,
roundToNearestQuarter,
} from "@/app/utils/manhourUtils";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils";
import zip from "lodash/zip";

export interface FastTimeEntryForm {
projectIds: TimeEntry["projectId"][];
inputHours: TimeEntry["inputHours"];
otHours: TimeEntry["otHours"];
remark: TimeEntry["remark"];
}

export interface Props extends Omit<ModalProps, "children"> {
onSave: (timeEntries: TimeEntry[], recordDate?: string) => Promise<void>;
onDelete?: () => void;
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
modalSx?: SxProps;
recordDate?: string;
isHoliday?: boolean;
}

const modalSx: SxProps = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "90%",
maxWidth: "sm",
maxHeight: "90%",
padding: 3,
display: "flex",
flexDirection: "column",
gap: 2,
};

let idOffset = Date.now();
const getID = () => {
return ++idOffset;
};

const FastTimeEntryModal: React.FC<Props> = ({
onSave,
open,
onClose,
allProjects,
assignedProjects,
modalSx: mSx,
recordDate,
isHoliday,
}) => {
const {
t,
i18n: { language },
} = useTranslation("home");

const { register, control, reset, trigger, formState, watch } =
useForm<FastTimeEntryForm>({
defaultValues: {
projectIds: [],
},
});

const projectIds = watch("projectIds");
const inputHours = watch("inputHours");
const otHours = watch("otHours");
const remark = watch("remark");

const selectedProjects = useMemo(() => {
return projectIds.map((id) => allProjects.find((p) => p.id === id));
}, [allProjects, projectIds]);

const normalHoursArray = distributeQuarters(
inputHours || 0,
selectedProjects.length,
);
const otHoursArray = distributeQuarters(
otHours || 0,
selectedProjects.length,
);

const projectsWithHours = zip(
selectedProjects,
normalHoursArray,
otHoursArray,
);

const saveHandler = useCallback(async () => {
const valid = await trigger();
if (valid) {
onSave(
projectsWithHours.map(([project, hour, othour]) => ({
id: getID(),
projectId: project?.id,
inputHours: hour,
otHours: othour,
remark,
})),
recordDate,
);
reset();
}
}, [projectsWithHours, trigger, onSave, recordDate, reset, remark]);

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
onClose?.(...args);
reset();
},
[onClose, reset],
);

return (
<Modal open={open} onClose={closeHandler}>
<Paper sx={{ ...modalSx, ...mSx }}>
{recordDate && (
<Typography variant="h6" marginBlockEnd={2}>
{shortDateFormatter(language).format(new Date(recordDate))}
</Typography>
)}
<FormControl fullWidth error={Boolean(formState.errors.projectIds)}>
<InputLabel shrink>{t("Project Code and Name")}</InputLabel>
<Controller
control={control}
name="projectIds"
render={({ field }) => (
<ProjectSelect
error={Boolean(formState.errors.projectIds)}
multiple
allProjects={allProjects}
assignedProjects={assignedProjects}
value={field.value}
onProjectSelect={(newIds) => {
field.onChange(
newIds.map((id) => (id === "" ? undefined : id)),
);
}}
/>
)}
rules={{
validate: (value) =>
value.length > 0 || t("Please choose at least 1 project."),
}}
/>
<FormHelperText>
{formState.errors.projectIds?.message ||
t(
"The inputted time will be evenly distributed among the selected projects.",
)}
</FormHelperText>
</FormControl>
<TextField
type="number"
label={t("Hours")}
fullWidth
{...register("inputHours", {
setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
validate: (value) => {
if (value) {
if (isHoliday) {
return t("Cannot input normal hours for holidays");
}

return (
(0 < value && value <= DAILY_NORMAL_MAX_HOURS) ||
t(
"Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}",
{ DAILY_NORMAL_MAX_HOURS },
)
);
} else {
return Boolean(value || otHours) || t("Required");
}
},
})}
error={Boolean(formState.errors.inputHours)}
helperText={formState.errors.inputHours?.message}
/>
<TextField
type="number"
label={t("Other Hours")}
fullWidth
{...register("otHours", {
setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
validate: (value) => (value ? value > 0 : true),
})}
error={Boolean(formState.errors.otHours)}
/>
<TextField
label={t("Remark")}
fullWidth
multiline
rows={2}
error={Boolean(formState.errors.remark)}
{...register("remark", {
validate: (value) =>
projectIds.every((id) => id) ||
value ||
t("Required for non-billable tasks"),
})}
helperText={
formState.errors.remark?.message ||
t("The remark will be added to all selected projects")
}
/>
<Accordion variant="outlined" sx={{ overflowY: "scroll" }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Typography variant="subtitle2">
{t("Hour distribution preview")}
</Typography>
</AccordionSummary>
<AccordionDetails>
{projectIds.length > 0 ? (
<ProjectHourSummary projectsWithHours={projectsWithHours} />
) : (
<Alert severity="warning">
{t("Please select some projects.")}
</Alert>
)}
</AccordionDetails>
</Accordion>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
startIcon={<Check />}
onClick={saveHandler}
>
{t("Save")}
</Button>
</Box>
</Paper>
</Modal>
);
};

const ProjectHourSummary: React.FC<{
projectsWithHours: [ProjectWithTasks?, number?, number?][];
}> = ({ projectsWithHours }) => {
const { t } = useTranslation("home");

return (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{projectsWithHours.map(([project, manhour, otManhour], index) => {
return (
<Box key={`${index}-${project?.id || "none"}`}>
<Typography variant="body2" component="div" fontWeight="bold">
{project
? `${project.code} - ${project.name}`
: t("Non-billable Task")}
</Typography>
<Box display="flex" gap={2}>
<Box>
<Typography variant="body2" component="div" fontWeight="bold">
{t("Hours")}
</Typography>
<Typography component="p">
{manhourFormatter.format(manhour || 0)}
</Typography>
</Box>
<Box>
<Typography variant="body2" component="div" fontWeight="bold">
{t("Other Hours")}
</Typography>
<Typography component="p">
{manhourFormatter.format(otManhour || 0)}
</Typography>
</Box>
</Box>
</Box>
);
})}
</Box>
);
};

export default FastTimeEntryModal;

+ 42
- 13
src/components/TimesheetTable/MobileTimesheetEntry.tsx Wyświetl plik

@@ -1,14 +1,7 @@
import { TimeEntry, RecordTimesheetInput } from "@/app/api/timesheets/actions";
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { Add, Edit } from "@mui/icons-material";
import {
Box,
Button,
Card,
CardContent,
IconButton,
Typography,
} from "@mui/material";
import { shortDateFormatter } from "@/app/utils/formatUtil";
import { Add } from "@mui/icons-material";
import { Box, Button, Stack, Typography } from "@mui/material";
import dayjs from "dayjs";
import React, { useCallback, useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
@@ -20,12 +13,14 @@ import TimesheetEditModal, {
import TimeEntryCard from "./TimeEntryCard";
import { HolidaysResult } from "@/app/api/holidays";
import { getHolidayForDate } from "@/app/utils/holidayUtils";
import FastTimeEntryModal from "./FastTimeEntryModal";

interface Props {
date: string;
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
companyHolidays: HolidaysResult[];
fastEntryEnabled?: boolean;
}

const MobileTimesheetEntry: React.FC<Props> = ({
@@ -33,6 +28,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({
allProjects,
assignedProjects,
companyHolidays,
fastEntryEnabled,
}) => {
const {
t,
@@ -51,7 +47,8 @@ const MobileTimesheetEntry: React.FC<Props> = ({
const holiday = getHolidayForDate(date, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

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

// Edit modal
@@ -103,6 +100,22 @@ const MobileTimesheetEntry: React.FC<Props> = ({
[clearErrors, currentEntries, date, setValue],
);

// Fast entry modal
const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false);
const closeFastEntryModal = useCallback(() => {
setFastEntryModalOpen(false);
}, []);
const openFastEntryModal = useCallback(() => {
setFastEntryModalOpen(true);
}, []);
const onSaveFastEntry = useCallback(
async (entries: TimeEntry[]) => {
setValue(date, [...currentEntries, ...entries]);
setFastEntryModalOpen(false);
},
[currentEntries, date, setValue],
);

return (
<>
<Typography
@@ -149,11 +162,16 @@ const MobileTimesheetEntry: React.FC<Props> = ({
{t("Add some time entries!")}
</Typography>
)}
<Box>
<Stack alignItems={"flex-start"} spacing={1}>
<Button startIcon={<Add />} onClick={openEditModal()}>
{t("Record time")}
</Button>
</Box>
{fastEntryEnabled && (
<Button startIcon={<Add />} onClick={openFastEntryModal}>
{t("Fast time entry")}
</Button>
)}
</Stack>
<TimesheetEditModal
allProjects={allProjects}
assignedProjects={assignedProjects}
@@ -161,8 +179,19 @@ const MobileTimesheetEntry: React.FC<Props> = ({
onClose={closeEditModal}
onSave={onSaveEntry}
isHoliday={Boolean(isHoliday)}
fastEntryEnabled={fastEntryEnabled}
{...editModalProps}
/>
{fastEntryEnabled && (
<FastTimeEntryModal
allProjects={allProjects}
assignedProjects={assignedProjects}
open={fastEntryModalOpen}
isHoliday={Boolean(isHoliday)}
onClose={closeFastEntryModal}
onSave={onSaveFastEntry}
/>
)}
</Box>
</>
);


+ 8
- 1
src/components/TimesheetTable/MobileTimesheetTable.tsx Wyświetl plik

@@ -15,6 +15,7 @@ interface Props {
leaveRecords: RecordLeaveInput;
companyHolidays: HolidaysResult[];
errorComponent?: React.ReactNode;
fastEntryEnabled?: boolean;
}

const MobileTimesheetTable: React.FC<Props> = ({
@@ -23,6 +24,7 @@ const MobileTimesheetTable: React.FC<Props> = ({
leaveRecords,
companyHolidays,
errorComponent,
fastEntryEnabled,
}) => {
const { watch } = useFormContext<RecordTimesheetInput>();
const currentInput = watch();
@@ -35,7 +37,12 @@ const MobileTimesheetTable: React.FC<Props> = ({
leaveEntries={leaveRecords}
timesheetEntries={currentInput}
EntryComponent={MobileTimesheetEntry}
entryComponentProps={{ allProjects, assignedProjects, companyHolidays }}
entryComponentProps={{
allProjects,
assignedProjects,
companyHolidays,
fastEntryEnabled,
}}
errorComponent={errorComponent}
/>
);


+ 81
- 13
src/components/TimesheetTable/ProjectSelect.tsx Wyświetl plik

@@ -1,6 +1,8 @@
import React, { useCallback, useMemo } from "react";
import {
Autocomplete,
Checkbox,
Chip,
ListSubheader,
MenuItem,
TextField,
@@ -8,15 +10,30 @@ import {
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
import { useTranslation } from "react-i18next";
import differenceBy from "lodash/differenceBy";
import intersectionWith from "lodash/intersectionWith";
import { TFunction } from "i18next";

interface Props {
interface CommonProps {
allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[];
error?: boolean;
multiple?: boolean;
}

interface SingleAutocompleteProps extends CommonProps {
value: number | undefined;
onProjectSelect: (projectId: number | string) => void;
multiple: false;
}

interface MultiAutocompleteProps extends CommonProps {
value: (number | undefined)[];
onProjectSelect: (projectIds: Array<number | string>) => void;
multiple: true;
}

type Props = SingleAutocompleteProps | MultiAutocompleteProps;

const getGroupName = (t: TFunction, groupName: string): string => {
switch (groupName) {
case "non-billable":
@@ -37,6 +54,8 @@ const AutocompleteProjectSelect: React.FC<Props> = ({
assignedProjects,
value,
onProjectSelect,
error,
multiple,
}) => {
const { t } = useTranslation("home");
const nonAssignedProjects = useMemo(() => {
@@ -63,17 +82,32 @@ const AutocompleteProjectSelect: React.FC<Props> = ({
];
}, [assignedProjects, nonAssignedProjects, t]);

const currentValue = options.find((o) => o.value === value) || options[0];
const currentValue = multiple
? intersectionWith(options, value, (option, v) => {
return option.value === (v ?? "");
})
: options.find((o) => o.value === value) || options[0];
// const currentValue = options.find((o) => o.value === value) || options[0];

const onChange = useCallback(
(event: React.SyntheticEvent, newValue: { value: number | string }) => {
onProjectSelect(newValue.value);
(
event: React.SyntheticEvent,
newValue: { value: number | string } | { value: number | string }[],
) => {
if (multiple) {
const multiNewValue = newValue as { value: number | string }[];
onProjectSelect(multiNewValue.map(({ value }) => value));
} else {
const singleNewVal = newValue as { value: number | string };
onProjectSelect(singleNewVal.value);
}
},
[onProjectSelect],
[onProjectSelect, multiple],
);

return (
<Autocomplete
multiple={multiple}
noOptionsText={t("No projects")}
disableClearable
fullWidth
@@ -82,22 +116,56 @@ const AutocompleteProjectSelect: React.FC<Props> = ({
groupBy={(option) => option.group}
getOptionLabel={(option) => option.label}
options={options}
disableCloseOnSelect={multiple}
renderTags={
multiple
? (value, getTagProps) =>
value.map((option, index) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...chipProps } = getTagProps({ index });
return (
<Chip
{...chipProps}
key={`${option.value}--${option.label}`}
label={option.label}
/>
);
})
: undefined
}
renderGroup={(params) => (
<>
<ListSubheader key={params.key}>
{getGroupName(t, params.group)}
</ListSubheader>
<React.Fragment key={`${params.key}-${params.group}`}>
<ListSubheader>{getGroupName(t, params.group)}</ListSubheader>
{params.children}
</>
</React.Fragment>
)}
renderOption={(params, option) => {
renderOption={(
params: React.HTMLAttributes<HTMLLIElement> & { key?: React.Key },
option,
{ selected },
) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { key, ...rest } = params;
return (
<MenuItem {...params} key={option.value} value={option.value}>
<MenuItem
{...rest}
disableRipple
value={option.value}
key={`${option.value}--${option.label}`}
>
{multiple && (
<Checkbox
disableRipple
key={`checkbox-${option.value}`}
checked={selected}
sx={{ transform: "translate(0)" }}
/>
)}
{option.label}
</MenuItem>
);
}}
renderInput={(params) => <TextField {...params} />}
renderInput={(params) => <TextField {...params} error={error} />}
/>
);
};


+ 5
- 0
src/components/TimesheetTable/TimesheetEditModal.tsx Wyświetl plik

@@ -34,6 +34,7 @@ export interface Props extends Omit<ModalProps, "children"> {
modalSx?: SxProps;
recordDate?: string;
isHoliday?: boolean;
fastEntryEnabled?: boolean;
}

const modalSx: SxProps = {
@@ -59,6 +60,7 @@ const TimesheetEditModal: React.FC<Props> = ({
modalSx: mSx,
recordDate,
isHoliday,
fastEntryEnabled,
}) => {
const {
t,
@@ -135,6 +137,7 @@ const TimesheetEditModal: React.FC<Props> = ({
name="projectId"
render={({ field }) => (
<ProjectSelect
multiple={false}
allProjects={allProjects}
assignedProjects={assignedProjects}
value={field.value}
@@ -173,6 +176,7 @@ const TimesheetEditModal: React.FC<Props> = ({
if (!projectId) {
return !id;
}
if (fastEntryEnabled) return true;
const taskGroups = taskGroupsByProject[projectId];
return taskGroups.some((tg) => tg.value === id);
},
@@ -202,6 +206,7 @@ const TimesheetEditModal: React.FC<Props> = ({
if (!projectId) {
return !id;
}
if (fastEntryEnabled) return true;
const projectTasks = allProjects.find((p) => p.id === projectId)
?.tasks;
return Boolean(projectTasks?.some((task) => task.id === id));


+ 3
- 1
src/components/TimesheetTable/TimesheetTable.tsx Wyświetl plik

@@ -14,6 +14,7 @@ interface Props {
assignedProjects: AssignedProject[];
leaveRecords: RecordLeaveInput;
companyHolidays: HolidaysResult[];
fastEntryEnabled?: boolean;
}

const TimesheetTable: React.FC<Props> = ({
@@ -21,6 +22,7 @@ const TimesheetTable: React.FC<Props> = ({
assignedProjects,
leaveRecords,
companyHolidays,
fastEntryEnabled,
}) => {
const { watch } = useFormContext<RecordTimesheetInput>();
const currentInput = watch();
@@ -33,7 +35,7 @@ const TimesheetTable: React.FC<Props> = ({
leaveEntries={leaveRecords}
timesheetEntries={currentInput}
EntryTableComponent={EntryInputTable}
entryTableProps={{ assignedProjects, allProjects }}
entryTableProps={{ assignedProjects, allProjects, fastEntryEnabled }}
/>
);
};


+ 3
- 0
src/components/UserWorkspacePage/UserWorkspacePage.tsx Wyświetl plik

@@ -35,6 +35,7 @@ export interface Props {
defaultTimesheets: RecordTimesheetInput;
holidays: HolidaysResult[];
teamTimesheets: TeamTimeSheets;
fastEntryEnabled?: boolean;
}

const menuItemSx: SxProps = {
@@ -51,6 +52,7 @@ const UserWorkspacePage: React.FC<Props> = ({
defaultTimesheets,
holidays,
teamTimesheets,
fastEntryEnabled,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);

@@ -170,6 +172,7 @@ const UserWorkspacePage: React.FC<Props> = ({
leaveTypes={leaveTypes}
/>
<TimesheetModal
fastEntryEnabled={fastEntryEnabled}
companyHolidays={holidays}
isOpen={isTimeheetModalVisible}
onClose={handleCloseTimesheetModal}


+ 2
- 0
src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx Wyświetl plik

@@ -44,6 +44,8 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {
defaultLeaveRecords={leaves}
leaveTypes={leaveTypes}
holidays={holidays}
// Change to access check
fastEntryEnabled={true}
/>
);
};


+ 3
- 0
src/i18n/en/report.json Wyświetl plik

@@ -1,4 +1,7 @@
{
"Number Of Days": "Number Of Days",
"Project Completion (<= %)": "Project Completion (<= %)",

"Project": "Project",
"Date Type": "Date Type"
}

+ 3
- 0
src/i18n/zh/report.json Wyświetl plik

@@ -2,6 +2,9 @@
"Staff Monthly Work Hours Analysis Report": "Staff Monthly Work Hours Analysis Report",
"Project Resource Overconsumption Report": "Project Resource Overconsumption Report",

"Number Of Days": "天數",
"Project Completion (<= %)": "項目完成度 (<= %)",

"Project": "項目",
"Date Type": "日期類型",
"Date": "日期",


Ładowanie…
Anuluj
Zapisz