added search filter and group by

This commit is contained in:
pramod mahajan 2025-07-31 16:02:15 +05:30
parent b864ed0529
commit e31bb7c487
10 changed files with 562 additions and 262 deletions

View File

@ -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 <ExpenseFilterSkeleton />;
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="p-2 text-start">
<div className="mb-3 w-100">
<label className="form-label">Created Date</label>
{/* <DateRangePicker
onRangeChange={setDateRange}
endDateMode="today"
DateDifference="6"
dateFormat="DD-MM-YYYY"
/> */}
<>
<div className="mb-2 text-start px-2">
<label htmlFor="groupBySelect" className="form-label">Group By :</label>
<select
id="groupBySelect"
className="form-select form-select-sm"
value={selectedGroup?.id || ""}
onChange={handleGroupChange}
>
{groupByList.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<DateRangePicker1
placeholder="DD-MM-YYYY To DD-MM-YYYY"
startField="startDate"
endField="endDate"
/>
</div>
<div className="row g-2">
<SelectMultiple
name="projectIds"
label="Projects : "
options={projectNames}
labelKey="name"
valueKey="id"
IsLoading={projectLoading}
/>
<SelectMultiple
name="createdByIds"
label="Submitted By : "
options={employees}
labelKey={(item) => `${item.firstName} ${item.lastName}`}
valueKey="id"
IsLoading={empLoading}
/>
<SelectMultiple
name="paidById"
label="Paid By : "
options={employees}
labelKey={(item) => `${item.firstName} ${item.lastName}`}
valueKey="id"
IsLoading={empLoading}
/>
<div className="mb-3">
<label className="form-label ">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-3 mb-2">
<input
type="checkbox"
className="form-check-input"
value={status.id}
checked={value.includes(status.id)}
onChange={(e) => {
const checked = e.target.checked;
onChange(
checked
? [...value, status.id]
: value.filter((v) => v !== status.id)
);
}}
/>
<label className="ms-2 mb-0">{status.displayName}</label>
</div>
)}
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="p-2 text-start">
<div className="mb-3 w-100">
<div className="d-flex align-items-center mb-2">
<label className="form-label me-2">Choose Date:</label>
<div className="form-check form-switch m-0">
<input
className="form-check-input"
type="checkbox"
id="switchOption1"
{...register("isTransactionDate")}
/>
))}
</div>
<label className="form-label mb-0 ms-2">
{isTransactionDate ? "Submitted": "Transaction" }
</label>
</div>
<DateRangePicker1
placeholder="DD-MM-YYYY To DD-MM-YYYY"
startField="startDate"
endField="endDate"
resetSignal={resetKey}
/>
</div>
<div className="row g-2">
<SelectMultiple
name="projectIds"
label="Projects :"
options={data.projects}
labelKey="name"
valueKey="id"
/>
<SelectMultiple
name="createdByIds"
label="Submitted By :"
options={data.createdBy}
labelKey={(item) => item.name}
valueKey="id"
/>
<SelectMultiple
name="paidById"
label="Paid By :"
options={data.paidBy}
labelKey={(item) => item.name}
valueKey="id"
/>
<div className="mb-3">
<label className="form-label">Status :</label>
<div className="row flex-wrap">
{data?.status
?.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((status) => (
<div className="col-6" key={status.id}>
<Controller
control={control}
name="statusIds"
render={({ field: { value = [], onChange } }) => (
<div className="d-flex align-items-center me-3 mb-2">
<input
type="checkbox"
className="form-check-input"
value={status.id}
checked={value.includes(status.id)}
onChange={(e) => {
const checked = e.target.checked;
onChange(
checked
? [...value, status.id]
: value.filter((v) => v !== status.id)
);
}}
/>
<label className="ms-2 mb-0">{status.name}</label>
</div>
)}
/>
</div>
))}
</div>
</div>
</div>
</div>
<div className="d-flex justify-content-end py-3 gap-2">
<button
type="button"
className="btn btn-secondary btn-xs"
onClick={onClear}
>
Clear
</button>
<button type="submit" className="btn btn-primary btn-xs">
Apply
</button>
</div>
</form>
</FormProvider>
<div className="d-flex justify-content-end py-3 gap-2">
<button
type="button"
className="btn btn-secondary btn-xs"
onClick={onClear}
>
Clear
</button>
<button type="submit" className="btn btn-primary btn-xs">
Apply
</button>
</div>
</form>
</FormProvider>
</>
);
};
export default ExpenseFilterPanel;
export default ExpenseFilterPanel;

