| @@ -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 []; | |||
| } | |||
| }); | |||
| @@ -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,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"; | |||
| @@ -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; | |||
| @@ -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); | |||
| }; | |||
| @@ -49,6 +49,7 @@ const CostAndExpenseReport: React.FC<Props> = ({ team, customer }) => { | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| formType={"download"} | |||
| criteria={searchCriteria} | |||
| onSearch={async (query: any) => { | |||
| let index = 0 | |||
| @@ -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) => { | |||
| @@ -69,7 +69,7 @@ const CreateCompany: React.FC<Props> = ({ | |||
| contactName: company?.contactName, | |||
| phone: company?.phone, | |||
| otHourTo: "", | |||
| otHourFrom: "", | |||
| otHourFrom: "", | |||
| normalHourTo: "", | |||
| normalHourFrom: "", | |||
| currency: company?.currency, | |||
| @@ -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); | |||
| @@ -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!!) | |||
| } | |||
| @@ -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> | |||
| @@ -47,6 +47,7 @@ const ProjectCompletionReport: React.FC<Props> = ( | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| formType={"download"} | |||
| criteria={searchCriteria} | |||
| onSearch={async (query: any) => { | |||
| console.log(query); | |||
| @@ -62,6 +62,7 @@ const ResourceOverconsumptionReport: React.FC<Props> = ({ team, customer }) => { | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| formType={"download"} | |||
| criteria={searchCriteria} | |||
| onSearch={async (query: any) => { | |||
| let index = 0 | |||
| @@ -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)} | |||
| @@ -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} | |||
| @@ -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} | |||
| /> | |||
| )} | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -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; | |||
| @@ -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> | |||
| </> | |||
| ); | |||
| @@ -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} | |||
| /> | |||
| ); | |||
| @@ -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} />} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -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)); | |||
| @@ -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 }} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -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} | |||
| @@ -44,6 +44,8 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||
| defaultLeaveRecords={leaves} | |||
| leaveTypes={leaveTypes} | |||
| holidays={holidays} | |||
| // Change to access check | |||
| fastEntryEnabled={true} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -1,4 +1,7 @@ | |||
| { | |||
| "Number Of Days": "Number Of Days", | |||
| "Project Completion (<= %)": "Project Completion (<= %)", | |||
| "Project": "Project", | |||
| "Date Type": "Date Type" | |||
| } | |||
| @@ -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": "日期", | |||