@@ -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": "日期", | |||