From cca2e56c6be00ad68ba64eba117eb3cfaf1a4751 Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Wed, 29 May 2024 18:22:11 +0800 Subject: [PATCH 01/13] update --- src/app/api/reports/index.ts | 2 +- .../GenerateProjectPotentialDelayReport.tsx | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/app/api/reports/index.ts b/src/app/api/reports/index.ts index bf39c0f..64729fd 100644 --- a/src/app/api/reports/index.ts +++ b/src/app/api/reports/index.ts @@ -36,7 +36,7 @@ export interface ProjectCashFlowReportRequest { // - Project Potential Delay Report export interface ProjectPotentialDelayReportFilter { - team: AutocompleteOptions[]; + team: string[]; client: AutocompleteOptions[]; numberOfDays: number; projectCompletion: number; diff --git a/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx b/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx index fd7dfba..c8b004e 100644 --- a/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx +++ b/src/components/GenerateProjectPotentialDelayReport/GenerateProjectPotentialDelayReport.tsx @@ -21,10 +21,8 @@ type SearchParamNames = keyof SearchQuery; const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients, subsidiaries }) => { const { t } = useTranslation("report"); - const teamCombo = teams.map(team => ({ - value: team.id, - label: `${team.code} - ${team.name}`, - })) + const teamCombo = teams.map(team => `${team.code} - ${team.name}`) + const clientCombo = clients.map(client => ({ value: `client: ${client.id}` , label: `${client.code} - ${client.name}`, @@ -44,7 +42,7 @@ const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients, const searchCriteria: Criterion[] = useMemo( () => [ - { label: t("Team"), paramName: "team", type: "autocomplete", options: teamCombo }, + { label: t("Team"), paramName: "team", type: "select", options: teamCombo }, { label: t("Client"), paramName: "client", type: "autocomplete", options: [...subsidiaryCombo, ...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") }, @@ -75,11 +73,12 @@ const GenerateProjectPotentialDelayReport: React.FC = ({ teams, clients, if (hasError) return false + const teamIndex = teamCombo.findIndex(team => team === query.team) const clientIndex = clientCombo.findIndex(client => client.value === query.client) const subsidiaryIndex = subsidiaryCombo.findIndex(subsidiary => subsidiary.value === query.client) const response = await fetchProjectPotentialDelayReport({ - teamId: typeof query.team === "number" ? query.team : "All", + teamId: teamIndex >= 0 ? teams[teamIndex].id : "All", clientId: clientIndex >= 0 ? clients[clientIndex].id : subsidiaryIndex >= 0 ? subsidiaries[subsidiaryIndex].id : "All", numberOfDays: parseInt(query.numberOfDays), projectCompletion: parseInt(query.projectCompletion), From 5fd20dcb0c47b834e7fdf7e8ade80ebdf628a316 Mon Sep 17 00:00:00 2001 From: "MSI\\derek" Date: Wed, 29 May 2024 18:22:34 +0800 Subject: [PATCH 02/13] report access right --- .../CostAndExpenseReport.tsx | 5 +++-- .../CostAndExpenseReportWrapper.tsx | 22 ++++++++++++++----- src/config/authConfig.ts | 4 ++++ src/middleware.ts | 17 +++++++++++++- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx b/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx index da15baf..1b837ee 100644 --- a/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx +++ b/src/components/CostAndExpenseReport/CostAndExpenseReport.tsx @@ -11,12 +11,13 @@ import { downloadFile } from "@/app/utils/commonUtil"; interface Props { team: TeamResult[]; customer: Customer[]; + needAll: boolean | undefined; } type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const CostAndExpenseReport: React.FC = ({ team, customer }) => { +const CostAndExpenseReport: React.FC = ({ team, customer, needAll }) => { const { t } = useTranslation("report"); const teamCombo = team.map((t) => `${t.name} - ${t.code}`); const custCombo = customer.map(c => ({label: `${c.name} - ${c.code}`, value: c.id})) @@ -28,7 +29,7 @@ const CostAndExpenseReport: React.FC = ({ team, customer }) => { paramName: "team", type: "select", options: teamCombo, - needAll: true, + needAll: needAll, }, { label: t("Client"), diff --git a/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx b/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx index 2b32c99..15598cc 100644 --- a/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx +++ b/src/components/CostAndExpenseReport/CostAndExpenseReportWrapper.tsx @@ -1,18 +1,30 @@ import React from "react"; import { fetchAllCustomers } from "@/app/api/customer"; -import { fetchTeam } from "@/app/api/team"; +import { fetchIndivTeam, fetchTeam } from "@/app/api/team"; import CostAndExpenseReport from "./CostAndExpenseReport"; import CostAndExpenseReportLoading from "./CostAndExpenseReportLoading"; - +import { headers, cookies } from 'next/headers'; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/config/authConfig"; +import { TEAM_LEAD } from "@/middleware"; interface SubComponents { Loading: typeof CostAndExpenseReportLoading; } const CostAndExpenseReportWrapper: React.FC & SubComponents = async () => { - const customers = await fetchAllCustomers() - const teams = await fetchTeam () + const session: any = await getServerSession(authOptions) + const teamId = session.staff?.team.id + const role = session!.role + let customers = await fetchAllCustomers() + let teams = await fetchTeam() + let needAll = true + + if (role === TEAM_LEAD) { + needAll = false + teams = teams.filter((team) => team.id === teamId); + } - return + return }; CostAndExpenseReportWrapper.Loading = CostAndExpenseReportLoading; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index 2c2b9da..65783d4 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -3,6 +3,8 @@ import CredentialsProvider from "next-auth/providers/credentials"; import { LOGIN_API_PATH } from "./api"; export interface SessionWithTokens extends Session { + staff?: any; + role?: String; abilities?: any[]; accessToken?: string; refreshToken?: string; @@ -52,12 +54,14 @@ export const authOptions: AuthOptions = { session({ session, token }) { const sessionWithToken: SessionWithTokens = { ...session, + role: token.role as String, // Add the data from the token to the session abilities: (token.abilities as ability[]).map( (item: ability) => item.actionSubjectCombo, ) as string[], accessToken: token.accessToken as string | undefined, refreshToken: token.refreshToken as string | undefined, + staff: token.staff as any }; // console.log(sessionWithToken) return sessionWithToken; diff --git a/src/middleware.ts b/src/middleware.ts index e3927b1..f9c8602 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,6 +3,21 @@ import { ability, authOptions } from "@/config/authConfig"; import { NextFetchEvent, NextResponse } from "next/server"; import { getToken } from "next-auth/jwt"; +// user groups +export const [ + SUPER_ADMIN, + TOP_MANAGEMENT, + TEAM_LEAD, + NORMAL_STAFF, + SUPPORTING_STAFF +] = [ + "Super Admin", + "Top Management", + "Team Leader", + "Normal Staff", + "Supporting Staff" +] + // abilities export const [ VIEW_USER, @@ -61,7 +76,7 @@ export default async function middleware( event: NextFetchEvent, ) { const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); - const token = await getToken({ req: req, secret: process.env.SECRET }); + // const token = await getToken({ req: req, secret: process.env.SECRET }); if (langPref) { // Redirect to same url without the lang query param + set cookies const newUrl = new URL(req.nextUrl); From 91e7e1d854c66511d85fc48e66596e1f680ff34d Mon Sep 17 00:00:00 2001 From: Wayne Date: Thu, 30 May 2024 00:09:57 +0900 Subject: [PATCH 03/13] Add delete on time amendment and more validation --- src/app/api/timesheets/actions.ts | 28 +++++ src/app/api/timesheets/utils.ts | 112 ++++++++++-------- src/components/LeaveModal/LeaveModal.tsx | 8 +- src/components/LeaveTable/LeaveEditModal.tsx | 42 +++++-- src/components/LeaveTable/LeaveEntryTable.tsx | 56 ++++++--- .../LeaveTable/MobileLeaveEntry.tsx | 5 +- .../TimesheetAmendment/TimesheetAmendment.tsx | 85 ++++++++++++- .../TimesheetTable/EntryInputTable.tsx | 10 +- .../TimesheetTable/FastTimeEntryModal.tsx | 2 +- .../TimesheetTable/MobileTimesheetEntry.tsx | 4 +- .../TimesheetTable/TimesheetEditModal.tsx | 22 +++- 11 files changed, 278 insertions(+), 96 deletions(-) diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 5d4ecb0..d362264 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -94,6 +94,34 @@ export const saveMemberLeave = async (data: { ); }; +export const deleteMemberEntry = async (data: { + staffId: number; + entryId: number; +}) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/deleteMemberEntry`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; + +export const deleteMemberLeave = async (data: { + staffId: number; + entryId: number; +}) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/deleteMemberLeave`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; + export const revalidateCacheAfterAmendment = () => { revalidatePath("/(main)/home"); }; diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts index 673a03d..bbb13c2 100644 --- a/src/app/api/timesheets/utils.ts +++ b/src/app/api/timesheets/utils.ts @@ -28,7 +28,7 @@ export const validateTimeEntry = ( if (!entry.inputHours && !entry.otHours) { error[isHoliday ? "otHours" : "inputHours"] = "Required"; } else if (entry.inputHours && isHoliday) { - error.inputHours = "Cannot input normal hours for holidays"; + error.inputHours = "Cannot input normal hours on holidays"; } else if (entry.inputHours && entry.inputHours <= 0) { error.inputHours = "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}"; @@ -55,16 +55,31 @@ export const validateTimeEntry = ( return Object.keys(error).length > 0 ? error : undefined; }; -export const isValidLeaveEntry = (entry: Partial): string => { +export type LeaveEntryError = { + [field in keyof LeaveEntry]?: string; +}; + +export const validateLeaveEntry = ( + entry: Partial, + isHoliday: boolean, +): LeaveEntryError | undefined => { // Test for errrors - let error: keyof LeaveEntry | "" = ""; + const error: LeaveEntryError = {}; if (!entry.leaveTypeId) { - error = "leaveTypeId"; - } else if (!entry.inputHours || !(entry.inputHours >= 0)) { - error = "inputHours"; + error.leaveTypeId = "Required"; + } else if (entry.inputHours && isHoliday) { + error.inputHours = "Cannot input normal hours on holidays"; + } else if (!entry.inputHours) { + error.inputHours = "Required"; + } else if ( + entry.inputHours && + (entry.inputHours <= 0 || entry.inputHours > DAILY_NORMAL_MAX_HOURS) + ) { + error.inputHours = + "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}"; } - return error; + return Object.keys(error).length > 0 ? error : undefined; }; export const validateTimesheet = ( @@ -95,27 +110,10 @@ export const validateTimesheet = ( } // Check total hours - const leaves = leaveRecords[date]; - const leaveHours = - leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; - - const totalInputHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.inputHours || 0); - }, 0); - - const totalOtHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.otHours || 0); - }, 0); - - if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { - errors[date] = - "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; - } else if ( - totalInputHours + totalOtHours + leaveHours > - TIMESHEET_DAILY_MAX_HOURS - ) { - errors[date] = - "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; + const leaves = leaveRecords[date] || []; + const totalHourError = checkTotalHours(timeEntries, leaves); + if (totalHourError) { + errors[date] = totalHourError; } }); @@ -125,48 +123,64 @@ export const validateTimesheet = ( export const validateLeaveRecord = ( leaveRecords: RecordLeaveInput, timesheet: RecordTimesheetInput, + companyHolidays: HolidaysResult[], ): { [date: string]: string } | undefined => { const errors: { [date: string]: string } = {}; + const holidays = new Set( + compact([ + ...getPublicHolidaysForNYears(2).map((h) => h.date), + ...companyHolidays.map((h) => convertDateArrayToString(h.date)), + ]), + ); + Object.keys(leaveRecords).forEach((date) => { const leaves = leaveRecords[date]; // Check each leave entry for (const entry of leaves) { - const entryError = isValidLeaveEntry(entry); + const entryError = validateLeaveEntry(entry, holidays.has(date)); if (entryError) { errors[date] = "There are errors in the entries"; + return; } } // Check total hours const timeEntries = timesheet[date] || []; - const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); - - const totalInputHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.inputHours || 0); - }, 0); - - const totalOtHours = timeEntries.reduce((acc, entry) => { - return acc + (entry.otHours || 0); - }, 0); - - if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { - errors[date] = - "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; - } else if ( - totalInputHours + totalOtHours + leaveHours > - TIMESHEET_DAILY_MAX_HOURS - ) { - errors[date] = - "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; + const totalHourError = checkTotalHours(timeEntries, leaves); + if (totalHourError) { + errors[date] = totalHourError; } }); return Object.keys(errors).length > 0 ? errors : undefined; }; +export const checkTotalHours = ( + timeEntries: TimeEntry[], + leaves: LeaveEntry[], +): string | undefined => { + const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); + + const totalInputHours = timeEntries.reduce((acc, entry) => { + return acc + (entry.inputHours || 0); + }, 0); + + const totalOtHours = timeEntries.reduce((acc, entry) => { + return acc + (entry.otHours || 0); + }, 0); + + if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { + return "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; + } else if ( + totalInputHours + totalOtHours + leaveHours > + TIMESHEET_DAILY_MAX_HOURS + ) { + return "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; + } +}; + export const DAILY_NORMAL_MAX_HOURS = 8; -export const LEAVE_DAILY_MAX_HOURS = 8; export const TIMESHEET_DAILY_MAX_HOURS = 20; diff --git a/src/components/LeaveModal/LeaveModal.tsx b/src/components/LeaveModal/LeaveModal.tsx index b22f8fc..e8f32d1 100644 --- a/src/components/LeaveModal/LeaveModal.tsx +++ b/src/components/LeaveModal/LeaveModal.tsx @@ -81,7 +81,11 @@ const LeaveModal: React.FC = ({ const onSubmit = useCallback>( async (data) => { - const errors = validateLeaveRecord(data, timesheetRecords); + const errors = validateLeaveRecord( + data, + timesheetRecords, + companyHolidays, + ); if (errors) { Object.keys(errors).forEach((date) => formProps.setError(date, { @@ -106,7 +110,7 @@ const LeaveModal: React.FC = ({ formProps.reset(newFormValues); onClose(); }, - [formProps, onClose, timesheetRecords, username], + [companyHolidays, formProps, onClose, timesheetRecords, username], ); const onCancel = useCallback(() => { diff --git a/src/components/LeaveTable/LeaveEditModal.tsx b/src/components/LeaveTable/LeaveEditModal.tsx index 524a268..05d1600 100644 --- a/src/components/LeaveTable/LeaveEditModal.tsx +++ b/src/components/LeaveTable/LeaveEditModal.tsx @@ -1,6 +1,6 @@ import { LeaveType } from "@/app/api/timesheets"; import { LeaveEntry } from "@/app/api/timesheets/actions"; -import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; +import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils"; import { shortDateFormatter } from "@/app/utils/formatUtil"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { Check, Delete } from "@mui/icons-material"; @@ -24,7 +24,7 @@ import { useTranslation } from "react-i18next"; export interface Props extends Omit { onSave: (leaveEntry: LeaveEntry, recordDate?: string) => Promise; - onDelete?: () => void; + onDelete?: () => Promise; leaveTypes: LeaveType[]; defaultValues?: Partial; modalSx?: SxProps; @@ -59,7 +59,7 @@ const LeaveEditModal: React.FC = ({ t, i18n: { language }, } = useTranslation("home"); - const { register, control, reset, getValues, trigger, formState } = + const { register, control, reset, getValues, trigger, formState, setError } = useForm({ defaultValues: { leaveTypeId: leaveTypes[0].id, @@ -73,10 +73,16 @@ const LeaveEditModal: React.FC = ({ const saveHandler = useCallback(async () => { const valid = await trigger(); if (valid) { - await onSave(getValues(), recordDate); - reset({ id: Date.now() }); + try { + await onSave(getValues(), recordDate); + reset({ id: Date.now() }); + } catch (e) { + setError("root", { + message: e instanceof Error ? e.message : "Unknown error", + }); + } } - }, [getValues, onSave, recordDate, reset, trigger]); + }, [getValues, onSave, recordDate, reset, setError, trigger]); const closeHandler = useCallback>( (...args) => { @@ -121,12 +127,19 @@ const LeaveEditModal: React.FC = ({ fullWidth {...register("inputHours", { setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), - validate: (value) => - (0 < value && value <= LEAVE_DAILY_MAX_HOURS) || - t( - "Input hours should be between 0 and {{LEAVE_DAILY_MAX_HOURS}}", - { LEAVE_DAILY_MAX_HOURS }, - ), + validate: (value) => { + if (isHoliday) { + return t("Cannot input normal hours on 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 }, + ) + ); + }, })} error={Boolean(formState.errors.inputHours)} helperText={formState.errors.inputHours?.message} @@ -138,6 +151,11 @@ const LeaveEditModal: React.FC = ({ rows={2} {...register("remark")} /> + {formState.errors.root?.message && ( + + {t(formState.errors.root.message, { DAILY_NORMAL_MAX_HOURS })} + + )} {onDelete && (