diff --git a/src/app/api/staff/actions.ts b/src/app/api/staff/actions.ts index 9f22094..d105d68 100644 --- a/src/app/api/staff/actions.ts +++ b/src/app/api/staff/actions.ts @@ -17,21 +17,18 @@ export type teamHistory = { id: number, team: string | number, from: Date | string, - to?: Date | string } export type gradeHistory = { id: number, grade: string | number, from: Date | string, - to?: Date | string } export type positionHistory = { id: number, position: string | number, from: Date | string, - to?: Date | string } export interface CreateStaffInputs { id?: number @@ -62,6 +59,11 @@ export interface CreateStaffInputs { delGradeHistory: number[]; positionHistory: positionHistory[]; delPositionHistory: number[]; + // new modal + salary: salary[]; + team: team[]; + grade: grade[]; + position: position[]; } export interface records { @@ -69,6 +71,35 @@ export interface CreateStaffInputs { name: string; // team: Team[]; } + export type DataLog = + | (salary & {id: number; type: "salary"}) + | (team & {id: number; type: "team"}) + | (grade & {id: number; type: "grade"}) + | (position & {id: number; type: "position"}) + + export type salary = { + from: string; + to: string; + salaryPoint: number; + } + + export type team = { + from: string; + to: string; + teamId: number; + } + + export type grade = { + from: string; + to: string; + gradeId: number; + } + + export type position = { + from: string; + to: string; + positionId: number; + } export interface salaryEffectiveInfo { id: number; diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index 22767b6..fe684a6 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -8,7 +8,7 @@ import Button, { ButtonProps } from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import Tab from "@mui/material/Tab"; import Tabs, { TabsProps } from "@mui/material/Tabs"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import React, { useCallback, useEffect, @@ -118,7 +118,7 @@ const hasErrorsInTab = ( const CreateProject: React.FC = ({ isEditMode, isCopyMode, - draftId, + draftId: initDraftId, isSubProject, mainProjects, defaultInputs, @@ -139,6 +139,7 @@ const CreateProject: React.FC = ({ customerTypes, abilities, }) => { + const [draftId, setDraftId] = useState(initDraftId) const [serverError, setServerError] = useState(""); const [tabIndex, setTabIndex] = useState(0); const { t } = useTranslation(); @@ -575,9 +576,17 @@ const CreateProject: React.FC = ({ formProps.reset(draftInputs); }, [draftId, formProps]); - const saveDraft = useCallback(() => { - saveToLocalStorage(draftId || Date.now(), formProps.getValues()); - router.replace("/projects"); + const saveDraft = useCallback(async () => { + const currentTimestamp = Date.now() + + saveToLocalStorage(draftId || currentTimestamp, formProps.getValues()); + + const success = await successDialog("Save Success", t) + + if (success && !draftId) { + setDraftId(currentTimestamp) + } + // router.replace("/projects"); }, [draftId, formProps, router]); const handleDeleteDraft = useCallback(() => { @@ -592,6 +601,21 @@ const CreateProject: React.FC = ({ }, t); }, [draftId, router]); + // Auto click the button + const buttonRef = useRef(null) + const searchParams = useSearchParams() + + useEffect(() => { + if (buttonRef) { + const autoClickButton = searchParams.get("autoClick") + // const autoClickList = ["start", "complete", "reopen"] + + if(autoClickButton && autoClickButton === "true") { + buttonRef.current?.click() + } + } + }, [buttonRef]) + return ( <> @@ -633,6 +657,7 @@ const CreateProject: React.FC = ({ {/* {!formProps.getValues("projectActualStart") && ( */} - {draftId && + {draftId && + ); diff --git a/src/components/CreateProject/ProjectClientDetails.tsx b/src/components/CreateProject/ProjectClientDetails.tsx index e75dfd4..5e86694 100644 --- a/src/components/CreateProject/ProjectClientDetails.tsx +++ b/src/components/CreateProject/ProjectClientDetails.tsx @@ -477,7 +477,7 @@ const ProjectClientDetails: React.FC = ({ { // console.log(values) onChange(values.floatValue) @@ -513,7 +513,7 @@ const ProjectClientDetails: React.FC = ({ { // console.log(values) onChange(values.floatValue) @@ -575,7 +575,8 @@ const ProjectClientDetails: React.FC = ({ control={control} options={allCustomers.map((customer) => ({ ...customer, - label: `${customer.code} - ${customer.name}`, + label: `${customer.name}`, + // label: `${customer.code} - ${customer.name}`, }))} name="clientId" label={t("Client")} @@ -624,7 +625,8 @@ const ProjectClientDetails: React.FC = ({ const subsidiary = subsidiaryMap[subsidiaryId]; return { id: subsidiary.id, - label: `${subsidiary.code} - ${subsidiary.name}`, + label: `${subsidiary.name}`, + // label: `${subsidiary.code} - ${subsidiary.name}`, }; }), ]} diff --git a/src/components/CreateStaff/CreateStaff.tsx b/src/components/CreateStaff/CreateStaff.tsx index 5343a71..d960cc0 100644 --- a/src/components/CreateStaff/CreateStaff.tsx +++ b/src/components/CreateStaff/CreateStaff.tsx @@ -10,9 +10,11 @@ import { } from "react-hook-form"; import { CreateStaffInputs, saveStaff, testing } from "@/app/api/staff/actions"; import { Button, Stack, Typography } from "@mui/material"; -import { comboProp} from "@/app/api/companys/actions"; +import { comboProp } from "@/app/api/companys/actions"; import StaffInfo from "./StaffInfo"; import { Check, Close } from "@mui/icons-material"; +import dayjs from "dayjs"; +import { SalaryEffectiveInfo } from "@/app/api/staff"; interface Field { id: string; @@ -45,15 +47,29 @@ const CreateStaff: React.FC = ({ combos }) => { const { t } = useTranslation(); const formProps = useForm(); const [serverError, setServerError] = useState(""); + const [errorMsg, setErrorMsg] = useState("An error has occurred. Please try again later.") const router = useRouter(); - const [tabIndex, setTabIndex] = useState(0); + // const [tabIndex, setTabIndex] = useState(0); const errors = formProps.formState.errors; + function chopSalaryPoints(input: string | number): number | null { + if (typeof input === 'string') { + const match = input.match(/(\d+) \((\d+) - (\d+)\)/); + if (match) { + return parseInt(match[1], 10); + } + } else if (typeof input === 'number') { + return input; + } + return null; + } + const onSubmit = useCallback>( async (data) => { try { console.log(data); + formProps.clearErrors() let haveError = false; const regex_email = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/ const regex_phone = /^\d{8}$/ @@ -96,20 +112,71 @@ const CreateStaff: React.FC = ({ combos }) => { haveError = true formProps.setError("employType", { message: t("Please Enter Employ Type."), type: "required" }) } - if (!data.salaryId) { + if (data.joinDate && data.departDate && new Date(data.departDate) <= new Date(data.joinDate)) { haveError = true - formProps.setError("salaryId", { message: t("Please Enter Salary."), type: "required" }) + formProps.setError("departDate", { message: t("Depart Date cannot be earlier than Join Date."), type: "required" }) } - if (data.joinDate &&data.departDate && new Date(data.departDate) <= new Date(data.joinDate)) { + if (!data.salaryEffectiveInfo || data.salaryEffectiveInfo.length < 1) { haveError = true - formProps.setError("departDate", { message: t("Depart Date cannot be earlier than Join Date."), type: "required" }) + formProps.setError("salaryId", { message: t("Please Enter Salary"), type: "required" }) + } + if (!data.gradeHistory || data.gradeHistory.length < 1) { + haveError = true + formProps.setError("gradeId", { message: t("Please Enter Grade"), type: "required" }) + } + if (!data.positionHistory || data.positionHistory.length < 1) { + console.log("asdadsasd") + haveError = true + formProps.setError("currentPositionId", { message: t("Please Enter Current Position"), type: "required" }) } if (haveError) { return } + const teamHistory = data.teamHistory + .map((item) => ({ + id: item.id, + team: combos.team.filter(team => team.label === item.team)[0].id, + from: dayjs(item.from).format('YYYY-MM-DD'), + })) + .sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime()) + + const gradeHistory = data.gradeHistory + .map((item) => ({ + id: item.id, + grade: combos.grade.filter(grade => grade.label === item.grade)[0].id, + from: dayjs(item.from).format('YYYY-MM-DD'), + })) + .sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime()) + + console.log(data.positionHistory) + const positionHistory = data.positionHistory + .map((item) => ({ + id: item.id, + position: combos.position.filter(position => position.label === item.position)[0].id, + from: dayjs(item.from).format('YYYY-MM-DD'), + })) + .sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime()) + + const salaryEffectiveInfo = data.salaryEffectiveInfo.map((item: SalaryEffectiveInfo) => ({ + id: item.id, + salaryPoint: chopSalaryPoints(item.salaryPoint), + date: dayjs(item.date).format('YYYY-MM-DD').toString() + })) // backend sort + const postData: CreateStaffInputs = { + // id: id, + ...data, + salaryEffectiveInfo: salaryEffectiveInfo, + teamHistory: teamHistory ?? [], + gradeHistory: gradeHistory ?? [], + positionHistory: positionHistory ?? [], + delTeamHistory: data.delTeamHistory ? data.delTeamHistory : [], + delGradeHistory: data.delGradeHistory ? data.delGradeHistory : [], + delPositionHistory: data.delPositionHistory ? data.delPositionHistory : [], + } console.log("passed") - console.log(data) - await saveStaff(data) + console.log(postData) + // return + await saveStaff(postData) router.replace("/settings/staff") } catch (e: any) { console.log(e); @@ -118,15 +185,19 @@ const CreateStaff: React.FC = ({ combos }) => { if (e.message === "Duplicated StaffId Found") { msg = t("Duplicated StaffId Found") } - setServerError(`${t("An error has occurred. Please try again later.")} ${msg} `); + setServerError(`${t(errorMsg)} ${msg} `); } }, - [router] + [errorMsg, router] ); - const handleCancel = () => { + + const errorKey = Object.keys(formProps.formState.errors)[0] + const err = errors[errorKey as keyof CreateStaffInputs] + + const handleCancel = useCallback(() => { router.back(); - }; + }, [router]); return ( <> @@ -141,6 +212,12 @@ const CreateStaff: React.FC = ({ combos }) => { {serverError} )} + {err && ( + + {err.message?.toString()} + + ) + } + )} /> @@ -191,7 +257,7 @@ const StaffInfo: React.FC = ({ combos }) => { {t("Grade")} - ( @@ -207,6 +273,30 @@ const StaffInfo: React.FC = ({ combos }) => { ))} )} + /> */} + ( + + + + + )} /> @@ -254,9 +344,12 @@ const StaffInfo: React.FC = ({ combos }) => { control={control} name="currentPositionId" render={({ field }) => ( - {combos.position.map((position, index) => ( @@ -268,6 +361,10 @@ const StaffInfo: React.FC = ({ combos }) => { ))} + + )} /> @@ -275,7 +372,7 @@ const StaffInfo: React.FC = ({ combos }) => { {t("Salary Point")} - ( @@ -294,7 +391,35 @@ const StaffInfo: React.FC = ({ combos }) => { ))} )} - /> + /> */} + ( + + + {/* + + )} + /> @@ -512,6 +637,36 @@ const StaffInfo: React.FC = ({ combos }) => { + {state.seModal && + + } + {state.teamModal && + + } + {state.gradeModal && + + } + {state.positionModal && + + } + {/* {tableKey && toggleModal("team")} combos={combos}/>} */} ); }; diff --git a/src/components/DateHoursTable/DateHoursTable.tsx b/src/components/DateHoursTable/DateHoursTable.tsx index f6d18e2..69a2b15 100644 --- a/src/components/DateHoursTable/DateHoursTable.tsx +++ b/src/components/DateHoursTable/DateHoursTable.tsx @@ -36,6 +36,7 @@ interface Props { EntryTableProps & { day: string; isHoliday: boolean } >; entryTableProps: EntryTableProps; + isSaturdayWorker: boolean } function DateHoursTable({ @@ -45,9 +46,9 @@ function DateHoursTable({ leaveEntries, timesheetEntries, companyHolidays, + isSaturdayWorker, }: Props) { const { t } = useTranslation("home"); - return ( @@ -71,6 +72,7 @@ function DateHoursTable({ timesheetEntries={timesheetEntries} EntryTableComponent={EntryTableComponent} entryTableProps={entryTableProps} + isSaturdayWorker={isSaturdayWorker} /> ); })} @@ -87,6 +89,7 @@ function DayRow({ entryTableProps, EntryTableComponent, companyHolidays, + isSaturdayWorker, }: { day: string; companyHolidays: HolidaysResult[]; @@ -96,16 +99,18 @@ function DayRow({ EntryTableProps & { day: string; isHoliday: boolean } >; entryTableProps: EntryTableProps; + isSaturdayWorker: boolean }) { const { t, i18n: { language }, } = useTranslation("home"); const dayJsObj = dayjs(day); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); const holiday = getHolidayForDate(day, companyHolidays); - const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; + const isWeekend = !isSaturdayWorker ? dayJsObj.day() === 0 || dayJsObj.day() === 6 : dayJsObj.day() === 0; + const isHoliday = holiday || isWeekend; const leaves = leaveEntries[day]; const leaveHours = diff --git a/src/components/EditStaff/EditStaff.tsx b/src/components/EditStaff/EditStaff.tsx index c66abfc..4a52ea6 100644 --- a/src/components/EditStaff/EditStaff.tsx +++ b/src/components/EditStaff/EditStaff.tsx @@ -88,7 +88,7 @@ const EditStaff: React.FC = ({ Staff, combos, SalaryEffectiveInfo, In id: item.id, team: item.team.name, from: dayjs(item.from.join()).toDate(), - to: item.to ? dayjs(item.to.join()).toDate() : "", + to: item.to ? dayjs(item.to.join()).toDate() : undefined, }) }) : [], delTeamHistory: [], @@ -97,7 +97,7 @@ const EditStaff: React.FC = ({ Staff, combos, SalaryEffectiveInfo, In id: item.id, grade: item.grade.name, from: dayjs(item.from.join()).toDate(), - to: item.to ? dayjs(item.to.join()).toDate() : "", + to: item.to ? dayjs(item.to.join()).toDate() : undefined, }) }) : [], delGradeHistory: [], @@ -106,7 +106,7 @@ const EditStaff: React.FC = ({ Staff, combos, SalaryEffectiveInfo, In id: item.id, position: item.position.name, from: dayjs(item.from.join()).toDate(), - to: item.to ? dayjs(item.to.join()).toDate() : "", + to: item.to ? dayjs(item.to.join()).toDate() : undefined, }) }) : [], delPositionHistory: [], @@ -180,21 +180,26 @@ const EditStaff: React.FC = ({ Staff, combos, SalaryEffectiveInfo, In id: item.id, team: combos.team.filter(team => team.label === item.team)[0].id, from: dayjs(item.from).format('YYYY-MM-DD'), - to: (item.to as string).length != 0 ? dayjs(item.to).format('YYYY-MM-DD') : undefined, + // to: item.to != undefined ? dayjs(item.to).format('YYYY-MM-DD') : undefined, })) + .sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime()) + const gradeHistory = data.gradeHistory.map((item) => ({ id: item.id, grade: combos.grade.filter(grade => grade.label === item.grade)[0].id, from: dayjs(item.from).format('YYYY-MM-DD'), - to: (item.to as string).length != 0 ? dayjs(item.to).format('YYYY-MM-DD') : undefined, + // to: item.to != undefined ? dayjs(item.to).format('YYYY-MM-DD') : undefined, })) + .sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime()) + const positionHistory = data.positionHistory.map((item) => ({ id: item.id, position: combos.position.filter(position => position.label === item.position)[0].id, from: dayjs(item.from).format('YYYY-MM-DD'), - to: (item.to as string).length != 0 ? dayjs(item.to).format('YYYY-MM-DD') : undefined, + // to: item.to != undefined ? dayjs(item.to).format('YYYY-MM-DD') : undefined, })) - + .sort((a, b) => new Date(a.from).getTime() - new Date(b.from).getTime()) + const salaryEffectiveInfo = data.salaryEffectiveInfo.map((item: SalaryEffectiveInfo) => ({ id: item.id, salaryPoint: chopSalaryPoints(item.salaryPoint), diff --git a/src/components/EditStaff/GradeHistoryModal.tsx b/src/components/EditStaff/GradeHistoryModal.tsx index 7461708..2f65d80 100644 --- a/src/components/EditStaff/GradeHistoryModal.tsx +++ b/src/components/EditStaff/GradeHistoryModal.tsx @@ -1,20 +1,26 @@ -import { Box, Button, Modal, Paper, SxProps, Typography } from "@mui/material" +import { Box, Button, Modal, ModalProps, Paper, SxProps, Tooltip, Typography } from "@mui/material" import StyledDataGrid from "../StyledDataGrid" import { useTranslation } from "react-i18next"; import { useFormContext } from "react-hook-form"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { GridActionsCellItem, GridEventListener, GridRowEditStopReasons, GridRowModel, GridRowModes, GridRowModesModel } from "@mui/x-data-grid"; +import { GridRenderEditCellParams, FooterPropsOverrides, GridActionsCellItem, GridCellParams, GridEventListener, GridRowEditStopReasons, GridRowId, GridRowIdGetter, GridRowModel, GridRowModes, GridRowModesModel, GridToolbarContainer, useGridApiRef, GridEditDateCell, GridEditSingleSelectCell } from "@mui/x-data-grid"; import AddIcon from '@mui/icons-material/Add'; import SaveIcon from '@mui/icons-material/Save'; import DeleteIcon from '@mui/icons-material/Delete'; import CancelIcon from '@mui/icons-material/Cancel'; import EditIcon from '@mui/icons-material/Edit'; import waitForCondition from "../utils/waitFor"; +import { gradeHistory } from "@/app/api/staff/actions"; +import { Add } from "@mui/icons-material"; +import { comboItem } from "../CreateStaff/CreateStaff"; +import { StaffEntryError, validateRowAndRowBefore } from "./validateDates"; +import { ProcessRowUpdateError } from "./TeamHistoryModal"; interface Props { open: boolean; onClose: () => void; - columns: any[] + combos: comboItem; + // columns: any[] } const modalSx: SxProps = { @@ -31,119 +37,217 @@ const modalSx: SxProps = { gap: 2, }; -const GradeHistoryModal: React.FC = async ({ open, onClose, columns }) => { +export type GradeModalRow = Partial< +gradeHistory & { + _isNew: boolean + _error: StaffEntryError; +}> +const thisField = "gradeHistory" +const GradeHistoryModal: React.FC = ({ open, onClose, combos }) => { const { t, // i18n: { language }, } = useTranslation(); - const { control, register, formState, trigger, watch, setValue, getValues } = useFormContext(); + const { setValue, getValues } = useFormContext(); const [rowModesModel, setRowModesModel] = useState({}); - const [count, setCount] = useState(0); + const apiRef = useGridApiRef() + const originalRows = getValues(thisField) const [_rows, setRows] = useState(() => { - const list = getValues('gradeHistory') + const list: GradeModalRow[] = getValues(thisField) return list && list.length > 0 ? list : [] }); const [_delRows, setDelRows] = useState([]); - const formValues = watch(); - - const handleClose = () => { - onClose(); - }; - - const looping = async () => { - for (let i = 0; i < _rows.length; i++) { - const id = _rows[i].id - setRowModesModel((prevRowModesModel) => ({ + const getRowId = useCallback>( + (row) => row.id!!, + [], + ); + const handleSave = useCallback( + (id: GridRowId) => () => { + setRowModesModel((prevRowModesModel) => ({ ...prevRowModesModel, [id]: { mode: GridRowModes.View } })); - } - return true; - } - const handleSaveAll = async () => { - // trigger save all - console.log(_rows) - await waitForCondition(async () => { - return looping() - }) - console.log(rowModesModel) - }; + }, + [setRowModesModel] + ); + + const onCancel = useCallback(() => { + setRows(originalRows) + onClose(); + }, [onClose, originalRows]); - const bigTesting = async () => { - await looping() - setTimeout(() => { - onClose() - }, 800) - } - - const handleRowEditStop: GridEventListener<"rowEditStop"> = ( - params, - event, - ) => { - if (params.reason === GridRowEditStopReasons.rowFocusOut) { - event.defaultMuiPrevented = true; + const handleClose = useCallback>( + (_, reason) => { + if (reason !== "backdropClick") { + onClose(); } - }; - // handle row update here - const processRowUpdate = - // useCallback( - (newRow: GridRowModel) => { - console.log(newRow) - const updatedRow = { ...newRow, updated: true }; - console.log(_rows) - if (_rows.length != 0) { - setRows((prev: any[]) => prev?.map((row: any) => (row.id === newRow.id ? updatedRow : row))); + }, [onClose]); + + const isSaved = useCallback(() => { + const saved = Object.keys(rowModesModel).every(key => { + rowModesModel[key].mode === GridRowModes.Edit + }) + return saved + }, [rowModesModel]) + + const doSave = useCallback(async () => { + try { + if (isSaved()) { + setValue(thisField, _rows) + onClose() + } + } catch (error) { + console.error(error); + } + }, [isSaved, onClose, _rows]) + + const addRow = useCallback(() => { + const id = Date.now() + const newEntry = { id, _isNew: true } satisfies GradeModalRow; + setRows((prev) => [...prev, newEntry]) + setRowModesModel((model) => ({ + ...model, + [getRowId(newEntry)]: { + mode: GridRowModes.Edit, + fieldToFocus: "grade", } - return updatedRow; + })) + }, []); + const onProcessRowUpdateError = useCallback( + (updateError: ProcessRowUpdateError) => { + const errors = updateError.errors; + // const prevRow = updateError.prevRow; + const currRow = updateError.currRow; + // if (updateError.prevRow) { + // apiRef.current.updateRows([{ ...prevRow, _error: errors }]); + // } + apiRef.current.updateRows([{ ...currRow, _error: errors }]); + }, + [apiRef, rowModesModel], + ); + + const processRowUpdate = useCallback(( + newRow: GridRowModel, + originalRow: GridRowModel + ) => { + const rowIndex = _rows.findIndex((row: GradeModalRow) => row.id === newRow.id); + const prevRow: GradeModalRow | null = rowIndex > 0 ? _rows[rowIndex - 1] : null; + const errors = validateRowAndRowBefore(prevRow, newRow) + console.log(errors) + if (errors) { + throw new ProcessRowUpdateError( + prevRow, + newRow, + "validation error", + errors + ) + } + const { _isNew, _error, ...updatedRow } = newRow; + + const rowToSave = { + ...updatedRow, + } + console.log(_rows) + if (_rows.length != 0) { + setRows((prev: any[]) => prev?.map((row: any) => (row.id === newRow.id ? rowToSave : row)).sort((a, b) => { + if (!a.from || !b.from) return 0; + return new Date(a.from).getTime() - new Date(b.from).getTime(); + })); + } + return rowToSave; } - // , [_rows, setValue, setRows]) + , [_rows, validateRowAndRowBefore]) - const handleSaveClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.View } - })); - }, - [setRowModesModel] - ); - const handleCancelClick = useCallback( + const handleCancel = useCallback( (id: any) => () => { setRowModesModel((prevRowModesModel) => ({ ...prevRowModesModel, [id]: { mode: GridRowModes.View, ignoreModifications: true } })); + const editedRow = _rows.find((r) => getRowId(r) === id) + if (editedRow?._isNew) { + setRows((rw) => rw.filter((r) => r.id !== id)) + } else { + setRows((rw) => + rw.map((r) => + getRowId(r) === id + ? { ...r, _error: undefined } + : r, + ), + ); + } }, - [setRowModesModel] - ); - - const handleEditClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.Edit } - })); - }, - [setRowModesModel] + [setRowModesModel, _rows] ); - const handleDeleteClick = useCallback( - (id: any) => () => { - setRows((prevRows: any) => prevRows.filter((row: any) => row.id !== id)); - setCount((prev: number) => prev - 1); - setDelRows((prevRowsId: number[]) => [...prevRowsId, id]) + const handleDelete = useCallback( + (id: GridRowId) => () => { + setRows((prevRows) => prevRows.filter((row) => row.id !== id)); + setDelRows((prevRowsId: number[]) => [...prevRowsId, id as number]) }, - [setRows, setCount, setDelRows] + [] ); useEffect(()=> { console.log(_rows) - setValue('gradeHistory', _rows) + // setValue(thisField, _rows) setValue('delGradeHistory', _delRows) }, [_rows, _delRows]) - const defaultCol = useMemo( - () => ( + const footer = ( + + + + ) + const columns = useMemo( + () => [ + { + field: 'grade', + headerName: 'grade', + flex: 1, + editable: true, + type: 'singleSelect', + valueOptions: combos.grade.map(item => item.label), + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ( + + ); + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, + { + field: 'from', + headerName: 'from', + flex: 1, + editable: true, + type: 'date', + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, { field: 'actions', type: 'actions', @@ -161,42 +265,29 @@ const GradeHistoryModal: React.FC = async ({ open, onClose, columns }) => sx={{ color: 'primary.main' }} - onClick={handleSaveClick(id)} + onClick={handleSave(id)} />, } label="Cancel" key="edit" - onClick={handleCancelClick(id)} + onClick={handleCancel(id)} /> ]; } return [ - } - label="Edit" - className="textPrimary" - onClick={handleEditClick(id)} - color="inherit" - key="edit" - />, } label="Delete" sx={{ color: 'error.main' }} - onClick={handleDeleteClick(id)} color="inherit" key="edit" /> + onClick={handleDelete(id)} color="inherit" key="edit" /> ]; } } - ), [rowModesModel, handleSaveClick, handleCancelClick, handleEditClick, handleDeleteClick] - ) - - let _columns: any[] = [] - if (columns) { - _columns = [...columns, defaultCol] - } + ], [combos, rowModesModel, handleSave, handleCancel, handleDelete]) + return ( @@ -204,25 +295,48 @@ const GradeHistoryModal: React.FC = async ({ open, onClose, columns }) => {t('GradeHistoryModal')} ) => { + let classname = ""; + if (params.row._error) { + classname = "hasError" + } + return classname; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { child: footer }, + }} /> - - @@ -231,4 +345,20 @@ const GradeHistoryModal: React.FC = async ({ open, onClose, columns }) => ) } +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some entries!")} + + ); +}; export default GradeHistoryModal \ No newline at end of file diff --git a/src/components/EditStaff/PositionHistoryModal.tsx b/src/components/EditStaff/PositionHistoryModal.tsx index 659e5f3..cea1002 100644 --- a/src/components/EditStaff/PositionHistoryModal.tsx +++ b/src/components/EditStaff/PositionHistoryModal.tsx @@ -1,20 +1,26 @@ -import { Box, Button, Modal, Paper, SxProps, Typography } from "@mui/material" +import { Box, Button, Modal, ModalProps, Paper, SxProps, Tooltip, Typography } from "@mui/material" import StyledDataGrid from "../StyledDataGrid" import { useTranslation } from "react-i18next"; import { useFormContext } from "react-hook-form"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { GridActionsCellItem, GridEventListener, GridRowEditStopReasons, GridRowModel, GridRowModes, GridRowModesModel } from "@mui/x-data-grid"; +import { GridRenderEditCellParams, FooterPropsOverrides, GridActionsCellItem, GridCellParams, GridEventListener, GridRowEditStopReasons, GridRowId, GridRowIdGetter, GridRowModel, GridRowModes, GridRowModesModel, GridToolbarContainer, useGridApiRef, GridEditDateCell, GridEditSingleSelectCell } from "@mui/x-data-grid"; import AddIcon from '@mui/icons-material/Add'; import SaveIcon from '@mui/icons-material/Save'; import DeleteIcon from '@mui/icons-material/Delete'; import CancelIcon from '@mui/icons-material/Cancel'; import EditIcon from '@mui/icons-material/Edit'; import waitForCondition from "../utils/waitFor"; +import { Add } from "@mui/icons-material"; +import { positionHistory } from "@/app/api/staff/actions"; +import { comboItem } from "../CreateStaff/CreateStaff"; +import { StaffEntryError, validateRowAndRowBefore } from "./validateDates"; +import { ProcessRowUpdateError } from "./TeamHistoryModal"; +import { createSearchParamsBailoutProxy } from "next/dist/client/components/searchparams-bailout-proxy"; interface Props { open: boolean; onClose: () => void; - columns: any[] + combos: comboItem; } const modalSx: SxProps = { @@ -31,171 +37,267 @@ const modalSx: SxProps = { gap: 2, }; -const PositionHistoryModal: React.FC = async ({ open, onClose, columns }) => { +export type PositionModalRow = Partial< +positionHistory & { + _isNew: boolean + _error: StaffEntryError; +}> + +const thisField = "positionHistory" +const PositionHistoryModal: React.FC = ({ + open, + onClose, + combos +}) => { const { t, // i18n: { language }, } = useTranslation(); - const { control, register, formState, trigger, watch, setValue, getValues } = useFormContext(); + const { setValue, getValues } = useFormContext(); const [rowModesModel, setRowModesModel] = useState({}); - const [count, setCount] = useState(0); + const apiRef = useGridApiRef() + const originalRows = getValues(thisField) const [_rows, setRows] = useState(() => { - const list = getValues('positionHistory') + const list: PositionModalRow[] = getValues(thisField) return list && list.length > 0 ? list : [] }); const [_delRows, setDelRows] = useState([]); - const formValues = watch(); + const getRowId = useCallback>( + (row) => row.id!!, + [], + ); - const handleClose = () => { - onClose(); - }; - - const looping = async () => { - for (let i = 0; i < _rows.length; i++) { - const id = _rows[i].id - setRowModesModel((prevRowModesModel) => ({ + const handleSave = useCallback( + (id: GridRowId) => () => { + setRowModesModel((prevRowModesModel) => ({ ...prevRowModesModel, [id]: { mode: GridRowModes.View } })); - } - return true; - } - const handleSaveAll = async () => { - // trigger save all - console.log(_rows) - await waitForCondition(async () => { - return looping() + }, + [setRowModesModel] + ); + + const onCancel = useCallback(() => { + console.log(originalRows) + setRows(originalRows) + onClose(); + }, [onClose, originalRows]); + + const handleClose = useCallback>( + (_, reason) => { + if (reason !== "backdropClick") { + onClose(); + } + }, [onClose]); + + const isSaved = useCallback(() => { + const saved = Object.keys(rowModesModel).every(key => { + rowModesModel[key].mode === GridRowModes.Edit }) - console.log(rowModesModel) - }; - - const bigTesting = async () => { - await looping() - setTimeout(() => { - onClose() - }, 800) - } - const handleRowEditStop: GridEventListener<"rowEditStop"> = ( - params, - event, - ) => { - if (params.reason === GridRowEditStopReasons.rowFocusOut) { - event.defaultMuiPrevented = true; + return saved + }, [rowModesModel]) + + const doSave = useCallback(async () => { + try { + if (isSaved()) { + setValue(thisField, _rows) + onClose() } - }; + } catch (error) { + console.error(error); + } + }, [isSaved, onClose, _rows]); + + const onProcessRowUpdateError = useCallback( + (updateError: ProcessRowUpdateError) => { + const errors = updateError.errors; + const prevRow = updateError.prevRow; + const currRow = updateError.currRow; + + // if (updateError.prevRow) { + // apiRef.current.updateRows([{ ...prevRow, _error: errors }]); + // } + apiRef.current.updateRows([{ ...currRow, _error: errors }]); + }, + [apiRef, rowModesModel], + ); // handle row update here - const processRowUpdate = - // useCallback( - (newRow: GridRowModel) => { - console.log(newRow) - const updatedRow = { ...newRow, updated: true }; + const processRowUpdate = useCallback(( + newRow: GridRowModel, + originalRow: GridRowModel + ) => { + const rowIndex = _rows.findIndex((row: PositionModalRow) => row.id === newRow.id); + const prevRow: PositionModalRow | null = rowIndex > 0 ? _rows[rowIndex - 1] : null; + const errors = validateRowAndRowBefore(prevRow, newRow) + console.log(errors) + if (errors) { + throw new ProcessRowUpdateError( + prevRow, + newRow, + "validation error", + errors + ) + } + const { _isNew, _error, ...updatedRow } = newRow; + + const rowToSave = { + ...updatedRow, + } console.log(_rows) if (_rows.length != 0) { - setRows((prev: any[]) => prev?.map((row: any) => (row.id === newRow.id ? updatedRow : row))); + setRows((prev: any[]) => prev?.map((row: any) => (row.id === newRow.id ? rowToSave : row)).sort((a, b) => { + if (!a.from || !b.from) return 0; + return new Date(a.from).getTime() - new Date(b.from).getTime(); + })); } - return updatedRow; - } - // , [_rows, setValue, setRows]) - - const handleSaveClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.View } - })); - }, - [setRowModesModel] - ); - const handleCancelClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.View, ignoreModifications: true } - })); - }, - [setRowModesModel] - ); + return rowToSave; + } + , [_rows, validateRowAndRowBefore]) - const handleEditClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.Edit } + const addRow = useCallback(() => { + const newEntry = { id: Date.now(), _isNew: true } satisfies PositionModalRow; + setRows((prev) => [...prev, newEntry]) + setRowModesModel((model) => ({ + ...model, + [getRowId(newEntry)]: { + mode: GridRowModes.Edit, + fieldToFocus: "position", + } + })) + }, [getRowId]); + + const handleCancel = useCallback( + (id: GridRowId) => () => { + setRowModesModel((model) => ({ + ...model, + [id]: { mode: GridRowModes.View, ignoreModifications: true } })); + const editedRow = _rows.find((row) => getRowId(row) === id); + console.log(editedRow) + if (editedRow?._isNew) { + setRows((rw) => rw.filter((r) => r.id !== id)) + } else { + setRows((rw) => + rw.map((r) => + getRowId(r) === id + ? { ...r, _error: undefined } + : r, + ), + ); + } }, - [setRowModesModel] + [setRowModesModel, _rows] ); - const handleDeleteClick = useCallback( - (id: any) => () => { - setRows((prevRows: any) => prevRows.filter((row: any) => row.id !== id)); - setCount((prev: number) => prev - 1); - setDelRows((prevRowsId: number[]) => [...prevRowsId, id]) + const handleDelete = useCallback( + (id: GridRowId) => () => { + setRows((prevRows) => prevRows.filter((row) => row.id !== id)); + setDelRows((prevRowsId: number[]) => [...prevRowsId, id as number]) }, - [setRows, setCount, setDelRows] + [setRows, setDelRows] ); useEffect(()=> { console.log(_rows) - setValue('positionHistory', _rows) + setValue(thisField, _rows) setValue('delPositionHistory', _delRows) }, [_rows, _delRows]) - const defaultCol = useMemo( - () => ( - { - field: 'actions', - type: 'actions', - headerName: 'edit', - width: 100, - cellClassName: 'actions', - getActions: ({ id }: { id: number }) => { - const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; - if (isInEditMode) { - return [ - } - label="Save" - key="edit" - sx={{ - color: 'primary.main' - }} - onClick={handleSaveClick(id)} - />, - } - label="Cancel" - key="edit" - onClick={handleCancelClick(id)} - /> - ]; - } + const columns = useMemo( + () => [ + { + field: 'position', + headerName: 'position', + flex: 1, + editable: true, + type: 'singleSelect', + valueOptions: combos.position.map(item => item.label), + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ( + + ); + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, + { + field: 'from', + headerName: 'from', + flex: 1, + editable: true, + type: 'date', + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, + { + field: 'actions', + type: 'actions', + headerName: 'edit', + width: 100, + cellClassName: 'actions', + getActions: ({ id }: { id: number }) => { + const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; + if (isInEditMode) { return [ } - label="Edit" - className="textPrimary" - onClick={handleEditClick(id)} - color="inherit" + icon={} + label="Save" key="edit" - />, - } - label="Delete" sx={{ - color: 'error.main' + color: 'primary.main' }} - onClick={handleDeleteClick(id)} color="inherit" key="edit" /> + onClick={handleSave(id)} + />, + } + label="Cancel" + key="edit" + onClick={handleCancel(id)} + /> ]; } + return [ + } + label="Delete" + sx={{ + color: 'error.main' + }} + onClick={handleDelete(id)} color="inherit" key="edit" /> + ]; } - ), [rowModesModel, handleSaveClick, handleCancelClick, handleEditClick, handleDeleteClick] - ) - - let _columns: any[] = [] - if (columns) { - _columns = [...columns, defaultCol] - } + } + ], [combos, rowModesModel, handleSave, handleCancel, handleDelete]) + + const footer = ( + + + + ) + return ( @@ -203,25 +305,48 @@ const PositionHistoryModal: React.FC = async ({ open, onClose, columns }) {t('PositionHistoryModal')} ) => { + let classname = ""; + if (params.row._error) { + classname = "hasError" + } + return classname; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { child: footer }, + }} /> - - @@ -230,4 +355,22 @@ const PositionHistoryModal: React.FC = async ({ open, onClose, columns }) ) } + +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some entries!")} + + ); +}; + export default PositionHistoryModal \ No newline at end of file diff --git a/src/components/EditStaff/SalaryEffectiveModel.tsx b/src/components/EditStaff/SalaryEffectiveModel.tsx index 334d19c..f120f98 100644 --- a/src/components/EditStaff/SalaryEffectiveModel.tsx +++ b/src/components/EditStaff/SalaryEffectiveModel.tsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Modal, Box, Typography, Button, TextField, FormControl, InputLabel, Select, MenuItem, Paper, SxProps } from '@mui/material'; +import { Modal, Box, Typography, Button, TextField, FormControl, InputLabel, Select, MenuItem, Paper, SxProps, ModalProps, Tooltip } from '@mui/material'; import { useForm, Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from '@/app/utils/formatUtil'; import dayjs from 'dayjs'; import { DatePicker } from '@mui/x-date-pickers'; -import { DataGrid, GridEventListener, GridRowEditStopParams, GridRowEditStopReasons, GridRowModel, GridRowModes, GridRowModesModel, GridToolbarContainer } from '@mui/x-data-grid'; +import { GridRenderEditCellParams, FooterPropsOverrides, GridEventListener, GridRowEditStopParams, GridRowEditStopReasons, GridRowIdGetter, GridRowModel, GridRowModes, GridRowModesModel, GridToolbarContainer, useGridApiRef, GridEditSingleSelectCell, GridCellParams, GridRowId } from '@mui/x-data-grid'; import StyledDataGrid from '../StyledDataGrid'; import AddIcon from '@mui/icons-material/Add'; import SaveIcon from '@mui/icons-material/Save'; @@ -14,12 +14,17 @@ import CancelIcon from '@mui/icons-material/Cancel'; import EditIcon from '@mui/icons-material/Edit'; import { GridActionsCellItem } from '@mui/x-data-grid'; import waitForCondition from '../utils/waitFor'; +import { StaffEntryError, validateRowAndRowBefore } from './validateDates'; +import { salaryEffectiveInfo } from '@/app/api/staff/actions'; +import { comboItem } from "../CreateStaff/CreateStaff"; +import { Add } from '@mui/icons-material'; +import { GridEditDateCell } from '@mui/x-data-grid'; interface SalaryEffectiveModelProps { open: boolean; onClose: () => void; modalSx?: SxProps; - columns: any[] + combos: comboItem; } const modalSx: SxProps = { @@ -36,125 +41,133 @@ const modalSx: SxProps = { gap: 2, }; - function EditToolbar(props: React.JSXElementConstructor | null | undefined | any) { - // const intl = useIntl(); - // const addRecordBtn = intl.formatMessage({ id: 'add' }); - const { count, setCount, setRows, setRowModesModel, _columns } = props; - let obj: { [key: string]: string } = {}; - for (let i = 0; i < _columns.length - 1; i++) { - obj[_columns[i].field as string] = ''; - } - - const handleClick = React.useCallback(() => { - const id = Math.random(); - setRows((oldRows: any) => [...oldRows, { id, ...obj, isNew: true }]); - setRowModesModel((oldModel: any) => ({ - ...oldModel, - [id]: { mode: GridRowModes.Edit, - // fieldToFocus: 'material' - } - })); - setCount((prev: number) => prev+1) - }, [count, setCount, setRowModesModel, setRows]) - - return ( - - - {/* */} - - ); - } +export type SeModalRow = Partial< +salaryEffectiveInfo & { + _isNew: boolean + _error: StaffEntryError; +} +> +export class ProcessRowUpdateError extends Error { + public readonly prevRow: T | null; + public readonly currRow: T; + public readonly errors: StaffEntryError | undefined; + constructor( + prevRow: T | null, + currRow: T, + message?: string, + errors?: StaffEntryError, + ) { + super(message); + this.prevRow = prevRow; + this.currRow = currRow; + this.errors = errors; -const SalaryEffectiveModel: React.FC = ({ open, onClose, modalSx: mSx, columns }) => { + Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); + } +} +const thisField = "salaryEffectiveInfo" +const SalaryEffectiveModel: React.FC = ({ open, onClose, modalSx: mSx, combos }) => { const { t, // i18n: { language }, } = useTranslation(); - const { control, register, formState, trigger, watch, setValue, getValues } = useFormContext(); + const { setValue, getValues } = useFormContext(); const [rowModesModel, setRowModesModel] = useState({}); - const [count, setCount] = useState(0); + const apiRef = useGridApiRef() const [_rows, setRows] = useState(() => { - const list = getValues('salaryEffectiveInfo') + const list: SeModalRow[] = getValues(thisField) return list && list.length > 0 ? list : [] }); + const originalRows = useMemo(() => _rows.filter(rw => rw._isNew !== true), [_rows]) const [_delRows, setDelRows] = useState([]); - const formValues = watch(); // This line of code is using the watch function from react-hook-form to get the current values of the form fields. + const getRowId = useCallback>( + (row) => row.id!!, + [], + ); - const handleClose = () => { - onClose(); - }; - - const looping = async () => { - for (let i = 0; i < _rows.length; i++) { - const id = _rows[i].id - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.View } - })); - } - return true; - } - const handleSaveAll = async () => { - // trigger save all - console.log(_rows) - await waitForCondition(async () => { - return looping() + const isSaved = useCallback(() => { + const saved = Object.keys(rowModesModel).every(key => { + rowModesModel[key].mode === GridRowModes.Edit }) - console.log(rowModesModel) - }; + return saved + }, [rowModesModel]) - const bigTesting = async () => { - await looping() - setTimeout(() => { - onClose() - }, 800) - } - // const handleSave = async () => { - // const isValid = await trigger(); - // // if (isValid) { - // // onSave(); - // // onClose(); - // // } - // }; + const doSave = useCallback(async () => { + try { + if (isSaved()) { + setValue(thisField, _rows) + onClose() + } + } catch (error) { + console.error(error); + } + }, [isSaved, onClose, _rows]); - const handleRowEditStop: GridEventListener<"rowEditStop"> = ( - params, - event, + const onCancel = useCallback(() => { + setRows(originalRows) + onClose(); + }, [onClose, originalRows]); + + const handleClose = useCallback>( + (_, reason) => { + if (reason !== "backdropClick") { + onClose(); + } + }, [onClose]); + + const onProcessRowUpdateError = useCallback( + (updateError: ProcessRowUpdateError) => { + const errors = updateError.errors; + const currRow = updateError.currRow; + console.log(errors) + apiRef.current.updateRows([{ ...currRow, _error: errors }]); + }, + [apiRef, rowModesModel], + ); + const processRowUpdate = useCallback(( + newRow: GridRowModel, + originalRow: GridRowModel ) => { - if (params.reason === GridRowEditStopReasons.rowFocusOut) { - event.defaultMuiPrevented = true; + const rowIndex = _rows.findIndex((row: SeModalRow) => row.id === newRow.id); + const prevRow: SeModalRow | null = rowIndex > 0 ? _rows[rowIndex - 1] : null; + const errors = validateRowAndRowBefore(prevRow, newRow) + console.log(errors) + if (errors) { + throw new ProcessRowUpdateError( + prevRow, + newRow, + "validation error", + errors + ) + } + const { _isNew, _error, ...updatedRow } = newRow; + const rowToSave = { + ...updatedRow, + _isNew, } - }; - - const processRowUpdate = - // useCallback( - (newRow: GridRowModel) => { - console.log(newRow) - const updatedRow = { ...newRow, updated: true }; console.log(_rows) if (_rows.length != 0) { - setRows((prev: any[]) => prev?.map((row: any) => (row.id === newRow.id ? updatedRow : row))); + setRows((prev) => prev?.map((row) => (row.id === newRow.id ? rowToSave : row)).sort((a, b) => { + if (!a.date || !b.date) return 0; + return new Date(a.date).getTime() - new Date(b.date).getTime(); + })); } - return updatedRow; + return rowToSave; } - // , [_rows, setValue, setRows]) + , [validateRowAndRowBefore, _rows]) - useEffect(()=> { - console.log(_rows) - setValue('salaryEffectiveInfo', _rows) - }, [_rows]) + // useEffect(()=> { + // console.log(_rows) + // setValue(thisField, _rows) + // }, [_rows]) useEffect(()=> { console.log(_delRows) setValue('delSalaryEffectiveInfo', _delRows) }, [_delRows]) - const handleSaveClick = useCallback((id: any) => () => { + const handleSave = useCallback((id: GridRowId) => () => { setRowModesModel((prevRowModesModel) => ({ ...prevRowModesModel, [id]: { mode: GridRowModes.View } @@ -163,17 +176,8 @@ const SalaryEffectiveModel: React.FC = ({ open, onClo [setRowModesModel] ); - const handleSaveClickAsync = useCallback(async(id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.View } - })); - }, - [setRowModesModel] -); - - const handleCancelClick = useCallback( - (id: any) => () => { + const handleCancel = useCallback( + (id: GridRowId) => () => { setRowModesModel((prevRowModesModel) => ({ ...prevRowModesModel, [id]: { mode: GridRowModes.View, ignoreModifications: true } @@ -182,27 +186,56 @@ const SalaryEffectiveModel: React.FC = ({ open, onClo [setRowModesModel] ); - const handleEditClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.Edit } - })); + const handleDelete = useCallback( + (id: GridRowId) => () => { + setRows((prevRows) => prevRows.filter((row: any) => row.id !== id)); + setDelRows((prevRowsId: number[]) => [...prevRowsId, id as number]) }, - [setRowModesModel] + [] ); - const handleDeleteClick = useCallback( - (id: any) => () => { - setRows((prevRows: any) => prevRows.filter((row: any) => row.id !== id)); - setCount((prev: number) => prev - 1); - setDelRows((prevRowsId: number[]) => [...prevRowsId, id]) - }, - [setRows, setCount, setDelRows] - ); - - const defaultCol = useMemo( - () => ( + + const columns = useMemo( + () => [ + { + field: 'salaryPoint', + headerName: 'salaryPoint', + flex: 1, + editable: true, + type: 'singleSelect', + valueOptions: combos.salary.map((item) => item.label), + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ( + + ); + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, + { + field: 'date', + headerName: 'date', + flex: 1, + editable: true, + type: 'date', + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, { field: 'actions', type: 'actions', @@ -220,47 +253,54 @@ const SalaryEffectiveModel: React.FC = ({ open, onClo sx={{ color: 'primary.main' }} - onClick={handleSaveClick(id)} + onClick={handleSave(id)} />, } label="Cancel" key="edit" - onClick={handleCancelClick(id)} + onClick={handleCancel(id)} /> ]; } return [ - } - label="Edit" - className="textPrimary" - onClick={handleEditClick(id)} - color="inherit" - key="edit" - />, } label="Delete" sx={{ color: 'error.main' }} - onClick={handleDeleteClick(id)} color="inherit" key="edit" /> + onClick={handleDelete(id)} color="inherit" key="edit" /> ]; } } - ), [rowModesModel, handleSaveClick, handleCancelClick, handleEditClick, handleDeleteClick] - ) + ], [combos, rowModesModel, handleSave, handleCancel, handleDelete]) - - let _columns: any[] = [] - if (columns) { - _columns = [...columns, defaultCol] - } + const addRow = useCallback(() => { + const newEntry = { id: Date.now(), _isNew: true } satisfies SeModalRow; + setRows((prev) => [...prev, newEntry]) + setRowModesModel((model) => ({ + ...model, + [getRowId(newEntry)]: { + mode: GridRowModes.Edit, + fieldToFocus: "team", + } + })) + }, [getRowId]); - useEffect(() => { - console.log(_rows) - }, [_rows]) + const footer = ( + + + + ) return ( @@ -269,33 +309,70 @@ const SalaryEffectiveModel: React.FC = ({ open, onClo {t('Salary Effective Date Change')} ) => { + let classname = ""; + if (params.row._error) { + classname = "hasError" + } + return classname; + }} slots={{ - toolbar: EditToolbar + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, }} slotProps={{ - toolbar: {count, setCount, setRows, setRowModesModel, _columns} + footer: { child: footer }, }} /> - - + {/* */} ); }; - +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some entries!")} + + ); +}; export default SalaryEffectiveModel; \ No newline at end of file diff --git a/src/components/EditStaff/StaffInfo.tsx b/src/components/EditStaff/StaffInfo.tsx index 9598b27..1c3a9f4 100644 --- a/src/components/EditStaff/StaffInfo.tsx +++ b/src/components/EditStaff/StaffInfo.tsx @@ -113,10 +113,6 @@ const StaffInfo: React.FC = ({ combos }) => { useEffect(() => { resetStaff() }, [defaultValues]); - - // useEffect(() => { - // console.log(state) - // }, [state]); const joinDate = watch("joinDate"); const departDate = watch("departDate"); @@ -126,112 +122,6 @@ const StaffInfo: React.FC = ({ combos }) => { if (departDate) clearErrors("departDate"); }, [joinDate, departDate]); - const salaryCols = useMemo( - () => [ - { - field: 'salaryPoint', - headerName: 'salaryPoint', - flex: 1, - editable: true, - type: 'singleSelect', - valueOptions: combos.salary.map((item) => item.label), - // valueOptions: [], - // width: 150 - }, - { - field: 'date', - headerName: 'date', - flex: 1, - editable: true, - type: 'date', - // width: 150 - }, - ], [combos]) - - const teamHistoryCols = useMemo( - () => [ - { - field: 'team', - headerName: 'team', - flex: 1, - editable: true, - type: 'singleSelect', - valueOptions: combos.team.map(item => item.label), - // valueOptions: [], - // width: 150 - }, - { - field: 'from', - headerName: 'from', - flex: 1, - editable: true, - type: 'date', - }, - { - field: 'to', - headerName: 'to', - flex: 1, - editable: true, - type: 'date', - }, - ], [combos]) - - const gradeHistoryCols = useMemo( - () => [ - { - field: 'grade', - headerName: 'grade', - flex: 1, - editable: true, - type: 'singleSelect', - valueOptions: combos.grade.map(item => item.label), - // valueOptions: [], - // width: 150 - }, - { - field: 'from', - headerName: 'from', - flex: 1, - editable: true, - type: 'date', - }, - { - field: 'to', - headerName: 'to', - flex: 1, - editable: true, - type: 'date', - }, - ], [combos]) - - const positionHistoryCols = useMemo( - () => [ - { - field: 'position', - headerName: 'position', - flex: 1, - editable: true, - type: 'singleSelect', - valueOptions: combos.position.map(item => item.label), - // valueOptions: [], - // width: 150 - }, - { - field: 'from', - headerName: 'from', - flex: 1, - editable: true, - type: 'date', - }, - { - field: 'to', - headerName: 'to', - flex: 1, - editable: true, - type: 'date', - }, - ], [combos]) - return ( @@ -311,6 +201,7 @@ const StaffInfo: React.FC = ({ combos }) => { label={t("Team")} style={{ flex: 1, marginRight: '8px' }} {...field} + disabled // error={Boolean(errors.teamId)} > {combos.team.map((team, index) => ( @@ -320,7 +211,7 @@ const StaffInfo: React.FC = ({ combos }) => { ))} @@ -367,6 +258,7 @@ const StaffInfo: React.FC = ({ combos }) => { style={{ flex: 1, marginRight: '8px' }} {...field} error={Boolean(errors.gradeId)} + disabled > {combos.grade.map((grade, index) => ( @@ -374,8 +266,7 @@ const StaffInfo: React.FC = ({ combos }) => { ))} - @@ -432,6 +323,7 @@ const StaffInfo: React.FC = ({ combos }) => { style={{ flex: 1, marginRight: '8px' }} {...field} error={Boolean(errors.currentPositionId)} + disabled > {combos.position.map((position, index) => ( = ({ combos }) => { ))} - @@ -656,7 +547,10 @@ const StaffInfo: React.FC = ({ combos }) => { label={t("Depart Date")} value={departDate ? dayjs(departDate) : null} onChange={(date) => { - if (!date) return; + if (!date) { + setValue("departDate", null); + return + }; dayjs(date).add(1, 'month') setValue("departDate", date.format(INPUT_DATE_FORMAT)); }} @@ -707,28 +601,28 @@ const StaffInfo: React.FC = ({ combos }) => { } {state.teamModal && } {state.gradeModal && } {state.positionModal && } diff --git a/src/components/EditStaff/TeamHistoryModal.tsx b/src/components/EditStaff/TeamHistoryModal.tsx index 92e8888..247efd7 100644 --- a/src/components/EditStaff/TeamHistoryModal.tsx +++ b/src/components/EditStaff/TeamHistoryModal.tsx @@ -1,20 +1,22 @@ -import { Box, Button, Modal, Paper, SxProps, Typography } from "@mui/material"; +import { Box, Button, Modal, ModalProps, Paper, SxProps, Tooltip, Typography } from "@mui/material"; import { useTranslation } from "react-i18next"; import StyledDataGrid from "../StyledDataGrid"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { GridActionsCellItem, GridEventListener, GridRowEditStopReasons, GridRowModel, GridRowModes, GridRowModesModel, GridToolbarContainer } from "@mui/x-data-grid"; -import AddIcon from '@mui/icons-material/Add'; +import { GridRenderEditCellParams, FooterPropsOverrides, GridActionsCellItem, GridCellParams, GridEventListener, GridRowEditStopReasons, GridRowId, GridRowIdGetter, GridRowModel, GridRowModes, GridRowModesModel, GridToolbarContainer, useGridApiRef, GridEditInputCell, GridColDef, GridEditDateCell, GridEditSingleSelectCell } from "@mui/x-data-grid"; import SaveIcon from '@mui/icons-material/Save'; import DeleteIcon from '@mui/icons-material/Delete'; import CancelIcon from '@mui/icons-material/Cancel'; -import EditIcon from '@mui/icons-material/Edit'; -import { useFormContext } from "react-hook-form"; +import { useForm, useFormContext } from "react-hook-form"; +import { Add } from "@mui/icons-material"; +import { CreateStaffInputs, teamHistory } from "@/app/api/staff/actions"; +import { StaffEntryError, validateRowAndRowBefore } from "./validateDates"; +import { comboItem } from "../CreateStaff/CreateStaff"; import waitForCondition from "../utils/waitFor"; interface Props { open: boolean; onClose: () => void; - columns: any[] + combos: comboItem; } const modalSx: SxProps = { @@ -31,172 +33,279 @@ interface Props { gap: 2, }; -const TeamHistoryModal: React.FC = async ({ open, onClose, columns }) => { + export type TeamModalRow = Partial< + teamHistory & { + _isNew: boolean + _error: StaffEntryError; + }> + + export class ProcessRowUpdateError extends Error { + public readonly prevRow: T | null; + public readonly currRow: T; + public readonly errors: StaffEntryError | undefined; + constructor( + prevRow: T | null, + currRow: T, + message?: string, + errors?: StaffEntryError, + ) { + super(message); + this.prevRow = prevRow; + this.currRow = currRow; + this.errors = errors; + + Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); + } + } + +const thisField = "teamHistory" +const TeamHistoryModal: React.FC = ({ + open, + onClose, + combos, +}) => { const { t, // i18n: { language }, } = useTranslation(); - const { control, register, formState, trigger, watch, setValue, getValues } = useFormContext(); + const { setValue, getValues } = useFormContext(); const [rowModesModel, setRowModesModel] = useState({}); - const [count, setCount] = useState(0); + const apiRef = useGridApiRef() const [_rows, setRows] = useState(() => { - const list = getValues('teamHistory') + const list: TeamModalRow[] = getValues(thisField) return list && list.length > 0 ? list : [] }); + const originalRows = useMemo(() => _rows.filter(rw => rw._isNew !== true), [_rows]) const [_delRows, setDelRows] = useState([]); - const formValues = watch(); + const getRowId = useCallback>( + (row) => row.id!!, + [], + ); - const handleClose = () => { - onClose(); - }; - - const looping = async () => { - for (let i = 0; i < _rows.length; i++) { - const id = _rows[i].id - setRowModesModel((prevRowModesModel) => ({ + const handleSave = useCallback( + (id: GridRowId) => () => { + setRowModesModel((prevRowModesModel) => ({ ...prevRowModesModel, [id]: { mode: GridRowModes.View } })); + }, + [setRowModesModel] + ); + + const onCancel = useCallback(() => { + setRows(originalRows) + onClose(); + }, [onClose, originalRows]); + + const handleClose = useCallback>( + (_, reason) => { + if (reason !== "backdropClick") { + onClose(); + } + }, [onClose]); + + const isSaved = useCallback(() => { + const saved = Object.keys(rowModesModel).every(key => { + rowModesModel[key].mode === GridRowModes.Edit + }) + return saved + }, [rowModesModel]) + + const doSave = useCallback(async () => { + try { + if (isSaved()) { + setValue(thisField, _rows) + onClose() + } + } catch (error) { + console.error(error); + } + }, [isSaved, onClose, _rows]); + + const onProcessRowUpdateError = useCallback( + (updateError: ProcessRowUpdateError) => { + const errors = updateError.errors; + const currRow = updateError.currRow; + console.log(errors) + apiRef.current.updateRows([{ ...currRow, _error: errors }]); + }, + [apiRef, rowModesModel], + ); + + const processRowUpdate = useCallback(( + newRow: GridRowModel, + originalRow: GridRowModel + ) => { + const rowIndex = _rows.findIndex((row: TeamModalRow) => row.id === newRow.id); + const prevRow: TeamModalRow | null = rowIndex > 0 ? _rows[rowIndex - 1] : null; + const errors = validateRowAndRowBefore(prevRow, newRow) + console.log(errors) + if (errors) { + throw new ProcessRowUpdateError( + prevRow, + newRow, + "validation error", + errors + ) + } + const { _isNew, _error, ...updatedRow } = newRow; + const rowToSave = { + ...updatedRow, } - return true; - } - const handleSaveAll = async () => { - // trigger save all console.log(_rows) - await waitForCondition(async () => { - return looping() - }) - console.log(rowModesModel) - }; - - const bigTesting = async () => { - await looping() - setTimeout(() => { - onClose() - }, 800) + if (_rows.length != 0) { + setRows((prev) => prev?.map((row) => (row.id === newRow.id ? rowToSave : row)).sort((a, b) => { + if (!a.from || !b.from) return 0; + return new Date(a.from).getTime() - new Date(b.from).getTime(); + })); + } + return rowToSave; } - const handleRowEditStop: GridEventListener<"rowEditStop"> = ( - params, - event, - ) => { - if (params.reason === GridRowEditStopReasons.rowFocusOut) { - event.defaultMuiPrevented = true; - } - }; - // handle row update here - const processRowUpdate = - // useCallback( - (newRow: GridRowModel) => { - console.log(newRow) - const updatedRow = { ...newRow, updated: true }; - console.log(_rows) - if (_rows.length != 0) { - setRows((prev: any[]) => prev?.map((row: any) => (row.id === newRow.id ? updatedRow : row))); + , [validateRowAndRowBefore, _rows]) + + const addRow = useCallback(() => { + const newEntry = { id: Date.now(), _isNew: true } satisfies TeamModalRow; + setRows((prev) => [...prev, newEntry]) + setRowModesModel((model) => ({ + ...model, + [getRowId(newEntry)]: { + mode: GridRowModes.Edit, + fieldToFocus: "team", } - return updatedRow; - } - // , [_rows, setValue, setRows]) + })) + }, [getRowId]); - const handleSaveClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.View } - })); - }, - [setRowModesModel] - ); - const handleCancelClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, + const handleCancel = useCallback( + (id: GridRowId) => () => { + setRowModesModel((model) => ({ + ...model, [id]: { mode: GridRowModes.View, ignoreModifications: true } })); + const editedRow = _rows.find((row) => getRowId(row) === id); + console.log(editedRow) + if (editedRow?._isNew) { + setRows((rw) => rw.filter((r) => r.id !== id)) + } else { + setRows((rw) => + rw.map((r) => + getRowId(r) === id + ? { ...r, _error: undefined } + : r, + ), + ); + } }, - [setRowModesModel] - ); - - const handleEditClick = useCallback( - (id: any) => () => { - setRowModesModel((prevRowModesModel) => ({ - ...prevRowModesModel, - [id]: { mode: GridRowModes.Edit } - })); - }, - [setRowModesModel] + [setRowModesModel, _rows] ); - const handleDeleteClick = useCallback( - (id: any) => () => { - setRows((prevRows: any) => prevRows.filter((row: any) => row.id !== id)); - setCount((prev: number) => prev - 1); - setDelRows((prevRowsId: number[]) => [...prevRowsId, id]) + const handleDelete = useCallback( + (id: GridRowId) => () => { + setRows((prevRows) => prevRows.filter((row) => getRowId(row) !== id)); + setDelRows((prevRowsId: number[]) => [...prevRowsId, id as number]) }, - [setRows, setCount, setDelRows] + [] ); useEffect(()=> { console.log(_rows) - setValue('teamHistory', _rows) + // setValue(thisField, _rows) setValue('delTeamHistory', _delRows) }, [_rows, _delRows]) - const defaultCol = useMemo( - () => ( - { - field: 'actions', - type: 'actions', - headerName: 'edit', - width: 100, - cellClassName: 'actions', - getActions: ({ id }: { id: number }) => { - const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; - if (isInEditMode) { - return [ - } - label="Save" - key="edit" - sx={{ - color: 'primary.main' - }} - onClick={handleSaveClick(id)} - />, - } - label="Cancel" - key="edit" - onClick={handleCancelClick(id)} - /> - ]; - } + const columns = useMemo( + () => [ + { + field: 'team', + headerName: 'team', + flex: 1, + editable: true, + type: 'singleSelect', + valueOptions: combos.team.map(item => item.label), + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ( + + ); + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, + { + field: 'from', + headerName: 'from', + flex: 1, + editable: true, + type: 'date', + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + } + }, + { + field: 'actions', + type: 'actions', + headerName: 'edit', + width: 100, + cellClassName: 'actions', + getActions: ({ id }: { id: number }) => { + const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; + if (isInEditMode) { return [ } - label="Edit" - className="textPrimary" - onClick={handleEditClick(id)} - color="inherit" + icon={} + label="Save" key="edit" - />, - } - label="Delete" sx={{ - color: 'error.main' + color: 'primary.main' }} - onClick={handleDeleteClick(id)} color="inherit" key="edit" /> + onClick={handleSave(id)} + />, + } + label="Cancel" + key="edit" + onClick={handleCancel(id)} + /> ]; } + return [ + } + label="Delete" + sx={{ + color: 'error.main' + }} + onClick={handleDelete(id)} color="inherit" key="edit" /> + ]; } - ), [rowModesModel, handleSaveClick, handleCancelClick, handleEditClick, handleDeleteClick] - ) - - let _columns: any[] = [] - if (columns) { - _columns = [...columns, defaultCol] - } + } + ], [combos, rowModesModel, handleSave, handleCancel, handleDelete]) + const footer = ( + + + + ) return ( @@ -204,25 +313,48 @@ const TeamHistoryModal: React.FC = async ({ open, onClose, columns }) => {t('TeamHistoryModal')} ) => { + let classname = ""; + if (params.row._error) { + classname = "hasError" + } + return classname; + }} + slots={{ + footer: FooterToolbar, + noRowsOverlay: NoRowsOverlay, + }} + slotProps={{ + footer: { child: footer }, + }} /> - - @@ -231,4 +363,20 @@ const TeamHistoryModal: React.FC = async ({ open, onClose, columns }) => ) } +const FooterToolbar: React.FC = ({ child }) => { + return {child}; +}; +const NoRowsOverlay: React.FC = () => { + const { t } = useTranslation("home"); + return ( + + {t("Add some entries!")} + + ); +}; export default TeamHistoryModal \ No newline at end of file diff --git a/src/components/EditStaff/validateDates.ts b/src/components/EditStaff/validateDates.ts new file mode 100644 index 0000000..29db6dd --- /dev/null +++ b/src/components/EditStaff/validateDates.ts @@ -0,0 +1,61 @@ +import { gradeHistory, positionHistory, salaryEffectiveInfo, teamHistory } from "@/app/api/staff/actions"; +import { GridRowId, GridRowModel } from "@mui/x-data-grid"; +import dayjs, { Dayjs } from "dayjs"; +import { TeamModalRow } from "./TeamHistoryModal"; +import { GradeModalRow } from "./GradeHistoryModal"; +import { PositionModalRow } from "./PositionHistoryModal"; +import { SeModalRow } from "./SalaryEffectiveModel"; +export type ValidateError = { + from: number[] + to: number[] +} + +type RowModel = Partial + +type AllFields = Partial + +export type StaffEntryError = { + [field in keyof AllFields]?: string; + }; + +export const validateRowAndRowBefore = ( + prevRow: RowModel | null, + currRow: RowModel +): StaffEntryError | undefined => { + const error: StaffEntryError = {} + + if (prevRow) { + if ('from' in currRow && currRow.from !== undefined) { + if (dayjs(prevRow.from).diff(dayjs(currRow.from)) == 0) { + error.from = "The date should not be the same as last entry" + } + } else if ('date' in currRow && currRow.date !== undefined) { + if (dayjs(prevRow.date).diff(dayjs(currRow.date)) == 0) { + error.date = "The date should not be the same as last entry" + } + } + } + console.log(currRow) + if ('from' in currRow && !currRow.from) { + error.from = "The date cannot be empty" + } + if ('date' in currRow && !currRow.date) { + error.date = "The date cannot be empty" + } + // Check specific fields based on row type + if ('grade' in currRow && !currRow.grade) { + error.grade = "Grade cannot be empty" + } + if ('position' in currRow && !currRow.position) { + error.position = "Position cannot be empty" + } + if ('team' in currRow && !currRow.team) { + error.team = "Team cannot be empty" + } + console.log("error") + console.log(error) + console.log(currRow.from) + + return Object.keys(error).length > 0 ? error : undefined; + +} \ No newline at end of file diff --git a/src/components/ExpenseSearch/ExpenseSearch.tsx b/src/components/ExpenseSearch/ExpenseSearch.tsx index c7807a3..c587ade 100644 --- a/src/components/ExpenseSearch/ExpenseSearch.tsx +++ b/src/components/ExpenseSearch/ExpenseSearch.tsx @@ -18,6 +18,7 @@ import { Divider, Grid, Stack, + TextField, Typography, } from "@mui/material"; import { INPUT_DATE_FORMAT, moneyFormatter } from "@/app/utils/formatUtil"; @@ -27,12 +28,18 @@ import { uniq } from "lodash"; import CreateExpenseModal from "./CreateExpenseModal"; import { ProjectResult } from "@/app/api/projects"; import StyledDataGrid from "../StyledDataGrid"; -import { GridCellParams, GridColDef, GridRowId, GridRowModes, GridRowModesModel } from "@mui/x-data-grid"; +import { GridCellParams, GridColDef, GridRenderEditCellParams, GridRowId, GridRowModes, GridRowModesModel, GridValueFormatterParams } from "@mui/x-data-grid"; import { useGridApiRef } from "@mui/x-data-grid"; import { GridEventListener } from "@mui/x-data-grid"; import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; import { deleteProjectExpense, updateProjectExpense } from "@/app/api/projectExpenses/actions"; import dayjs from "dayjs"; +import React from "react"; +import { NumberFormatValues, NumericFormat } from "react-number-format"; + +interface CustomMoneyComponentProps { + params: GridRenderEditCellParams; +} interface Props { expenses: ProjectExpensesResultFormatted[] @@ -86,6 +93,8 @@ const ExpenseSearch: React.FC = ({ expenses, projects }) => { [] ); + + const columns = useMemo[]>( () => [ { @@ -215,6 +224,47 @@ const ExpenseSearch: React.FC = ({ expenses, projects }) => { [validateRow], ); + // Money format : 000,000,000.00 + const CustomMoneyFormat = useCallback((value: number) => { + if (value) { + return moneyFormatter.format(value); + } else { + return "" + } +}, []) + +const CustomMoneyComponent: React.FC = ({ params }) => { + const { id, value, field } = params; + const ref = React.useRef(); + + const handleValueChange = (newValue: NumberFormatValues) => { + apiRef.current.setEditCellValue({ id, field, value: newValue.value }); + }; + + return { + console.log(values) + handleValueChange(values) + }} + customInput={TextField} + thousandSeparator + valueIsNumericString + decimalScale={2} + fixedDecimalScale + value={value} + inputRef={ref} + InputProps={{ + sx: { + '& .MuiInputBase-input': { + textAlign: 'right', + mb: 2 + } + } + }} + />; +} const editColumn = useMemo( () => [ { @@ -234,7 +284,13 @@ const ExpenseSearch: React.FC = ({ expenses, projects }) => { headerName: t("Amount (HKD)"), editable: true, flex: 0.5, - type: 'number' + type: 'number', + renderEditCell: (params: GridRenderEditCellParams) => { + return + }, + valueFormatter: (params: GridValueFormatterParams) => { + return CustomMoneyFormat(params.value as number) + } }, { field: "issuedDate", headerName: t("Issue Date"), diff --git a/src/components/InvoiceSearch/InvoiceSearch.tsx b/src/components/InvoiceSearch/InvoiceSearch.tsx index f7c3abe..1e2a517 100644 --- a/src/components/InvoiceSearch/InvoiceSearch.tsx +++ b/src/components/InvoiceSearch/InvoiceSearch.tsx @@ -13,7 +13,7 @@ import { deleteInvoice, importIssuedInovice, importReceivedInovice, updateInvoic import { deleteDialog, errorDialogWithContent, successDialog } from "../Swal/CustomAlerts"; import { invoiceList, issuedInvoiceList, issuedInvoiceSearchForm, receivedInvoiceList, receivedInvoiceSearchForm } from "@/app/api/invoices"; import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; -import { GridCellParams, GridColDef, GridEventListener, GridRowId, GridRowModes, GridRowModesModel } from "@mui/x-data-grid"; +import { GridCellParams, GridColDef, GridEventListener, GridRenderEditCellParams, GridRowId, GridRowModes, GridRowModesModel, GridValueFormatterParams } from "@mui/x-data-grid"; import { useGridApiRef } from "@mui/x-data-grid"; import StyledDataGrid from "../StyledDataGrid"; @@ -22,8 +22,11 @@ import CreateInvoiceModal from "./CreateInvoiceModal"; import { ProjectResult } from "@/app/api/projects"; import { IMPORT_INVOICE, IMPORT_RECEIPT } from "@/middleware"; import InvoiceSearchLoading from "./InvoiceSearchLoading"; +import { NumberFormatValues, NumericFormat } from "react-number-format"; - +interface CustomMoneyComponentProps { + params: GridRenderEditCellParams; +} interface Props { invoices: invoiceList[]; @@ -287,6 +290,48 @@ const InvoiceSearch: React.FC & SubComponents = ({ invoices, projects, ab // setSelectedRow([]); }; + // Money format : 000,000,000.00 + const CustomMoneyFormat = useCallback((value: number) => { + if (value) { + return moneyFormatter.format(value); + } else { + return "" + } + }, []) + + const CustomMoneyComponent: React.FC = ({ params }) => { + const { id, value, field } = params; + const ref = React.useRef(); + + const handleValueChange = (newValue: NumberFormatValues) => { + apiRef.current.setEditCellValue({ id, field, value: newValue.value }); + }; + + return { + console.log(values) + handleValueChange(values) + }} + customInput={TextField} + thousandSeparator + valueIsNumericString + decimalScale={2} + fixedDecimalScale + value={value} + inputRef={ref} + InputProps={{ + sx: { + '& .MuiInputBase-input': { + textAlign: 'right', + mb: 2 + } + } + }} + />; + } + const combinedColumns = useMemo[]>( () => [ { @@ -329,7 +374,13 @@ const InvoiceSearch: React.FC & SubComponents = ({ invoices, projects, ab headerName: t("Amount (HKD)"), editable: true, flex: 0.5, - type: 'number' + type: 'number', + renderEditCell: (params: GridRenderEditCellParams) => { + return + }, + valueFormatter: (params: GridValueFormatterParams) => { + return CustomMoneyFormat(params.value as number) + } }, { field: "receiptDate", @@ -351,7 +402,13 @@ const InvoiceSearch: React.FC & SubComponents = ({ invoices, projects, ab headerName: t("Actual Received Amount (HKD)"), editable: true, flex: 0.5, - type: 'number' + type: 'number', + renderEditCell: (params: GridRenderEditCellParams) => { + return + }, + valueFormatter: (params: GridValueFormatterParams) => { + return CustomMoneyFormat(params.value as number) + } }, ], [t] diff --git a/src/components/LeaveModal/LeaveCalendar.tsx b/src/components/LeaveModal/LeaveCalendar.tsx index e0dcaeb..36be589 100644 --- a/src/components/LeaveModal/LeaveCalendar.tsx +++ b/src/components/LeaveModal/LeaveCalendar.tsx @@ -36,6 +36,7 @@ export interface Props { timesheetRecords: RecordTimesheetInput; isFullTime: boolean; joinDate: Dayjs; + isSaturdayWorker: boolean } interface EventClickArg { @@ -57,6 +58,7 @@ const LeaveCalendar: React.FC = ({ leaveRecords, isFullTime, joinDate, + isSaturdayWorker }) => { const { t, @@ -190,7 +192,8 @@ const LeaveCalendar: React.FC = ({ ({ event }: EventClickArg) => { const dayJsObj = dayjs(event.startStr); const holiday = getHolidayForDate(event.startStr, companyHolidays); - const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; + const isWeekend = !isSaturdayWorker ? dayJsObj.day() === 0 || dayJsObj.day() === 6 : dayJsObj.day() === 0; + const isHoliday = holiday || isWeekend; if ( event.extendedProps.calendar === "leaveEntry" && @@ -210,7 +213,8 @@ const LeaveCalendar: React.FC = ({ (e: { dateStr: string; dayEl: HTMLElement }) => { const dayJsObj = dayjs(e.dateStr); const holiday = getHolidayForDate(e.dateStr, companyHolidays); - const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; + const isWeekend = !isSaturdayWorker ? dayJsObj.day() === 0 || dayJsObj.day() === 6 : dayJsObj.day() === 0; + const isHoliday = holiday || isWeekend; openLeaveEditModal(undefined, e.dateStr, Boolean(isHoliday)); }, @@ -224,7 +228,8 @@ const LeaveCalendar: React.FC = ({ } const dayJsObj = dayjs(date); const holiday = getHolidayForDate(date, companyHolidays); - const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; + const isWeekend = !isSaturdayWorker ? dayJsObj.day() === 0 || dayJsObj.day() === 6 : dayJsObj.day() === 0; + const isHoliday = holiday || isWeekend; const leaves = localLeaveRecords[date] || []; const timesheets = timesheetRecords[date] || []; diff --git a/src/components/LeaveModal/LeaveModal.tsx b/src/components/LeaveModal/LeaveModal.tsx index 0bd5687..f715e60 100644 --- a/src/components/LeaveModal/LeaveModal.tsx +++ b/src/components/LeaveModal/LeaveModal.tsx @@ -25,6 +25,7 @@ const modalSx: SxProps = { interface Props extends LeaveCalendarProps { open: boolean; onClose: () => void; + isSaturdayWorker: boolean } const LeaveModal: React.FC = ({ @@ -37,6 +38,7 @@ const LeaveModal: React.FC = ({ timesheetRecords, isFullTime, joinDate, + isSaturdayWorker }) => { const { t } = useTranslation("home"); const isMobile = useIsMobile(); @@ -51,6 +53,7 @@ const LeaveModal: React.FC = ({ allProjects={allProjects} leaveRecords={leaveRecords} timesheetRecords={timesheetRecords} + isSaturdayWorker={isSaturdayWorker} /> ); diff --git a/src/components/ProjectSearch/ProjectSearch.tsx b/src/components/ProjectSearch/ProjectSearch.tsx index a221ec6..6a7ecf0 100644 --- a/src/components/ProjectSearch/ProjectSearch.tsx +++ b/src/components/ProjectSearch/ProjectSearch.tsx @@ -7,13 +7,14 @@ import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; import EditNote from "@mui/icons-material/EditNote"; import uniq from "lodash/uniq"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { MAINTAIN_PROJECT } from "@/middleware"; import { reverse, uniqBy } from "lodash"; import { loadDrafts } from "@/app/utils/draftUtils"; import { TeamResult } from "@/app/api/team"; import { Customer } from "@/app/api/customer"; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; type ProjectResultOrDraft = ProjectResult & { isDraft?: boolean }; @@ -141,6 +142,32 @@ const ProjectSearch: React.FC = ({ [router], ); + const onProjectStatusClick = useCallback( + (project: ProjectResultOrDraft) => { + const status = project.status.toLocaleLowerCase() + console.log(status) + + if (status && statusList.includes(status)) { + /* switch (status) { + case "pending to start": + router.push(`/projects/edit?id=${project.id}&autoClick=start`); + break; + case "on-going": + router.push(`/projects/edit?id=${project.id}&autoClick=complete`); + break; + case "completed": + router.push(`/projects/edit?id=${project.id}&autoClick=reopen`); + break; + } */ + router.push(`/projects/edit?id=${project.id}&autoClick=true`); + } + }, + [router], + ); + + const statusList = ["pending to start", "on-going","completed"] + const ignoreStatusList = ["draft", "deleted"] + const columns = useMemo[]>( () => [ { @@ -165,7 +192,32 @@ const ProjectSearch: React.FC = ({ { name: "category", label: t("Project Category") }, { name: "team", label: t("Team") }, { name: "client", label: t("Client") }, - { name: "status", label: t("Status") }, + { + name: "status", + label: t("Status"), + // type: "link", + // onClick: onProjectStatusClick, + // underlines: ignoreStatusList.reduce((acc, cur) => ({...acc, [cur]: "none"}), {}), + // colors: ignoreStatusList.reduce((acc, cur) => ({...acc, [cur]: "inherit"}), {}), + }, + // { + // name: "status", + // label: t("Status"), + // type: "button", + // onClick: onProjectStatusClick, + // variants: ignoreStatusList.reduce((acc, cur) => ({...acc, [cur]: "text"}), {}), + // colors: ignoreStatusList.reduce((acc, cur) => ({...acc, [cur]: "inherit"}), {}), + // } + { + name: "status", + label: t(""), + onClick: onProjectStatusClick, + buttonIcon: , + disabled: !abilities.includes(MAINTAIN_PROJECT), + disabledRows: { + status: ignoreStatusList + } + }, ], [t, onProjectClick], ); diff --git a/src/components/SearchResults/SearchResults.tsx b/src/components/SearchResults/SearchResults.tsx index 2fe6bf3..031bf44 100644 --- a/src/components/SearchResults/SearchResults.tsx +++ b/src/components/SearchResults/SearchResults.tsx @@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"; import { convertDateArrayToString, moneyFormatter } from "@/app/utils/formatUtil"; import DoneIcon from '@mui/icons-material/Done'; import CloseIcon from '@mui/icons-material/Close'; +import { Button, Link, LinkOwnProps } from "@mui/material"; export interface ResultWithId { id: string | number; @@ -25,7 +26,6 @@ export interface ResultWithId { interface BaseColumn { name: keyof T; label: string; - color?: IconButtonOwnProps["color"]; needTranslation?: boolean; type?: string; isHidden?: boolean; @@ -34,13 +34,25 @@ interface BaseColumn { interface ColumnWithAction extends BaseColumn { onClick: (item: T) => void; buttonIcon: React.ReactNode; + color?: IconButtonOwnProps["color"]; disabled?: boolean; - disabledRows?: { [columnName in keyof T]: string[] }; // Filter the row which is going to be disabled + disabledRows?: { [columnValue in keyof T]: string[] }; // Filter the row which is going to be disabled +} + +interface LinkColumn extends BaseColumn { + // href: string; + onClick: (item: T) => void; + underline: LinkOwnProps["underline"]; + underlines: { [columnValue in keyof T]: LinkOwnProps["underline"] }; + color: LinkOwnProps["color"]; + colors: { [columnValue in keyof T]: LinkOwnProps["color"] }; } export type Column = | BaseColumn - | ColumnWithAction; + | ColumnWithAction + | LinkColumn +; interface Props { items: T[]; @@ -55,6 +67,12 @@ function isActionColumn( return Boolean((column as ColumnWithAction).onClick); } +function isLinkColumn( + column: Column, +): column is LinkColumn { + return column.type === "link"; +} + function SearchResults({ items, columns, @@ -101,6 +119,43 @@ function SearchResults({ return false; }; + function convertObjectKeysToLowercase(obj: T): object | undefined { + return obj ? Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]) + ) : undefined; + } + + // Link Component Functions + function handleLinkColors( + column: LinkColumn, + value: T[keyof T], + ): LinkOwnProps["color"] { + const colors = convertObjectKeysToLowercase(column.colors); + console.log(colors) + const valueKey = String(value).toLowerCase() as keyof typeof colors; + + if (colors && valueKey in colors) { + return colors[valueKey]; + } + + return column.color ?? "primary"; + }; + + function handleLinkUnderlines( + column: LinkColumn, + value: T[keyof T], + ): LinkOwnProps["underline"] { + const underlines = convertObjectKeysToLowercase(column.underlines); + console.log(underlines) + const valueKey = String(value).toLowerCase() as keyof typeof underlines; + + if (underlines && valueKey in underlines) { + return underlines[valueKey]; + } + + return column.underline ?? "always"; + }; + const table = ( <> @@ -125,15 +180,15 @@ function SearchResults({ return ( - {isActionColumn(column) ? ( - column.onClick(item)} - disabled={Boolean(column.disabled) || Boolean(disabledRows(column, item))} - > - {column.buttonIcon} - - ) : + {isActionColumn(column) && !column?.type ? ( + column.onClick(item)} + disabled={Boolean(column.disabled) || Boolean(disabledRows(column, item))} + > + {column.buttonIcon} + + ) : column?.type === "date" ? ( <>{convertDateArrayToString(item[columnName] as number[])} ) : @@ -143,9 +198,18 @@ function SearchResults({ column?.type === "checkbox" ? ( Boolean(item[columnName]) ? : + ) : + isLinkColumn(column) ? ( + column.onClick(item)} + underline={handleLinkUnderlines(column, item[columnName])} + color={handleLinkColors(column, item[columnName])} + > + {item[columnName] as string} + ) : ( - <>{column?.needTranslation ? t(item[columnName] as string) : item[columnName]} + <>{column?.needTranslation ? t(item[columnName] as string) : item[columnName] as string} )} ); diff --git a/src/components/TimeLeaveModal/TimeLeaveModal.tsx b/src/components/TimeLeaveModal/TimeLeaveModal.tsx index 36aaab9..7aa61ad 100644 --- a/src/components/TimeLeaveModal/TimeLeaveModal.tsx +++ b/src/components/TimeLeaveModal/TimeLeaveModal.tsx @@ -54,6 +54,7 @@ interface Props { isFullTime: boolean; joinDate: Dayjs; miscTasks: Task[]; + isSaturdayWorker: boolean } const modalSx: SxProps = { @@ -81,6 +82,7 @@ const TimeLeaveModal: React.FC = ({ isFullTime, joinDate, miscTasks, + isSaturdayWorker, }) => { const { t } = useTranslation("home"); @@ -227,6 +229,7 @@ const TimeLeaveModal: React.FC = ({ leaveTypes, miscTasks, }} + isSaturdayWorker={isSaturdayWorker} /> {errorComponent} diff --git a/src/components/TimesheetAmendment/TimesheetAmendment.tsx b/src/components/TimesheetAmendment/TimesheetAmendment.tsx index d12999c..95561a9 100644 --- a/src/components/TimesheetAmendment/TimesheetAmendment.tsx +++ b/src/components/TimesheetAmendment/TimesheetAmendment.tsx @@ -512,4 +512,4 @@ const TimesheetAmendment: React.FC = ({ ); }; -export default TimesheetAmendment; +export default TimesheetAmendment; \ No newline at end of file diff --git a/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx b/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx index 4c7b0a3..6cfd193 100644 --- a/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx +++ b/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx @@ -82,4 +82,4 @@ export const TimesheetAmendmentModal: React.FC = ({ ); -}; +}; \ No newline at end of file diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index 07c21d2..32d3a25 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -248,4 +248,4 @@ const UserWorkspacePage: React.FC = ({ ); }; -export default UserWorkspacePage; +export default UserWorkspacePage; \ No newline at end of file diff --git a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx index aaa7a4e..17c865b 100644 --- a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx +++ b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx @@ -89,4 +89,4 @@ const UserWorkspaceWrapper: React.FC = async () => { ); }; -export default UserWorkspaceWrapper; +export default UserWorkspaceWrapper; \ No newline at end of file diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index 5e12b22..53e9fba 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -58,14 +58,6 @@ export const authOptions: AuthOptions = { jwt(params) { // Add the data from user to the token const { token, user, account, trigger, session } = params; - // console.log("--------------------------") - // console.log("%c [ token ]:", 'font-size:13px; background:#A888B5; color:#bf2c9f;', token) - // console.log("%c [ user ]:", 'font-size:13px; background:pink; color:#bf2c9f;', user) - // console.log("%c [ account ]:", 'font-size:13px; background:pink; color:#bf2c9f;', account) - // console.log("%c [ session ]:", 'font-size:13px; background:#FFD2A0; color:#bf2c9f;', session) - // console.log("%c [ trigger ]:", 'font-size:13px; background:#EFB6C8; color:#bf2c9f;', trigger) - // console.log(params) - // console.log("--------------------------") if (trigger === "update" && session?.accessToken && session?.refreshToken) { token.accessToken = session.accessToken diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index c82cd5d..1104503 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -21,6 +21,7 @@ "Submit Fail": "Submit Fail", "Do you want to delete?": "Do you want to delete", "Delete Success": "Delete Success", + "Save Success": "Save Success", "Details": "Details", "Delete": "Delete", diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 53774bf..3033104 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -16,7 +16,8 @@ "Submit Fail": "提交失敗", "Do you want to delete?": "你是否確認要刪除?", "Delete Success": "刪除成功", - + "Save Success": "儲存成功", + "Date": "日期", "Month": "月份", diff --git a/src/middleware.ts b/src/middleware.ts index 2277652..c2519f5 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -71,7 +71,8 @@ export const [ GENERATE_PROJECT_CASH_FLOW_REPORT, GENERATE_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT, GENERATE_CROSS_TEAM_CHARGE_REPORT, - VIEW_ALL_PROJECTS + VIEW_ALL_PROJECTS, + SATURDAY_WORKERS ] = [ 'MAINTAIN_USER', 'MAINTAIN_TIMESHEET', @@ -124,7 +125,8 @@ export const [ 'G_PROJECT_CASH_FLOW_REPORT', 'G_STAFF_MONTHLY_WORK_HOURS_ANALYSIS_REPORT', 'G_CROSS_TEAM_CHARGE_REPORT', - 'VIEW_ALL_PROJECTS' + 'VIEW_ALL_PROJECTS', + 'SATURDAY_WORKERS' ] const PRIVATE_ROUTES = [