Compare commits

...

2 Commits

Author SHA1 Message Date
e31bb7c487 added search filter and group by 2025-07-31 16:02:15 +05:30
b864ed0529 added flatepicker for dates 2025-07-31 08:46:40 +05:30
12 changed files with 822 additions and 408 deletions

View File

@ -1,153 +1,196 @@
// components/Expense/ExpenseFilterPanel.jsx
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";
import DateRangePicker from "../common/DateRangePicker";
import DateRangePicker, { DateRangePicker1 } from "../common/DateRangePicker";
import SelectMultiple from "../common/SelectMultiple";
import { useProjectName } from "../../hooks/useProjects";
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) => {
onApply(data);
const handleGroupChange = (e) => {
const group = groupByList.find((g) => g.id === e.target.value);
if (group) setSelectedGroup(group);
};
const onSubmit = (formData) => {
onApply({
...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>
<>
<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>
<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,28 +5,28 @@ 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();
const IsExpenseEditable = useHasUserPermission();
const IsExpesneApprpve = useHasUserPermission(APPROVE_EXPENSE);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
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(
@ -63,7 +63,9 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
key = item.status?.displayName || "Unknown";
break;
case "paidBy":
key = `${item.paidBy?.firstName ?? ""} ${item.paidBy?.lastName ?? ""}`.trim();
key = `${item.paidBy?.firstName ?? ""} ${
item.paidBy?.lastName ?? ""
}`.trim();
break;
case "project":
key = item.project?.name || "Unknown Project";
@ -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";
}
@ -88,20 +93,21 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
key: "expensesType",
label: "Expense Type",
getValue: (e) => e.expensesType?.name || "N/A",
align:"text-start",
align: "text-start",
},
{
key: "paymentMode",
label: "Payment Mode",
getValue: (e) => e.paymentMode?.name || "N/A",
align:"text-start"
align: "text-start",
},
{
key: "paidBy",
label: "Paid By",
align:"text-start",
align: "text-start",
getValue: (e) =>
`${e.paidBy?.firstName ?? ""} ${e.paidBy?.lastName ?? ""}`.trim() || "N/A",
`${e.paidBy?.firstName ?? ""} ${e.paidBy?.lastName ?? ""}`.trim() ||
"N/A",
customRender: (e) => (
<div className="d-flex align-items-center">
<Avatar
@ -111,16 +117,18 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
lastName={e.paidBy?.lastName}
/>
<span>
{`${e.paidBy?.firstName ?? ""} ${e.paidBy?.lastName ?? ""}`.trim() || "N/A"}
{`${e.paidBy?.firstName ?? ""} ${
e.paidBy?.lastName ?? ""
}`.trim() || "N/A"}
</span>
</div>
)
),
},
{
key: "submitted",
label: "Submitted",
getValue: (e) => formatUTCToLocalTime(e?.createdAt),
isAlwaysVisible: true
isAlwaysVisible: true,
},
{
key: "amount",
@ -131,24 +139,30 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
</>
),
isAlwaysVisible: true,
align: "text-end"
align: "text-end",
},
{
key: "status",
label: "Status",
align:"text-center",
align: "text-center",
getValue: (e) => (
<span className={`badge bg-label-${getColorNameFromHex(e?.status?.color) || "secondary"}`}>
<span
className={`badge bg-label-${
getColorNameFromHex(e?.status?.color) || "secondary"
}`}
>
{e.status?.name || "Unknown"}
</span>
)
}
),
},
];
if (isInitialLoading) return <ExpenseTableSkeleton />;
if (isError) return <div>{error}</div>;
const grouped = groupBy ? groupByField(data?.data ?? [], groupBy) : { All: data?.data ?? [] };
const grouped = groupBy
? groupByField(data?.data ?? [], groupBy)
: { All: data?.data ?? [] };
const IsGroupedByDate = ["transactionDate", "createdAt"].includes(groupBy);
return (
@ -160,7 +174,7 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
role="dialog"
style={{
display: "block",
backgroundColor: "rgba(0,0,0,0.5)"
backgroundColor: "rgba(0,0,0,0.5)",
}}
aria-hidden="false"
>
@ -177,8 +191,11 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
)}
<div className="card">
<div className="card-datatable table-responsive">
<div className="dataTables_wrapper no-footer px-2">
<div
className="card-datatable table-responsive "
id="horizontal-example"
>
<div className="dataTables_wrapper no-footer px-2 ">
<table className="table border-top dataTable text-nowrap">
<thead>
<tr>
@ -194,67 +211,80 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
</th>
)
)}
<th>Action</th>
<th className="sticky-action-column bg-white text-center">
Action
</th>
</tr>
</thead>
<tbody>
{Object.entries(grouped).map(([group, expenses]) => (
<React.Fragment key={group}>
<tr className="tr-group text-dark">
<td colSpan={8} className="text-start">
<strong>{IsGroupedByDate ? formatUTCToLocalTime(group) : group}</strong>
</td>
</tr>
{expenses.map((expense) => (
<tr key={expense.id}>
{expenseColumns.map(
(col) =>
(col.isAlwaysVisible || groupBy !== col.key) && (
<td key={col.key} className={`d-table-cell ${col.align ?? ""}`}>
{col.customRender
? col.customRender(expense)
: col.getValue(expense)}
</td>
)
)}
<td>
<div className="d-flex justify-content-center gap-2">
<i
className="bx bx-show text-primary cursor-pointer"
onClick={() =>
setViewExpense({ expenseId: expense.id, view: true })
}
></i>
{(expense.status.name === "Draft" ||
expense.status.name === "Rejected") &&
expense.createdBy.id === SelfId && (
<i
className="bx bx-edit text-secondary cursor-pointer"
onClick={() =>
setManageExpenseModal({
IsOpen: true,
expenseId: expense.id
})
}
></i>
)}
{expense.status.name === "Draft" &&
expense?.createdBy?.id === SelfId && (
<i
className="bx bx-trash text-danger cursor-pointer"
onClick={() => {
setIsDeleteModalOpen(true);
setDeletingId(expense.id);
}}
></i>
)}
</div>
{Object.keys(grouped).length > 0 ? (
Object.entries(grouped).map(([group, expenses]) => (
<React.Fragment key={group}>
<tr className="tr-group text-dark">
<td colSpan={8} className="text-start">
<strong>
{IsGroupedByDate
? formatUTCToLocalTime(group)
: group}
</strong>
</td>
</tr>
))}
</React.Fragment>
))}
{data?.data?.length === 0 && (
{expenses.map((expense) => (
<tr key={expense.id}>
{expenseColumns.map(
(col) =>
(col.isAlwaysVisible || groupBy !== col.key) && (
<td
key={col.key}
className={`d-table-cell ${col.align ?? ""}`}
>
{col.customRender
? col.customRender(expense)
: col.getValue(expense)}
</td>
)
)}
<td className="sticky-action-column bg-white">
<div className="d-flex justify-content-center gap-2">
<i
className="bx bx-show text-primary cursor-pointer"
onClick={() =>
setViewExpense({
expenseId: expense.id,
view: true,
})
}
></i>
{(expense.status.name === "Draft" ||
expense.status.name === "Rejected") &&
expense.createdBy.id === SelfId && (
<i
className="bx bx-edit text-secondary cursor-pointer"
onClick={() =>
setManageExpenseModal({
IsOpen: true,
expenseId: expense.id,
})
}
></i>
)}
{expense.status.name === "Draft" &&
expense.createdBy.id === SelfId && (
<i
className="bx bx-trash text-danger cursor-pointer"
onClick={() => {
setIsDeleteModalOpen(true);
setDeletingId(expense.id);
}}
></i>
)}
</div>
</td>
</tr>
))}
</React.Fragment>
))
) : (
<tr>
<td colSpan={8} className="text-center py-4">
No Expense Found
@ -278,4 +308,3 @@ const ExpenseList = ({ filters, groupBy = "transactionDate" }) => {
};
export default ExpenseList;

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

@ -23,6 +23,7 @@ import {
} from "../../hooks/useExpense";
import ExpenseSkeleton from "./ExpenseSkeleton";
import moment from "moment";
import DatePicker from "../common/DatePicker";
const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
const {
@ -44,6 +45,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
watch,
setValue,
reset,
control,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
@ -171,7 +173,7 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
}
);
const onSubmit = (fromdata) => {
let payload = {...fromdata,transactionDate: moment.utc(fromdata.transactionDate, 'YYYY-MM-DD').toISOString()}
let payload = {...fromdata,transactionDate: moment.utc(fromdata.transactionDate, 'DD-MM-YYYY').toISOString()}
if (expenseToEdit) {
const editPayload = { ...payload, id: data.id };
ExpenseUpdate({ id: data.id, payload: editPayload });
@ -321,13 +323,18 @@ const ManageExpense = ({ closeModal, expenseToEdit = null }) => {
<label for="transactionDate" className="form-label ">
Transaction Date
</label>
<input
{/* <input
type="date"
className="form-control form-control-sm"
placeholder="YYYY-MM-DD"
id="flatpickr-date"
{...register("transactionDate")}
/>
/> */}
<DatePicker
name="transactionDate"
control={control}
/>
{errors.transactionDate && (
<small className="danger-text">
{errors.transactionDate.message}

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,39 +1,69 @@
import React, { useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { useController } from "react-hook-form";
const DatePicker = ({ onDateChange }) => {
const DatePicker = ({
name,
control,
placeholder = "DD-MM-YYYY",
className = "",
allowText = false,
maxDate=new Date(),
...rest
}) => {
const inputRef = useRef(null);
useEffect(() => {
const fp = flatpickr(inputRef.current, {
dateFormat: "Y-m-d",
defaultDate: new Date(),
onChange: (selectedDates, dateStr) => {
if (onDateChange) {
onDateChange(dateStr); // Pass selected date to parent
}
}
});
const {
field: { onChange, value, ref }
} = useController({
name,
control
});
return () => {
// Cleanup flatpickr instance
fp.destroy();
};
}, [onDateChange]);
useEffect(() => {
if (inputRef.current) {
flatpickr(inputRef.current, {
dateFormat: "d-m-Y",
allowInput: allowText,
defaultDate: value
? flatpickr.parseDate(value, "Y-m-d")
: null,
maxDate:maxDate,
onChange: function (selectedDates, dateStr) {
onChange(dateStr);
},
...rest
});
}
}, [inputRef]);
return (
<div className="container mt-3">
<div className="mb-3">
{/* <label htmlFor="flatpickr-single" className="form-label">
Select Date
</label> */}
<input
type="text"
id="flatpickr-single"
className="form-control"
placeholder="YYYY-MM-DD"
ref={inputRef}
/>
</div>
<div className={` position-relative ${className}`}>
<input
type="text"
className="form-control form-control-sm "
placeholder={placeholder}
defaultValue={
value ? flatpickr.formatDate(flatpickr.parseDate(value, "Y-m-d"), "d-m-Y") : ""
}
ref={(el) => {
inputRef.current = el;
ref(el);
}}
readOnly={!allowText}
autoComplete="off"
/>
<span
className="position-absolute top-50 end-0 pe-1 translate-middle-y cursor-pointer"
onClick={() => {
if (inputRef.current && inputRef.current._flatpickr) {
inputRef.current._flatpickr.open();
}
}}
>
<i className="bx bx-calendar bx-sm fs-5 text-muted"></i>
</span>
</div>
);
};

View File

@ -1,10 +1,10 @@
import React, { useEffect, useRef } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
const DateRangePicker = ({
md,
sm,
onRangeChange,
DateDifference = 7,
DateDifference = 7,
endDateMode = "yesterday",
}) => {
const inputRef = useRef(null);
@ -12,25 +12,25 @@ const DateRangePicker = ({
useEffect(() => {
const endDate = new Date();
if (endDateMode === "yesterday") {
endDate.setDate(endDate.getDate() - 1);
endDate.setDate(endDate.getDate() - 1);
}
endDate.setHours(0, 0, 0, 0);
const startDate = new Date(endDate);
const startDate = new Date(endDate);
startDate.setDate(endDate.getDate() - (DateDifference - 1));
startDate.setHours(0, 0, 0, 0);
const fp = flatpickr(inputRef.current, {
mode: "range",
dateFormat: "Y-m-d",
altInput: true,
altFormat: "d-m-Y",
defaultDate: [startDate, endDate],
static: false,
dateFormat: "Y-m-d",
altInput: true,
altFormat: "d-m-Y",
defaultDate: [startDate, endDate],
static: false,
// appendTo: document.body,
clickOpens: true,
maxDate: endDate,
maxDate: endDate,
onChange: (selectedDates, dateStr) => {
const [startDateString, endDateString] = dateStr.split(" To ");
onRangeChange?.({ startDate: startDateString, endDate: endDateString });
@ -38,8 +38,8 @@ const DateRangePicker = ({
});
onRangeChange?.({
startDate: startDate.toLocaleDateString("en-CA"),
endDate: endDate.toLocaleDateString("en-CA"),
startDate: startDate.toLocaleDateString("en-CA"),
endDate: endDate.toLocaleDateString("en-CA"),
});
return () => {
@ -48,22 +48,129 @@ const DateRangePicker = ({
}, [onRangeChange, DateDifference, endDateMode]);
return (
<div className={`col-${sm} col-sm-${md} px-1 position-relative`}>
<input
type="text"
className="form-control form-control-sm ps-2 pe-5 me-4"
placeholder="From to End"
id="flatpickr-range"
ref={inputRef}
/>
<i
className="bx bx-calendar calendar-icon cursor-pointer position-absolute top-50 translate-middle-y "
style={{right:"12px"}}
></i>
</div>
<div className={`col-${sm} col-sm-${md} px-1 position-relative`}>
<input
type="text"
className="form-control form-control-sm ps-2 pe-5 me-4"
placeholder="From to End"
id="flatpickr-range"
ref={inputRef}
/>
<i
className="bx bx-calendar calendar-icon cursor-pointer position-absolute top-50 translate-middle-y "
style={{ right: "12px" }}
></i>
</div>
);
};
export default DateRangePicker;
export const DateRangePicker1 = ({
startField = "startDate",
endField = "endDate",
placeholder = "Select date range",
className = "",
allowText = false,
resetSignal, // <- NEW prop
...rest
}) => {
const inputRef = useRef(null);
const { control, setValue, getValues } = useFormContext();
const {
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 instance = flatpickr(inputRef.current, {
mode: "range",
dateFormat: "d-m-Y",
allowInput: allowText,
onChange: (selectedDates) => {
if (selectedDates.length === 2) {
const [start, end] = selectedDates;
const format = (d) => flatpickr.formatDate(d, "d-m-Y");
setValue(startField, format(start), { shouldValidate: true });
setValue(endField, format(end), { shouldValidate: true });
} else {
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}`}>
<input
type="text"
className="form-control form-control-sm"
placeholder={placeholder}
defaultValue={formattedValue}
ref={(el) => {
inputRef.current = el;
ref(el);
}}
readOnly={!allowText}
autoComplete="off"
/>
<span
className="position-absolute top-50 end-0 pe-1 translate-middle-y cursor-pointer"
onClick={() => inputRef.current?._flatpickr?.open()}
>
<i className="bx bx-calendar bx-sm fs-5 text-muted"></i>
</span>
</div>
);
};

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
);
@ -81,48 +83,9 @@ const ExpensePage = () => {
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 { setOffcanvasContent, setShowTrigger } = useFab();
const onSubmit = (data) => {
setFilter(data);
};
const isValidDate = (date) => {
return 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 clearFilter = () => {
setFilter({
projectIds: [],
@ -140,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);
@ -159,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,
@ -187,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;
};