| @@ -12,6 +12,9 @@ export interface ClaimInputFormByStaff { | |||
| status: string; | |||
| addClaimDetails: ClaimDetailTable[] | |||
| // is grid editing | |||
| isGridEditing: boolean | null; | |||
| } | |||
| export interface ClaimDetailTable { | |||
| @@ -5,9 +5,10 @@ import "server-only"; | |||
| export interface Claim { | |||
| id: number; | |||
| created: string; | |||
| name: string; | |||
| cost: number; | |||
| code: string; | |||
| created: number[]; | |||
| project: ProjectCombo; | |||
| amount: number; | |||
| type: "Expense" | "Petty Cash"; | |||
| status: "Not Submitted" | "Waiting for Approval" | "Approved" | "Rejected"; | |||
| remarks: string; | |||
| @@ -41,8 +42,8 @@ export const preloadClaims = () => { | |||
| }; | |||
| export const fetchClaims = cache(async () => { | |||
| return mockClaims; | |||
| // return serverFetchJson<Claim[]>(`${BASE_API_URL}/claim`); | |||
| // return mockClaims; | |||
| return serverFetchJson<Claim[]>(`${BASE_API_URL}/claim`); | |||
| }); | |||
| export const fetchProjectCombo = cache(async () => { | |||
| @@ -54,7 +55,7 @@ export const fetchProjectCombo = cache(async () => { | |||
| // export const fetchAllCustomers = cache(async () => { | |||
| // return serverFetchJson<Customer[]>(`${BASE_API_URL}/customer`); | |||
| // }); | |||
| /* | |||
| const mockClaims: Claim[] = [ | |||
| { | |||
| id: 1, | |||
| @@ -83,4 +84,4 @@ const mockClaims: Claim[] = [ | |||
| status: "Rejected", | |||
| remarks: "Duplicate Claim Form", | |||
| }, | |||
| ]; | |||
| ];*/ | |||
| @@ -23,6 +23,15 @@ export const convertDateToString = (date: Date, format: string = OUTPUT_DATE_FOR | |||
| return dayjs(date).format(format) | |||
| } | |||
| export const convertDateArrayToString = (dateArray: number[], format: string = OUTPUT_DATE_FORMAT, needTime: boolean = false) => { | |||
| if (dateArray.length === 6) { | |||
| if (!needTime) { | |||
| const dateString = `${dateArray[0]}-${dateArray[1]}-${dateArray[2]}` | |||
| return dayjs(dateString).format(format) | |||
| } | |||
| } | |||
| } | |||
| const shortDateFormatter_en = new Intl.DateTimeFormat("en-HK", { | |||
| weekday: "short", | |||
| year: "numeric", | |||
| @@ -15,6 +15,7 @@ import { ClaimInputFormByStaff, saveClaim } from "@/app/api/claims/actions"; | |||
| import { DoneAll } from "@mui/icons-material"; | |||
| import { expenseTypeCombo } from "@/app/utils/comboUtil"; | |||
| import { convertDateToString } from "@/app/utils/formatUtil"; | |||
| import { errorDialog, submitDialog, successDialog, warningDialog } from "../Swal/CustomAlerts"; | |||
| export interface Props { | |||
| projectCombo: ProjectCombo[] | |||
| @@ -40,17 +41,49 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||
| const onSubmit = useCallback<SubmitHandler<ClaimInputFormByStaff>>( | |||
| async (data, event) => { | |||
| try { | |||
| console.log(data.addClaimDetails[0].newSupportingDocument); | |||
| console.log((event?.nativeEvent as any).submitter.name); | |||
| if (data.isGridEditing) { | |||
| warningDialog(t("Please save all the rows before submitting"), t) | |||
| return false | |||
| } | |||
| let haveError = false | |||
| if (data.addClaimDetails.length === 0 || data.addClaimDetails.filter(row => String(row.description).trim().length === 0 || String(row.amount).trim().length === 0 || row.project === null || row.project === undefined || ((row.oldSupportingDocument === null || row.oldSupportingDocument === undefined) && (row.newSupportingDocument === null || row.newSupportingDocument === undefined))).length > 0) { | |||
| haveError = true | |||
| formProps.setError("addClaimDetails", { message: "Claim details include empty fields", type: "required" }) | |||
| } | |||
| if (data.addClaimDetails.length > 0 && data.addClaimDetails.filter(row => row.invoiceDate.getTime() > new Date().getTime()).length > 0) { | |||
| haveError = true | |||
| formProps.setError("addClaimDetails", { message: "Claim details include invalid invoice date", type: "invalid_date" }) | |||
| } | |||
| if (data.addClaimDetails.length > 0 && data.addClaimDetails.filter(row => row.project === null || row.project === undefined).length > 0) { | |||
| haveError = true | |||
| formProps.setError("addClaimDetails", { message: "Claim details include empty project", type: "invalid_project" }) | |||
| } | |||
| if (data.addClaimDetails.length > 0 && data.addClaimDetails.filter(row => row.amount <= 0).length > 0) { | |||
| haveError = true | |||
| formProps.setError("addClaimDetails", { message: "Claim details include invalid amount", type: "invalid_amount" }) | |||
| } | |||
| if (haveError) { | |||
| return false | |||
| } | |||
| const buttonName = (event?.nativeEvent as any).submitter.name | |||
| console.log(JSON.stringify(data)) | |||
| const formData = new FormData() | |||
| // formData.append("expenseType", data.expenseType) | |||
| formData.append("expenseType", data.expenseType) | |||
| data.addClaimDetails.forEach((claimDetail) => { | |||
| formData.append("addClaimDetailIds", JSON.stringify(claimDetail.id)) | |||
| formData.append("addClaimDetailInvoiceDates", convertDateToString(claimDetail.invoiceDate, "YYYY-MM-DD")) | |||
| formData.append("addClaimDetailProjectIds", JSON.stringify(claimDetail.project.id)) | |||
| formData.append("addClaimDetailDescriptions", claimDetail.description) | |||
| formData.append("addClaimDetailAmounts", JSON.stringify(claimDetail.amount)) | |||
| formData.append("addClaimDetailNewSupportingDocuments", claimDetail.newSupportingDocument) | |||
| formData.append("addClaimDetailOldSupportingDocumentIds", JSON.stringify(claimDetail?.oldSupportingDocument?.id ?? -1)) | |||
| }) | |||
| // for (let i = 0; i < data.addClaimDetails.length; i++) { | |||
| // // formData.append("addClaimDetails[]", JSON.stringify(data.addClaimDetails[i])) | |||
| // // formData.append("addClaimDetailsFiles[]", data.addClaimDetails[i].newSupportingDocument) | |||
| // console.log(data.addClaimDetails[i].invoiceDate) | |||
| // // data.addClaimDetails[i].invoiceDate = convertDateToString(data.addClaimDetails[i].invoiceDate) | |||
| // const updatedData = { | |||
| // id: data.addClaimDetails[i].id, | |||
| // // project: data.addClaimDetails[i].project, | |||
| @@ -63,22 +96,24 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||
| // formData.append("addClaimDetailsFiles", data.addClaimDetails[i].newSupportingDocument) | |||
| // formData.append("testFiles", data.addClaimDetails[i].newSupportingDocument) | |||
| // } | |||
| // if (buttonName === "submit") { | |||
| // formData.append("status", "Not Submitted") | |||
| // } else if (buttonName === "save") { | |||
| // formData.append("status", "Waiting for Approval") | |||
| // } | |||
| // formData.append("id", "-1") | |||
| if (buttonName === "submit") { | |||
| formData.append("status", "Waiting for Approval") | |||
| } else if (buttonName === "save") { | |||
| formData.append("status", "Not Submitted") | |||
| } | |||
| formData.append("id", "-1") | |||
| // for (let i = 0; i < data.addClaimDetails.length; i++) { | |||
| // // const formData = new FormData(); | |||
| // // formData.append("newSupportingDocument", data.addClaimDetails[i].oldSupportingDocument); | |||
| // data.addClaimDetails[i].oldSupportingDocument = new Blob([data.addClaimDetails[i].oldSupportingDocument], {type: data.addClaimDetails[i].oldSupportingDocument.type}) | |||
| // } | |||
| await saveClaim(formData) | |||
| setServerError(""); | |||
| // await saveProject(data); | |||
| // router.replace("/projects"); | |||
| submitDialog(async () => { | |||
| const response = await saveClaim(formData); | |||
| if (response.message === "Success") { | |||
| successDialog(t("Submit Success"), t).then(() => { | |||
| router.replace("/staffReimbursement"); | |||
| }) | |||
| } | |||
| }, t) | |||
| } catch (e) { | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| } | |||
| @@ -107,10 +142,10 @@ const ClaimDetail: React.FC<Props> = ({ projectCombo }) => { | |||
| <Button variant="text" startIcon={<Close />} onClick={handleCancel}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="outlined" name="save" startIcon={<Check />} type="submit"> | |||
| <Button variant="outlined" name="save" startIcon={<Check />} type="submit" disabled={Boolean(formProps.watch("isGridEditing"))}> | |||
| {t("Save")} | |||
| </Button> | |||
| <Button variant="contained" name="submit" startIcon={<DoneAll />} type="submit"> | |||
| <Button variant="contained" name="submit" startIcon={<DoneAll />} type="submit" disabled={Boolean(formProps.watch("isGridEditing"))}> | |||
| {t("Submit")} | |||
| </Button> | |||
| </Stack> | |||
| @@ -157,7 +157,7 @@ const ClaimFormInputGrid: React.FC<ClaimFormInputGridProps> = ({ | |||
| projectCombo, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const { control, setValue, getValues, formState: { errors } } = useFormContext<ClaimInputFormByStaff>(); | |||
| const { control, setValue, getValues, formState: { errors }, clearErrors, setError } = useFormContext<ClaimInputFormByStaff>(); | |||
| const { fields } = useFieldArray({ | |||
| control, | |||
| name: "addClaimDetails" | |||
| @@ -459,6 +459,56 @@ const ClaimFormInputGrid: React.FC<ClaimFormInputGridProps> = ({ | |||
| }, | |||
| ], [rows, rowModesModel, t],); | |||
| // check error | |||
| useEffect(() => { | |||
| if (getValues("addClaimDetails") === undefined || getValues("addClaimDetails") === null) { | |||
| return; | |||
| } | |||
| if (getValues("addClaimDetails").length === 0) { | |||
| clearErrors("addClaimDetails") | |||
| } else { | |||
| console.log(rows) | |||
| if (rows.filter(row => String(row.description).trim().length === 0 || String(row.amount).trim().length === 0 || row.project === null || row.project === undefined || ((row.oldSupportingDocument === null || row.oldSupportingDocument === undefined) && (row.newSupportingDocument === null || row.newSupportingDocument === undefined))).length > 0) { | |||
| setError("addClaimDetails", { message: "Claim details include empty fields", type: "required" }) | |||
| } else { | |||
| let haveError = false | |||
| if (rows.filter(row => row.invoiceDate.getTime() > new Date().getTime()).length > 0) { | |||
| haveError = true | |||
| setError("addClaimDetails", { message: "Claim details include invalid invoice date", type: "invalid_date" }) | |||
| } | |||
| if (rows.filter(row => row.project === null || row.project === undefined).length > 0) { | |||
| haveError = true | |||
| setError("addClaimDetails", { message: "Claim details include empty project", type: "invalid_project" }) | |||
| } | |||
| if (rows.filter(row => row.amount <= 0).length > 0) { | |||
| haveError = true | |||
| setError("addClaimDetails", { message: "Claim details include invalid amount", type: "invalid_amount" }) | |||
| } | |||
| if (!haveError) { | |||
| clearErrors("addClaimDetails") | |||
| } | |||
| } | |||
| } | |||
| }, [rows, rowModesModel]) | |||
| // check editing | |||
| useEffect(() => { | |||
| const filteredByKey = Object.fromEntries( | |||
| Object.entries(rowModesModel).filter(([key, value]) => rowModesModel[key].mode === 'edit')) | |||
| if (Object.keys(filteredByKey).length > 0) { | |||
| setValue("isGridEditing", true) | |||
| } else { | |||
| setValue("isGridEditing", false) | |||
| } | |||
| }, [rowModesModel]) | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| @@ -482,11 +532,17 @@ const ClaimFormInputGrid: React.FC<ClaimFormInputGridProps> = ({ | |||
| }, | |||
| }} | |||
| > | |||
| {Boolean(errors.addClaimDetails?.type === "required") && <Typography sx={(theme) => ({ color: theme.palette.error.main })} variant="overline" display='inline-block' noWrap> | |||
| {Boolean(errors.addClaimDetails?.type === "required") && <Typography sx={(theme) => ({ color: theme.palette.error.main, ml: 3, mt: 1 })} variant="overline" display='inline-block' noWrap> | |||
| {t("Please ensure at least one row is created, and all the fields are inputted and saved")} | |||
| </Typography>} | |||
| {Boolean(errors.addClaimDetails?.type === "format") && <Typography sx={(theme) => ({ color: theme.palette.error.main })} variant="overline" display='inline-block' noWrap> | |||
| {t("Please ensure the date formats are correct")} | |||
| {Boolean(errors.addClaimDetails?.type === "invalid_date") && <Typography sx={(theme) => ({ color: theme.palette.error.main, ml: 3, mt: 1 })} variant="overline" display='inline-block' noWrap> | |||
| {t("Please ensure the date are correct")} | |||
| </Typography>} | |||
| {Boolean(errors.addClaimDetails?.type === "invalid_project") && <Typography sx={(theme) => ({ color: theme.palette.error.main, ml: 3, mt: 1 })} variant="overline" display='inline-block' noWrap> | |||
| {t("Please ensure the projects are selected")} | |||
| </Typography>} | |||
| {Boolean(errors.addClaimDetails?.type === "invalid_amount") && <Typography sx={(theme) => ({ color: theme.palette.error.main, ml: 3, mt: 1 })} variant="overline" display='inline-block' noWrap> | |||
| {t("Please ensure the amount are correct")} | |||
| </Typography>} | |||
| <div style={{ height: 400, width: "100%" }}> | |||
| <DataGrid | |||
| @@ -8,6 +8,7 @@ import SearchResults, { Column } from "../SearchResults/index"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| import { dateInRange } from "@/app/utils/commonUtil"; | |||
| import { claimStatusCombo, expenseTypeCombo } from "@/app/utils/comboUtil"; | |||
| import { convertDateArrayToString, convertDateToString } from "@/app/utils/formatUtil"; | |||
| interface Props { | |||
| claims: Claim[]; | |||
| @@ -54,9 +55,10 @@ const ClaimSearch: React.FC<Props> = ({ claims }) => { | |||
| // onClick: onClaimClick, | |||
| // buttonIcon: <EditNote />, | |||
| // }, | |||
| { name: "created", label: t("Creation Date") }, | |||
| { name: "name", label: t("Related Project Name") }, | |||
| { name: "cost", label: t("Amount (HKD)") }, | |||
| { name: "created", label: t("Creation Date"), type: "date" }, | |||
| { name: "code", label: t("Claim Code") }, | |||
| { name: "project", label: t("Related Project Name") }, | |||
| { name: "amount", label: t("Amount (HKD)") }, | |||
| { name: "type", label: t("Expense Type"), needTranslation: true }, | |||
| { name: "status", label: t("Status"), needTranslation: true }, | |||
| { name: "remarks", label: t("Remarks") }, | |||
| @@ -71,13 +73,13 @@ const ClaimSearch: React.FC<Props> = ({ claims }) => { | |||
| onSearch={(query) => { | |||
| setFilteredClaims( | |||
| claims.filter( | |||
| (claim) => | |||
| dateInRange(claim.created, query.created, query.createdTo ?? undefined) && | |||
| claim.name.toLowerCase().includes(query.name.toLowerCase()) && | |||
| (claim.type.toLowerCase().includes(query.type.toLowerCase()) || query.type.toLowerCase() === "all") && | |||
| (claim.status.toLowerCase().includes(query.status.toLowerCase()) || query.status.toLowerCase() === "all") | |||
| (claim) => | |||
| dateInRange(convertDateArrayToString(claim.created, "YYYY-MM-DD")!!, query.created, query.createdTo ?? undefined) && | |||
| // claim.project.name.toLowerCase().includes(query.name.toLowerCase()) && | |||
| (claim.type.toLowerCase().includes(query.type.toLowerCase()) || query.type.toLowerCase() === "all") && | |||
| (claim.status.toLowerCase().includes(query.status.toLowerCase()) || query.status.toLowerCase() === "all") | |||
| ), | |||
| ); | |||
| ); | |||
| }} | |||
| /> | |||
| <SearchResults<Claim> items={filteredClaims} columns={columns} /> | |||
| @@ -475,17 +475,17 @@ const ProjectClientDetails: React.FC<Props> = ({ | |||
| > | |||
| <InputLabel>{t("Client Subsidiary")}</InputLabel> | |||
| <Controller | |||
| rules={{ | |||
| validate: (value) => { | |||
| if ( | |||
| !customerSubsidiaryIds.find( | |||
| (subsidiaryId) => subsidiaryId === value, | |||
| ) | |||
| ) { | |||
| return t("Please choose a valid subsidiary"); | |||
| } else return true; | |||
| }, | |||
| }} | |||
| // rules={{ | |||
| // validate: (value) => { | |||
| // if ( | |||
| // !customerSubsidiaryIds.find( | |||
| // (subsidiaryId) => subsidiaryId === value, | |||
| // ) | |||
| // ) { | |||
| // return t("Please choose a valid subsidiary"); | |||
| // } else return true; | |||
| // }, | |||
| // }} | |||
| defaultValue={customerSubsidiaryIds[0]} | |||
| control={control} | |||
| name="clientSubsidiaryId" | |||
| @@ -14,6 +14,7 @@ import TableRow from "@mui/material/TableRow"; | |||
| import IconButton, { IconButtonOwnProps, IconButtonPropsColorOverrides } from "@mui/material/IconButton"; | |||
| import { t } from "i18next"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { convertDateArrayToString } from "@/app/utils/formatUtil"; | |||
| export interface ResultWithId { | |||
| id: string | number; | |||
| @@ -23,7 +24,8 @@ interface BaseColumn<T extends ResultWithId> { | |||
| name: keyof T; | |||
| label: string; | |||
| color?: IconButtonOwnProps["color"]; | |||
| needTranslation?: boolean | |||
| needTranslation?: boolean; | |||
| type?: string; | |||
| } | |||
| interface ColumnWithAction<T extends ResultWithId> extends BaseColumn<T> { | |||
| @@ -101,9 +103,13 @@ function SearchResults<T extends ResultWithId>({ | |||
| > | |||
| {column.buttonIcon} | |||
| </IconButton> | |||
| ) : ( | |||
| <>{column?.needTranslation ? t(item[columnName] as string) : item[columnName]}</> | |||
| )} | |||
| ) : | |||
| column?.type === "date" ? ( | |||
| <>{convertDateArrayToString(item[columnName] as number[])}</> | |||
| ) : | |||
| ( | |||
| <>{column?.needTranslation ? t(item[columnName] as string) : item[columnName]}</> | |||
| )} | |||
| </TableCell> | |||
| ); | |||
| })} | |||
| @@ -26,6 +26,11 @@ | |||
| "Approved": "Approved", | |||
| "Rejected": "Rejected", | |||
| "Please ensure at least one row is created, and all the fields are inputted and saved": "Please ensure at least one row is created, and all the fields are inputted and saved", | |||
| "Please ensure the date are correct": "Please ensure the date are correct", | |||
| "Please ensure the projects are selected": "Please ensure the projects are selected", | |||
| "Please ensure the amount are correct": "Please ensure the amount are correct", | |||
| "Description": "Description", | |||
| "Actions": "Actions" | |||
| } | |||
| @@ -11,6 +11,12 @@ | |||
| "Approved": "Approved", | |||
| "Rejected": "Rejected", | |||
| "Do you want to submit?": "Do you want to submit?", | |||
| "Submit Success": "Submit Success", | |||
| "Submit Fail": "Submit Fail", | |||
| "Do you want to delete?": "Do you want to delete", | |||
| "Delete Success": "Delete Success", | |||
| "Search": "Search", | |||
| "Search Criteria": "Search Criteria", | |||
| "Cancel": "Cancel", | |||
| @@ -26,6 +26,11 @@ | |||
| "Approved": "已批核", | |||
| "Rejected": "已拒絕", | |||
| "Please ensure at least one row is created, and all the fields are inputted and saved": "請確保已建立至少一行, 及已輸入和儲存所有欄位", | |||
| "Please ensure the date are correct": "請確保所有日期輸入正確", | |||
| "Please ensure the projects are selected": "請確保所有項目欄位已選擇", | |||
| "Please ensure the amount are correct": "請確保所有金額輸入正確", | |||
| "Description": "描述", | |||
| "Actions": "行動" | |||
| } | |||
| @@ -9,6 +9,12 @@ | |||
| "Approved": "已批核", | |||
| "Rejected": "已拒絕", | |||
| "Do you want to submit?": "你是否確認要提交?", | |||
| "Submit Success": "提交成功", | |||
| "Submit Fail": "提交失敗", | |||
| "Do you want to delete?": "你是否確認要刪除?", | |||
| "Delete Success": "刪除成功", | |||
| "Search": "搜尋", | |||
| "Search Criteria": "搜尋條件", | |||
| "Cancel": "取消", | |||