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