| @@ -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; | |||
| @@ -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<Props> = ({ | |||
| isEditMode, | |||
| isCopyMode, | |||
| draftId, | |||
| draftId: initDraftId, | |||
| isSubProject, | |||
| mainProjects, | |||
| defaultInputs, | |||
| @@ -139,6 +139,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| 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<Props> = ({ | |||
| 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<Props> = ({ | |||
| }, t); | |||
| }, [draftId, router]); | |||
| // Auto click the button | |||
| const buttonRef = useRef<HTMLButtonElement>(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 ( | |||
| <> | |||
| <FormProvider {...formProps}> | |||
| @@ -633,6 +657,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| <Stack direction="row" gap={1}> | |||
| {/* {!formProps.getValues("projectActualStart") && ( */} | |||
| <Button | |||
| ref={buttonRef} | |||
| name={buttonData.buttonName} | |||
| type="submit" | |||
| variant="contained" | |||
| @@ -770,7 +795,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| > | |||
| {t("Save Draft")} | |||
| </Button> | |||
| {draftId && | |||
| {draftId && | |||
| <Button | |||
| variant="outlined" | |||
| color="error" | |||
| @@ -100,7 +100,7 @@ const CreateProjectWrapper: React.FC<Props> = async (props) => { | |||
| projectInfo.projectId = null | |||
| projectInfo.projectCode = projectInfo.projectCode + "-copy" | |||
| projectInfo.projectName = projectInfo.projectName + "-copy" | |||
| projectInfo.projectStatus = "" | |||
| projectInfo.projectStatus = "" // backend will assign "Pending To Start" status | |||
| Object.entries(projectInfo.milestones).forEach(([key, value]) => { | |||
| projectInfo.milestones[Number(key)].payments.forEach(({ ...rest}, idx, orig) => { | |||
| orig[idx] = { ...rest, id: rest.id * -1 } | |||
| @@ -39,6 +39,7 @@ import { | |||
| } from "@/app/utils/formatUtil"; | |||
| import isDate from "lodash/isDate"; | |||
| import BulkAddPaymentModal from "./BulkAddPaymentModal"; | |||
| import { deleteDialog } from "../Swal/CustomAlerts"; | |||
| interface Props { | |||
| taskGroupId: TaskGroup["id"]; | |||
| @@ -292,6 +293,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| setPayments((currentPayments) => [...currentPayments, ...entries]); | |||
| setBulkAddModalOpen(false); | |||
| }, []); | |||
| const onBulkDelete = useCallback(() => { | |||
| deleteDialog(() => { | |||
| setPayments([]) | |||
| }, t) | |||
| }, []); | |||
| const footer = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| @@ -311,6 +317,15 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| > | |||
| {t("Bulk Add Payment Milestones")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Delete />} | |||
| color={"error"} | |||
| onClick={onBulkDelete} | |||
| size="small" | |||
| > | |||
| {t("Delete Payment Milestones")} | |||
| </Button> | |||
| </Box> | |||
| ); | |||
| @@ -477,7 +477,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| <NumericFormat | |||
| label={t("Expected Total Project Fee")} | |||
| fullWidth | |||
| prefix="$" | |||
| prefix="HK$" | |||
| onValueChange={(values) => { | |||
| // console.log(values) | |||
| onChange(values.floatValue) | |||
| @@ -513,7 +513,7 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| <NumericFormat | |||
| label={t("Sub-Contract Fee")} | |||
| fullWidth | |||
| prefix="$" | |||
| prefix="HK$" | |||
| onValueChange={(values) => { | |||
| // console.log(values) | |||
| onChange(values.floatValue) | |||
| @@ -575,7 +575,8 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| 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<Props> = ({ | |||
| const subsidiary = subsidiaryMap[subsidiaryId]; | |||
| return { | |||
| id: subsidiary.id, | |||
| label: `${subsidiary.code} - ${subsidiary.name}`, | |||
| label: `${subsidiary.name}`, | |||
| // label: `${subsidiary.code} - ${subsidiary.name}`, | |||
| }; | |||
| }), | |||
| ]} | |||
| @@ -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<formProps> = ({ combos }) => { | |||
| const { t } = useTranslation(); | |||
| const formProps = useForm<CreateStaffInputs>(); | |||
| 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<SubmitHandler<CreateStaffInputs>>( | |||
| 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<formProps> = ({ 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<formProps> = ({ 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<formProps> = ({ combos }) => { | |||
| {serverError} | |||
| </Typography> | |||
| )} | |||
| {err && ( | |||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||
| {err.message?.toString()} | |||
| </Typography> | |||
| ) | |||
| } | |||
| <StaffInfo combos={combos}/> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| @@ -9,9 +9,10 @@ import Typography from "@mui/material/Typography"; | |||
| import { CreateGroupInputs } from "@/app/api/group/actions"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useCallback, useEffect } from "react"; | |||
| import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; | |||
| import { CreateStaffInputs } from "@/app/api/staff/actions"; | |||
| import { | |||
| Button, | |||
| Checkbox, | |||
| FormControl, | |||
| InputLabel, | |||
| @@ -25,11 +26,52 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { DemoItem } from "@mui/x-date-pickers/internals/demo"; | |||
| import dayjs from "dayjs"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import TableModal from "./TableModal"; | |||
| import { Preview } from "@mui/icons-material"; | |||
| import SalaryEffectiveModel from "../EditStaff/SalaryEffectiveModel"; | |||
| import TeamHistoryModal from "../EditStaff/TeamHistoryModal"; | |||
| import GradeHistoryModal from "../EditStaff/GradeHistoryModal"; | |||
| import PositionHistoryModal from "../EditStaff/PositionHistoryModal"; | |||
| interface Props { | |||
| combos: comboItem; | |||
| } | |||
| type tableKey = "salary" | "team" | "grade" | "position"; | |||
| //// | |||
| const initState = { | |||
| teamModal: false, | |||
| seModal: false, | |||
| gradeModal: false, | |||
| positionModal: false, | |||
| } | |||
| const enum REDUCER_ACTION_TYPE { | |||
| TOGGLE_TEAM_MODAL, | |||
| TOGGLE_SALARY_EFFECTIVE_MODAL, | |||
| TOGGLE_GRADE_MODAL, | |||
| TOGGLE_POSITION_MODAL, | |||
| } | |||
| type ReducerAction = { | |||
| type: REDUCER_ACTION_TYPE | |||
| } | |||
| const reducer = (state: typeof initState, action: ReducerAction): typeof initState => { | |||
| switch (action.type) { | |||
| case REDUCER_ACTION_TYPE.TOGGLE_TEAM_MODAL: | |||
| return { ...state, teamModal: !state.teamModal }; | |||
| case REDUCER_ACTION_TYPE.TOGGLE_SALARY_EFFECTIVE_MODAL: | |||
| return { ...state, seModal: !state.seModal }; | |||
| case REDUCER_ACTION_TYPE.TOGGLE_GRADE_MODAL: | |||
| return { ...state, gradeModal: !state.gradeModal }; | |||
| case REDUCER_ACTION_TYPE.TOGGLE_POSITION_MODAL: | |||
| return { ...state, positionModal: !state.positionModal }; | |||
| default: | |||
| return state; | |||
| } | |||
| } | |||
| ///// | |||
| const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| const { | |||
| t, | |||
| @@ -45,6 +87,14 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| getValues, | |||
| clearErrors | |||
| } = useFormContext<CreateStaffInputs>(); | |||
| const [tableKey, setTableKey] = useState<tableKey>() | |||
| const [isOpen, setIsOpen] = useState(false) | |||
| const [state, dispatch] = useReducer(reducer, initState) | |||
| const toggleSeModal = () => dispatch({ type: REDUCER_ACTION_TYPE.TOGGLE_SALARY_EFFECTIVE_MODAL}) | |||
| const toggleTeamModal = () => dispatch({ type: REDUCER_ACTION_TYPE.TOGGLE_TEAM_MODAL}) | |||
| const toggleGradeModal = () => dispatch({ type: REDUCER_ACTION_TYPE.TOGGLE_GRADE_MODAL}) | |||
| const togglePositionModal = () => dispatch({ type: REDUCER_ACTION_TYPE.TOGGLE_POSITION_MODAL}) | |||
| const employType = [ | |||
| { id: 1, label: "FT" }, | |||
| @@ -64,6 +114,11 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| } | |||
| }, [defaultValues]); | |||
| const toggleModal = useCallback((key: tableKey) => { | |||
| setIsOpen(!isOpen) | |||
| setTableKey(key) | |||
| }, [isOpen]) | |||
| const joinDate = getValues("joinDate"); | |||
| const departDate = getValues("departDate"); | |||
| @@ -148,17 +203,28 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| control={control} | |||
| name="teamId" | |||
| render={({ field }) => ( | |||
| <Select | |||
| label={t("Team")} | |||
| {...field} | |||
| // error={Boolean(errors.teamId)} | |||
| > | |||
| {combos.team.map((team, index) => ( | |||
| <MenuItem key={`${team.id}-${index}`} value={team.id}> | |||
| {t(team.label)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| <Box display="flex" justifyContent="space-between" alignItems="center"> | |||
| <Select | |||
| label={t("Team")} | |||
| style={{ flex: 1, marginRight: '8px' }} | |||
| {...field} | |||
| // error={Boolean(errors.teamId)} | |||
| disabled | |||
| > | |||
| {combos.team.map((team, index) => ( | |||
| <MenuItem key={`${team.id}-${index}`} value={team.id}> | |||
| {t(team.label)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| onClick={toggleTeamModal} | |||
| > | |||
| {t("Team History")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| @@ -191,7 +257,7 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel required>{t("Grade")}</InputLabel> | |||
| <Controller | |||
| {/* <Controller | |||
| control={control} | |||
| name="gradeId" | |||
| render={({ field }) => ( | |||
| @@ -207,6 +273,30 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| ))} | |||
| </Select> | |||
| )} | |||
| /> */} | |||
| <Controller | |||
| control={control} | |||
| name="gradeId" | |||
| render={({ field }) => ( | |||
| <Box display="flex" justifyContent="space-between" alignItems="center"> | |||
| <Select | |||
| label={t("Grade")} | |||
| style={{ flex: 1, marginRight: '8px' }} | |||
| {...field} | |||
| error={Boolean(errors.gradeId)} | |||
| disabled | |||
| > | |||
| {combos.grade.map((grade, index) => ( | |||
| <MenuItem key={`${grade.id}-${index}`} value={grade.id}> | |||
| {t(grade.label)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| <Button variant="contained" size="small" onClick={toggleGradeModal}> | |||
| {t("Grade History")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| @@ -254,9 +344,12 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| control={control} | |||
| name="currentPositionId" | |||
| render={({ field }) => ( | |||
| <Select | |||
| <Box display="flex" justifyContent="space-between" alignItems="center"> | |||
| <Select | |||
| label={t("Current Position")} | |||
| style={{ flex: 1, marginRight: '8px' }} | |||
| {...field} | |||
| disabled | |||
| error={Boolean(errors.currentPositionId)} | |||
| > | |||
| {combos.position.map((position, index) => ( | |||
| @@ -268,6 +361,10 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| <Button variant="contained" size="small" onClick={togglePositionModal}> | |||
| {t("Position History")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| @@ -275,7 +372,7 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel required>{t("Salary Point")}</InputLabel> | |||
| <Controller | |||
| {/* <Controller | |||
| control={control} | |||
| name="salaryId" | |||
| render={({ field }) => ( | |||
| @@ -294,7 +391,35 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| ))} | |||
| </Select> | |||
| )} | |||
| /> | |||
| /> */} | |||
| <Controller | |||
| control={control} | |||
| name="salaryId" | |||
| render={({ field }) => ( | |||
| <Box display="flex" justifyContent="space-between" alignItems="center"> | |||
| <Select | |||
| label={t("Salary Point")} | |||
| {...field} | |||
| error={Boolean(errors.salaryId)} | |||
| style={{ flex: 1, marginRight: '8px' }} | |||
| disabled | |||
| > | |||
| {combos.salary.map((salary, index) => ( | |||
| <MenuItem | |||
| key={`${salary.id}-${index}`} | |||
| value={salary.id} | |||
| > | |||
| {t(salary.label)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| {/* <Button variant="contained" size="small" onClick={() => toggleModal("salary")}> */} | |||
| <Button variant="contained" size="small" onClick={toggleSeModal}> | |||
| {t("Edit Salary")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| @@ -512,6 +637,36 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| </Grid> | |||
| </Box> | |||
| </CardContent> | |||
| {state.seModal && | |||
| <SalaryEffectiveModel | |||
| open={state.seModal} | |||
| onClose={toggleSeModal} | |||
| combos={combos} | |||
| // columns={salaryCols} | |||
| /> | |||
| } | |||
| {state.teamModal && | |||
| <TeamHistoryModal | |||
| open={state.teamModal} | |||
| onClose={toggleTeamModal} | |||
| combos={combos} | |||
| /> | |||
| } | |||
| {state.gradeModal && | |||
| <GradeHistoryModal | |||
| open={state.gradeModal} | |||
| onClose={toggleGradeModal} | |||
| combos={combos} | |||
| /> | |||
| } | |||
| {state.positionModal && | |||
| <PositionHistoryModal | |||
| open={state.positionModal} | |||
| onClose={togglePositionModal} | |||
| combos={combos} | |||
| /> | |||
| } | |||
| {/* {tableKey && <TableModal tableKey={tableKey} isOpen={isOpen} onClose={() => toggleModal("team")} combos={combos}/>} */} | |||
| </Card> | |||
| ); | |||
| }; | |||
| @@ -36,6 +36,7 @@ interface Props<EntryTableProps = object> { | |||
| EntryTableProps & { day: string; isHoliday: boolean } | |||
| >; | |||
| entryTableProps: EntryTableProps; | |||
| isSaturdayWorker: boolean | |||
| } | |||
| function DateHoursTable<EntryTableProps>({ | |||
| @@ -45,9 +46,9 @@ function DateHoursTable<EntryTableProps>({ | |||
| leaveEntries, | |||
| timesheetEntries, | |||
| companyHolidays, | |||
| isSaturdayWorker, | |||
| }: Props<EntryTableProps>) { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <TableContainer sx={{ maxHeight: 400 }}> | |||
| <Table stickyHeader> | |||
| @@ -71,6 +72,7 @@ function DateHoursTable<EntryTableProps>({ | |||
| timesheetEntries={timesheetEntries} | |||
| EntryTableComponent={EntryTableComponent} | |||
| entryTableProps={entryTableProps} | |||
| isSaturdayWorker={isSaturdayWorker} | |||
| /> | |||
| ); | |||
| })} | |||
| @@ -87,6 +89,7 @@ function DayRow<EntryTableProps>({ | |||
| entryTableProps, | |||
| EntryTableComponent, | |||
| companyHolidays, | |||
| isSaturdayWorker, | |||
| }: { | |||
| day: string; | |||
| companyHolidays: HolidaysResult[]; | |||
| @@ -96,16 +99,18 @@ function DayRow<EntryTableProps>({ | |||
| 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 = | |||
| @@ -88,7 +88,7 @@ const EditStaff: React.FC<formProps> = ({ 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<formProps> = ({ 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<formProps> = ({ 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<formProps> = ({ 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), | |||
| @@ -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<Props> = async ({ open, onClose, columns }) => { | |||
| export type GradeModalRow = Partial< | |||
| gradeHistory & { | |||
| _isNew: boolean | |||
| _error: StaffEntryError; | |||
| }> | |||
| const thisField = "gradeHistory" | |||
| const GradeHistoryModal: React.FC<Props> = ({ 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<GridRowModesModel>({}); | |||
| 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<number[]>([]); | |||
| 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<GridRowIdGetter<GradeModalRow>>( | |||
| (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<NonNullable<ModalProps["onClose"]>>( | |||
| (_, 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<GradeModalRow>) => { | |||
| 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<GradeModalRow>, | |||
| originalRow: GridRowModel<GradeModalRow> | |||
| ) => { | |||
| 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 = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={addRow} | |||
| size="small" | |||
| > | |||
| {t("Add Record")} | |||
| </Button> | |||
| </Box> | |||
| ) | |||
| const columns = useMemo( | |||
| () => [ | |||
| { | |||
| field: 'grade', | |||
| headerName: 'grade', | |||
| flex: 1, | |||
| editable: true, | |||
| type: 'singleSelect', | |||
| valueOptions: combos.grade.map(item => item.label), | |||
| renderEditCell(params: GridRenderEditCellParams<GradeModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = ( | |||
| <GridEditSingleSelectCell variant="outlined" {...params} /> | |||
| ); | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| field: 'from', | |||
| headerName: 'from', | |||
| flex: 1, | |||
| editable: true, | |||
| type: 'date', | |||
| renderEditCell(params: GridRenderEditCellParams<GradeModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = <GridEditDateCell {...params} />; | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| field: 'actions', | |||
| type: 'actions', | |||
| @@ -161,42 +265,29 @@ const GradeHistoryModal: React.FC<Props> = async ({ open, onClose, columns }) => | |||
| sx={{ | |||
| color: 'primary.main' | |||
| }} | |||
| onClick={handleSaveClick(id)} | |||
| onClick={handleSave(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<CancelIcon />} | |||
| label="Cancel" | |||
| key="edit" | |||
| onClick={handleCancelClick(id)} | |||
| onClick={handleCancel(id)} | |||
| /> | |||
| ]; | |||
| } | |||
| return [ | |||
| <GridActionsCellItem | |||
| icon={<EditIcon />} | |||
| label="Edit" | |||
| className="textPrimary" | |||
| onClick={handleEditClick(id)} | |||
| color="inherit" | |||
| key="edit" | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<DeleteIcon />} | |||
| 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 ( | |||
| <Modal open={open} onClose={handleClose}> | |||
| <Paper sx={{ ...modalSx }}> | |||
| @@ -204,25 +295,48 @@ const GradeHistoryModal: React.FC<Props> = async ({ open, onClose, columns }) => | |||
| {t('GradeHistoryModal')} | |||
| </Typography> | |||
| <StyledDataGrid | |||
| getRowId={getRowId} | |||
| apiRef={apiRef} | |||
| rows={_rows} | |||
| columns={_columns} | |||
| columns={columns} | |||
| editMode="row" | |||
| autoHeight | |||
| sx={{ | |||
| "--DataGrid-overlayHeight": "100px", | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||
| border: "1px solid", | |||
| borderColor: "error.main", | |||
| }, | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||
| border: "1px solid", | |||
| borderColor: "warning.main", | |||
| }, | |||
| }} | |||
| disableColumnMenu | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={setRowModesModel} | |||
| onRowEditStop={handleRowEditStop} | |||
| processRowUpdate={processRowUpdate} | |||
| // slots={{ | |||
| // toolbar: EditToolbar | |||
| // }} | |||
| // slotProps={{ | |||
| // toolbar: {count, setCount, setRows, setRowModesModel, _columns} | |||
| // }} | |||
| onProcessRowUpdateError={onProcessRowUpdateError} | |||
| getCellClassName={(params: GridCellParams<GradeModalRow>) => { | |||
| let classname = ""; | |||
| if (params.row._error) { | |||
| classname = "hasError" | |||
| } | |||
| return classname; | |||
| }} | |||
| slots={{ | |||
| footer: FooterToolbar, | |||
| noRowsOverlay: NoRowsOverlay, | |||
| }} | |||
| slotProps={{ | |||
| footer: { child: footer }, | |||
| }} | |||
| /> | |||
| <Box display="flex" justifyContent="flex-end" gap={2}> | |||
| <Button variant="text" onClick={handleClose}> | |||
| {t('Cancel')} | |||
| <Button variant="text" onClick={onCancel}> | |||
| {t('Close')} | |||
| </Button> | |||
| <Button variant="contained" onClick={bigTesting}> | |||
| <Button variant="contained" onClick={doSave}> | |||
| {t("Save")} | |||
| </Button> | |||
| </Box> | |||
| @@ -231,4 +345,20 @@ const GradeHistoryModal: React.FC<Props> = async ({ open, onClose, columns }) => | |||
| </Modal> | |||
| ) | |||
| } | |||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||
| }; | |||
| const NoRowsOverlay: React.FC = () => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| height="100%" | |||
| > | |||
| <Typography variant="caption">{t("Add some entries!")}</Typography> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default GradeHistoryModal | |||
| @@ -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<Props> = async ({ open, onClose, columns }) => { | |||
| export type PositionModalRow = Partial< | |||
| positionHistory & { | |||
| _isNew: boolean | |||
| _error: StaffEntryError; | |||
| }> | |||
| const thisField = "positionHistory" | |||
| const PositionHistoryModal: React.FC<Props> = ({ | |||
| 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<GridRowModesModel>({}); | |||
| 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<number[]>([]); | |||
| const formValues = watch(); | |||
| const getRowId = useCallback<GridRowIdGetter<PositionModalRow>>( | |||
| (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<NonNullable<ModalProps["onClose"]>>( | |||
| (_, 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<PositionModalRow>) => { | |||
| 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<PositionModalRow>, | |||
| originalRow: GridRowModel<PositionModalRow> | |||
| ) => { | |||
| 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 [ | |||
| <GridActionsCellItem | |||
| icon={<SaveIcon />} | |||
| label="Save" | |||
| key="edit" | |||
| sx={{ | |||
| color: 'primary.main' | |||
| }} | |||
| onClick={handleSaveClick(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<CancelIcon />} | |||
| 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<PositionModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = ( | |||
| <GridEditSingleSelectCell variant="outlined" {...params} /> | |||
| ); | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| field: 'from', | |||
| headerName: 'from', | |||
| flex: 1, | |||
| editable: true, | |||
| type: 'date', | |||
| renderEditCell(params: GridRenderEditCellParams<PositionModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = <GridEditDateCell {...params} />; | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| 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 [ | |||
| <GridActionsCellItem | |||
| icon={<EditIcon />} | |||
| label="Edit" | |||
| className="textPrimary" | |||
| onClick={handleEditClick(id)} | |||
| color="inherit" | |||
| icon={<SaveIcon />} | |||
| label="Save" | |||
| key="edit" | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<DeleteIcon />} | |||
| label="Delete" | |||
| sx={{ | |||
| color: 'error.main' | |||
| color: 'primary.main' | |||
| }} | |||
| onClick={handleDeleteClick(id)} color="inherit" key="edit" /> | |||
| onClick={handleSave(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<CancelIcon />} | |||
| label="Cancel" | |||
| key="edit" | |||
| onClick={handleCancel(id)} | |||
| /> | |||
| ]; | |||
| } | |||
| return [ | |||
| <GridActionsCellItem | |||
| icon={<DeleteIcon />} | |||
| 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 = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={addRow} | |||
| size="small" | |||
| > | |||
| {t("Add Record")} | |||
| </Button> | |||
| </Box> | |||
| ) | |||
| return ( | |||
| <Modal open={open} onClose={handleClose}> | |||
| <Paper sx={{ ...modalSx }}> | |||
| @@ -203,25 +305,48 @@ const PositionHistoryModal: React.FC<Props> = async ({ open, onClose, columns }) | |||
| {t('PositionHistoryModal')} | |||
| </Typography> | |||
| <StyledDataGrid | |||
| getRowId={getRowId} | |||
| apiRef={apiRef} | |||
| rows={_rows} | |||
| columns={_columns} | |||
| columns={columns} | |||
| editMode="row" | |||
| autoHeight | |||
| sx={{ | |||
| "--DataGrid-overlayHeight": "100px", | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||
| border: "1px solid", | |||
| borderColor: "error.main", | |||
| }, | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||
| border: "1px solid", | |||
| borderColor: "warning.main", | |||
| }, | |||
| }} | |||
| disableColumnMenu | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={setRowModesModel} | |||
| onRowEditStop={handleRowEditStop} | |||
| processRowUpdate={processRowUpdate} | |||
| // slots={{ | |||
| // toolbar: EditToolbar | |||
| // }} | |||
| // slotProps={{ | |||
| // toolbar: {count, setCount, setRows, setRowModesModel, _columns} | |||
| // }} | |||
| onProcessRowUpdateError={onProcessRowUpdateError} | |||
| getCellClassName={(params: GridCellParams<PositionModalRow>) => { | |||
| let classname = ""; | |||
| if (params.row._error) { | |||
| classname = "hasError" | |||
| } | |||
| return classname; | |||
| }} | |||
| slots={{ | |||
| footer: FooterToolbar, | |||
| noRowsOverlay: NoRowsOverlay, | |||
| }} | |||
| slotProps={{ | |||
| footer: { child: footer }, | |||
| }} | |||
| /> | |||
| <Box display="flex" justifyContent="flex-end" gap={2}> | |||
| <Button variant="text" onClick={handleClose}> | |||
| {t('Cancel')} | |||
| <Button variant="text" onClick={onCancel}> | |||
| {t('Close')} | |||
| </Button> | |||
| <Button variant="contained" onClick={bigTesting}> | |||
| <Button variant="contained" onClick={doSave}> | |||
| {t("Save")} | |||
| </Button> | |||
| </Box> | |||
| @@ -230,4 +355,22 @@ const PositionHistoryModal: React.FC<Props> = async ({ open, onClose, columns }) | |||
| </Modal> | |||
| ) | |||
| } | |||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||
| }; | |||
| const NoRowsOverlay: React.FC = () => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| height="100%" | |||
| > | |||
| <Typography variant="caption">{t("Add some entries!")}</Typography> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default PositionHistoryModal | |||
| @@ -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<any> | 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 ( | |||
| <GridToolbarContainer> | |||
| <Button color="primary" startIcon={<AddIcon />} onClick={handleClick}> | |||
| {"addRecordBtn"} | |||
| </Button> | |||
| {/* <Button color="primary" startIcon={<AddIcon />} onClick={handleSave}> | |||
| SAVE | |||
| </Button> */} | |||
| </GridToolbarContainer> | |||
| ); | |||
| } | |||
| export type SeModalRow = Partial< | |||
| salaryEffectiveInfo & { | |||
| _isNew: boolean | |||
| _error: StaffEntryError; | |||
| } | |||
| > | |||
| export class ProcessRowUpdateError<T> 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<SalaryEffectiveModelProps> = ({ open, onClose, modalSx: mSx, columns }) => { | |||
| Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); | |||
| } | |||
| } | |||
| const thisField = "salaryEffectiveInfo" | |||
| const SalaryEffectiveModel: React.FC<SalaryEffectiveModelProps> = ({ 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<GridRowModesModel>({}); | |||
| 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<number[]>([]); | |||
| 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<GridRowIdGetter<SeModalRow>>( | |||
| (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<NonNullable<ModalProps["onClose"]>>( | |||
| (_, reason) => { | |||
| if (reason !== "backdropClick") { | |||
| onClose(); | |||
| } | |||
| }, [onClose]); | |||
| const onProcessRowUpdateError = useCallback( | |||
| (updateError: ProcessRowUpdateError<SeModalRow>) => { | |||
| const errors = updateError.errors; | |||
| const currRow = updateError.currRow; | |||
| console.log(errors) | |||
| apiRef.current.updateRows([{ ...currRow, _error: errors }]); | |||
| }, | |||
| [apiRef, rowModesModel], | |||
| ); | |||
| const processRowUpdate = useCallback(( | |||
| newRow: GridRowModel<SeModalRow>, | |||
| originalRow: GridRowModel<SeModalRow> | |||
| ) => { | |||
| 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<SalaryEffectiveModelProps> = ({ 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<SalaryEffectiveModelProps> = ({ 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<SeModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = ( | |||
| <GridEditSingleSelectCell variant="outlined" {...params} /> | |||
| ); | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| field: 'date', | |||
| headerName: 'date', | |||
| flex: 1, | |||
| editable: true, | |||
| type: 'date', | |||
| renderEditCell(params: GridRenderEditCellParams<SeModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = <GridEditDateCell {...params} />; | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| field: 'actions', | |||
| type: 'actions', | |||
| @@ -220,47 +253,54 @@ const SalaryEffectiveModel: React.FC<SalaryEffectiveModelProps> = ({ open, onClo | |||
| sx={{ | |||
| color: 'primary.main' | |||
| }} | |||
| onClick={handleSaveClick(id)} | |||
| onClick={handleSave(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<CancelIcon />} | |||
| label="Cancel" | |||
| key="edit" | |||
| onClick={handleCancelClick(id)} | |||
| onClick={handleCancel(id)} | |||
| /> | |||
| ]; | |||
| } | |||
| return [ | |||
| <GridActionsCellItem | |||
| icon={<EditIcon />} | |||
| label="Edit" | |||
| className="textPrimary" | |||
| onClick={handleEditClick(id)} | |||
| color="inherit" | |||
| key="edit" | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<DeleteIcon />} | |||
| 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 = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={addRow} | |||
| size="small" | |||
| > | |||
| {t("Add Record")} | |||
| </Button> | |||
| </Box> | |||
| ) | |||
| return ( | |||
| <Modal open={open} onClose={handleClose}> | |||
| @@ -269,33 +309,70 @@ const SalaryEffectiveModel: React.FC<SalaryEffectiveModelProps> = ({ open, onClo | |||
| {t('Salary Effective Date Change')} | |||
| </Typography> | |||
| <StyledDataGrid | |||
| getRowId={getRowId} | |||
| apiRef={apiRef} | |||
| rows={_rows} | |||
| columns={_columns} | |||
| columns={columns} | |||
| editMode="row" | |||
| autoHeight | |||
| sx={{ | |||
| "--DataGrid-overlayHeight": "100px", | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||
| border: "1px solid", | |||
| borderColor: "error.main", | |||
| }, | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||
| border: "1px solid", | |||
| borderColor: "warning.main", | |||
| }, | |||
| }} | |||
| disableColumnMenu | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={setRowModesModel} | |||
| onRowEditStop={handleRowEditStop} | |||
| onProcessRowUpdateError={onProcessRowUpdateError} | |||
| processRowUpdate={processRowUpdate} | |||
| getCellClassName={(params: GridCellParams<SeModalRow>) => { | |||
| 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 }, | |||
| }} | |||
| /> | |||
| <Box display="flex" justifyContent="flex-end" gap={2}> | |||
| <Button variant="text" onClick={handleClose}> | |||
| <Button variant="text" onClick={onCancel}> | |||
| {t('Cancel')} | |||
| </Button> | |||
| <Button variant="contained" onClick={bigTesting}> | |||
| {/* <Button variant="contained" onClick={handleSaveAll}> */} | |||
| {t("Save")} | |||
| </Button> | |||
| <Button variant="contained" onClick={doSave}> | |||
| {t("Save")} | |||
| </Button> | |||
| </Box> | |||
| {/* </FormControl> */} | |||
| </Paper> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||
| }; | |||
| const NoRowsOverlay: React.FC = () => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| height="100%" | |||
| > | |||
| <Typography variant="caption">{t("Add some entries!")}</Typography> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default SalaryEffectiveModel; | |||
| @@ -113,10 +113,6 @@ const StaffInfo: React.FC<Props> = ({ 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<Props> = ({ 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 ( | |||
| <Card sx={{ display: "block" }}> | |||
| <CardContent component={Stack} spacing={4}> | |||
| @@ -311,6 +201,7 @@ const StaffInfo: React.FC<Props> = ({ 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<Props> = ({ combos }) => { | |||
| ))} | |||
| </Select> | |||
| <Button variant="contained" size="small" onClick={toggleTeamModal} | |||
| disabled={getValues("teamHistory").length == 0} | |||
| // disabled={getValues("teamHistory").length == 0} | |||
| > | |||
| {t("Team History")} | |||
| </Button> | |||
| @@ -367,6 +258,7 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| style={{ flex: 1, marginRight: '8px' }} | |||
| {...field} | |||
| error={Boolean(errors.gradeId)} | |||
| disabled | |||
| > | |||
| {combos.grade.map((grade, index) => ( | |||
| <MenuItem key={`${grade.id}-${index}`} value={grade.id}> | |||
| @@ -374,8 +266,7 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| <Button variant="contained" size="small" onClick={toggleGradeModal} | |||
| disabled={getValues("gradeHistory").length == 0}> | |||
| <Button variant="contained" size="small" onClick={toggleGradeModal}> | |||
| {t("Grade History")} | |||
| </Button> | |||
| </Box> | |||
| @@ -432,6 +323,7 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| style={{ flex: 1, marginRight: '8px' }} | |||
| {...field} | |||
| error={Boolean(errors.currentPositionId)} | |||
| disabled | |||
| > | |||
| {combos.position.map((position, index) => ( | |||
| <MenuItem | |||
| @@ -442,8 +334,7 @@ const StaffInfo: React.FC<Props> = ({ combos }) => { | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| <Button variant="contained" size="small" onClick={togglePositionModal} | |||
| disabled={getValues("positionHistory").length == 0}> | |||
| <Button variant="contained" size="small" onClick={togglePositionModal}> | |||
| {t("Position History")} | |||
| </Button> | |||
| </Box> | |||
| @@ -656,7 +547,10 @@ const StaffInfo: React.FC<Props> = ({ 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<Props> = ({ combos }) => { | |||
| <SalaryEffectiveModel | |||
| open={state.seModal} | |||
| onClose={toggleSeModal} | |||
| columns={salaryCols} | |||
| combos={combos} | |||
| /> | |||
| } | |||
| {state.teamModal && | |||
| <TeamHistoryModal | |||
| open={state.teamModal} | |||
| onClose={toggleTeamModal} | |||
| columns={teamHistoryCols} | |||
| combos={combos} | |||
| /> | |||
| } | |||
| {state.gradeModal && | |||
| <GradeHistoryModal | |||
| open={state.gradeModal} | |||
| onClose={toggleGradeModal} | |||
| columns={gradeHistoryCols} | |||
| combos={combos} | |||
| /> | |||
| } | |||
| {state.positionModal && | |||
| <PositionHistoryModal | |||
| open={state.positionModal} | |||
| onClose={togglePositionModal} | |||
| columns={positionHistoryCols} | |||
| combos={combos} | |||
| /> | |||
| } | |||
| </Card> | |||
| @@ -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<Props> = async ({ open, onClose, columns }) => { | |||
| export type TeamModalRow = Partial< | |||
| teamHistory & { | |||
| _isNew: boolean | |||
| _error: StaffEntryError; | |||
| }> | |||
| export class ProcessRowUpdateError<T> 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<Props> = ({ | |||
| 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<GridRowModesModel>({}); | |||
| 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<number[]>([]); | |||
| const formValues = watch(); | |||
| const getRowId = useCallback<GridRowIdGetter<TeamModalRow>>( | |||
| (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<NonNullable<ModalProps["onClose"]>>( | |||
| (_, 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<TeamModalRow>) => { | |||
| const errors = updateError.errors; | |||
| const currRow = updateError.currRow; | |||
| console.log(errors) | |||
| apiRef.current.updateRows([{ ...currRow, _error: errors }]); | |||
| }, | |||
| [apiRef, rowModesModel], | |||
| ); | |||
| const processRowUpdate = useCallback(( | |||
| newRow: GridRowModel<TeamModalRow>, | |||
| originalRow: GridRowModel<TeamModalRow> | |||
| ) => { | |||
| 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 [ | |||
| <GridActionsCellItem | |||
| icon={<SaveIcon />} | |||
| label="Save" | |||
| key="edit" | |||
| sx={{ | |||
| color: 'primary.main' | |||
| }} | |||
| onClick={handleSaveClick(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<CancelIcon />} | |||
| label="Cancel" | |||
| key="edit" | |||
| onClick={handleCancelClick(id)} | |||
| /> | |||
| ]; | |||
| } | |||
| const columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| field: 'team', | |||
| headerName: 'team', | |||
| flex: 1, | |||
| editable: true, | |||
| type: 'singleSelect', | |||
| valueOptions: combos.team.map(item => item.label), | |||
| renderEditCell(params: GridRenderEditCellParams<TeamModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = ( | |||
| <GridEditSingleSelectCell variant="outlined" {...params} /> | |||
| ); | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| field: 'from', | |||
| headerName: 'from', | |||
| flex: 1, | |||
| editable: true, | |||
| type: 'date', | |||
| renderEditCell(params: GridRenderEditCellParams<TeamModalRow>) { | |||
| const errorMessage = params.row._error?.[params.field as keyof StaffEntryError] | |||
| const content = <GridEditDateCell {...params} />; | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| 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 [ | |||
| <GridActionsCellItem | |||
| icon={<EditIcon />} | |||
| label="Edit" | |||
| className="textPrimary" | |||
| onClick={handleEditClick(id)} | |||
| color="inherit" | |||
| icon={<SaveIcon />} | |||
| label="Save" | |||
| key="edit" | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<DeleteIcon />} | |||
| label="Delete" | |||
| sx={{ | |||
| color: 'error.main' | |||
| color: 'primary.main' | |||
| }} | |||
| onClick={handleDeleteClick(id)} color="inherit" key="edit" /> | |||
| onClick={handleSave(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<CancelIcon />} | |||
| label="Cancel" | |||
| key="edit" | |||
| onClick={handleCancel(id)} | |||
| /> | |||
| ]; | |||
| } | |||
| return [ | |||
| <GridActionsCellItem | |||
| icon={<DeleteIcon />} | |||
| 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 = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={addRow} | |||
| size="small" | |||
| > | |||
| {t("Add Record")} | |||
| </Button> | |||
| </Box> | |||
| ) | |||
| return ( | |||
| <Modal open={open} onClose={handleClose}> | |||
| <Paper sx={{ ...modalSx }}> | |||
| @@ -204,25 +313,48 @@ const TeamHistoryModal: React.FC<Props> = async ({ open, onClose, columns }) => | |||
| {t('TeamHistoryModal')} | |||
| </Typography> | |||
| <StyledDataGrid | |||
| getRowId={getRowId} | |||
| apiRef={apiRef} | |||
| rows={_rows} | |||
| columns={_columns} | |||
| columns={columns} | |||
| editMode="row" | |||
| autoHeight | |||
| sx={{ | |||
| "--DataGrid-overlayHeight": "100px", | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||
| border: "1px solid", | |||
| borderColor: "error.main", | |||
| }, | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||
| border: "1px solid", | |||
| borderColor: "warning.main", | |||
| }, | |||
| }} | |||
| disableColumnMenu | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={setRowModesModel} | |||
| onRowEditStop={handleRowEditStop} | |||
| processRowUpdate={processRowUpdate} | |||
| // slots={{ | |||
| // toolbar: EditToolbar | |||
| // }} | |||
| // slotProps={{ | |||
| // toolbar: {count, setCount, setRows, setRowModesModel, _columns} | |||
| // }} | |||
| onProcessRowUpdateError={onProcessRowUpdateError} | |||
| getCellClassName={(params: GridCellParams<TeamModalRow>) => { | |||
| let classname = ""; | |||
| if (params.row._error) { | |||
| classname = "hasError" | |||
| } | |||
| return classname; | |||
| }} | |||
| slots={{ | |||
| footer: FooterToolbar, | |||
| noRowsOverlay: NoRowsOverlay, | |||
| }} | |||
| slotProps={{ | |||
| footer: { child: footer }, | |||
| }} | |||
| /> | |||
| <Box display="flex" justifyContent="flex-end" gap={2}> | |||
| <Button variant="text" onClick={handleClose}> | |||
| {t('Cancel')} | |||
| <Button variant="text" onClick={onCancel}> | |||
| {t('Close')} | |||
| </Button> | |||
| <Button variant="contained" onClick={bigTesting}> | |||
| <Button variant="contained" onClick={doSave}> | |||
| {t("Save")} | |||
| </Button> | |||
| </Box> | |||
| @@ -231,4 +363,20 @@ const TeamHistoryModal: React.FC<Props> = async ({ open, onClose, columns }) => | |||
| </Modal> | |||
| ) | |||
| } | |||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||
| }; | |||
| const NoRowsOverlay: React.FC = () => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| height="100%" | |||
| > | |||
| <Typography variant="caption">{t("Add some entries!")}</Typography> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default TeamHistoryModal | |||
| @@ -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<TeamModalRow & GradeModalRow & PositionModalRow & SeModalRow> | |||
| type AllFields = Partial<teamHistory & gradeHistory & positionHistory & salaryEffectiveInfo> | |||
| 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; | |||
| } | |||
| @@ -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<Props> = ({ expenses, projects }) => { | |||
| [] | |||
| ); | |||
| const columns = useMemo<Column<ProjectExpensesResultFormatted>[]>( | |||
| () => [ | |||
| { | |||
| @@ -215,6 +224,47 @@ const ExpenseSearch: React.FC<Props> = ({ 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<CustomMoneyComponentProps> = ({ params }) => { | |||
| const { id, value, field } = params; | |||
| const ref = React.useRef(); | |||
| const handleValueChange = (newValue: NumberFormatValues) => { | |||
| apiRef.current.setEditCellValue({ id, field, value: newValue.value }); | |||
| }; | |||
| return <NumericFormat | |||
| fullWidth | |||
| prefix="HK$" | |||
| onValueChange={(values) => { | |||
| 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<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| @@ -234,7 +284,13 @@ const ExpenseSearch: React.FC<Props> = ({ expenses, projects }) => { | |||
| headerName: t("Amount (HKD)"), | |||
| editable: true, | |||
| flex: 0.5, | |||
| type: 'number' | |||
| type: 'number', | |||
| renderEditCell: (params: GridRenderEditCellParams) => { | |||
| return <CustomMoneyComponent params={params} /> | |||
| }, | |||
| valueFormatter: (params: GridValueFormatterParams) => { | |||
| return CustomMoneyFormat(params.value as number) | |||
| } | |||
| }, | |||
| { field: "issuedDate", | |||
| headerName: t("Issue Date"), | |||
| @@ -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<Props> & 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<CustomMoneyComponentProps> = ({ params }) => { | |||
| const { id, value, field } = params; | |||
| const ref = React.useRef(); | |||
| const handleValueChange = (newValue: NumberFormatValues) => { | |||
| apiRef.current.setEditCellValue({ id, field, value: newValue.value }); | |||
| }; | |||
| return <NumericFormat | |||
| fullWidth | |||
| prefix="HK$" | |||
| onValueChange={(values) => { | |||
| 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<Column<invoiceList>[]>( | |||
| () => [ | |||
| { | |||
| @@ -329,7 +374,13 @@ const InvoiceSearch: React.FC<Props> & SubComponents = ({ invoices, projects, ab | |||
| headerName: t("Amount (HKD)"), | |||
| editable: true, | |||
| flex: 0.5, | |||
| type: 'number' | |||
| type: 'number', | |||
| renderEditCell: (params: GridRenderEditCellParams) => { | |||
| return <CustomMoneyComponent params={params} /> | |||
| }, | |||
| valueFormatter: (params: GridValueFormatterParams) => { | |||
| return CustomMoneyFormat(params.value as number) | |||
| } | |||
| }, | |||
| { | |||
| field: "receiptDate", | |||
| @@ -351,7 +402,13 @@ const InvoiceSearch: React.FC<Props> & SubComponents = ({ invoices, projects, ab | |||
| headerName: t("Actual Received Amount (HKD)"), | |||
| editable: true, | |||
| flex: 0.5, | |||
| type: 'number' | |||
| type: 'number', | |||
| renderEditCell: (params: GridRenderEditCellParams) => { | |||
| return <CustomMoneyComponent params={params} /> | |||
| }, | |||
| valueFormatter: (params: GridValueFormatterParams) => { | |||
| return CustomMoneyFormat(params.value as number) | |||
| } | |||
| }, | |||
| ], | |||
| [t] | |||
| @@ -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<Props> = ({ | |||
| leaveRecords, | |||
| isFullTime, | |||
| joinDate, | |||
| isSaturdayWorker | |||
| }) => { | |||
| const { | |||
| t, | |||
| @@ -190,7 +192,8 @@ const LeaveCalendar: React.FC<Props> = ({ | |||
| ({ 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<Props> = ({ | |||
| (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<Props> = ({ | |||
| } | |||
| 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] || []; | |||
| @@ -25,6 +25,7 @@ const modalSx: SxProps = { | |||
| interface Props extends LeaveCalendarProps { | |||
| open: boolean; | |||
| onClose: () => void; | |||
| isSaturdayWorker: boolean | |||
| } | |||
| const LeaveModal: React.FC<Props> = ({ | |||
| @@ -37,6 +38,7 @@ const LeaveModal: React.FC<Props> = ({ | |||
| timesheetRecords, | |||
| isFullTime, | |||
| joinDate, | |||
| isSaturdayWorker | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const isMobile = useIsMobile(); | |||
| @@ -51,6 +53,7 @@ const LeaveModal: React.FC<Props> = ({ | |||
| allProjects={allProjects} | |||
| leaveRecords={leaveRecords} | |||
| timesheetRecords={timesheetRecords} | |||
| isSaturdayWorker={isSaturdayWorker} | |||
| /> | |||
| ); | |||
| @@ -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<Props> = ({ | |||
| [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<Column<ProjectResult>[]>( | |||
| () => [ | |||
| { | |||
| @@ -165,7 +192,32 @@ const ProjectSearch: React.FC<Props> = ({ | |||
| { 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: <PlayCircleOutlineIcon />, | |||
| disabled: !abilities.includes(MAINTAIN_PROJECT), | |||
| disabledRows: { | |||
| status: ignoreStatusList | |||
| } | |||
| }, | |||
| ], | |||
| [t, onProjectClick], | |||
| ); | |||
| @@ -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<T extends ResultWithId> { | |||
| name: keyof T; | |||
| label: string; | |||
| color?: IconButtonOwnProps["color"]; | |||
| needTranslation?: boolean; | |||
| type?: string; | |||
| isHidden?: boolean; | |||
| @@ -34,13 +34,25 @@ interface BaseColumn<T extends ResultWithId> { | |||
| interface ColumnWithAction<T extends ResultWithId> extends BaseColumn<T> { | |||
| 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<T extends ResultWithId> extends BaseColumn<T> { | |||
| // 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<T extends ResultWithId> = | |||
| | BaseColumn<T> | |||
| | ColumnWithAction<T>; | |||
| | ColumnWithAction<T> | |||
| | LinkColumn<T> | |||
| ; | |||
| interface Props<T extends ResultWithId> { | |||
| items: T[]; | |||
| @@ -55,6 +67,12 @@ function isActionColumn<T extends ResultWithId>( | |||
| return Boolean((column as ColumnWithAction<T>).onClick); | |||
| } | |||
| function isLinkColumn<T extends ResultWithId>( | |||
| column: Column<T>, | |||
| ): column is LinkColumn<T> { | |||
| return column.type === "link"; | |||
| } | |||
| function SearchResults<T extends ResultWithId>({ | |||
| items, | |||
| columns, | |||
| @@ -101,6 +119,43 @@ function SearchResults<T extends ResultWithId>({ | |||
| return false; | |||
| }; | |||
| function convertObjectKeysToLowercase<T extends object>(obj: T): object | undefined { | |||
| return obj ? Object.fromEntries( | |||
| Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]) | |||
| ) : undefined; | |||
| } | |||
| // Link Component Functions | |||
| function handleLinkColors<T extends ResultWithId>( | |||
| column: LinkColumn<T>, | |||
| 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<T extends ResultWithId>( | |||
| column: LinkColumn<T>, | |||
| 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 = ( | |||
| <> | |||
| <TableContainer sx={{ maxHeight: 440 }}> | |||
| @@ -125,15 +180,15 @@ function SearchResults<T extends ResultWithId>({ | |||
| return ( | |||
| <TableCell key={`${columnName.toString()}-${idx}`}> | |||
| {isActionColumn(column) ? ( | |||
| <IconButton | |||
| color={column.color ?? "primary"} | |||
| onClick={() => column.onClick(item)} | |||
| disabled={Boolean(column.disabled) || Boolean(disabledRows(column, item))} | |||
| > | |||
| {column.buttonIcon} | |||
| </IconButton> | |||
| ) : | |||
| {isActionColumn(column) && !column?.type ? ( | |||
| <IconButton | |||
| color={column.color ?? "primary"} | |||
| onClick={() => column.onClick(item)} | |||
| disabled={Boolean(column.disabled) || Boolean(disabledRows(column, item))} | |||
| > | |||
| {column.buttonIcon} | |||
| </IconButton> | |||
| ) : | |||
| column?.type === "date" ? ( | |||
| <>{convertDateArrayToString(item[columnName] as number[])}</> | |||
| ) : | |||
| @@ -143,9 +198,18 @@ function SearchResults<T extends ResultWithId>({ | |||
| column?.type === "checkbox" ? ( | |||
| Boolean(item[columnName]) ? | |||
| <DoneIcon color="primary" /> : <CloseIcon color="error"/> | |||
| ) : | |||
| isLinkColumn(column) ? ( | |||
| <Link | |||
| onClick={() => column.onClick(item)} | |||
| underline={handleLinkUnderlines(column, item[columnName])} | |||
| color={handleLinkColors(column, item[columnName])} | |||
| > | |||
| {item[columnName] as string} | |||
| </Link> | |||
| ) : | |||
| ( | |||
| <>{column?.needTranslation ? t(item[columnName] as string) : item[columnName]}</> | |||
| <>{column?.needTranslation ? t(item[columnName] as string) : item[columnName] as string}</> | |||
| )} | |||
| </TableCell> | |||
| ); | |||
| @@ -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<Props> = ({ | |||
| isFullTime, | |||
| joinDate, | |||
| miscTasks, | |||
| isSaturdayWorker, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| @@ -227,6 +229,7 @@ const TimeLeaveModal: React.FC<Props> = ({ | |||
| leaveTypes, | |||
| miscTasks, | |||
| }} | |||
| isSaturdayWorker={isSaturdayWorker} | |||
| /> | |||
| </Box> | |||
| {errorComponent} | |||
| @@ -512,4 +512,4 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
| ); | |||
| }; | |||
| export default TimesheetAmendment; | |||
| export default TimesheetAmendment; | |||
| @@ -82,4 +82,4 @@ export const TimesheetAmendmentModal: React.FC<Props> = ({ | |||
| </Card> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| }; | |||
| @@ -248,4 +248,4 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| ); | |||
| }; | |||
| export default UserWorkspacePage; | |||
| export default UserWorkspacePage; | |||
| @@ -89,4 +89,4 @@ const UserWorkspaceWrapper: React.FC = async () => { | |||
| ); | |||
| }; | |||
| export default UserWorkspaceWrapper; | |||
| export default UserWorkspaceWrapper; | |||
| @@ -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 | |||
| @@ -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", | |||
| @@ -16,7 +16,8 @@ | |||
| "Submit Fail": "提交失敗", | |||
| "Do you want to delete?": "你是否確認要刪除?", | |||
| "Delete Success": "刪除成功", | |||
| "Save Success": "儲存成功", | |||
| "Date": "日期", | |||
| "Month": "月份", | |||
| @@ -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 = [ | |||