View File

@ -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";
}

View File

@ -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
}
projectIds: [],
statusIds: [],
createdByIds: [],
paidById: [],
isTransactionDate: true,
startDate: null,
endDate: null,
};

View File

@ -222,3 +222,62 @@ export const ExpenseTableSkeleton = ({ groups = 3, rowsPerGroup = 3 }) => {
</div>
);
};
export const ExpenseFilterSkeleton = () => {
return (
<div className="p-3 text-start">
{/* Created Date Label and Skeleton */}
<div className="mb-3 w-100">
<SkeletonLine height={14} width="120px" className="mb-1" />
<SkeletonLine height={36} />
</div>
<div className="row g-2">
{/* Project Select */}
<div className="col-12 col-md-4 mb-3">
<SkeletonLine height={14} width="80px" className="mb-1" />
<SkeletonLine height={36} />
</div>
{/* Submitted By Select */}
<div className="col-12 col-md-4 mb-3">
<SkeletonLine height={14} width="100px" className="mb-1" />
<SkeletonLine height={36} />
</div>
{/* Paid By Select */}
<div className="col-12 col-md-4 mb-3">
<SkeletonLine height={14} width="70px" className="mb-1" />
<SkeletonLine height={36} />
</div>
{/* Status Checkboxes */}
<div className="col-12 mb-3">
<SkeletonLine height={14} width="80px" className="mb-2" />
<div className="d-flex flex-wrap">
{[...Array(3)].map((_, i) => (
<div className="d-flex align-items-center me-3 mb-2" key={i}>
<div
className="form-check-input bg-secondary me-2"
style={{
height: "16px",
width: "16px",
borderRadius: "3px",
}}
/>
<SkeletonLine height={14} width="60px" />
</div>
))}
</div>
</div>
</div>
{/* Buttons */}
<div className="d-flex justify-content-end py-3 gap-2">
<SkeletonLine height={30} width="80px" className="rounded" />
<SkeletonLine height={30} width="80px" className="rounded" />
</div>
</div>
);
};

View File

