diff --git a/src/components/Expenses/ExpenseList.jsx b/src/components/Expenses/ExpenseList.jsx index 15c238d4..7eb4d28a 100644 --- a/src/components/Expenses/ExpenseList.jsx +++ b/src/components/Expenses/ExpenseList.jsx @@ -10,26 +10,18 @@ import { ExpenseTableSkeleton } from "./ExpenseSkeleton"; import ConfirmModal from "../common/ConfirmModal"; import { useProfile } from "../../hooks/useProfile"; -const ExpenseList = () => { +const ExpenseList = ({filters}) => { const [deletingId, setDeletingId] = useState(null); const [IsDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { setViewExpense, setManageExpenseModal } = useExpenseContext(); const [currentPage, setCurrentPage] = useState(1); const pageSize = 10; - const {profile} = useProfile() -console.log(profile) - const filter = { - projectIds: [], - statusIds: [], - createdByIds: [], - paidById: [], - startDate: null, - endDate: null, - }; + const { profile } = useProfile(); + const { mutate: DeleteExpense, isPending } = useDeleteExpense(); const { data, isLoading, isError, isInitialLoading, error, isFetching } = - useExpenseList(10, currentPage, filter); + useExpenseList(10, currentPage, filters); const handleDelete = (id) => { setDeletingId(id); @@ -37,8 +29,8 @@ console.log(profile) { id }, { onSettled: () => { - setDeletingId(null); - setIsDeleteModalOpen(false) + setDeletingId(null); + setIsDeleteModalOpen(false); }, } ); @@ -55,9 +47,42 @@ console.log(profile) setCurrentPage(page); } }; + const STATUS_ORDER = [ + "Draft", + "Review Pending", + "Approval Pending", + "Process Pending", + "Processed", + "Paid", + "Rejected", + ]; + + const groupExpensesByDateAndStatus = (expenses) => { + const grouped = {}; + + expenses.forEach((expense) => { + const dateKey = expense.transactionDate.split("T")[0]; + if (!grouped[dateKey]) grouped[dateKey] = []; + grouped[dateKey].push(expense); + }); + + const sortedDates = Object.keys(grouped).sort( + (a, b) => new Date(b) - new Date(a) + ); + + return sortedDates.map((date) => ({ + date, + expenses: grouped[date].sort((a, b) => { + return ( + STATUS_ORDER.indexOf(a.status.name) - + STATUS_ORDER.indexOf(b.status.name) + ); + }), + })); + }; + return ( <> - {IsDeleteModalOpen && (
- setIsDeleteModalOpen(false)} loading={isPending} - paramData={deletingId} + paramData={deletingId} />
)} -
-
-
- +
+
-
- - + + {/* - - - - - - - - - - {/* {isLoading && ( - - + */} + + + + + + - )} - - {!isInitialLoading && items.length === 0 && ( - - - - )} */} - - {!isInitialLoading && - items.map((expense) => ( - - - - - - - - + {!isInitialLoading && + groupExpensesByDateAndStatus(items).map( + ({ date, expenses }) => ( + <> + + + {expenses.map((expense) => ( + + + + - - ))} - -
+
Date Time
-
-
Expense Type
-
-
Payment Mode
-
-
Paid By
-
- Amount - - Status - - Action -
- Loading... - +
Expense Type
+
+
Payment Mode
+
+
Paid By
+
+ Amount + + Status + + Action +
- No expenses found. -
-
- - {formatUTCToLocalTime(expense.transactionDate)} - -
-
- {expense.expensesType?.name || "N/A"} - - {expense.paymentMode?.name || "N/A"} - -
- - - {`${expense.paidBy?.firstName ?? ""} ${ - expense.paidBy?.lastName ?? "" - }`.trim() || "N/A"} - -
-
- - {expense?.amount} - - - {expense.status?.displayName || "Unknown"} - - -
- - setViewExpense({ - expenseId: expense.id, - view: true, - }) - } - > - - - {(expense.status.name === 'Draft' || expense.status.name === 'Rejected') && (expense.createdBy.id === profile.employeeInfo.id ) &&( - - setManageExpenseModal({ - IsOpen: true, - expenseId: expense.id, - }) - } - > - - - )} - {expense.status.name == "Draft" && ( - { - setIsDeleteModalOpen(true) - setDeletingId(expense.id) - }} - > - {/* {deletingId === expense.id ? ( -
- - Loading... + +
+ {formatUTCToLocalTime(date)} +
+ {expense.expensesType?.name || "N/A"} + + {expense.paymentMode?.name || "N/A"} + +
+ + + {`${expense.paidBy?.firstName ?? ""} ${ + expense.paidBy?.lastName ?? "" + }`.trim() || "N/A"}
- ) : ( */} - - {/* )} */} - - )} - -
+ + + + {expense?.amount} + + + + {expense.status?.displayName || "Unknown"} + + + +
+ + + setViewExpense({ + expenseId: expense.id, + view: true, + }) + } + > + + + + {(expense.status.name === "Draft" || + expense.status.name === "Rejected") && + expense.createdBy.id === + profile?.employeeInfo.id ? ( + + setManageExpenseModal({ + IsOpen: true, + expenseId: expense.id, + }) + } + > + ) : ( + + )} + + + + {expense.status.name === "Draft" ? ( + { + setIsDeleteModalOpen(true); + setDeletingId(expense.id); + }} + > + ) : ( + + )} + +
+ + + ))} + + ) + )} + + +
+ {!isInitialLoading && items.length > 0 && ( + + )}
- {!isInitialLoading && items.length > 0 && ( - - )}
- ); }; diff --git a/src/components/Expenses/ExpenseSchema.js b/src/components/Expenses/ExpenseSchema.js index 705efe6b..dfa5ffec 100644 --- a/src/components/Expenses/ExpenseSchema.js +++ b/src/components/Expenses/ExpenseSchema.js @@ -88,4 +88,24 @@ export const defaultExpense = { export const ActionSchema = z.object({ comment : z.string().min(1,{message:"Please leave comment"}), selectedStatus: z.string().min(1, { message: "Please select a status" }), -}) \ No newline at end of file +}) + + +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(), + +}); + +export const defaultFilter = { + projectIds:[], + statusIds:[], + createdByIds:[], + paidById:[], + startDate:"", + endDate:"" +} \ No newline at end of file diff --git a/src/components/Expenses/ExpenseSkeleton.jsx b/src/components/Expenses/ExpenseSkeleton.jsx index 904c2e9d..85aa2b1a 100644 --- a/src/components/Expenses/ExpenseSkeleton.jsx +++ b/src/components/Expenses/ExpenseSkeleton.jsx @@ -123,17 +123,19 @@ export const ExpenseDetailsSkeleton = () => { ); }; -const SkeletonCell = ({ width = "100%", height = 20, className = "" }) => ( +const SkeletonCell = ({ width = "100%", height = 20, className = "", style = {} }) => (
); -export const ExpenseTableSkeleton = ({ rows = 5 }) => { + +export const ExpenseTableSkeleton = ({ groups = 3, rowsPerGroup = 3 }) => { return ( { - {[...Array(rows)].map((_, idx) => ( - - {/* Date Time colSpan=2 */} - + {[...Array(groups)].map((_, groupIdx) => ( + + {/* Fake Date Group Header Row */} + + + - {/* Expense Type */} - + {/* Rows under this group */} + {[...Array(rowsPerGroup)].map((__, rowIdx) => ( + + {/* Date Time colSpan=2 */} + - {/* Payment Mode */} - + {/* Expense Type */} + - {/* Paid By (Avatar + name) */} - + {/* Payment Mode */} + - {/* Amount */} - + {/* Paid By (Avatar + name) */} + - {/* Status */} - + {/* Amount */} + - {/* Action (icons) */} - - + {/* Status */} + + + {/* Action */} + + + ))} + ))}
-
- -
-
+ +
- -
+
+ +
+
- - + + -
- - -
-
+ + - - +
+ + +
+
- - + + -
- {[...Array(3)].map((__, i) => ( - - ))} -
-
+ + +
+ {[...Array(3)].map((__, i) => ( + + ))} +
+
); -}; \ No newline at end of file +}; diff --git a/src/components/Expenses/ManageExpense.jsx b/src/components/Expenses/ManageExpense.jsx index c9f63386..f33fb2f7 100644 --- a/src/components/Expenses/ManageExpense.jsx +++ b/src/components/Expenses/ManageExpense.jsx @@ -133,7 +133,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => { }; useEffect(() => { - if (expenseToEdit && data) { + if (expenseToEdit && data && employees) { reset({ projectId: data.project.id || "", expensesTypeId: data.expensesType.id || "", @@ -170,6 +170,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => { } ); const onSubmit = (payload) => { + debugger if (expenseToEdit) { const editPayload = { ...payload, id: data.id }; ExpenseUpdate({ id: data.id, payload: editPayload }); @@ -194,7 +195,6 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => { isLoading ) return ; - return (
@@ -520,7 +520,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => { (fileError?.fileSize?.message || fileError?.contentType?.message || fileError?.base64Data?.message, - fileError?.documentId.message) + fileError?.documentId?.message) }
))} @@ -532,7 +532,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => { diff --git a/src/components/Expenses/ViewExpense.jsx b/src/components/Expenses/ViewExpense.jsx index 28142cb4..a9f7ae0f 100644 --- a/src/components/Expenses/ViewExpense.jsx +++ b/src/components/Expenses/ViewExpense.jsx @@ -26,7 +26,7 @@ const ViewExpense = ({ ExpenseId }) => { }, }); - const { mutate: MakeAction } = useActionOnExpense(()=>reset()); + const { mutate: MakeAction } = useActionOnExpense(() => reset()); const onSubmit = (formData) => { const Payload = { @@ -38,7 +38,7 @@ const ViewExpense = ({ ExpenseId }) => { MakeAction(Payload); }; - if (isLoading) return + if (isLoading) return ; const handleImageLoad = (id) => { setImageLoaded((prev) => ({ ...prev, [id]: true })); }; @@ -52,7 +52,7 @@ const ViewExpense = ({ ExpenseId }) => {
{/* Expense Info Rows */} -
+
-
+
-
- - - - {data?.status?.displayName} - -
+
+ -
- -
{data.preApproved ? "Yes" : "No"}
-
+ + {data?.status?.displayName} + +
+ +
+ +
{data.preApproved ? "Yes" : "No"}
+
@@ -153,7 +157,7 @@ const ViewExpense = ({ ExpenseId }) => {
-
+
@@ -163,52 +167,61 @@ const ViewExpense = ({ ExpenseId }) => { - {data?.documents && data?.documents?.map((doc) => ( -
+ {data?.documents && + data?.documents?.map((doc) => (
- {doc.contentType === "application/pdf" ? ( -
- ) : ( - <> - {!imageLoaded[doc.id] && ( -
- Loading... -
- )} - {doc.fileName} handleImageLoad(doc.id)} - onClick={() => - setDocumentView({ IsOpen: true, Image: doc.preSignedUrl }) - } - /> - - )} -
+
+ {doc.contentType === "application/pdf" ? ( +
+ +
+ ) : ( + <> + {!imageLoaded[doc.id] && ( +
+ Loading... +
+ )} + {doc.fileName} handleImageLoad(doc.id)} + onClick={() => + setDocumentView({ + IsOpen: true, + Image: doc.preSignedUrl, + }) + } + /> + + )} +
-
- {doc.fileName} -
- +
+ {doc.fileName} +
+ +
-
- ))} + ))}

@@ -237,9 +250,8 @@ const ViewExpense = ({ ExpenseId }) => { handleSubmit(onSubmit)(); }} className="btn btn-primary btn-sm cursor-pointer mx-2 border-0" - > - {status.displayName || status.name} + {status.displayName || status.name} ))}
diff --git a/src/components/common/DateRangePicker.jsx b/src/components/common/DateRangePicker.jsx index e7239c53..a79c9d4b 100644 --- a/src/components/common/DateRangePicker.jsx +++ b/src/components/common/DateRangePicker.jsx @@ -1,6 +1,8 @@ import React, { useEffect, useRef } from "react"; const DateRangePicker = ({ + md, + sm, onRangeChange, DateDifference = 7, endDateMode = "yesterday", @@ -25,11 +27,12 @@ const DateRangePicker = ({ altInput: true, altFormat: "d-m-Y", defaultDate: [startDate, endDate], - static: true, + static: false, + appendTo: document.body, clickOpens: true, maxDate: endDate, // ✅ Disable future dates onChange: (selectedDates, dateStr) => { - const [startDateString, endDateString] = dateStr.split(" to "); + const [startDateString, endDateString] = dateStr.split(" To "); onRangeChange?.({ startDate: startDateString, endDate: endDateString }); }, }); @@ -45,13 +48,29 @@ const DateRangePicker = ({ }, [onRangeChange, DateDifference, endDateMode]); return ( - +
+ + + +
+ ); }; diff --git a/src/components/common/SelectMultiple.jsx b/src/components/common/SelectMultiple.jsx index 272f8944..0873637e 100644 --- a/src/components/common/SelectMultiple.jsx +++ b/src/components/common/SelectMultiple.jsx @@ -1,12 +1,13 @@ import React, { useState, useEffect, useRef } from "react"; import { useFormContext } from "react-hook-form"; +import { createPortal } from "react-dom"; import "./MultiSelectDropdown.css"; const SelectMultiple = ({ name, options = [], label = "Select options", - labelKey = "name", + labelKey = "name", // Can now be a function or a string valueKey = "id", placeholder = "Please select...", IsLoading = false, @@ -16,11 +17,18 @@ const SelectMultiple = ({ const [isOpen, setIsOpen] = useState(false); const [searchText, setSearchText] = useState(""); + const containerRef = useRef(null); const dropdownRef = useRef(null); + const [dropdownStyles, setDropdownStyles] = useState({ top: 0, left: 0, width: 0 }); + useEffect(() => { const handleClickOutside = (e) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target)) { + if ( + containerRef.current && + !containerRef.current.contains(e.target) && + (!dropdownRef.current || !dropdownRef.current.contains(e.target)) + ) { setIsOpen(false); } }; @@ -28,6 +36,21 @@ const SelectMultiple = ({ return () => document.removeEventListener("mousedown", handleClickOutside); }, []); + useEffect(() => { + if (isOpen && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setDropdownStyles({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width, + }); + } + }, [isOpen]); + + const getLabel = (item) => { + return typeof labelKey === "function" ? labelKey(item) : item[labelKey]; + }; + const handleCheckboxChange = (value) => { const updated = selectedValues.includes(value) ? selectedValues.filter((v) => v !== value) @@ -36,96 +59,113 @@ const SelectMultiple = ({ setValue(name, updated, { shouldValidate: true }); }; - const filteredOptions = options.filter((item) => - item[labelKey]?.toLowerCase().includes(searchText.toLowerCase()) - ); + const filteredOptions = options.filter((item) => { + const label = getLabel(item); + return label?.toLowerCase().includes(searchText.toLowerCase()); + }); - return ( -
- - -
setIsOpen((prev) => !prev)} - > - 0 - ? "placeholder-style-selected" - : "placeholder-style" - } - > -
- {selectedValues.length > 0 ? ( - selectedValues.map((val) => { - const found = options.find((opt) => opt[valueKey] === val); - return ( - - {found ? found[labelKey] : ""} - - ); - }) - ) : ( - {placeholder} - )} -
-
- + const dropdownElement = ( +
+
+ setSearchText(e.target.value)} + className="multi-select-dropdown-search-input" + style={{ width: "100%", padding: 4 }} + />
- {isOpen && ( -
-
+ {filteredOptions.map((item) => { + const labelVal = getLabel(item); + const valueVal = item[valueKey]; + const isChecked = selectedValues.includes(valueVal); + + return ( +
setSearchText(e.target.value)} - className="multi-select-dropdown-search-input" + type="checkbox" + className="custom-checkbox form-check-input" + checked={isChecked} + onChange={() => handleCheckboxChange(valueVal)} + style={{ marginRight: 8 }} /> +
+ ); + })} - {filteredOptions.map((item) => { - const labelVal = item[labelKey]; - const valueVal = item[valueKey]; - const isChecked = selectedValues.includes(valueVal); - - return ( -
- handleCheckboxChange(valueVal)} - /> - -
- ); - })} - {!IsLoading && filteredOptions.length === 0 && ( -
- -
- )} - {IsLoading && filteredOptions.length === 0 && ( -
- -
- )} + {!IsLoading && filteredOptions.length === 0 && ( +
+ +
+ )} + {IsLoading && filteredOptions.length === 0 && ( +
+
)}
); + + return ( + <> +
+ + +
setIsOpen((prev) => !prev)} + style={{ cursor: "pointer" }} + > + 0 ? "placeholder-style-selected" : "placeholder-style" + } + > +
+ {selectedValues.length > 0 ? ( + selectedValues.map((val) => { + const found = options.find((opt) => opt[valueKey] === val); + const label = found ? getLabel(found) : ""; + return ( + + {label} + + ); + }) + ) : ( + {placeholder} + )} +
+
+ +
+
+ + {isOpen && createPortal(dropdownElement, document.body)} + + ); }; export default SelectMultiple; diff --git a/src/pages/Expense/ExpensePage.jsx b/src/pages/Expense/ExpensePage.jsx index c4bd8526..90624d61 100644 --- a/src/pages/Expense/ExpensePage.jsx +++ b/src/pages/Expense/ExpensePage.jsx @@ -1,16 +1,107 @@ -import React, { createContext, useContext, useState } from "react"; - +import React, { + createContext, + useContext, + useState, + useRef, + useEffect, +} from "react"; +import { + useForm, + useFieldArray, + FormProvider, + useFormContext, + Controller, +} from "react-hook-form"; import ExpenseList from "../../components/Expenses/ExpenseList"; import ViewExpense from "../../components/Expenses/ViewExpense"; import Breadcrumb from "../../components/common/Breadcrumb"; import GlobalModel from "../../components/common/GlobalModel"; import PreviewDocument from "../../components/Expenses/PreviewDocument"; import ManageExpense from "../../components/Expenses/ManageExpense"; +import { useProjectName } from "../../hooks/useProjects"; +import { useExpenseStatus } from "../../hooks/masterHook/useMaster"; +import { + useEmployees, + useEmployeesAllOrByProjectId, +} from "../../hooks/useEmployees"; +import { useSelector } from "react-redux"; +import DateRangePicker from "../../components/common/DateRangePicker"; +import SelectMultiple from "../../components/common/SelectMultiple"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + defaultFilter, + SearchSchema, +} from "../../components/Expenses/ExpenseSchema"; + +const SelectDropdown = ({ + label, + options = [], + loading = false, + placeholder = "Select...", + valueKey = "id", + labelKey = "name", + selectedValues = [], + onChange, + isMulti = false, +}) => { + const handleChange = (e) => { + const selected = Array.from( + e.target.selectedOptions, + (option) => option.value + ); + onChange && onChange(selected); + }; + + return ( +
+ +
+ {options.map((option) => { + const checked = selectedValues.includes(option[valueKey]); + return ( +
+ { + let newSelected; + if (checked) { + newSelected = selectedValues.filter( + (val) => val !== option[valueKey] + ); + } else { + newSelected = [...selectedValues, option[valueKey]]; + } + onChange(newSelected); + }} + /> + +
+ ); + })} +
+
+ ); +}; export const ExpenseContext = createContext(); export const useExpenseContext = () => useContext(ExpenseContext); const ExpensePage = () => { + const [isOpen, setIsOpen] = useState(false); + const [filters,setFilter] = useState() + const dropdownRef = useRef(null); + const shouldCloseOnOutsideClick = useRef(false); + const selectedProjectId = useSelector( + (store) => store.localVariables.projectId + ); const [ManageExpenseModal, setManageExpenseModal] = useState({ IsOpen: null, expenseId: null, @@ -30,6 +121,81 @@ const ExpensePage = () => { setDocumentView, }; + const methods = useForm({ + resolver: zodResolver(SearchSchema), + defaultValues: defaultFilter, + }); + const { + register, + handleSubmit, + control, + getValues, + trigger, + setValue, + watch, + reset, + formState: { errors }, + } = methods; + + const { projectNames, loading: projectLoading } = useProjectName(); + const { ExpenseStatus, loading: statusLoading, error } = useExpenseStatus(); + const { employees, loading: empLoading } = useEmployeesAllOrByProjectId( + true, + selectedProjectId, + true + ); + + const onSubmit = (data) => { + setFilter(data) + }; + const setDateRange = ({ startDate, endDate }) => { + setValue( + "startDate", + startDate ? new Date(startDate).toISOString().split("T")[0] : null + ); + setValue( + "endDate", + endDate ? new Date(endDate).toISOString().split("T")[0] : null + ); + }; + + const toggleDropdown = () => { + setIsOpen((prev) => { + shouldCloseOnOutsideClick.current = !prev; + return !prev; + }); + }; + + useEffect(() => { + function handleClickOutside(event) { + if ( + shouldCloseOnOutsideClick.current && + dropdownRef.current && + dropdownRef.current.contains(event.target) + ) { + setIsOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + const clearFilter =()=>{ + setFilter( + { + projectIds: [], + statusIds: [], + createdByIds: [], + paidById: [], + startDate: null, + endDate: null, + }) + reset() + + } + return (
@@ -42,16 +208,136 @@ const ExpensePage = () => {
-
- - +
+
+ setIsOpen((v) => !v)} + > + {isOpen && ( +
+ +
{ + onSubmit(data); + setIsOpen(false); + })} + > +
+ +
+ +
+
+ +
+ {ExpenseStatus.map((status) => ( + ( +
+ { + if (e.target.checked) { + onChange([...value, status.id]); + } else { + onChange( + value.filter( + (v) => v !== status.id + ) + ); + } + }} + /> + +
+ )} + /> + ))} +
+
+
+ +
+ + + `${item.firstName} ${item.lastName}` + } + valueKey="id" + IsLoading={empLoading} + /> + + `${item.firstName} ${item.lastName}` + } + valueKey="id" + IsLoading={empLoading} + /> +
+
+ + +
+
+
+
+ )} +
- + {ManageExpenseModal.IsOpen && (