added filter for expense list

This commit is contained in:
pramod mahajan 2025-07-25 18:04:28 +05:30
parent 909031edb8
commit bca476ba90
6 changed files with 475 additions and 158 deletions

View File

@ -10,25 +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();
const filter = {
projectIds: [],
statusIds: [],
createdByIds: [],
paidById: [],
startDate: null,
endDate: null,
};
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);
@ -121,7 +114,7 @@ const ExpenseList = () => {
className="dataTables_wrapper no-footer px-2"
>
<table
className="datatables-users table border-top dataTable no-footer dtr-column text-nowrap"
className="datatables-users table border-top dataTable no-footer dtr-column text-nowrap px-3"
aria-describedby="DataTables_Table_0_info"
id="horizontal-example"
>
@ -150,7 +143,7 @@ const ExpenseList = () => {
<div className="text-start ms-5">Expense Type</div>
</th>
<th
className="sorting sorting_desc d-none d-sm-table-cell"
className="sorting sorting_desc d-table-cell"
tabIndex="0"
aria-controls="DataTables_Table_0"
rowSpan="1"
@ -158,10 +151,10 @@ const ExpenseList = () => {
aria-label="Payment Mode: activate to sort column ascending"
aria-sort="descending"
>
<div className="text-start ms-5">Payment Mode</div>
<div className="text-start ">Payment Mode</div>
</th>
<th
className="sorting sorting_desc d-none d-sm-table-cell"
className="sorting sorting_desc d-table-cell"
tabIndex="0"
aria-controls="DataTables_Table_0"
rowSpan="1"
@ -172,7 +165,7 @@ const ExpenseList = () => {
<div className="text-start ms-5">Paid By</div>
</th>
<th
className="sorting d-none d-md-table-cell"
className="sorting d-table-cell"
tabIndex="0"
aria-controls="DataTables_Table_0"
rowSpan="1"
@ -203,26 +196,24 @@ const ExpenseList = () => {
</th>
</tr>
</thead>
<tbody>
<tbody >
{!isInitialLoading &&
groupExpensesByDateAndStatus(items).map(
({ date, expenses }) => (
<>
<tr key={`date-${date}`} className="bg-light text-dark">
<tr key={`date-${date}`} className="tr-group text-dark">
<td colSpan={7} className="text-start">
<strong>{formatUTCToLocalTime(date)}</strong>
</td>
</tr>
{expenses.map((expense) => (
<tr key={expense.id}>
<td className="text-start d-none d-sm-table-cell ms-5">
</tr> {expenses.map((expense) => (
<tr key={expense.id} >
<td className="text-start d-table-cell ms-5">
{expense.expensesType?.name || "N/A"}
</td>
<td className="text-start d-none d-sm-table-cell ms-5">
<td className="text-start d-table-cell ms-5">
{expense.paymentMode?.name || "N/A"}
</td>
<td className="text-start d-none d-sm-table-cell ms-5">
<td className="text-start d-table-cell ms-5">
<div className="d-flex align-items-center">
<Avatar
size="xs"
@ -237,7 +228,7 @@ const ExpenseList = () => {
</span>
</div>
</td>
<td className="d-none d-md-table-cell text-end">
<td className="d-table-cell text-end">
<i className="bx bx-rupee b-xs"></i>
{expense?.amount}
</td>

View File

@ -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" }),
})
})
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:""
}

View File

@ -135,7 +135,7 @@ const SkeletonCell = ({ width = "100%", height = 20, className = "", style = {}
/>
);
export const ExpenseTableSkeleton = ({ groups = 3, rowsPerGroup = 6 }) => {
export const ExpenseTableSkeleton = ({ groups = 3, rowsPerGroup = 3 }) => {
return (
<table
className="card-body table border-top dataTable no-footer dtr-column text-nowrap"

View File

@ -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 (
<input
type="text"
className="form-control form-control-sm ms-1"
placeholder="From to End"
id="flatpickr-range"
ref={inputRef}
/>
<div className={`col-${sm} col-sm-${md} px-1 position-relative`}>
<input
type="text"
className="form-control form-control-sm ps-2 pe-5 "
placeholder="From to End"
id="flatpickr-range"
ref={inputRef}
/>
<i
className="bx bx-calendar calendar-icon cursor-pointer"
style={{
position: "absolute",
top: "50%",
right: "12px",
transform: "translateY(-50%)",
color: "#6c757d",
fontSize: "1.1rem",
}}
></i>
</div>
);
};

View File

@ -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 (
<div ref={dropdownRef} className="multi-select-dropdown-container">
<label className="form-label mb-1">{label}</label>
<div
className="multi-select-dropdown-header"
onClick={() => setIsOpen((prev) => !prev)}
>
<span
className={
selectedValues.length > 0
? "placeholder-style-selected"
: "placeholder-style"
}
>
<div className="selected-badges-container">
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
const found = options.find((opt) => opt[valueKey] === val);
return (
<span
key={val}
className="badge badge-selected-item mx-1 mb-1"
>
{found ? found[labelKey] : ""}
</span>
);
})
) : (
<span className="placeholder-text">{placeholder}</span>
)}
</div>
</span>
<i className="bx bx-chevron-down"></i>
const dropdownElement = (
<div
ref={dropdownRef}
className="multi-select-dropdown-options"
style={{
position: "absolute",
top: dropdownStyles.top,
left: dropdownStyles.left,
width: dropdownStyles.width,
zIndex: 9999,
backgroundColor: "white",
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
borderRadius: 4,
maxHeight: 300,
overflowY: "auto",
}}
>
<div className="multi-select-dropdown-search" style={{ padding: 8 }}>
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="multi-select-dropdown-search-input"
style={{ width: "100%", padding: 4 }}
/>
</div>
{isOpen && (
<div className="multi-select-dropdown-options">
<div className="multi-select-dropdown-search">
{filteredOptions.map((item) => {
const labelVal = getLabel(item);
const valueVal = item[valueKey];
const isChecked = selectedValues.includes(valueVal);
return (
<div
key={valueVal}
className={`multi-select-dropdown-option ${isChecked ? "selected" : ""}`}
style={{ display: "flex", alignItems: "center", padding: "4px 8px" }}
>
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => 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 }}
/>
<label className="text-secondary">{labelVal}</label>
</div>
);
})}
{filteredOptions.map((item) => {
const labelVal = item[labelKey];
const valueVal = item[valueKey];
const isChecked = selectedValues.includes(valueVal);
return (
<div
key={valueVal}
className={`multi-select-dropdown-option ${
isChecked ? "selected" : ""
}`}
>
<input
type="checkbox"
className="custom-checkbox form-check-input"
checked={isChecked}
onChange={() => handleCheckboxChange(valueVal)}
/>
<label className="text-secondary">{labelVal}</label>
</div>
);
})}
{!IsLoading && filteredOptions.length === 0 && (
<div className="multi-select-dropdown-Not-found">
<label className="text-muted">
Not Found {`'${searchText}'`}
</label>
</div>
)}
{IsLoading && filteredOptions.length === 0 && (
<div className="multi-select-dropdown-Not-found">
<label className="text-muted">Loading...</label>
</div>
)}
{!IsLoading && filteredOptions.length === 0 && (
<div className="multi-select-dropdown-Not-found" style={{ padding: 8 }}>
<label className="text-muted">Not Found {`'${searchText}'`}</label>
</div>
)}
{IsLoading && filteredOptions.length === 0 && (
<div className="multi-select-dropdown-Not-found" style={{ padding: 8 }}>
<label className="text-muted">Loading...</label>
</div>
)}
</div>
);
return (
<>
<div ref={containerRef} className="multi-select-dropdown-container" style={{ position: "relative" }}>
<label className="form-label mb-1">{label}</label>
<div
className="multi-select-dropdown-header"
onClick={() => setIsOpen((prev) => !prev)}
style={{ cursor: "pointer" }}
>
<span
className={
selectedValues.length > 0 ? "placeholder-style-selected" : "placeholder-style"
}
>
<div className="selected-badges-container">
{selectedValues.length > 0 ? (
selectedValues.map((val) => {
const found = options.find((opt) => opt[valueKey] === val);
const label = found ? getLabel(found) : "";
return (
<span key={val} className="badge badge-selected-item mx-1 mb-1">
{label}
</span>
);
})
) : (
<span className="placeholder-text">{placeholder}</span>
)}
</div>
</span>
<i className="bx bx-chevron-down"></i>
</div>
</div>
{isOpen && createPortal(dropdownElement, document.body)}
</>
);
};
export default SelectMultiple;

View File

@ -1,5 +1,17 @@
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";
@ -8,11 +20,88 @@ 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 (
<div className="select-dropdown">
<label>{label}</label>
<div className="dropdown-menu show">
{options.map((option) => {
const checked = selectedValues.includes(option[valueKey]);
return (
<div key={option[valueKey]} className="form-check">
<input
type="checkbox"
className="form-check-input"
id={`checkbox-${option[valueKey]}`}
checked={checked}
onChange={() => {
let newSelected;
if (checked) {
newSelected = selectedValues.filter(
(val) => val !== option[valueKey]
);
} else {
newSelected = [...selectedValues, option[valueKey]];
}
onChange(newSelected);
}}
/>
<label
className="form-check-label"
htmlFor={`checkbox-${option[valueKey]}`}
>
{option[labelKey] || option[valueKey]}
</label>
</div>
);
})}
</div>
</div>
);
};
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,
@ -32,8 +121,81 @@ const ExpensePage = () => {
setDocumentView,
};
const { projectNames } = useProjectName();
const {} = useExpenseStatus();
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 (
<ExpenseContext.Provider value={contextValue}>
<div className="container-fluid">
@ -46,50 +208,135 @@ const ExpensePage = () => {
<div className="card my-1 text-start px-0">
<div className="card-body py-1 px-1">
<div className="row">
<div className="col-5 col-sm-4">
<label className="mb-0">
<input
type="search"
className="form-control form-control-sm"
placeholder="Search Expense..."
aria-controls="DataTables_Table_0"
/>
</label>
<div className="dropdown d-inline-block" >
<div className="col-5 col-sm-4 d-flex aligin-items-center">
<div
className="dropdown d-inline-block mt-2 align-items-center"
ref={dropdownRef}
>
<i
className="bx bx-slider-alt ms-2"
id="filterDropdown"
data-bs-toggle="dropdown"
role="button"
aria-expanded="false"
aria-expanded={isOpen}
style={{ cursor: "pointer" }}
onClick={() => setIsOpen((v) => !v)}
></i>
{isOpen && (
<div
className="dropdown-menu p-3 overflow-hidden show d-flex align-items-center"
style={{ minWidth: "500px" }}
>
<FormProvider {...methods}>
<form
className="p-2 p-sm-0"
onSubmit={handleSubmit((data) => {
onSubmit(data);
setIsOpen(false);
})}
>
<div className="w-100">
<DateRangePicker
onRangeChange={setDateRange}
endDateMode="today"
DateDifference="6"
dateFormat="DD-MM-YYYY"
/>
</div>
<div
className="dropdown-menu p-3"
aria-labelledby="filterDropdown"
style={{ minWidth: "500px",}}
>
<div className="row g-2">
<div className="col-md-6">
<label className="form-label">Project</label>
<select className="form-select form-select-sm">
<option value="1">Project A</option>
<option value="2">Project B</option>
<option value="3">Project C</option>
</select>
</div>
<div className="row g-2">
<div className="col-12 ">
<label className="form-label d-block text-secondary">
Select Status
</label>
<div className="d-flex flex-wrap">
{ExpenseStatus.map((status) => (
<Controller
key={status.id}
control={control}
name="statusIds"
render={({
field: { value = [], onChange },
}) => (
<div className="d-flex align-items-center me-4 mb-2">
<input
type="checkbox"
className="form-check-input form-check-input-sm"
value={status.id}
checked={value.includes(status.id)}
onChange={(e) => {
if (e.target.checked) {
onChange([...value, status.id]);
} else {
onChange(
value.filter(
(v) => v !== status.id
)
);
}
}}
/>
<label className="ms-2 mb-0">
{status.displayName}
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<div className="col-md-6">
<label className="form-label">Select Project</label>
<select className="form-select form-select-sm">
{projectNames?.map((project)=>(
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div className="row g-2">
<SelectMultiple
name="projectIds"
label="Select Projects"
options={projectNames}
labelKey="name"
valueKey="id"
IsLoading={projectLoading}
/>
<SelectMultiple
name="createdByIds"
label="Select creator"
options={employees}
labelKey={(item) =>
`${item.firstName} ${item.lastName}`
}
valueKey="id"
IsLoading={empLoading}
/>
<SelectMultiple
name="paidById"
label="Select Paid by"
options={employees}
labelKey={(item) =>
`${item.firstName} ${item.lastName}`
}
valueKey="id"
IsLoading={empLoading}
/>
</div>
<div className="d-flex justify-content-end py-1 gap-2">
<button
type="button"
className="btn btn-secondary btn-xs"
onClick={() => {
clearFilter()
setIsOpen(false);
}}
>
Clear
</button>
<button
type="submit"
className="btn btn-primary btn-xs"
>
Apply
</button>
</div>
</form>
</FormProvider>
</div>
</div>
)}
</div>
</div>
<div className="col-7 col-sm-8 text-end gap-2">
@ -115,7 +362,7 @@ const ExpensePage = () => {
</div>
</div>
<ExpenseList />
<ExpenseList filters={filters} />
{ManageExpenseModal.IsOpen && (
<GlobalModel
isOpen={ManageExpenseModal.IsOpen}