@ -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 }) => {
</div>
</div>
<div className="col-12 col-md-4 mb-3 h-100">
<div className="d-flex ">
<label className="form-label me-2 mb-0 fw-semibold">
Paid By :
</label>
<div className="text-muted">
{data.paidBy.firstName} {data.paidBy.lastName}
<div className="row align-items-center mb-3">
<div className="col-6">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
Paid By:
</label>
<div className="text-muted">
{data.paidBy.firstName} {data.paidBy.lastName}
</div>
</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3 h-100 text-start">
<label className="form-label me-2 mb-0 fw-semibold">Status :</label>
<span
className={`badge bg-label-${
getColorNameFromHex(data?.status?.color) || "secondary"
}`}
>
{data?.status?.displayName}
</span>
<div className="col-6">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
Status:
</label>
<span
className={`badge bg-label-${
getColorNameFromHex(data?.status?.color) || "secondary"
}`}
>
{data?.status?.displayName}
</span>
</div>
</div>
</div>
<div className="col-12 col-md-4 mb-3 h-100">
@ -157,7 +164,7 @@ const ViewExpense = ({ ExpenseId }) => {
</div>
</div>
<div className="col-12 col-md-auto mb-3 h-100">
<div className="col-12 col-md-6 mb-3 h-100">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
Project :
@ -166,27 +173,88 @@ const ViewExpense = ({ ExpenseId }) => {
</div>
</div>
<div className="col-12 col-md-auto mb-3 h-100">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
Created By :
</label>
<div className="text-muted">
{data?.createdBy?.firstName} {data?.createdBy?.lastName}
<div className="row align-items-center mb-3">
<div className="col-12 col-md-auto">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
Created At:
</label>
<div className="text-muted">
{formatUTCToLocalTime(data?.createdAt, true)}
</div>
</div>
</div>
{data.createdBy && (
<div className="col-12 col-md">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
Created By:
</label>
<div className="d-flex align-items-center gap-1">
<Avatar
size="xs"
classAvatar="m-0"
firstName={data.createdBy?.firstName}
lastName={data.createdBy?.lastName}
/>
<span className="text-muted">
{`${data.createdBy?.firstName ?? ""} ${
data.createdBy?.lastName ?? ""
}`.trim() || "N/A"}
</span>
</div>
</div>
</div>
)}
</div>
<div className="col-12 col-md-auto mb-3 h-100">
<div className="d-flex">
<label className="form-label me-2 mb-0 fw-semibold">
Created At :
</label>
<div className="text-muted">
{formatUTCToLocalTime(data?.createdAt, true)}
{data.reviewedBy && (
<div className="col-12 col-md-auto mb-3 h-100">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
Reviewed By :
</label>
<div className="d-flex align-items-center">
<Avatar
size="xs"
classAvatar="m-0"
firstName={data.reviewedBy?.firstName}
lastName={data.reviewedBy?.lastName}
/>
<span className="text-muted">
{`${data.reviewedBy?.firstName ?? ""} ${
data.reviewedBy?.lastName ?? ""
}`.trim() || "N/A"}
</span>
</div>
</div>
</div>
</div>
)}
{data.approvedBy && (
<div className="col-12 col-md-auto mb-3 h-100">
<div className="d-flex align-items-center">
<label className="form-label me-2 mb-0 fw-semibold">
Approved By :
</label>
<div className="d-flex align-items-center">
<Avatar
size="xs"
classAvatar="m-0"
firstName={data.approvedBy?.firstName}
lastName={data.approvedBy?.lastName}
/>
<span className="text-muted">
{`${data.approvedBy?.firstName ?? ""} ${
data.approvedBy?.lastName ?? ""
}`.trim() || "N/A"}
</span>
</div>
</div>
</div>
)}
</div>
<div className="text-start">
@ -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 && (
<div className="col-12 mb-3 text-start">
<label className="form-label me-2 mb-0 fw-semibold">Comment:</label>
<textarea
className="form-control form-control-sm"
{...register("comment")}
rows="2"
/>
{errors.comment && (
<small className="danger-text">{errors.comment.message}</small>
{nextStatusWithPermission.length > 0 && (
<>
<label className="form-label me-2 mb-0 fw-semibold">
Comment:
</label>
<textarea
className="form-control form-control-sm"
{...register("comment")}
rows="2"
/>
{errors.comment && (
<small className="danger-text">{errors.comment.message}</small>
)}
</>
)}
<input type="hidden" {...register("selectedStatus")} />

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react";
import { useController, useFormContext } from "react-hook-form";
import { useController, useFormContext, useWatch } from "react-hook-form";
const DateRangePicker = ({
md,
sm,
@ -67,13 +67,17 @@ const DateRangePicker = ({
export default DateRangePicker;
export const DateRangePicker1 = ({
startField = "startDate",
endField = "endDate",
label,
placeholder = "Select date range",
className = "",
allowText = false,
resetSignal, // <- NEW prop
...rest
}) => {
const inputRef = useRef(null);
@ -83,46 +87,72 @@ export const DateRangePicker1 = ({
field: { ref },
} = useController({ name: startField, control });
const applyDefaultDates = () => {
const today = new Date();
const past = new Date();
past.setDate(today.getDate() - 6);
const format = (d) => flatpickr.formatDate(d, "d-m-Y");
const formattedStart = format(past);
const formattedEnd = format(today);
setValue(startField, formattedStart, { shouldValidate: true });
setValue(endField, formattedEnd, { shouldValidate: true });
if (inputRef.current?._flatpickr) {
inputRef.current._flatpickr.setDate([past, today]);
}
};
useEffect(() => {
if (!inputRef.current || inputRef.current._flatpickr) return;
const defaultStart = getValues(startField);
const defaultEnd = getValues(endField);
const instance = flatpickr(inputRef.current, {
mode: "range",
dateFormat: "d-m-Y",
allowInput: allowText,
defaultDate:
defaultStart && defaultEnd
? [
flatpickr.parseDate(defaultStart, "d-m-Y"),
flatpickr.parseDate(defaultEnd, "d-m-Y"),
]
: null,
onChange: (selectedDates, dateStr, fp) => {
onChange: (selectedDates) => {
if (selectedDates.length === 2) {
const [start, end] = selectedDates;
const format = (d) => flatpickr.formatDate(d, "d-m-Y");
setValue(startField, format(start));
setValue(endField, format(end));
setValue(startField, format(start), { shouldValidate: true });
setValue(endField, format(end), { shouldValidate: true });
} else {
setValue(startField, "");
setValue(endField, "");
setValue(startField, "", { shouldValidate: true });
setValue(endField, "", { shouldValidate: true });
}
},
...rest,
});
// Apply default if empty
const currentStart = getValues(startField);
const currentEnd = getValues(endField);
if (!currentStart && !currentEnd) {
applyDefaultDates();
} else if (currentStart && currentEnd) {
instance.setDate([
flatpickr.parseDate(currentStart, "d-m-Y"),
flatpickr.parseDate(currentEnd, "d-m-Y"),
]);
}
return () => instance.destroy();
}, []);
// Reapply default range on resetSignal change
useEffect(() => {
if (resetSignal !== undefined) {
applyDefaultDates();
}
}, [resetSignal]);
const start = getValues(startField);
const end = getValues(endField);
const formattedValue = start && end ? `${start} To ${end}` : "";
return (
<div className={` position-relative ${className}`}>
<div className={`position-relative ${className}`}>
<input
type="text"
className="form-control form-control-sm"

View File

@ -5,17 +5,41 @@ import { queryClient } from "../layouts/AuthLayout";
import { useSelector } from "react-redux";
// -------------------Query------------------------------------------------------
export const useExpenseList = (pageSize, pageNumber, filter) => {
const cleanFilter = (filter) => {
const cleaned = { ...filter };
["projectIds", "statusIds", "createdByIds", "paidById"].forEach((key) => {
if (Array.isArray(cleaned[key]) && cleaned[key].length === 0) {
delete cleaned[key];
}
});
if (!cleaned.startDate) delete cleaned.startDate;
if (!cleaned.endDate) delete cleaned.endDate;
return cleaned;
};
export const useExpenseList = (pageSize, pageNumber, filter, searchString = '') => {
return useQuery({
queryKey: ["Expenses", pageNumber, pageSize, filter],
queryFn: async () =>
await ExpenseRepository.GetExpenseList(pageSize, pageNumber, filter).then(
(res) => res.data
),
queryKey: ["Expenses", pageNumber, pageSize, filter, searchString],
queryFn: async () => {
const cleanedFilter = cleanFilter(filter);
const response = await ExpenseRepository.GetExpenseList(
pageSize,
pageNumber,
cleanedFilter,
searchString
);
return response.data;
},
keepPreviousData: true,
});
};
export const useExpense = (ExpenseId) => {
return useQuery({
queryKey: ["Expense", ExpenseId],
@ -27,6 +51,17 @@ export const useExpense = (ExpenseId) => {
});
};
export const useExpenseFilter = () => {
return useQuery({
queryKey: ["ExpenseFilter"],
queryFn: async () =>
await ExpenseRepository.GetExpenseFilter().then(
(res) => res.data
),
});
};
// ---------------------------Mutation---------------------------------------------
export const useCreateExpnse = (onSuccessCallBack) => {

View File

@ -53,7 +53,9 @@ export const useExpenseContext = () => {
const ExpensePage = () => {
const [isOpen, setIsOpen] = useState(false);
const [filters, setFilter] = useState();
const [groupBy, setGropBy] = useState("transactionDate");
const IsCreatedAble = useHasUserPermission(CREATE_EXEPENSE);
const [searchText, setSearchText] = useState("");
const selectedProjectId = useSelector(
(store) => store.localVariables.projectId
);
@ -82,13 +84,8 @@ const ExpensePage = () => {
defaultValues: defaultFilter,
});
const { setOffcanvasContent, setShowTrigger } = useFab();
const clearFilter = () => {
setFilter({
projectIds: [],
@ -106,7 +103,12 @@ const ExpensePage = () => {
setOffcanvasContent(
"Expense Filters",
<ExpenseFilterPanel onApply={(data) => setFilter(data)} />
<ExpenseFilterPanel
onApply={(data) => {
setFilter(data);
}}
handleGroupBy={(groupId) => setGropBy(groupId)}
/>
);
return () => {
setOffcanvasContent("", null);
@ -125,20 +127,32 @@ const ExpensePage = () => {
/>
{IsViewAll || IsViewSelf ? (
<>
<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 d-flex aligin-items-center"></div>
<div className="col-7 col-sm-8 text-end gap-2">
<div className="card my-1 px-0">
<div className="card-body py-2 px-3">
<div className="row align-items-center">
<div className="col-12 col-sm-6 col-md-4">
<div className="input-group input-group-sm">
<span className="input-group-text" id="search-label">
Search
</span>
<input
type="search"
className="form-control"
placeholder="Search Expense"
aria-label="Search"
aria-describedby="search-label"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
</div>
<div className="col-12 col-sm-6 col-md-8 text-end mt-2 mt-sm-0">
{IsCreatedAble && (
<button
type="button"
data-bs-toggle="tooltip"
data-bs-offset="0,8"
data-bs-placement="top"
data-bs-custom-class="tooltip"
title="Add New Expense"
className={`p-1 me-2 bg-primary rounded-circle `}
className="p-1 me-2 bg-primary rounded-circle"
onClick={() =>
setManageExpenseModal({
IsOpen: true,
@ -153,7 +167,8 @@ const ExpensePage = () => {
</div>
</div>
</div>
<ExpenseList filters={filters} />
<ExpenseList filters={filters} groupBy={groupBy} searchText={searchText}/>
</>
) : (
<div className="card text-center py-1">

View File

@ -2,12 +2,12 @@ import { api } from "../utils/axiosClient";
const ExpenseRepository = {
GetExpenseList: ( pageSize, pageNumber, filter ) => {
GetExpenseList: ( pageSize, pageNumber, filter,searchString ) => {
const payloadJsonString = JSON.stringify(filter);
return api.get(`/api/expense/list?pageSize=${pageSize}&pageNumber=${pageNumber}&filter=${payloadJsonString}`);
return api.get(`/api/expense/list?pageSize=${pageSize}&pageNumber=${pageNumber}&filter=${payloadJsonString}&searchString=${searchString}`);
},
GetExpenseDetails:(id)=>api.get(`/api/Expense/details/${id}`),
@ -16,7 +16,8 @@ const ExpenseRepository = {
DeleteExpense:(id)=>api.delete(`/api/Expense/delete/${id}`),
ActionOnExpense:(data)=>api.post('/api/expense/action',data),
GetExpenseFilter:()=>api.get('/api/Expense/filter')
}

View File

@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
export const formatFileSize=(bytes)=> {
if (bytes < 1024) return bytes + " B";
else if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
@ -34,3 +36,14 @@ export const getColorNameFromHex = (hex) => {
return null; //
};
export const useDebounce = (value, delay = 500) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
};