added search filter and group by
This commit is contained in:
parent
b864ed0529
commit
e31bb7c487
@ -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;
|
@ -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";
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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")} />
|
||||
|
@ -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"
|
||||
|
@ -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) => {
|
||||
|
@ -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">
|
||||
|
@ -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')
|
||||
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user