| @@ -11,12 +11,53 @@ export interface CashFlow { | |||||
| teamLeader: string; | teamLeader: string; | ||||
| startDate: string; | startDate: string; | ||||
| startDateFrom: string; | startDateFrom: string; | ||||
| startDateTo: string; | |||||
| startDateFromTo: string; | |||||
| targetEndDate: string; | targetEndDate: string; | ||||
| client: string; | client: string; | ||||
| subsidiary: 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 = () => { | export const preloadProjects = () => { | ||||
| fetchProjectsCashFlow(); | fetchProjectsCashFlow(); | ||||
| }; | }; | ||||
| @@ -24,3 +65,36 @@ export const preloadProjects = () => { | |||||
| export const fetchProjectsCashFlow = cache(async () => { | export const fetchProjectsCashFlow = cache(async () => { | ||||
| return serverFetchJson<CashFlow[]>(`${BASE_API_URL}/dashboard/searchCashFlowProject`); | 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 { | export interface ProjectPotentialDelayReportFilter { | ||||
| team: string[]; | team: string[]; | ||||
| client: string[]; | client: string[]; | ||||
| numberOfDays: number; | |||||
| projectCompletion: number; | |||||
| } | } | ||||
| export interface ProjectPotentialDelayReportRequest { | export interface ProjectPotentialDelayReportRequest { | ||||
| teamId: number | "All"; | teamId: number | "All"; | ||||
| clientId: number | "All"; | clientId: number | "All"; | ||||
| numberOfDays: number; | |||||
| projectCompletion: number; | |||||
| } | } | ||||
| // - Monthly Work Hours Report | // - Monthly Work Hours Report | ||||
| @@ -13,6 +13,10 @@ export type TimeEntryError = { | |||||
| [field in keyof TimeEntry]?: string; | [field in keyof TimeEntry]?: string; | ||||
| }; | }; | ||||
| interface TimeEntryValidationOptions { | |||||
| skipTaskValidation?: boolean; | |||||
| } | |||||
| /** | /** | ||||
| * @param entry - the time entry | * @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 | * @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 = ( | export const validateTimeEntry = ( | ||||
| entry: Partial<TimeEntry>, | entry: Partial<TimeEntry>, | ||||
| isHoliday: boolean, | isHoliday: boolean, | ||||
| options: TimeEntryValidationOptions = {}, | |||||
| ): TimeEntryError | undefined => { | ): TimeEntryError | undefined => { | ||||
| // Test for errors | // Test for errors | ||||
| const error: TimeEntryError = {}; | const error: TimeEntryError = {}; | ||||
| @@ -41,10 +46,12 @@ export const validateTimeEntry = ( | |||||
| // If there is a project id, there should also be taskGroupId, taskId, inputHours | // If there is a project id, there should also be taskGroupId, taskId, inputHours | ||||
| if (entry.projectId) { | 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 { | } else { | ||||
| if (!entry.remark) { | if (!entry.remark) { | ||||
| @@ -71,6 +78,7 @@ export const validateTimesheet = ( | |||||
| timesheet: RecordTimesheetInput, | timesheet: RecordTimesheetInput, | ||||
| leaveRecords: RecordLeaveInput, | leaveRecords: RecordLeaveInput, | ||||
| companyHolidays: HolidaysResult[], | companyHolidays: HolidaysResult[], | ||||
| options: TimeEntryValidationOptions = {}, | |||||
| ): { [date: string]: string } | undefined => { | ): { [date: string]: string } | undefined => { | ||||
| const errors: { [date: string]: string } = {}; | const errors: { [date: string]: string } = {}; | ||||
| @@ -86,7 +94,7 @@ export const validateTimesheet = ( | |||||
| // Check each entry | // Check each entry | ||||
| for (const entry of timeEntries) { | for (const entry of timeEntries) { | ||||
| const entryErrors = validateTimeEntry(entry, holidays.has(date)); | |||||
| const entryErrors = validateTimeEntry(entry, holidays.has(date), options); | |||||
| if (entryErrors) { | if (entryErrors) { | ||||
| errors[date] = "There are errors in the entries"; | errors[date] = "There are errors in the entries"; | ||||
| @@ -52,7 +52,7 @@ export const convertTimeArrayToString = ( | |||||
| format: string = OUTPUT_TIME_FORMAT, | format: string = OUTPUT_TIME_FORMAT, | ||||
| needTime: boolean = false, | needTime: boolean = false, | ||||
| ) => { | ) => { | ||||
| let timeString = ""; | |||||
| let timeString = null; | |||||
| if (timeArray !== null && timeArray !== undefined) { | if (timeArray !== null && timeArray !== undefined) { | ||||
| const hour = timeArray[0] || 0; | const hour = timeArray[0] || 0; | ||||
| @@ -1,3 +1,19 @@ | |||||
| import zipWith from "lodash/zipWith"; | |||||
| export const roundToNearestQuarter = (n: number): number => { | export const roundToNearestQuarter = (n: number): number => { | ||||
| return Math.round(n / 0.25) * 0.25; | 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 ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| formType={"download"} | |||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={async (query: any) => { | onSearch={async (query: any) => { | ||||
| let index = 0 | let index = 0 | ||||
| @@ -47,10 +47,10 @@ const CompanyDetails: React.FC<Props> = ({ | |||||
| // console.log(content) | // console.log(content) | ||||
| useEffect(() => { | 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]) | }, [content]) | ||||
| return ( | return ( | ||||
| @@ -125,10 +125,11 @@ const CompanyDetails: React.FC<Props> = ({ | |||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <TimePicker | <TimePicker | ||||
| label={t("Normal Hour From")} | 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(content.normalHourFrom[0]).minute(content.normalHourFrom[1]) : | ||||
| dayjs().hour(9).minute(0)} | dayjs().hour(9).minute(0)} | ||||
| onChange={(time) => { | onChange={(time) => { | ||||
| console.log(time?.format("HH:mm:ss")) | |||||
| if (!time) return; | if (!time) return; | ||||
| setValue("normalHourFrom", time.format("HH:mm:ss")); | setValue("normalHourFrom", time.format("HH:mm:ss")); | ||||
| }} | }} | ||||
| @@ -144,7 +145,7 @@ const CompanyDetails: React.FC<Props> = ({ | |||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <TimePicker | <TimePicker | ||||
| label={t("Normal Hour To")} | 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(content.normalHourTo[0]).minute(content.normalHourTo[1]) : | ||||
| dayjs().hour(18).minute(0)} | dayjs().hour(18).minute(0)} | ||||
| onChange={(time) => { | onChange={(time) => { | ||||
| @@ -163,7 +164,7 @@ const CompanyDetails: React.FC<Props> = ({ | |||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <TimePicker | <TimePicker | ||||
| label={t("OT Hour From")} | 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(content.otHourFrom[0]).minute(content.otHourFrom[1]) : | ||||
| dayjs().hour(20).minute(0)} | dayjs().hour(20).minute(0)} | ||||
| onChange={(time) => { | onChange={(time) => { | ||||
| @@ -182,7 +183,7 @@ const CompanyDetails: React.FC<Props> = ({ | |||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <TimePicker | <TimePicker | ||||
| label={t("OT Hour To")} | 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(content.otHourTo[0]).minute(content.otHourTo[1]) : | ||||
| dayjs().hour(8).minute(0)} | dayjs().hour(8).minute(0)} | ||||
| onChange={(time) => { | onChange={(time) => { | ||||
| @@ -69,7 +69,7 @@ const CreateCompany: React.FC<Props> = ({ | |||||
| contactName: company?.contactName, | contactName: company?.contactName, | ||||
| phone: company?.phone, | phone: company?.phone, | ||||
| otHourTo: "", | otHourTo: "", | ||||
| otHourFrom: "", | |||||
| otHourFrom: "", | |||||
| normalHourTo: "", | normalHourTo: "", | ||||
| normalHourFrom: "", | normalHourFrom: "", | ||||
| currency: company?.currency, | currency: company?.currency, | ||||
| @@ -46,6 +46,7 @@ const GenerateMonthlyWorkHoursReport: React.FC<Props> = ({ staffs }) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| formType={"download"} | |||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={async (query: any) => { | onSearch={async (query: any) => { | ||||
| const index = staffCombo.findIndex((staff) => staff === query.staff); | const index = staffCombo.findIndex((staff) => staff === query.staff); | ||||
| @@ -4,7 +4,7 @@ import React, { useMemo } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { ProjectPotentialDelayReportFilter } from "@/app/api/reports"; | 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 { downloadFile } from "@/app/utils/commonUtil"; | ||||
| import { TeamResult } from "@/app/api/team"; | import { TeamResult } from "@/app/api/team"; | ||||
| import { Customer } from "@/app/api/customer"; | import { Customer } from "@/app/api/customer"; | ||||
| @@ -21,13 +21,19 @@ const GenerateProjectPotentialDelayReport: React.FC<Props> = ({ teams, clients } | |||||
| const { t } = useTranslation("report"); | const { t } = useTranslation("report"); | ||||
| const teamCombo = teams.map(team => `${team.code} - ${team.name}`) | const teamCombo = teams.map(team => `${team.code} - ${team.name}`) | ||||
| const clientCombo = clients.map(client => `${client.code} - ${client.name}`) | const clientCombo = clients.map(client => `${client.code} - ${client.name}`) | ||||
| const [errors, setErrors] = React.useState({ | |||||
| numberOfDays: false, | |||||
| projectCompletion: false, | |||||
| }) | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { label: t("Team"), paramName: "team", type: "select", options: teamCombo }, | { label: t("Team"), paramName: "team", type: "select", options: teamCombo }, | ||||
| { label: t("Client"), paramName: "client", type: "select", options: clientCombo }, | { 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 ( | return ( | ||||
| @@ -36,10 +42,32 @@ const GenerateProjectPotentialDelayReport: React.FC<Props> = ({ teams, clients } | |||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={async (query) => { | 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 teamIndex = teamCombo.findIndex(team => team === query.team) | ||||
| const clientIndex = clientCombo.findIndex(client => client === query.client) | 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) { | if (response) { | ||||
| downloadFile(new Uint8Array(response.blobValue), response.filename!!) | downloadFile(new Uint8Array(response.blobValue), response.filename!!) | ||||
| } | } | ||||
| @@ -19,9 +19,10 @@ import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import ProgressByClientSearch from "@/components/ProgressByClientSearch"; | import ProgressByClientSearch from "@/components/ProgressByClientSearch"; | ||||
| import { Suspense } from "react"; | import { Suspense } from "react"; | ||||
| import ProgressCashFlowSearch from "@/components/ProgressCashFlowSearch"; | 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 { Input, Label } from "reactstrap"; | ||||
| import { CashFlow } from "@/app/api/cashflow"; | import { CashFlow } from "@/app/api/cashflow"; | ||||
| import dayjs from 'dayjs'; | |||||
| interface Props { | interface Props { | ||||
| projects: CashFlow[]; | projects: CashFlow[]; | ||||
| @@ -34,19 +35,115 @@ const ProjectCashFlow: React.FC = () => { | |||||
| const todayDate = new Date(); | const todayDate = new Date(); | ||||
| const [selectionModel, setSelectionModel]: any[] = React.useState([]); | const [selectionModel, setSelectionModel]: any[] = React.useState([]); | ||||
| const [projectData, setProjectData]: 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( | const [cashFlowYear, setCashFlowYear]: any[] = React.useState( | ||||
| todayDate.getFullYear(), | todayDate.getFullYear(), | ||||
| ); | ); | ||||
| const [anticipateCashFlowYear, setAnticipateCashFlowYear]: any[] = React.useState( | const [anticipateCashFlowYear, setAnticipateCashFlowYear]: any[] = React.useState( | ||||
| todayDate.getFullYear(), | 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 fetchData = async () => { | ||||
| const cashFlowProject = await fetchProjectsCashFlow(); | const cashFlowProject = await fetchProjectsCashFlow(); | ||||
| console.log(cashFlowProject) | |||||
| setProjectData(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(() => { | useEffect(() => { | ||||
| fetchData() | fetchData() | ||||
| }, []); | }, []); | ||||
| useEffect(() => { | |||||
| fetchChartData() | |||||
| fetchReceivableAndExpenditureData() | |||||
| fetchAnticipateData() | |||||
| }, [cashFlowYear,selectedProjectIdList]); | |||||
| const columns = [ | const columns = [ | ||||
| { | { | ||||
| id: "projectCode", | id: "projectCode", | ||||
| @@ -170,7 +267,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| text: "Monthly Income and Expenditure(HKD)", | text: "Monthly Income and Expenditure(HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 350000, | |||||
| max: monthlyChartLeftMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| labels: { | labels: { | ||||
| formatter: function (val) { | formatter: function (val) { | ||||
| @@ -185,7 +282,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| text: "Monthly Expenditure (HKD)", | text: "Monthly Expenditure (HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 350000, | |||||
| max: monthlyChartLeftMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -195,7 +292,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| text: "Cumulative Income and Expenditure(HKD)", | text: "Cumulative Income and Expenditure(HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 850000, | |||||
| max: monthlyChartRightMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| labels: { | labels: { | ||||
| formatter: function (val) { | formatter: function (val) { | ||||
| @@ -211,7 +308,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| text: "Cumulative Expenditure (HKD)", | text: "Cumulative Expenditure (HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 850000, | |||||
| max: monthlyChartRightMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| }, | }, | ||||
| ], | ], | ||||
| @@ -224,34 +321,25 @@ const ProjectCashFlow: React.FC = () => { | |||||
| name: "Monthly_Income", | name: "Monthly_Income", | ||||
| type: "column", | type: "column", | ||||
| color: "#ffde91", | color: "#ffde91", | ||||
| data: [0, 110000, 0, 0, 185000, 0, 0, 189000, 0, 0, 300000, 0], | |||||
| data: monthlyIncomeList, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "Monthly_Expenditure", | name: "Monthly_Expenditure", | ||||
| type: "column", | type: "column", | ||||
| color: "#82b59a", | color: "#82b59a", | ||||
| data: [ | |||||
| 0, 160000, 120000, 120000, 55000, 55000, 55000, 55000, 55000, 70000, | |||||
| 55000, 55000, | |||||
| ], | |||||
| data: monthlyExpenditureList, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "Cumulative_Income", | name: "Cumulative_Income", | ||||
| type: "line", | type: "line", | ||||
| color: "#EE6D7A", | color: "#EE6D7A", | ||||
| data: [ | |||||
| 0, 100000, 100000, 100000, 300000, 300000, 300000, 500000, 500000, | |||||
| 500000, 800000, 800000, | |||||
| ], | |||||
| data: monthlyCumulativeIncomeList, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "Cumulative_Expenditure", | name: "Cumulative_Expenditure", | ||||
| type: "line", | type: "line", | ||||
| color: "#7cd3f2", | 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)", | text: "Anticipate Monthly Income and Expenditure(HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 350000, | |||||
| max: monthlyChartLeftMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| labels: { | labels: { | ||||
| formatter: function (val) { | formatter: function (val) { | ||||
| @@ -310,7 +398,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| text: "Monthly Expenditure (HKD)", | text: "Monthly Expenditure (HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 350000, | |||||
| max: monthlyChartLeftMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -320,7 +408,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| text: "Cumulative Income and Expenditure(HKD)", | text: "Cumulative Income and Expenditure(HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 850000, | |||||
| max: monthlyChartRightMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| labels: { | labels: { | ||||
| formatter: function (val) { | formatter: function (val) { | ||||
| @@ -336,7 +424,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| text: "Cumulative Expenditure (HKD)", | text: "Cumulative Expenditure (HKD)", | ||||
| }, | }, | ||||
| min: 0, | min: 0, | ||||
| max: 850000, | |||||
| max: monthlyChartRightMax, | |||||
| tickAmount: 5, | tickAmount: 5, | ||||
| }, | }, | ||||
| ], | ], | ||||
| @@ -365,7 +453,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| const accountsReceivableOptions: ApexOptions = { | const accountsReceivableOptions: ApexOptions = { | ||||
| colors: ["#20E647"], | colors: ["#20E647"], | ||||
| series: [80], | |||||
| series: [receivedPercentage], | |||||
| chart: { | chart: { | ||||
| height: 350, | height: 350, | ||||
| type: "radialBar", | type: "radialBar", | ||||
| @@ -414,7 +502,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| const expenditureOptions: ApexOptions = { | const expenditureOptions: ApexOptions = { | ||||
| colors: ["#20E647"], | colors: ["#20E647"], | ||||
| series: [95], | |||||
| series: [expenditurePercentage], | |||||
| chart: { | chart: { | ||||
| height: 350, | height: 350, | ||||
| type: "radialBar", | type: "radialBar", | ||||
| @@ -548,12 +636,6 @@ const ProjectCashFlow: React.FC = () => { | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| const [ledgerData, setLedgerData]: any[] = React.useState(ledgerRows); | 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( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| @@ -569,6 +651,19 @@ const ProjectCashFlow: React.FC = () => { | |||||
| [t], | [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 ( | return ( | ||||
| <> | <> | ||||
| {/* <Suspense fallback={<ProgressCashFlowSearch.Loading />}> | {/* <Suspense fallback={<ProgressCashFlowSearch.Loading />}> | ||||
| @@ -577,11 +672,21 @@ const ProjectCashFlow: React.FC = () => { | |||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={(query) => { | 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 | <CustomDatagrid | ||||
| rows={projectData} | |||||
| rows={filteredResult} | |||||
| columns={columns} | columns={columns} | ||||
| columnWidth={200} | columnWidth={200} | ||||
| dataGridHeight={300} | dataGridHeight={300} | ||||
| @@ -666,7 +771,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| className="text-lg font-medium ml-5" | className="text-lg font-medium ml-5" | ||||
| style={{ color: "#6b87cf" }} | style={{ color: "#6b87cf" }} | ||||
| > | > | ||||
| 1,000,000.00 | |||||
| {totalInvoiced.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} | |||||
| </div> | </div> | ||||
| <hr /> | <hr /> | ||||
| <div | <div | ||||
| @@ -679,7 +784,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| className="text-lg font-medium ml-5" | className="text-lg font-medium ml-5" | ||||
| style={{ color: "#6b87cf" }} | style={{ color: "#6b87cf" }} | ||||
| > | > | ||||
| 800,000.00 | |||||
| {totalReceived.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} | |||||
| </div> | </div> | ||||
| <hr /> | <hr /> | ||||
| <div | <div | ||||
| @@ -692,7 +797,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| className="text-lg font-medium ml-5 mb-2" | className="text-lg font-medium ml-5 mb-2" | ||||
| style={{ color: "#6b87cf" }} | style={{ color: "#6b87cf" }} | ||||
| > | > | ||||
| 200,000.00 | |||||
| {receivable.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} | |||||
| </div> | </div> | ||||
| </Card> | </Card> | ||||
| </Card> | </Card> | ||||
| @@ -728,7 +833,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| className="text-lg font-medium ml-5" | className="text-lg font-medium ml-5" | ||||
| style={{ color: "#6b87cf" }} | style={{ color: "#6b87cf" }} | ||||
| > | > | ||||
| 800,000.00 | |||||
| {totalBudget.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} | |||||
| </div> | </div> | ||||
| <hr /> | <hr /> | ||||
| <div | <div | ||||
| @@ -741,7 +846,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| className="text-lg font-medium ml-5" | className="text-lg font-medium ml-5" | ||||
| style={{ color: "#6b87cf" }} | style={{ color: "#6b87cf" }} | ||||
| > | > | ||||
| 760,000.00 | |||||
| {totalExpenditure.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} | |||||
| </div> | </div> | ||||
| <hr /> | <hr /> | ||||
| <div | <div | ||||
| @@ -754,7 +859,7 @@ const ProjectCashFlow: React.FC = () => { | |||||
| className="text-lg font-medium ml-5 mb-2" | className="text-lg font-medium ml-5 mb-2" | ||||
| style={{ color: "#6b87cf" }} | style={{ color: "#6b87cf" }} | ||||
| > | > | ||||
| 40,000.00 | |||||
| {expenditureReceivable.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} | |||||
| </div> | </div> | ||||
| </Card> | </Card> | ||||
| </Card> | </Card> | ||||
| @@ -47,6 +47,7 @@ const ProjectCompletionReport: React.FC<Props> = ( | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| formType={"download"} | |||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={async (query: any) => { | onSearch={async (query: any) => { | ||||
| console.log(query); | console.log(query); | ||||
| @@ -62,6 +62,7 @@ const ResourceOverconsumptionReport: React.FC<Props> = ({ team, customer }) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| formType={"download"} | |||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={async (query: any) => { | onSearch={async (query: any) => { | ||||
| let index = 0 | let index = 0 | ||||
| @@ -22,21 +22,6 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||||
| import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { Box, FormHelperText } from "@mui/material"; | import { Box, FormHelperText } from "@mui/material"; | ||||
| import { DateCalendar } from "@mui/x-date-pickers"; | |||||
| import { fetchLateStartReport } from "@/app/api/reports/actions"; | |||||
| import { LateStartReportRequest } from "@/app/api/reports"; | |||||
| import { fetchTeamCombo } from "@/app/api/team/actions"; | |||||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||||
| import { | |||||
| Unstable_NumberInput as BaseNumberInput, | |||||
| NumberInputProps, | |||||
| numberInputClasses, | |||||
| } from "@mui/base/Unstable_NumberInput"; | |||||
| import { | |||||
| StyledButton, | |||||
| StyledInputElement, | |||||
| StyledInputRoot, | |||||
| } from "@/theme/colorConst"; | |||||
| import { InputAdornment, NumberInput } from "../utils/numberInput"; | import { InputAdornment, NumberInput } from "../utils/numberInput"; | ||||
| interface BaseCriterion<T extends string> { | interface BaseCriterion<T extends string> { | ||||
| @@ -48,6 +33,9 @@ interface BaseCriterion<T extends string> { | |||||
| interface TextCriterion<T extends string> extends BaseCriterion<T> { | interface TextCriterion<T extends string> extends BaseCriterion<T> { | ||||
| type: "text"; | type: "text"; | ||||
| textType?: React.HTMLInputTypeAttribute; | |||||
| error?: boolean; | |||||
| helperText?: React.ReactNode; | |||||
| } | } | ||||
| interface SelectCriterion<T extends string> extends BaseCriterion<T> { | interface SelectCriterion<T extends string> extends BaseCriterion<T> { | ||||
| @@ -190,9 +178,12 @@ function SearchBox<T extends string>({ | |||||
| {c.type === "text" && ( | {c.type === "text" && ( | ||||
| <TextField | <TextField | ||||
| label={c.label} | label={c.label} | ||||
| type={c.textType ?? "text"} | |||||
| fullWidth | fullWidth | ||||
| onChange={makeInputChangeHandler(c.paramName)} | onChange={makeInputChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | value={inputs[c.paramName]} | ||||
| error={Boolean(c.error)} | |||||
| helperText={Boolean(c.error) && c.helperText} | |||||
| /> | /> | ||||
| )} | )} | ||||
| {c.type === "select" && ( | {c.type === "select" && ( | ||||
| @@ -216,7 +207,7 @@ function SearchBox<T extends string>({ | |||||
| )} | )} | ||||
| {c.type === "number" && ( | {c.type === "number" && ( | ||||
| <NumberInput | <NumberInput | ||||
| // defaultValue={90} | |||||
| placeholder={c.label} | |||||
| min={50} | min={50} | ||||
| max={99} | max={99} | ||||
| onChange={makeNumberChangeHandler(c.paramName)} | onChange={makeNumberChangeHandler(c.paramName)} | ||||
| @@ -42,6 +42,7 @@ interface Props { | |||||
| defaultTimesheets?: RecordTimesheetInput; | defaultTimesheets?: RecordTimesheetInput; | ||||
| leaveRecords: RecordLeaveInput; | leaveRecords: RecordLeaveInput; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const modalSx: SxProps = { | const modalSx: SxProps = { | ||||
| @@ -63,6 +64,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| defaultTimesheets, | defaultTimesheets, | ||||
| leaveRecords, | leaveRecords, | ||||
| companyHolidays, | companyHolidays, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| @@ -83,7 +85,9 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | ||||
| async (data) => { | async (data) => { | ||||
| const errors = validateTimesheet(data, leaveRecords, companyHolidays); | |||||
| const errors = validateTimesheet(data, leaveRecords, companyHolidays, { | |||||
| skipTaskValidation: fastEntryEnabled, | |||||
| }); | |||||
| if (errors) { | if (errors) { | ||||
| Object.keys(errors).forEach((date) => | Object.keys(errors).forEach((date) => | ||||
| formProps.setError(date, { | formProps.setError(date, { | ||||
| @@ -108,7 +112,14 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| formProps.reset(newFormValues); | formProps.reset(newFormValues); | ||||
| onClose(); | onClose(); | ||||
| }, | }, | ||||
| [companyHolidays, formProps, leaveRecords, onClose, username], | |||||
| [ | |||||
| companyHolidays, | |||||
| fastEntryEnabled, | |||||
| formProps, | |||||
| leaveRecords, | |||||
| onClose, | |||||
| username, | |||||
| ], | |||||
| ); | ); | ||||
| const onCancel = useCallback(() => { | const onCancel = useCallback(() => { | ||||
| @@ -165,6 +176,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| leaveRecords={leaveRecords} | leaveRecords={leaveRecords} | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| {errorComponent} | {errorComponent} | ||||
| @@ -202,6 +214,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| {t("Timesheet Input")} | {t("Timesheet Input")} | ||||
| </Typography> | </Typography> | ||||
| <MobileTimesheetTable | <MobileTimesheetTable | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| companyHolidays={companyHolidays} | companyHolidays={companyHolidays} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| @@ -35,6 +35,7 @@ import { | |||||
| validateTimeEntry, | validateTimeEntry, | ||||
| } from "@/app/api/timesheets/utils"; | } from "@/app/api/timesheets/utils"; | ||||
| import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | ||||
| import FastTimeEntryModal from "./FastTimeEntryModal"; | |||||
| dayjs.extend(isBetween); | dayjs.extend(isBetween); | ||||
| @@ -43,6 +44,7 @@ interface Props { | |||||
| isHoliday: boolean; | isHoliday: boolean; | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| export type TimeEntryRow = Partial< | export type TimeEntryRow = Partial< | ||||
| @@ -58,6 +60,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| allProjects, | allProjects, | ||||
| assignedProjects, | assignedProjects, | ||||
| isHoliday, | isHoliday, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const taskGroupsByProject = useMemo(() => { | const taskGroupsByProject = useMemo(() => { | ||||
| @@ -114,7 +117,9 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| "", | "", | ||||
| ) as TimeEntryRow; | ) as TimeEntryRow; | ||||
| const error = validateTimeEntry(row, isHoliday); | |||||
| const error = validateTimeEntry(row, isHoliday, { | |||||
| skipTaskValidation: fastEntryEnabled, | |||||
| }); | |||||
| // Test for warnings | // Test for warnings | ||||
| let isPlanned; | let isPlanned; | ||||
| @@ -133,7 +138,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| apiRef.current.updateRows([{ id, _error: error, isPlanned }]); | apiRef.current.updateRows([{ id, _error: error, isPlanned }]); | ||||
| return !error; | return !error; | ||||
| }, | }, | ||||
| [apiRef, day, isHoliday, milestonesByProject], | |||||
| [apiRef, day, fastEntryEnabled, isHoliday, milestonesByProject], | |||||
| ); | ); | ||||
| const handleCancel = useCallback( | const handleCancel = useCallback( | ||||
| @@ -230,6 +235,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | ||||
| return ( | return ( | ||||
| <ProjectSelect | <ProjectSelect | ||||
| multiple={false} | |||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| value={params.value} | value={params.value} | ||||
| @@ -406,6 +412,19 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| (entry) => entry.isPlanned !== undefined && !entry.isPlanned, | (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 = ( | const footer = ( | ||||
| <Box display="flex" gap={2} alignItems="center"> | <Box display="flex" gap={2} alignItems="center"> | ||||
| <Button | <Button | ||||
| @@ -417,6 +436,15 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| > | > | ||||
| {t("Record time")} | {t("Record time")} | ||||
| </Button> | </Button> | ||||
| <Button | |||||
| disableRipple | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={openFastEntryModal} | |||||
| size="small" | |||||
| > | |||||
| {t("Fast time entry")} | |||||
| </Button> | |||||
| {hasOutOfPlannedStages && ( | {hasOutOfPlannedStages && ( | ||||
| <Typography color="warning.main" variant="body2"> | <Typography color="warning.main" variant="body2"> | ||||
| {t("There are entries for stages out of planned dates!")} | {t("There are entries for stages out of planned dates!")} | ||||
| @@ -426,49 +454,61 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| return ( | 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 { 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 dayjs from "dayjs"; | ||||
| import React, { useCallback, useMemo, useState } from "react"; | import React, { useCallback, useMemo, useState } from "react"; | ||||
| import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
| @@ -20,12 +13,14 @@ import TimesheetEditModal, { | |||||
| import TimeEntryCard from "./TimeEntryCard"; | import TimeEntryCard from "./TimeEntryCard"; | ||||
| import { HolidaysResult } from "@/app/api/holidays"; | import { HolidaysResult } from "@/app/api/holidays"; | ||||
| import { getHolidayForDate } from "@/app/utils/holidayUtils"; | import { getHolidayForDate } from "@/app/utils/holidayUtils"; | ||||
| import FastTimeEntryModal from "./FastTimeEntryModal"; | |||||
| interface Props { | interface Props { | ||||
| date: string; | date: string; | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const MobileTimesheetEntry: React.FC<Props> = ({ | const MobileTimesheetEntry: React.FC<Props> = ({ | ||||
| @@ -33,6 +28,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| allProjects, | allProjects, | ||||
| assignedProjects, | assignedProjects, | ||||
| companyHolidays, | companyHolidays, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { | const { | ||||
| t, | t, | ||||
| @@ -51,7 +47,8 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| const holiday = getHolidayForDate(date, companyHolidays); | const holiday = getHolidayForDate(date, companyHolidays); | ||||
| const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | 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); | const currentEntries = watch(date); | ||||
| // Edit modal | // Edit modal | ||||
| @@ -103,6 +100,22 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| [clearErrors, currentEntries, date, setValue], | [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 ( | return ( | ||||
| <> | <> | ||||
| <Typography | <Typography | ||||
| @@ -149,11 +162,16 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| {t("Add some time entries!")} | {t("Add some time entries!")} | ||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| <Box> | |||||
| <Stack alignItems={"flex-start"} spacing={1}> | |||||
| <Button startIcon={<Add />} onClick={openEditModal()}> | <Button startIcon={<Add />} onClick={openEditModal()}> | ||||
| {t("Record time")} | {t("Record time")} | ||||
| </Button> | </Button> | ||||
| </Box> | |||||
| {fastEntryEnabled && ( | |||||
| <Button startIcon={<Add />} onClick={openFastEntryModal}> | |||||
| {t("Fast time entry")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | |||||
| <TimesheetEditModal | <TimesheetEditModal | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| @@ -161,8 +179,19 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| onClose={closeEditModal} | onClose={closeEditModal} | ||||
| onSave={onSaveEntry} | onSave={onSaveEntry} | ||||
| isHoliday={Boolean(isHoliday)} | isHoliday={Boolean(isHoliday)} | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| {...editModalProps} | {...editModalProps} | ||||
| /> | /> | ||||
| {fastEntryEnabled && ( | |||||
| <FastTimeEntryModal | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| open={fastEntryModalOpen} | |||||
| isHoliday={Boolean(isHoliday)} | |||||
| onClose={closeFastEntryModal} | |||||
| onSave={onSaveFastEntry} | |||||
| /> | |||||
| )} | |||||
| </Box> | </Box> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -15,6 +15,7 @@ interface Props { | |||||
| leaveRecords: RecordLeaveInput; | leaveRecords: RecordLeaveInput; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| errorComponent?: React.ReactNode; | errorComponent?: React.ReactNode; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const MobileTimesheetTable: React.FC<Props> = ({ | const MobileTimesheetTable: React.FC<Props> = ({ | ||||
| @@ -23,6 +24,7 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||||
| leaveRecords, | leaveRecords, | ||||
| companyHolidays, | companyHolidays, | ||||
| errorComponent, | errorComponent, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { watch } = useFormContext<RecordTimesheetInput>(); | const { watch } = useFormContext<RecordTimesheetInput>(); | ||||
| const currentInput = watch(); | const currentInput = watch(); | ||||
| @@ -35,7 +37,12 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||||
| leaveEntries={leaveRecords} | leaveEntries={leaveRecords} | ||||
| timesheetEntries={currentInput} | timesheetEntries={currentInput} | ||||
| EntryComponent={MobileTimesheetEntry} | EntryComponent={MobileTimesheetEntry} | ||||
| entryComponentProps={{ allProjects, assignedProjects, companyHolidays }} | |||||
| entryComponentProps={{ | |||||
| allProjects, | |||||
| assignedProjects, | |||||
| companyHolidays, | |||||
| fastEntryEnabled, | |||||
| }} | |||||
| errorComponent={errorComponent} | errorComponent={errorComponent} | ||||
| /> | /> | ||||
| ); | ); | ||||
| @@ -1,6 +1,8 @@ | |||||
| import React, { useCallback, useMemo } from "react"; | import React, { useCallback, useMemo } from "react"; | ||||
| import { | import { | ||||
| Autocomplete, | Autocomplete, | ||||
| Checkbox, | |||||
| Chip, | |||||
| ListSubheader, | ListSubheader, | ||||
| MenuItem, | MenuItem, | ||||
| TextField, | TextField, | ||||
| @@ -8,15 +10,30 @@ import { | |||||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import differenceBy from "lodash/differenceBy"; | import differenceBy from "lodash/differenceBy"; | ||||
| import intersectionWith from "lodash/intersectionWith"; | |||||
| import { TFunction } from "i18next"; | import { TFunction } from "i18next"; | ||||
| interface Props { | |||||
| interface CommonProps { | |||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| error?: boolean; | |||||
| multiple?: boolean; | |||||
| } | |||||
| interface SingleAutocompleteProps extends CommonProps { | |||||
| value: number | undefined; | value: number | undefined; | ||||
| onProjectSelect: (projectId: number | string) => void; | 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 => { | const getGroupName = (t: TFunction, groupName: string): string => { | ||||
| switch (groupName) { | switch (groupName) { | ||||
| case "non-billable": | case "non-billable": | ||||
| @@ -37,6 +54,8 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| assignedProjects, | assignedProjects, | ||||
| value, | value, | ||||
| onProjectSelect, | onProjectSelect, | ||||
| error, | |||||
| multiple, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const nonAssignedProjects = useMemo(() => { | const nonAssignedProjects = useMemo(() => { | ||||
| @@ -63,17 +82,32 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| ]; | ]; | ||||
| }, [assignedProjects, nonAssignedProjects, t]); | }, [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( | 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 ( | return ( | ||||
| <Autocomplete | <Autocomplete | ||||
| multiple={multiple} | |||||
| noOptionsText={t("No projects")} | noOptionsText={t("No projects")} | ||||
| disableClearable | disableClearable | ||||
| fullWidth | fullWidth | ||||
| @@ -82,22 +116,56 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| groupBy={(option) => option.group} | groupBy={(option) => option.group} | ||||
| getOptionLabel={(option) => option.label} | getOptionLabel={(option) => option.label} | ||||
| options={options} | 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) => ( | 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} | {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 ( | 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} | {option.label} | ||||
| </MenuItem> | </MenuItem> | ||||
| ); | ); | ||||
| }} | }} | ||||
| renderInput={(params) => <TextField {...params} />} | |||||
| renderInput={(params) => <TextField {...params} error={error} />} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -34,6 +34,7 @@ export interface Props extends Omit<ModalProps, "children"> { | |||||
| modalSx?: SxProps; | modalSx?: SxProps; | ||||
| recordDate?: string; | recordDate?: string; | ||||
| isHoliday?: boolean; | isHoliday?: boolean; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const modalSx: SxProps = { | const modalSx: SxProps = { | ||||
| @@ -59,6 +60,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| modalSx: mSx, | modalSx: mSx, | ||||
| recordDate, | recordDate, | ||||
| isHoliday, | isHoliday, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { | const { | ||||
| t, | t, | ||||
| @@ -135,6 +137,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| name="projectId" | name="projectId" | ||||
| render={({ field }) => ( | render={({ field }) => ( | ||||
| <ProjectSelect | <ProjectSelect | ||||
| multiple={false} | |||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| value={field.value} | value={field.value} | ||||
| @@ -173,6 +176,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| if (!projectId) { | if (!projectId) { | ||||
| return !id; | return !id; | ||||
| } | } | ||||
| if (fastEntryEnabled) return true; | |||||
| const taskGroups = taskGroupsByProject[projectId]; | const taskGroups = taskGroupsByProject[projectId]; | ||||
| return taskGroups.some((tg) => tg.value === id); | return taskGroups.some((tg) => tg.value === id); | ||||
| }, | }, | ||||
| @@ -202,6 +206,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| if (!projectId) { | if (!projectId) { | ||||
| return !id; | return !id; | ||||
| } | } | ||||
| if (fastEntryEnabled) return true; | |||||
| const projectTasks = allProjects.find((p) => p.id === projectId) | const projectTasks = allProjects.find((p) => p.id === projectId) | ||||
| ?.tasks; | ?.tasks; | ||||
| return Boolean(projectTasks?.some((task) => task.id === id)); | return Boolean(projectTasks?.some((task) => task.id === id)); | ||||
| @@ -14,6 +14,7 @@ interface Props { | |||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| leaveRecords: RecordLeaveInput; | leaveRecords: RecordLeaveInput; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const TimesheetTable: React.FC<Props> = ({ | const TimesheetTable: React.FC<Props> = ({ | ||||
| @@ -21,6 +22,7 @@ const TimesheetTable: React.FC<Props> = ({ | |||||
| assignedProjects, | assignedProjects, | ||||
| leaveRecords, | leaveRecords, | ||||
| companyHolidays, | companyHolidays, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { watch } = useFormContext<RecordTimesheetInput>(); | const { watch } = useFormContext<RecordTimesheetInput>(); | ||||
| const currentInput = watch(); | const currentInput = watch(); | ||||
| @@ -33,7 +35,7 @@ const TimesheetTable: React.FC<Props> = ({ | |||||
| leaveEntries={leaveRecords} | leaveEntries={leaveRecords} | ||||
| timesheetEntries={currentInput} | timesheetEntries={currentInput} | ||||
| EntryTableComponent={EntryInputTable} | EntryTableComponent={EntryInputTable} | ||||
| entryTableProps={{ assignedProjects, allProjects }} | |||||
| entryTableProps={{ assignedProjects, allProjects, fastEntryEnabled }} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -35,6 +35,7 @@ export interface Props { | |||||
| defaultTimesheets: RecordTimesheetInput; | defaultTimesheets: RecordTimesheetInput; | ||||
| holidays: HolidaysResult[]; | holidays: HolidaysResult[]; | ||||
| teamTimesheets: TeamTimeSheets; | teamTimesheets: TeamTimeSheets; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const menuItemSx: SxProps = { | const menuItemSx: SxProps = { | ||||
| @@ -51,6 +52,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| defaultTimesheets, | defaultTimesheets, | ||||
| holidays, | holidays, | ||||
| teamTimesheets, | teamTimesheets, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
| @@ -170,6 +172,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| leaveTypes={leaveTypes} | leaveTypes={leaveTypes} | ||||
| /> | /> | ||||
| <TimesheetModal | <TimesheetModal | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| companyHolidays={holidays} | companyHolidays={holidays} | ||||
| isOpen={isTimeheetModalVisible} | isOpen={isTimeheetModalVisible} | ||||
| onClose={handleCloseTimesheetModal} | onClose={handleCloseTimesheetModal} | ||||
| @@ -44,6 +44,8 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||||
| defaultLeaveRecords={leaves} | defaultLeaveRecords={leaves} | ||||
| leaveTypes={leaveTypes} | leaveTypes={leaveTypes} | ||||
| holidays={holidays} | holidays={holidays} | ||||
| // Change to access check | |||||
| fastEntryEnabled={true} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -1,4 +1,7 @@ | |||||
| { | { | ||||
| "Number Of Days": "Number Of Days", | |||||
| "Project Completion (<= %)": "Project Completion (<= %)", | |||||
| "Project": "Project", | "Project": "Project", | ||||
| "Date Type": "Date Type" | "Date Type": "Date Type" | ||||
| } | } | ||||
| @@ -2,6 +2,9 @@ | |||||
| "Staff Monthly Work Hours Analysis Report": "Staff Monthly Work Hours Analysis Report", | "Staff Monthly Work Hours Analysis Report": "Staff Monthly Work Hours Analysis Report", | ||||
| "Project Resource Overconsumption Report": "Project Resource Overconsumption Report", | "Project Resource Overconsumption Report": "Project Resource Overconsumption Report", | ||||
| "Number Of Days": "天數", | |||||
| "Project Completion (<= %)": "項目完成度 (<= %)", | |||||
| "Project": "項目", | "Project": "項目", | ||||
| "Date Type": "日期類型", | "Date Type": "日期類型", | ||||
| "Date": "日期", | "Date": "日期", | ||||