From e31bb7c487ea4fe3d3c2a8aa7dc89be1119e1628 Mon Sep 17 00:00:00 2001 From: pramod mahajan Date: Thu, 31 Jul 2025 16:02:15 +0530 Subject: [PATCH] added search filter and group by --- .../Expenses/ExpenseFilterPanel.jsx | 269 ++++++++++-------- src/components/Expenses/ExpenseList.jsx | 11 +- src/components/Expenses/ExpenseSchema.js | 144 ++++++---- src/components/Expenses/ExpenseSkeleton.jsx | 59 ++++ src/components/Expenses/ViewExpense.jsx | 159 ++++++++--- src/components/common/DateRangePicker.jsx | 66 +++-- src/hooks/useExpense.js | 47 ++- src/pages/Expense/ExpensePage.jsx | 49 ++-- src/repositories/ExpsenseRepository.jsx | 7 +- src/utils/appUtils.js | 13 + 10 files changed, 562 insertions(+), 262 deletions(-) diff --git a/src/components/Expenses/ExpenseFilterPanel.jsx b/src/components/Expenses/ExpenseFilterPanel.jsx index 4c47527c..bfe67a95 100644 --- a/src/components/Expenses/ExpenseFilterPanel.jsx +++ b/src/components/Expenses/ExpenseFilterPanel.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState,useMemo } from "react"; import { FormProvider, useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { defaultFilter, SearchSchema } from "./ExpenseSchema"; @@ -11,153 +11,186 @@ import { useExpenseStatus } from "../../hooks/masterHook/useMaster"; import { useEmployeesAllOrByProjectId } from "../../hooks/useEmployees"; import { useSelector } from "react-redux"; import moment from "moment"; +import { useExpenseFilter } from "../../hooks/useExpense"; +import { ExpenseFilterSkeleton } from "./ExpenseSkeleton"; -const ExpenseFilterPanel = ({ onApply }) => { - const selectedProjectId = useSelector( - (store) => store.localVariables.projectId - ); - const { projectNames, loading: projectLoading } = useProjectName(); - const { ExpenseStatus = [] } = useExpenseStatus(); - const { employees, loading: empLoading } = useEmployeesAllOrByProjectId( - true, - selectedProjectId, - true - ); + +const ExpenseFilterPanel = ({ onApply, handleGroupBy }) => { + const selectedProjectId = useSelector((store) => store.localVariables.projectId); + const { data, isLoading } = useExpenseFilter(); + + const groupByList = useMemo(() => [ + { id: "transactionDate", name: "Transaction Date" }, + { id: "status", name: "Status" }, + { id: "paidBy", name: "Paid By" }, + { id: "project", name: "Project" }, + { id: "paymentMode", name: "Payment Mode" }, + { id: "expensesType", name: "Expense Type" }, + {id: "createdAt",name:"Submitted"} + ], []); + + const [selectedGroup, setSelectedGroup] = useState(groupByList[0]); + const [resetKey, setResetKey] = useState(0); const methods = useForm({ resolver: zodResolver(SearchSchema), defaultValues: defaultFilter, }); - const { control, handleSubmit, setValue, reset } = methods; - - const isValidDate = (date) => date instanceof Date && !isNaN(date); - const setDateRange = ({ startDate, endDate }) => { - const parsedStart = new Date(startDate); - const parsedEnd = new Date(endDate); - setValue( - "startDate", - isValidDate(parsedStart) ? parsedStart.toISOString().split("T")[0] : null - ); - setValue( - "endDate", - isValidDate(parsedEnd) ? parsedEnd.toISOString().split("T")[0] : null - ); - }; + const { control, register, handleSubmit, reset, watch } = methods; + const isTransactionDate = watch("isTransactionDate"); const closePanel = () => { document.querySelector(".offcanvas.show .btn-close")?.click(); }; - const onSubmit = (data) => { + const handleGroupChange = (e) => { + const group = groupByList.find((g) => g.id === e.target.value); + if (group) setSelectedGroup(group); + }; + + const onSubmit = (formData) => { onApply({ - ...data, - startDate: moment.utc(data.startDate, "DD-MM-YYYY").toISOString(), - endDate: moment.utc(data.endDate, "DD-MM-YYYY").toISOString(), + ...formData, + startDate: moment.utc(formData.startDate, "DD-MM-YYYY").toISOString(), + endDate: moment.utc(formData.endDate, "DD-MM-YYYY").toISOString(), }); + handleGroupBy(selectedGroup.id); closePanel(); }; const onClear = () => { reset(defaultFilter); + setResetKey((prev) => prev + 1); + setSelectedGroup(groupByList[0]); onApply(defaultFilter); + handleGroupBy(groupByList[0].id); closePanel(); }; + if (isLoading) return ; + return ( - -
-
- - {/* */} + <> +
+ + +
- -
- -
- - `${item.firstName} ${item.lastName}`} - valueKey="id" - IsLoading={empLoading} - /> - `${item.firstName} ${item.lastName}`} - valueKey="id" - IsLoading={empLoading} - /> - -
- -
- {ExpenseStatus.map((status) => ( - ( -
- { - const checked = e.target.checked; - onChange( - checked - ? [...value, status.id] - : value.filter((v) => v !== status.id) - ); - }} - /> - -
- )} + + +
+
+ +
+ - ))} +
+ +
+ + +
+ +
+ + item.name} + valueKey="id" + /> + item.name} + valueKey="id" + /> + +
+ +
+ {data?.status + ?.slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map((status) => ( +
+ ( +
+ { + const checked = e.target.checked; + onChange( + checked + ? [...value, status.id] + : value.filter((v) => v !== status.id) + ); + }} + /> + +
+ )} + /> +
+ ))} +
-
-
- - -
- - +
+ + +
+ + + ); }; -export default ExpenseFilterPanel; +export default ExpenseFilterPanel; \ No newline at end of file diff --git a/src/components/Expenses/ExpenseList.jsx b/src/components/Expenses/ExpenseList.jsx index c44be107..ea9f41c6 100644 --- a/src/components/Expenses/ExpenseList.jsx +++ b/src/components/Expenses/ExpenseList.jsx @@ -5,13 +5,13 @@ import { useExpenseContext } from "../../pages/Expense/ExpensePage"; import { formatDate, formatUTCToLocalTime } from "../../utils/dateUtils"; import Pagination from "../common/Pagination"; import { APPROVE_EXPENSE } from "../../utils/constants"; -import { getColorNameFromHex } from "../../utils/appUtils"; +import { getColorNameFromHex, useDebounce } from "../../utils/appUtils"; import { ExpenseTableSkeleton } from "./ExpenseSkeleton"; import ConfirmModal from "../common/ConfirmModal"; import { useHasUserPermission } from "../../hooks/useHasUserPermission"; import { useSelector } from "react-redux"; -const ExpenseList = ({ filters, groupBy = "transactionDate" }) => { +const ExpenseList = ({ filters, groupBy = "transactionDate",searchText }) => { const [deletingId, setDeletingId] = useState(null); const [IsDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { setViewExpense, setManageExpenseModal } = useExpenseContext(); @@ -19,12 +19,14 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => { const IsExpesneApprpve = useHasUserPermission(APPROVE_EXPENSE); const [currentPage, setCurrentPage] = useState(1); const pageSize = 20; + const debouncedSearch = useDebounce(searchText, 500); const { mutate: DeleteExpense, isPending } = useDeleteExpense(); const { data, isLoading, isError, isInitialLoading, error } = useExpenseList( pageSize, currentPage, - filters + filters, + debouncedSearch ); const SelfId = useSelector( @@ -74,6 +76,9 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => { case "expensesType": key = item.expensesType?.name || "Unknown Type"; break; + case "createdAt": + key = item.createdAt?.split("T")[0] || "Unknown Type"; + break; default: key = "Others"; } diff --git a/src/components/Expenses/ExpenseSchema.js b/src/components/Expenses/ExpenseSchema.js index 901239ad..c74bcd38 100644 --- a/src/components/Expenses/ExpenseSchema.js +++ b/src/components/Expenses/ExpenseSchema.js @@ -8,69 +8,80 @@ const ALLOWED_TYPES = [ "image/jpeg", ]; - export const ExpenseSchema = (expenseTypes) => { return z .object({ projectId: z.string().min(1, { message: "Project is required" }), - expensesTypeId: z.string().min(1, { message: "Expense type is required" }), + expensesTypeId: z + .string() + .min(1, { message: "Expense type is required" }), paymentModeId: z.string().min(1, { message: "Payment mode is required" }), paidById: z.string().min(1, { message: "Employee name is required" }), transactionDate: z - .string() - .min(1, { message: "Date is required" }) - .refine((val) => { - const selected = new Date(val); - const today = new Date(); + .string() + .min(1, { message: "Date is required" }) + .refine( + (val) => { + const selected = new Date(val); + const today = new Date(); - // Set both to midnight to avoid time-related issues - selected.setHours(0, 0, 0, 0); - today.setHours(0, 0, 0, 0); + // Set both to midnight to avoid time-related issues + selected.setHours(0, 0, 0, 0); + today.setHours(0, 0, 0, 0); - return selected <= today; - }, { message: "Future dates are not allowed" }), + return selected <= today; + }, + { message: "Future dates are not allowed" } + ), transactionId: z.string().optional(), description: z.string().min(1, { message: "Description is required" }), location: z.string().min(1, { message: "Location is required" }), supplerName: z.string().min(1, { message: "Supplier name is required" }), - amount: z - .coerce - .number({ invalid_type_error: "Amount is required and must be a number" }) + amount: z.coerce + .number({ + invalid_type_error: "Amount is required and must be a number", + }) .min(1, "Amount must be Enter") .refine((val) => /^\d+(\.\d{1,2})?$/.test(val.toString()), { message: "Amount must have at most 2 decimal places", }), - noOfPersons: z.coerce - .number() - .optional(), + noOfPersons: z.coerce.number().optional(), billAttachments: z .array( z.object({ fileName: z.string().min(1, { message: "Filename is required" }), base64Data: z.string().nullable(), - contentType: z.string().refine((val) => ALLOWED_TYPES.includes(val), { - message: "Only PDF, PNG, JPG, or JPEG files are allowed", - }), - documentId:z.string().optional(), + contentType: z + .string() + .refine((val) => ALLOWED_TYPES.includes(val), { + message: "Only PDF, PNG, JPG, or JPEG files are allowed", + }), + documentId: z.string().optional(), fileSize: z.number().max(MAX_FILE_SIZE, { message: "File size must be less than or equal to 5MB", }), description: z.string().optional(), - isActive:z.boolean().default(true) + isActive: z.boolean().default(true), }) ) .nonempty({ message: "At least one file attachment is required" }), + reimburseTransactionId: z.string().optional(), + reimburseDate: z.string().optional(), + reimburseById: z.string().optional(), + }) .refine( (data) => { - return !data.projectId || (data.paidById && data.paidById.trim() !== ""); + return ( + !data.projectId || (data.paidById && data.paidById.trim() !== "") + ); }, { message: "Please select who paid (employee)", path: ["paidById"], } ) - .superRefine((data, ctx) => { + .superRefine((data, ctx) => { const expenseType = expenseTypes.find((et) => et.id === data.expensesTypeId); if (expenseType?.noOfPersonsRequired && (!data.noOfPersons || data.noOfPersons < 1)) { ctx.addIssue({ @@ -79,45 +90,70 @@ export const ExpenseSchema = (expenseTypes) => { path: ["noOfPersons"], }); } + + if (isEndProcess) { + if (!data.reimburseTransactionId || data.reimburseTransactionId.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reimburse Transaction ID is required", + path: ["reimburseTransactionId"], + }); + } + if (!data.reimburseDate) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reimburse Date is required", + path: ["reimburseDate"], + }); + } + if (!data.reimburseById) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Reimburse By is required", + path: ["reimburseById"], + }); + } + } }); }; export const defaultExpense = { - projectId: "", - expensesTypeId: "", - paymentModeId: "", - paidById: "", - transactionDate: "", - transactionId: "", - description: "", - location: "", - supplerName: "", - amount: "", - noOfPersons: "", - billAttachments: [], - } + projectId: "", + expensesTypeId: "", + paymentModeId: "", + paidById: "", + transactionDate: "", + transactionId: "", + description: "", + location: "", + supplerName: "", + amount: "", + noOfPersons: "", + billAttachments: [], +}; export const ActionSchema = z.object({ - comment : z.string().min(1,{message:"Please leave comment"}), - selectedStatus: z.string().min(1, { message: "Please select a status" }), -}) + comment: z.string().min(1, { message: "Please leave comment" }), + selectedStatus: z.string().min(1, { message: "Please select a status" }), +}); - -export const SearchSchema = z.object({ - projectIds: z.array(z.string()).optional(), +export const SearchSchema = z.object({ + projectIds: z.array(z.string()).optional(), statusIds: z.array(z.string()).optional(), createdByIds: z.array(z.string()).optional(), paidById: z.array(z.string()).optional(), -startDate: z.string().optional(), -endDate: z.string().optional(), - + startDate: z.string().optional(), + endDate: z.string().optional(), + isTransactionDate: z.boolean().default(true), }); export const defaultFilter = { - projectIds:[], - statusIds:[], - createdByIds:[], - paidById:[], - startDate:null, - endDate:null -} \ No newline at end of file + projectIds: [], + statusIds: [], + createdByIds: [], + paidById: [], + isTransactionDate: true, + startDate: null, + endDate: null, +}; + diff --git a/src/components/Expenses/ExpenseSkeleton.jsx b/src/components/Expenses/ExpenseSkeleton.jsx index e4a3f208..8f73db5c 100644 --- a/src/components/Expenses/ExpenseSkeleton.jsx +++ b/src/components/Expenses/ExpenseSkeleton.jsx @@ -222,3 +222,62 @@ export const ExpenseTableSkeleton = ({ groups = 3, rowsPerGroup = 3 }) => {
); }; + + +export const ExpenseFilterSkeleton = () => { + return ( +
+ {/* Created Date Label and Skeleton */} +
+ + +
+ +
+ {/* Project Select */} +
+ + +
+ + {/* Submitted By Select */} +
+ + +
+ + {/* Paid By Select */} +
+ + +
+ + {/* Status Checkboxes */} +
+ +
+ {[...Array(3)].map((_, i) => ( +
+
+ +
+ ))} +
+
+
+ + {/* Buttons */} +
+ + +
+
+ ); +}; diff --git a/src/components/Expenses/ViewExpense.jsx b/src/components/Expenses/ViewExpense.jsx index 6aafea03..2e37c066 100644 --- a/src/components/Expenses/ViewExpense.jsx +++ b/src/components/Expenses/ViewExpense.jsx @@ -16,6 +16,7 @@ import { REVIEW_EXPENSE } from "../../utils/constants"; import { useProfile } from "../../hooks/useProfile"; import { useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; +import Avatar from "../common/Avatar"; const ViewExpense = ({ ExpenseId }) => { const { data, isLoading, isError, error } = useExpense(ExpenseId); @@ -126,26 +127,32 @@ const ViewExpense = ({ ExpenseId }) => {
-
-
- -
- {data.paidBy.firstName} {data.paidBy.lastName} +
+
+
+ +
+ {data.paidBy.firstName} {data.paidBy.lastName} +
-
-
- - - {data?.status?.displayName} - +
+
+ + + {data?.status?.displayName} + +
+
@@ -157,7 +164,7 @@ const ViewExpense = ({ ExpenseId }) => {
-
+
-
-
- -
- {data?.createdBy?.firstName} {data?.createdBy?.lastName} +
+
+
+ +
+ {formatUTCToLocalTime(data?.createdAt, true)} +
+ + {data.createdBy && ( +
+
+ +
+ + + {`${data.createdBy?.firstName ?? ""} ${ + data.createdBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
+
+ )}
-
-
- -
- {formatUTCToLocalTime(data?.createdAt, true)} + {data.reviewedBy && ( +
+
+ + +
+ + + {`${data.reviewedBy?.firstName ?? ""} ${ + data.reviewedBy?.lastName ?? "" + }`.trim() || "N/A"} + +
-
+ )} + {data.approvedBy && ( +
+
+ + +
+ + + {`${data.approvedBy?.firstName ?? ""} ${ + data.approvedBy?.lastName ?? "" + }`.trim() || "N/A"} + +
+
+
+ )}
@@ -208,7 +276,7 @@ const ViewExpense = ({ ExpenseId }) => { if (type.includes("zip") || type.includes("rar")) return "bxs-file-archive"; - return "bx bx-file"; // Default + return "bx bx-file"; }; const isImage = doc.contentType?.includes("image"); @@ -258,15 +326,20 @@ const ViewExpense = ({ ExpenseId }) => { {Array.isArray(data?.nextStatus) && data.nextStatus.length > 0 && (
- - -